diff --git a/develop/docker-compose.yml b/develop/docker-compose.yml
index d0dc8ec6da..e37999f71d 100644
--- a/develop/docker-compose.yml
+++ b/develop/docker-compose.yml
@@ -29,6 +29,7 @@ services:
- DOCKER_RUNNER=true
- TEXLIVE_IMAGE=texlive-full # docker build texlive -t texlive-full
- COMPILES_HOST_DIR=${PWD}/compiles
+ - OUTPUT_HOST_DIR=${PWD}/output
user: root
volumes:
- ${PWD}/compiles:/overleaf/services/clsi/compiles
diff --git a/docker-compose.yml b/docker-compose.yml
index 08d6db6fe7..2d43c2db18 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -75,9 +75,13 @@ services:
## Sandboxed Compiles: https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles
SANDBOXED_COMPILES: 'true'
- SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true'
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
- SANDBOXED_COMPILES_HOST_DIR: '/home/user/sharelatex_data/data/compiles'
+ SANDBOXED_COMPILES_HOST_DIR_COMPILES: '/home/user/sharelatex_data/data/compiles'
+ ### Bind-mount source for /var/lib/overleaf/data/output inside the container.
+ SANDBOXED_COMPILES_HOST_DIR_OUTPUT: '/home/user/sharelatex_data/data/output'
+ ### Backwards compatibility (before Server Pro 5.5)
+ DOCKER_RUNNER: 'true'
+ SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true'
## Works with test LDAP server shown at bottom of docker compose
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
diff --git a/libraries/access-token-encryptor/buildscript.txt b/libraries/access-token-encryptor/buildscript.txt
index 967c3063ad..36fd724a80 100644
--- a/libraries/access-token-encryptor/buildscript.txt
+++ b/libraries/access-token-encryptor/buildscript.txt
@@ -7,4 +7,4 @@ access-token-encryptor
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/fetch-utils/buildscript.txt b/libraries/fetch-utils/buildscript.txt
index 716d3f0d5c..a158079aee 100644
--- a/libraries/fetch-utils/buildscript.txt
+++ b/libraries/fetch-utils/buildscript.txt
@@ -7,4 +7,4 @@ fetch-utils
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/fetch-utils/index.js b/libraries/fetch-utils/index.js
index 643dcc752b..60e8180c7d 100644
--- a/libraries/fetch-utils/index.js
+++ b/libraries/fetch-utils/index.js
@@ -23,11 +23,11 @@ async function fetchJson(url, opts = {}) {
}
async function fetchJsonWithResponse(url, opts = {}) {
- const { fetchOpts } = parseOpts(opts)
+ const { fetchOpts, detachSignal } = parseOpts(opts)
fetchOpts.headers = fetchOpts.headers ?? {}
fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
- const response = await performRequest(url, fetchOpts)
+ const response = await performRequest(url, fetchOpts, detachSignal)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
@@ -53,8 +53,8 @@ async function fetchStream(url, opts = {}) {
}
async function fetchStreamWithResponse(url, opts = {}) {
- const { fetchOpts, abortController } = parseOpts(opts)
- const response = await performRequest(url, fetchOpts)
+ const { fetchOpts, abortController, detachSignal } = parseOpts(opts)
+ const response = await performRequest(url, fetchOpts, detachSignal)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
@@ -76,8 +76,8 @@ async function fetchStreamWithResponse(url, opts = {}) {
* @throws {RequestFailedError} if the response has a failure status code
*/
async function fetchNothing(url, opts = {}) {
- const { fetchOpts } = parseOpts(opts)
- const response = await performRequest(url, fetchOpts)
+ const { fetchOpts, detachSignal } = parseOpts(opts)
+ const response = await performRequest(url, fetchOpts, detachSignal)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
@@ -108,9 +108,9 @@ async function fetchRedirect(url, opts = {}) {
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
*/
async function fetchRedirectWithResponse(url, opts = {}) {
- const { fetchOpts } = parseOpts(opts)
+ const { fetchOpts, detachSignal } = parseOpts(opts)
fetchOpts.redirect = 'manual'
- const response = await performRequest(url, fetchOpts)
+ const response = await performRequest(url, fetchOpts, detachSignal)
if (response.status < 300 || response.status >= 400) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
@@ -142,8 +142,8 @@ async function fetchString(url, opts = {}) {
}
async function fetchStringWithResponse(url, opts = {}) {
- const { fetchOpts } = parseOpts(opts)
- const response = await performRequest(url, fetchOpts)
+ const { fetchOpts, detachSignal } = parseOpts(opts)
+ const response = await performRequest(url, fetchOpts, detachSignal)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
@@ -178,13 +178,14 @@ function parseOpts(opts) {
const abortController = new AbortController()
fetchOpts.signal = abortController.signal
+ let detachSignal = () => {}
if (opts.signal) {
- abortOnSignal(abortController, opts.signal)
+ detachSignal = abortOnSignal(abortController, opts.signal)
}
if (opts.body instanceof Readable) {
abortOnDestroyedRequest(abortController, fetchOpts.body)
}
- return { fetchOpts, abortController }
+ return { fetchOpts, abortController, detachSignal }
}
function setupJsonBody(fetchOpts, json) {
@@ -208,6 +209,9 @@ function abortOnSignal(abortController, signal) {
abortController.abort(signal.reason)
}
signal.addEventListener('abort', listener)
+ return () => {
+ signal.removeEventListener('abort', listener)
+ }
}
function abortOnDestroyedRequest(abortController, stream) {
@@ -226,11 +230,12 @@ function abortOnDestroyedResponse(abortController, response) {
})
}
-async function performRequest(url, fetchOpts) {
+async function performRequest(url, fetchOpts, detachSignal) {
let response
try {
response = await fetch(url, fetchOpts)
} catch (err) {
+ detachSignal()
if (fetchOpts.body instanceof Readable) {
fetchOpts.body.destroy()
}
@@ -239,6 +244,7 @@ async function performRequest(url, fetchOpts) {
method: fetchOpts.method ?? 'GET',
})
}
+ response.body.on('close', detachSignal)
if (fetchOpts.body instanceof Readable) {
response.body.on('close', () => {
if (!fetchOpts.body.readableEnded) {
diff --git a/libraries/fetch-utils/test/unit/FetchUtilsTests.js b/libraries/fetch-utils/test/unit/FetchUtilsTests.js
index e9fd0ff231..691e90778d 100644
--- a/libraries/fetch-utils/test/unit/FetchUtilsTests.js
+++ b/libraries/fetch-utils/test/unit/FetchUtilsTests.js
@@ -1,6 +1,9 @@
const { expect } = require('chai')
+const fs = require('node:fs')
+const events = require('node:events')
const { FetchError, AbortError } = require('node-fetch')
const { Readable } = require('node:stream')
+const { pipeline } = require('node:stream/promises')
const { once } = require('node:events')
const { TestServer } = require('./helpers/TestServer')
const selfsigned = require('selfsigned')
@@ -203,6 +206,31 @@ describe('fetch-utils', function () {
).to.be.rejectedWith(AbortError)
expect(stream.destroyed).to.be.true
})
+
+ it('detaches from signal on success', async function () {
+ const signal = AbortSignal.timeout(10_000)
+ for (let i = 0; i < 20; i++) {
+ const s = await fetchStream(this.url('/hello'), { signal })
+ expect(events.getEventListeners(signal, 'abort')).to.have.length(1)
+ await pipeline(s, fs.createWriteStream('/dev/null'))
+ expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
+ }
+ })
+
+ it('detaches from signal on error', async function () {
+ const signal = AbortSignal.timeout(10_000)
+ for (let i = 0; i < 20; i++) {
+ try {
+ await fetchStream(this.url('/500'), { signal })
+ } catch (err) {
+ if (err instanceof RequestFailedError && err.response.status === 500)
+ continue
+ throw err
+ } finally {
+ expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
+ }
+ }
+ })
})
describe('fetchNothing', function () {
@@ -391,9 +419,16 @@ async function* infiniteIterator() {
async function abortOnceReceived(func, server) {
const controller = new AbortController()
const promise = func(controller.signal)
+ expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(1)
await once(server.events, 'request-received')
controller.abort()
- return await promise
+ try {
+ return await promise
+ } finally {
+ expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(
+ 0
+ )
+ }
}
async function expectRequestAborted(req) {
diff --git a/libraries/logger/buildscript.txt b/libraries/logger/buildscript.txt
index 7db4c0c4c3..afe93c28dc 100644
--- a/libraries/logger/buildscript.txt
+++ b/libraries/logger/buildscript.txt
@@ -7,4 +7,4 @@ logger
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/metrics/buildscript.txt b/libraries/metrics/buildscript.txt
index 2919cb8335..74fcbdb6c6 100644
--- a/libraries/metrics/buildscript.txt
+++ b/libraries/metrics/buildscript.txt
@@ -7,4 +7,4 @@ metrics
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/mongo-utils/buildscript.txt b/libraries/mongo-utils/buildscript.txt
index ee90b005fb..a4e4fe7802 100644
--- a/libraries/mongo-utils/buildscript.txt
+++ b/libraries/mongo-utils/buildscript.txt
@@ -7,4 +7,4 @@ mongo-utils
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/o-error/buildscript.txt b/libraries/o-error/buildscript.txt
index b50683a5d6..81d3eb3252 100644
--- a/libraries/o-error/buildscript.txt
+++ b/libraries/o-error/buildscript.txt
@@ -7,4 +7,4 @@ o-error
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/object-persistor/buildscript.txt b/libraries/object-persistor/buildscript.txt
index c74883c30d..9ca6929a03 100644
--- a/libraries/object-persistor/buildscript.txt
+++ b/libraries/object-persistor/buildscript.txt
@@ -7,4 +7,4 @@ object-persistor
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/overleaf-editor-core/buildscript.txt b/libraries/overleaf-editor-core/buildscript.txt
index e49dc289bf..03b7f06791 100644
--- a/libraries/overleaf-editor-core/buildscript.txt
+++ b/libraries/overleaf-editor-core/buildscript.txt
@@ -7,4 +7,4 @@ overleaf-editor-core
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/promise-utils/buildscript.txt b/libraries/promise-utils/buildscript.txt
index ea5a4a8ef3..51a6dad532 100644
--- a/libraries/promise-utils/buildscript.txt
+++ b/libraries/promise-utils/buildscript.txt
@@ -7,4 +7,4 @@ promise-utils
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/ranges-tracker/buildscript.txt b/libraries/ranges-tracker/buildscript.txt
index 2b3aaa82d9..d112f852a7 100644
--- a/libraries/ranges-tracker/buildscript.txt
+++ b/libraries/ranges-tracker/buildscript.txt
@@ -7,4 +7,4 @@ ranges-tracker
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/redis-wrapper/buildscript.txt b/libraries/redis-wrapper/buildscript.txt
index 311348bd7c..89de51417a 100644
--- a/libraries/redis-wrapper/buildscript.txt
+++ b/libraries/redis-wrapper/buildscript.txt
@@ -7,4 +7,4 @@ redis-wrapper
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/settings/buildscript.txt b/libraries/settings/buildscript.txt
index a731e6eb1c..ed79480d31 100644
--- a/libraries/settings/buildscript.txt
+++ b/libraries/settings/buildscript.txt
@@ -7,4 +7,4 @@ settings
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/libraries/stream-utils/buildscript.txt b/libraries/stream-utils/buildscript.txt
index 0a8776469f..ad7265549c 100644
--- a/libraries/stream-utils/buildscript.txt
+++ b/libraries/stream-utils/buildscript.txt
@@ -7,4 +7,4 @@ stream-utils
--is-library=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/package-lock.json b/package-lock.json
index 5f0eb0baf1..48f2da293a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"services/analytics",
"services/chat",
"services/clsi",
+ "services/clsi-cache",
"services/clsi-perf",
"services/contacts",
"services/docstore",
@@ -1264,10 +1265,11 @@
}
},
"node_modules/@babel/cli": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.24.8.tgz",
- "integrity": "sha512-isdp+G6DpRyKc+3Gqxy2rjzgF7Zj9K0mzLNnxz+E/fgeag8qT3vVulX4gY9dGO1q0y+0lUv6V3a+uhUzMzrwXg==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.27.0.tgz",
+ "integrity": "sha512-bZfxn8DRxwiVzDO5CEeV+7IqXeCkzI4yYnrQbpwjT76CUyossQc6RYE7n+xfm0/2k40lPaCpW0FhxYs7EBAetw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"commander": "^6.2.0",
@@ -1286,7 +1288,7 @@
},
"optionalDependencies": {
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3",
- "chokidar": "^3.4.0"
+ "chokidar": "^3.6.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
@@ -1333,22 +1335,22 @@
}
},
"node_modules/@babel/core": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz",
- "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
+ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
- "@babel/generator": "^7.26.9",
+ "@babel/generator": "^7.26.10",
"@babel/helper-compilation-targets": "^7.26.5",
"@babel/helper-module-transforms": "^7.26.0",
- "@babel/helpers": "^7.26.9",
- "@babel/parser": "^7.26.9",
+ "@babel/helpers": "^7.26.10",
+ "@babel/parser": "^7.26.10",
"@babel/template": "^7.26.9",
- "@babel/traverse": "^7.26.9",
- "@babel/types": "^7.26.9",
+ "@babel/traverse": "^7.26.10",
+ "@babel/types": "^7.26.10",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -1370,14 +1372,14 @@
"dev": true
},
"node_modules/@babel/generator": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz",
- "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
+ "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.26.9",
- "@babel/types": "^7.26.9",
+ "@babel/parser": "^7.27.0",
+ "@babel/types": "^7.27.0",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2"
@@ -1387,25 +1389,13 @@
}
},
"node_modules/@babel/helper-annotate-as-pure": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz",
- "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz",
+ "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/types": "^7.24.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.24.7.tgz",
- "integrity": "sha512-xZeCVVdwb4MsDBkkyZ64tReWYrLRHlMN72vP7Bdm3OUOuyFZExhsHUUnuWnm2/XOlAJzR0LfPpB56WXZn0X/lA==",
- "dev": true,
- "dependencies": {
- "@babel/traverse": "^7.24.7",
- "@babel/types": "^7.24.7"
+ "@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1442,17 +1432,18 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.0.tgz",
- "integrity": "sha512-GYM6BxeQsETc9mnct+nIIpf63SAyzvyYN7UB/IlTyd+MBg06afFGp0mIeUqGyWgS2mxad6vqbMrHVlaL3m70sQ==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
+ "integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.24.7",
- "@babel/helper-member-expression-to-functions": "^7.24.8",
- "@babel/helper-optimise-call-expression": "^7.24.7",
- "@babel/helper-replace-supers": "^7.25.0",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7",
- "@babel/traverse": "^7.25.0",
+ "@babel/helper-annotate-as-pure": "^7.25.9",
+ "@babel/helper-member-expression-to-functions": "^7.25.9",
+ "@babel/helper-optimise-call-expression": "^7.25.9",
+ "@babel/helper-replace-supers": "^7.26.5",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
+ "@babel/traverse": "^7.27.0",
"semver": "^6.3.1"
},
"engines": {
@@ -1463,13 +1454,14 @@
}
},
"node_modules/@babel/helper-create-regexp-features-plugin": {
- "version": "7.25.2",
- "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.2.tgz",
- "integrity": "sha512-+wqVGP+DFmqwFD3EH6TMTfUNeqDehV3E/dl+Sd54eaXqm17tEUNbEIn4sVivVowbvUpOtIGxdo3GoXyDH9N/9g==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz",
+ "integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.24.7",
- "regexpu-core": "^5.3.1",
+ "@babel/helper-annotate-as-pure": "^7.25.9",
+ "regexpu-core": "^6.2.0",
"semver": "^6.3.1"
},
"engines": {
@@ -1480,10 +1472,11 @@
}
},
"node_modules/@babel/helper-define-polyfill-provider": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz",
- "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==",
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
+ "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/helper-compilation-targets": "^7.22.6",
"@babel/helper-plugin-utils": "^7.22.5",
@@ -1496,13 +1489,14 @@
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz",
- "integrity": "sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz",
+ "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.24.8",
- "@babel/types": "^7.24.8"
+ "@babel/traverse": "^7.25.9",
+ "@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1541,35 +1535,38 @@
}
},
"node_modules/@babel/helper-optimise-call-expression": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz",
- "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz",
+ "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/types": "^7.24.7"
+ "@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz",
- "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==",
+ "version": "7.26.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz",
+ "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-remap-async-to-generator": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.0.tgz",
- "integrity": "sha512-NhavI2eWEIz/H9dbrG0TuOicDhNexze43i5z7lEqwYm0WEZVTwnPpA0EafUTP7+6/W79HWIP2cTe3Z5NiSTVpw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz",
+ "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.24.7",
- "@babel/helper-wrap-function": "^7.25.0",
- "@babel/traverse": "^7.25.0"
+ "@babel/helper-annotate-as-pure": "^7.25.9",
+ "@babel/helper-wrap-function": "^7.25.9",
+ "@babel/traverse": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1579,14 +1576,15 @@
}
},
"node_modules/@babel/helper-replace-supers": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.0.tgz",
- "integrity": "sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==",
+ "version": "7.26.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz",
+ "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-member-expression-to-functions": "^7.24.8",
- "@babel/helper-optimise-call-expression": "^7.24.7",
- "@babel/traverse": "^7.25.0"
+ "@babel/helper-member-expression-to-functions": "^7.25.9",
+ "@babel/helper-optimise-call-expression": "^7.25.9",
+ "@babel/traverse": "^7.26.5"
},
"engines": {
"node": ">=6.9.0"
@@ -1595,27 +1593,15 @@
"@babel/core": "^7.0.0"
}
},
- "node_modules/@babel/helper-simple-access": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz",
- "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==",
- "dev": true,
- "dependencies": {
- "@babel/traverse": "^7.24.7",
- "@babel/types": "^7.24.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz",
- "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz",
+ "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.24.7",
- "@babel/types": "^7.24.7"
+ "@babel/traverse": "^7.25.9",
+ "@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1649,28 +1635,29 @@
}
},
"node_modules/@babel/helper-wrap-function": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.0.tgz",
- "integrity": "sha512-s6Q1ebqutSiZnEjaofc/UKDyC4SbzV5n5SrA2Gq8UawLycr3i04f1dX4OzoQVnexm6aOCh37SQNYlJ/8Ku+PMQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz",
+ "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/template": "^7.25.0",
- "@babel/traverse": "^7.25.0",
- "@babel/types": "^7.25.0"
+ "@babel/template": "^7.25.9",
+ "@babel/traverse": "^7.25.9",
+ "@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
- "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
+ "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.26.9",
- "@babel/types": "^7.26.9"
+ "@babel/template": "^7.27.0",
+ "@babel/types": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
@@ -1692,12 +1679,12 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
- "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
+ "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.26.9"
+ "@babel/types": "^7.27.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1707,13 +1694,14 @@
}
},
"node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
- "version": "7.25.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz",
- "integrity": "sha512-wUrcsxZg6rqBXG05HG1FPYgsP6EvwF4WpBbxIpWIIYnH8wG0gzx3yZY3dtEHas4sTAOGkbTsc9EGPxwff8lRoA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz",
+ "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/traverse": "^7.25.3"
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/traverse": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1723,12 +1711,13 @@
}
},
"node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.0.tgz",
- "integrity": "sha512-Bm4bH2qsX880b/3ziJ8KD711LT7z4u8CFudmjqle65AZj/HNUFhEf90dqYv6O86buWvSBmeQDjv0Tn2aF/bIBA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz",
+ "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1738,12 +1727,13 @@
}
},
"node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.0.tgz",
- "integrity": "sha512-lXwdNZtTmeVOOFtwM/WDe7yg1PL8sYhRk/XH0FzbR2HDQ0xC+EnQ/JHeoMYSavtU115tnUk0q9CDyq8si+LMAA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz",
+ "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1753,14 +1743,15 @@
}
},
"node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.7.tgz",
- "integrity": "sha512-+izXIbke1T33mY4MSNnrqhPXDz01WYhEf3yF5NbnUtkiNnm+XBZJl3kNfoK6NKmYlz/D07+l2GWVK/QfDkNCuQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz",
+ "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7",
- "@babel/plugin-transform-optional-chaining": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
+ "@babel/plugin-transform-optional-chaining": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1770,13 +1761,14 @@
}
},
"node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.0.tgz",
- "integrity": "sha512-tggFrk1AIShG/RUQbEwt2Tr/E+ObkfwrPjR6BjbRvsx24+PSjK8zrq0GWPNCjo8qpRx4DuJzlcvWJqlm+0h3kw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz",
+ "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/traverse": "^7.25.0"
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/traverse": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1834,69 +1826,6 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-syntax-async-generators": {
- "version": "7.8.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
- "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-class-properties": {
- "version": "7.12.13",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
- "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.12.13"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-class-static-block": {
- "version": "7.14.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
- "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.14.5"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-dynamic-import": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
- "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-export-namespace-from": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz",
- "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.3"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
"node_modules/@babel/plugin-syntax-flow": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.25.9.tgz",
@@ -1913,12 +1842,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-assertions": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.7.tgz",
- "integrity": "sha512-Ec3NRUMoi8gskrkBe3fNmEQfxDvY8bgfQpz6jlk/41kX9eUjvpyqWU7PBP/pLAvMaSQjbMNKJmvX57jP+M6bPg==",
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz",
+ "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1928,12 +1858,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-attributes": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz",
- "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==",
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz",
+ "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1954,25 +1885,14 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-syntax-json-strings": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
- "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
"node_modules/@babel/plugin-syntax-jsx": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz",
- "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz",
+ "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -1981,42 +1901,6 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
- "version": "7.10.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
- "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.10.4"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
- "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-numeric-separator": {
- "version": "7.10.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
- "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.10.4"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
"node_modules/@babel/plugin-syntax-object-rest-spread": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
@@ -2029,67 +1913,14 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-syntax-optional-catch-binding": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
- "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-optional-chaining": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
- "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-private-property-in-object": {
- "version": "7.14.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
- "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.14.5"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-top-level-await": {
- "version": "7.14.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
- "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.14.5"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
"node_modules/@babel/plugin-syntax-typescript": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz",
- "integrity": "sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz",
+ "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2115,12 +1946,13 @@
}
},
"node_modules/@babel/plugin-transform-arrow-functions": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.7.tgz",
- "integrity": "sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz",
+ "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2130,15 +1962,15 @@
}
},
"node_modules/@babel/plugin-transform-async-generator-functions": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.0.tgz",
- "integrity": "sha512-uaIi2FdqzjpAMvVqvB51S42oC2JEVgh0LDsGfZVDysWE8LrJtQC2jvKmOqEYThKyB7bDEb7BP1GYWDm7tABA0Q==",
+ "version": "7.26.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz",
+ "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/helper-remap-async-to-generator": "^7.25.0",
- "@babel/plugin-syntax-async-generators": "^7.8.4",
- "@babel/traverse": "^7.25.0"
+ "@babel/helper-plugin-utils": "^7.26.5",
+ "@babel/helper-remap-async-to-generator": "^7.25.9",
+ "@babel/traverse": "^7.26.8"
},
"engines": {
"node": ">=6.9.0"
@@ -2148,14 +1980,15 @@
}
},
"node_modules/@babel/plugin-transform-async-to-generator": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz",
- "integrity": "sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz",
+ "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/helper-remap-async-to-generator": "^7.24.7"
+ "@babel/helper-module-imports": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/helper-remap-async-to-generator": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2165,12 +1998,13 @@
}
},
"node_modules/@babel/plugin-transform-block-scoped-functions": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.7.tgz",
- "integrity": "sha512-yO7RAz6EsVQDaBH18IDJcMB1HnrUn2FJ/Jslc/WtPPWcjhpUJXU/rjbwmluzp7v/ZzWcEhTMXELnnsz8djWDwQ==",
+ "version": "7.26.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz",
+ "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.26.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2180,12 +2014,13 @@
}
},
"node_modules/@babel/plugin-transform-block-scoping": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.0.tgz",
- "integrity": "sha512-yBQjYoOjXlFv9nlXb3f1casSHOZkWr29NX+zChVanLg5Nc157CrbEX9D7hxxtTpuFy7Q0YzmmWfJxzvps4kXrQ==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz",
+ "integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8"
+ "@babel/helper-plugin-utils": "^7.26.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2195,13 +2030,14 @@
}
},
"node_modules/@babel/plugin-transform-class-properties": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.7.tgz",
- "integrity": "sha512-vKbfawVYayKcSeSR5YYzzyXvsDFWU2mD8U5TFeXtbCPLFUqe7GyCgvO6XDHzje862ODrOwy6WCPmKeWHbCFJ4w==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz",
+ "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-create-class-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2211,14 +2047,14 @@
}
},
"node_modules/@babel/plugin-transform-class-static-block": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.7.tgz",
- "integrity": "sha512-HMXK3WbBPpZQufbMG4B46A90PkuuhN9vBCb5T8+VAHqvAqvcLi+2cKoukcpmUYkszLhScU3l1iudhrks3DggRQ==",
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz",
+ "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-class-static-block": "^7.14.5"
+ "@babel/helper-create-class-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2228,16 +2064,17 @@
}
},
"node_modules/@babel/plugin-transform-classes": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.0.tgz",
- "integrity": "sha512-xyi6qjr/fYU304fiRwFbekzkqVJZ6A7hOjWZd+89FVcBqPV3S9Wuozz82xdpLspckeaafntbzglaW4pqpzvtSw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz",
+ "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.24.7",
- "@babel/helper-compilation-targets": "^7.24.8",
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/helper-replace-supers": "^7.25.0",
- "@babel/traverse": "^7.25.0",
+ "@babel/helper-annotate-as-pure": "^7.25.9",
+ "@babel/helper-compilation-targets": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/helper-replace-supers": "^7.25.9",
+ "@babel/traverse": "^7.25.9",
"globals": "^11.1.0"
},
"engines": {
@@ -2248,13 +2085,14 @@
}
},
"node_modules/@babel/plugin-transform-computed-properties": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz",
- "integrity": "sha512-25cS7v+707Gu6Ds2oY6tCkUwsJ9YIDbggd9+cu9jzzDgiNq7hR/8dkzxWfKWnTic26vsI3EsCXNd4iEB6e8esQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz",
+ "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/template": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/template": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2264,12 +2102,13 @@
}
},
"node_modules/@babel/plugin-transform-destructuring": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.8.tgz",
- "integrity": "sha512-36e87mfY8TnRxc7yc6M9g9gOB7rKgSahqkIKwLpz4Ppk2+zC2Cy1is0uwtuSG6AE4zlTOUa+7JGz9jCJGLqQFQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz",
+ "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2279,13 +2118,14 @@
}
},
"node_modules/@babel/plugin-transform-dotall-regex": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.7.tgz",
- "integrity": "sha512-ZOA3W+1RRTSWvyqcMJDLqbchh7U4NRGqwRfFSVbOLS/ePIP4vHB5e8T8eXcuqyN1QkgKyj5wuW0lcS85v4CrSw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz",
+ "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-create-regexp-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2295,12 +2135,13 @@
}
},
"node_modules/@babel/plugin-transform-duplicate-keys": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.7.tgz",
- "integrity": "sha512-JdYfXyCRihAe46jUIliuL2/s0x0wObgwwiGxw/UbgJBr20gQBThrokO4nYKgWkD7uBaqM7+9x5TU7NkExZJyzw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz",
+ "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2310,13 +2151,14 @@
}
},
"node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.0.tgz",
- "integrity": "sha512-YLpb4LlYSc3sCUa35un84poXoraOiQucUTTu8X1j18JV+gNa8E0nyUf/CjZ171IRGr4jEguF+vzJU66QZhn29g==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz",
+ "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.25.0",
- "@babel/helper-plugin-utils": "^7.24.8"
+ "@babel/helper-create-regexp-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2326,13 +2168,13 @@
}
},
"node_modules/@babel/plugin-transform-dynamic-import": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.7.tgz",
- "integrity": "sha512-sc3X26PhZQDb3JhORmakcbvkeInvxz+A8oda99lj7J60QRuPZvNAk9wQlTBS1ZynelDrDmTU4pw1tyc5d5ZMUg==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz",
+ "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-dynamic-import": "^7.8.3"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2342,13 +2184,13 @@
}
},
"node_modules/@babel/plugin-transform-exponentiation-operator": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.7.tgz",
- "integrity": "sha512-Rqe/vSc9OYgDajNIK35u7ot+KeCoetqQYFXM4Epf7M7ez3lWlOjrDjrwMei6caCVhfdw+mIKD4cgdGNy5JQotQ==",
+ "version": "7.26.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz",
+ "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-builder-binary-assignment-operator-visitor": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2358,13 +2200,13 @@
}
},
"node_modules/@babel/plugin-transform-export-namespace-from": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.7.tgz",
- "integrity": "sha512-v0K9uNYsPL3oXZ/7F9NNIbAj2jv1whUEtyA6aujhekLs56R++JDQuzRcP2/z4WX5Vg/c5lE9uWZA0/iUoFhLTA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz",
+ "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-export-namespace-from": "^7.8.3"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2390,13 +2232,14 @@
}
},
"node_modules/@babel/plugin-transform-for-of": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.7.tgz",
- "integrity": "sha512-wo9ogrDG1ITTTBsy46oGiN1dS9A7MROBTcYsfS8DtsImMkHk9JXJ3EWQM6X2SUw4x80uGPlwj0o00Uoc6nEE3g==",
+ "version": "7.26.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz",
+ "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.26.5",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2406,14 +2249,15 @@
}
},
"node_modules/@babel/plugin-transform-function-name": {
- "version": "7.25.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.1.tgz",
- "integrity": "sha512-TVVJVdW9RKMNgJJlLtHsKDTydjZAbwIsn6ySBPQaEAUU5+gVvlJt/9nRmqVbsV/IBanRjzWoaAQKLoamWVOUuA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz",
+ "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-compilation-targets": "^7.24.8",
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/traverse": "^7.25.1"
+ "@babel/helper-compilation-targets": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/traverse": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2423,13 +2267,13 @@
}
},
"node_modules/@babel/plugin-transform-json-strings": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.7.tgz",
- "integrity": "sha512-2yFnBGDvRuxAaE/f0vfBKvtnvvqU8tGpMHqMNpTN2oWMKIR3NqFkjaAgGwawhqK/pIN2T3XdjGPdaG0vDhOBGw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz",
+ "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-json-strings": "^7.8.3"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2439,12 +2283,13 @@
}
},
"node_modules/@babel/plugin-transform-literals": {
- "version": "7.25.2",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.2.tgz",
- "integrity": "sha512-HQI+HcTbm9ur3Z2DkO+jgESMAMcYLuN/A7NRw9juzxAezN9AvqvUTnpKP/9kkYANz6u7dFlAyOu44ejuGySlfw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz",
+ "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2454,13 +2299,13 @@
}
},
"node_modules/@babel/plugin-transform-logical-assignment-operators": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.7.tgz",
- "integrity": "sha512-4D2tpwlQ1odXmTEIFWy9ELJcZHqrStlzK/dAOWYyxX3zT0iXQB6banjgeOJQXzEc4S0E0a5A+hahxPaEFYftsw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz",
+ "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2470,12 +2315,13 @@
}
},
"node_modules/@babel/plugin-transform-member-expression-literals": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.7.tgz",
- "integrity": "sha512-T/hRC1uqrzXMKLQ6UCwMT85S3EvqaBXDGf0FaMf4446Qx9vKwlghvee0+uuZcDUCZU5RuNi4781UQ7R308zzBw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz",
+ "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2485,13 +2331,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-amd": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.7.tgz",
- "integrity": "sha512-9+pB1qxV3vs/8Hdmz/CulFB8w2tuu6EB94JZFsjdqxQokwGa9Unap7Bo2gGBGIvPmDIVvQrom7r5m/TCDMURhg==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz",
+ "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-module-transforms": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2501,14 +2348,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-commonjs": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz",
- "integrity": "sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA==",
+ "version": "7.26.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz",
+ "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.24.8",
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/helper-simple-access": "^7.24.7"
+ "@babel/helper-module-transforms": "^7.26.0",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2518,15 +2365,16 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.0.tgz",
- "integrity": "sha512-YPJfjQPDXxyQWg/0+jHKj1llnY5f/R6a0p/vP4lPymxLu7Lvl4k2WMitqi08yxwQcCVUUdG9LCUj4TNEgAp3Jw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz",
+ "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.25.0",
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/helper-validator-identifier": "^7.24.7",
- "@babel/traverse": "^7.25.0"
+ "@babel/helper-module-transforms": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "@babel/traverse": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2536,13 +2384,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-umd": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.7.tgz",
- "integrity": "sha512-3aytQvqJ/h9z4g8AsKPLvD4Zqi2qT+L3j7XoFFu1XBlZWEl2/1kWnhmAbxpLgPrHSY0M6UA02jyTiwUVtiKR6A==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz",
+ "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-module-transforms": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2552,13 +2401,14 @@
}
},
"node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.24.7.tgz",
- "integrity": "sha512-/jr7h/EWeJtk1U/uz2jlsCioHkZk1JJZVcc8oQsJ1dUlaJD83f4/6Zeh2aHt9BIFokHIsSeDfhUmju0+1GPd6g==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz",
+ "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-create-regexp-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2568,12 +2418,13 @@
}
},
"node_modules/@babel/plugin-transform-new-target": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.7.tgz",
- "integrity": "sha512-RNKwfRIXg4Ls/8mMTza5oPF5RkOW8Wy/WgMAp1/F1yZ8mMbtwXW+HDoJiOsagWrAhI5f57Vncrmr9XeT4CVapA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz",
+ "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2583,13 +2434,13 @@
}
},
"node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz",
- "integrity": "sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ==",
+ "version": "7.26.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz",
+ "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3"
+ "@babel/helper-plugin-utils": "^7.26.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2599,13 +2450,13 @@
}
},
"node_modules/@babel/plugin-transform-numeric-separator": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.7.tgz",
- "integrity": "sha512-e6q1TiVUzvH9KRvicuxdBTUj4AdKSRwzIyFFnfnezpCfP2/7Qmbb8qbU2j7GODbl4JMkblitCQjKYUaX/qkkwA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz",
+ "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-numeric-separator": "^7.10.4"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2630,15 +2481,15 @@
}
},
"node_modules/@babel/plugin-transform-object-rest-spread": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.7.tgz",
- "integrity": "sha512-4QrHAr0aXQCEFni2q4DqKLD31n2DL+RxcwnNjDFkSG0eNQ/xCavnRkfCUjsyqGC2OviNJvZOF/mQqZBw7i2C5Q==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz",
+ "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-compilation-targets": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
- "@babel/plugin-transform-parameters": "^7.24.7"
+ "@babel/helper-compilation-targets": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/plugin-transform-parameters": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2648,13 +2499,14 @@
}
},
"node_modules/@babel/plugin-transform-object-super": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.7.tgz",
- "integrity": "sha512-A/vVLwN6lBrMFmMDmPPz0jnE6ZGx7Jq7d6sT/Ev4H65RER6pZ+kczlf1DthF5N0qaPHBsI7UXiE8Zy66nmAovg==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz",
+ "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/helper-replace-supers": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/helper-replace-supers": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2664,13 +2516,13 @@
}
},
"node_modules/@babel/plugin-transform-optional-catch-binding": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.7.tgz",
- "integrity": "sha512-uLEndKqP5BfBbC/5jTwPxLh9kqPWWgzN/f8w6UwAIirAEqiIVJWWY312X72Eub09g5KF9+Zn7+hT7sDxmhRuKA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz",
+ "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-optional-catch-binding": "^7.8.3"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2680,14 +2532,14 @@
}
},
"node_modules/@babel/plugin-transform-optional-chaining": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz",
- "integrity": "sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz",
+ "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7",
- "@babel/plugin-syntax-optional-chaining": "^7.8.3"
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2697,12 +2549,13 @@
}
},
"node_modules/@babel/plugin-transform-parameters": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.7.tgz",
- "integrity": "sha512-yGWW5Rr+sQOhK0Ot8hjDJuxU3XLRQGflvT4lhlSY0DFvdb3TwKaY26CJzHtYllU0vT9j58hc37ndFPsqT1SrzA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz",
+ "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2712,13 +2565,14 @@
}
},
"node_modules/@babel/plugin-transform-private-methods": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.7.tgz",
- "integrity": "sha512-COTCOkG2hn4JKGEKBADkA8WNb35TGkkRbI5iT845dB+NyqgO8Hn+ajPbSnIQznneJTa3d30scb6iz/DhH8GsJQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz",
+ "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-create-class-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2728,15 +2582,15 @@
}
},
"node_modules/@babel/plugin-transform-private-property-in-object": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz",
- "integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz",
+ "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.24.7",
- "@babel/helper-create-class-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+ "@babel/helper-annotate-as-pure": "^7.25.9",
+ "@babel/helper-create-class-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2746,12 +2600,13 @@
}
},
"node_modules/@babel/plugin-transform-property-literals": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.7.tgz",
- "integrity": "sha512-EMi4MLQSHfd2nrCqQEWxFdha2gBCqU4ZcCng4WBGZ5CJL4bBRW0ptdqqDdeirGZcpALazVVNJqRmsO8/+oNCBA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz",
+ "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2776,12 +2631,13 @@
}
},
"node_modules/@babel/plugin-transform-react-display-name": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.7.tgz",
- "integrity": "sha512-H/Snz9PFxKsS1JLI4dJLtnJgCJRoo0AUm3chP6NYr+9En1JMKloheEiLIhlp5MDVznWo+H3AAC1Mc8lmUEpsgg==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.9.tgz",
+ "integrity": "sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2807,16 +2663,17 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx": {
- "version": "7.25.2",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.2.tgz",
- "integrity": "sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz",
+ "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.24.7",
- "@babel/helper-module-imports": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/plugin-syntax-jsx": "^7.24.7",
- "@babel/types": "^7.25.2"
+ "@babel/helper-annotate-as-pure": "^7.25.9",
+ "@babel/helper-module-imports": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/plugin-syntax-jsx": "^7.25.9",
+ "@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2826,12 +2683,45 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-development": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.24.7.tgz",
- "integrity": "sha512-QG9EnzoGn+Qar7rxuW+ZOsbWOt56FvvI93xInqsZDC5fsekx1AlIO4KIJ5M+D0p0SqSH156EpmZyXq630B8OlQ==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.9.tgz",
+ "integrity": "sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/plugin-transform-react-jsx": "^7.24.7"
+ "@babel/plugin-transform-react-jsx": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz",
+ "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz",
+ "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2841,13 +2731,14 @@
}
},
"node_modules/@babel/plugin-transform-react-pure-annotations": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.7.tgz",
- "integrity": "sha512-PLgBVk3fzbmEjBJ/u8kFzOqS9tUeDjiaWud/rRym/yjCo/M9cASPlnrd2ZmmZpQT40fOOrvR8jh+n8jikrOhNA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.9.tgz",
+ "integrity": "sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-annotate-as-pure": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2857,12 +2748,13 @@
}
},
"node_modules/@babel/plugin-transform-regenerator": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.7.tgz",
- "integrity": "sha512-lq3fvXPdimDrlg6LWBoqj+r/DEWgONuwjuOuQCSYgRroXDH/IdM1C0IZf59fL5cHLpjEH/O6opIRBbqv7ELnuA==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz",
+ "integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
+ "@babel/helper-plugin-utils": "^7.26.5",
"regenerator-transform": "^0.15.2"
},
"engines": {
@@ -2872,13 +2764,31 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-transform-reserved-words": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.7.tgz",
- "integrity": "sha512-0DUq0pHcPKbjFZCfTss/pGkYMfy3vFWydkUBd9r0GHpIyfs2eCDENvqadMycRS9wZCXR41wucAfJHJmwA0UmoQ==",
+ "node_modules/@babel/plugin-transform-regexp-modifiers": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz",
+ "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-create-regexp-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-reserved-words": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz",
+ "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2888,12 +2798,13 @@
}
},
"node_modules/@babel/plugin-transform-shorthand-properties": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz",
- "integrity": "sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz",
+ "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2903,13 +2814,14 @@
}
},
"node_modules/@babel/plugin-transform-spread": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.7.tgz",
- "integrity": "sha512-x96oO0I09dgMDxJaANcRyD4ellXFLLiWhuwDxKZX5g2rWP1bTPkBSwCYv96VDXVT1bD9aPj8tppr5ITIh8hBng==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz",
+ "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2919,12 +2831,13 @@
}
},
"node_modules/@babel/plugin-transform-sticky-regex": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.7.tgz",
- "integrity": "sha512-kHPSIJc9v24zEml5geKg9Mjx5ULpfncj0wRpYtxbvKyTtHCYDkVE3aHQ03FrpEo4gEe2vrJJS1Y9CJTaThA52g==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz",
+ "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2934,12 +2847,13 @@
}
},
"node_modules/@babel/plugin-transform-template-literals": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz",
- "integrity": "sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw==",
+ "version": "7.26.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz",
+ "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.26.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2949,12 +2863,13 @@
}
},
"node_modules/@babel/plugin-transform-typeof-symbol": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.8.tgz",
- "integrity": "sha512-adNTUpDCVnmAE58VEqKlAA6ZBlNkMnWD0ZcW76lyNFN3MJniyGFZfNwERVk8Ap56MCnXztmDr19T4mPTztcuaw==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz",
+ "integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.8"
+ "@babel/helper-plugin-utils": "^7.26.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2964,16 +2879,17 @@
}
},
"node_modules/@babel/plugin-transform-typescript": {
- "version": "7.25.2",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.2.tgz",
- "integrity": "sha512-lBwRvjSmqiMYe/pS0+1gggjJleUJi7NzjvQ1Fkqtt69hBa/0t1YuW/MLQMAPixfwaQOHUXsd6jeU3Z+vdGv3+A==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.0.tgz",
+ "integrity": "sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.24.7",
- "@babel/helper-create-class-features-plugin": "^7.25.0",
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7",
- "@babel/plugin-syntax-typescript": "^7.24.7"
+ "@babel/helper-annotate-as-pure": "^7.25.9",
+ "@babel/helper-create-class-features-plugin": "^7.27.0",
+ "@babel/helper-plugin-utils": "^7.26.5",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
+ "@babel/plugin-syntax-typescript": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2983,12 +2899,13 @@
}
},
"node_modules/@babel/plugin-transform-unicode-escapes": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.7.tgz",
- "integrity": "sha512-U3ap1gm5+4edc2Q/P+9VrBNhGkfnf+8ZqppY71Bo/pzZmXhhLdqgaUl6cuB07O1+AQJtCLfaOmswiNbSQ9ivhw==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz",
+ "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2998,13 +2915,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-property-regex": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.7.tgz",
- "integrity": "sha512-uH2O4OV5M9FZYQrwc7NdVmMxQJOCCzFeYudlZSzUAHRFeOujQefa92E74TQDVskNHCzOXoigEuoyzHDhaEaK5w==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz",
+ "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-create-regexp-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -3014,13 +2932,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-regex": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.7.tgz",
- "integrity": "sha512-hlQ96MBZSAXUq7ltkjtu3FJCCSMx/j629ns3hA3pXnBXjanNP0LHi+JpPeA81zaWgVK1VGH95Xuy7u0RyQ8kMg==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz",
+ "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-create-regexp-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -3030,13 +2949,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-sets-regex": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.7.tgz",
- "integrity": "sha512-2G8aAvF4wy1w/AGZkemprdGMRg5o6zPNhbHVImRz3lss55TYCBd6xStN19rt8XJHq20sqV0JbyWjOWwQRwV/wg==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz",
+ "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.24.7",
- "@babel/helper-plugin-utils": "^7.24.7"
+ "@babel/helper-create-regexp-features-plugin": "^7.25.9",
+ "@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -3065,93 +2985,80 @@
"hasInstallScript": true
},
"node_modules/@babel/preset-env": {
- "version": "7.25.3",
- "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.3.tgz",
- "integrity": "sha512-QsYW7UeAaXvLPX9tdVliMJE7MD7M6MLYVTovRTIwhoYQVFHR1rM4wO8wqAezYi3/BpSD+NzVCZ69R6smWiIi8g==",
+ "version": "7.26.9",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz",
+ "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.25.2",
- "@babel/helper-compilation-targets": "^7.25.2",
- "@babel/helper-plugin-utils": "^7.24.8",
- "@babel/helper-validator-option": "^7.24.8",
- "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.3",
- "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.0",
- "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.0",
- "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7",
- "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.0",
+ "@babel/compat-data": "^7.26.8",
+ "@babel/helper-compilation-targets": "^7.26.5",
+ "@babel/helper-plugin-utils": "^7.26.5",
+ "@babel/helper-validator-option": "^7.25.9",
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9",
+ "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9",
"@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
- "@babel/plugin-syntax-async-generators": "^7.8.4",
- "@babel/plugin-syntax-class-properties": "^7.12.13",
- "@babel/plugin-syntax-class-static-block": "^7.14.5",
- "@babel/plugin-syntax-dynamic-import": "^7.8.3",
- "@babel/plugin-syntax-export-namespace-from": "^7.8.3",
- "@babel/plugin-syntax-import-assertions": "^7.24.7",
- "@babel/plugin-syntax-import-attributes": "^7.24.7",
- "@babel/plugin-syntax-import-meta": "^7.10.4",
- "@babel/plugin-syntax-json-strings": "^7.8.3",
- "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
- "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
- "@babel/plugin-syntax-numeric-separator": "^7.10.4",
- "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
- "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
- "@babel/plugin-syntax-optional-chaining": "^7.8.3",
- "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
- "@babel/plugin-syntax-top-level-await": "^7.14.5",
+ "@babel/plugin-syntax-import-assertions": "^7.26.0",
+ "@babel/plugin-syntax-import-attributes": "^7.26.0",
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
- "@babel/plugin-transform-arrow-functions": "^7.24.7",
- "@babel/plugin-transform-async-generator-functions": "^7.25.0",
- "@babel/plugin-transform-async-to-generator": "^7.24.7",
- "@babel/plugin-transform-block-scoped-functions": "^7.24.7",
- "@babel/plugin-transform-block-scoping": "^7.25.0",
- "@babel/plugin-transform-class-properties": "^7.24.7",
- "@babel/plugin-transform-class-static-block": "^7.24.7",
- "@babel/plugin-transform-classes": "^7.25.0",
- "@babel/plugin-transform-computed-properties": "^7.24.7",
- "@babel/plugin-transform-destructuring": "^7.24.8",
- "@babel/plugin-transform-dotall-regex": "^7.24.7",
- "@babel/plugin-transform-duplicate-keys": "^7.24.7",
- "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.0",
- "@babel/plugin-transform-dynamic-import": "^7.24.7",
- "@babel/plugin-transform-exponentiation-operator": "^7.24.7",
- "@babel/plugin-transform-export-namespace-from": "^7.24.7",
- "@babel/plugin-transform-for-of": "^7.24.7",
- "@babel/plugin-transform-function-name": "^7.25.1",
- "@babel/plugin-transform-json-strings": "^7.24.7",
- "@babel/plugin-transform-literals": "^7.25.2",
- "@babel/plugin-transform-logical-assignment-operators": "^7.24.7",
- "@babel/plugin-transform-member-expression-literals": "^7.24.7",
- "@babel/plugin-transform-modules-amd": "^7.24.7",
- "@babel/plugin-transform-modules-commonjs": "^7.24.8",
- "@babel/plugin-transform-modules-systemjs": "^7.25.0",
- "@babel/plugin-transform-modules-umd": "^7.24.7",
- "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7",
- "@babel/plugin-transform-new-target": "^7.24.7",
- "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
- "@babel/plugin-transform-numeric-separator": "^7.24.7",
- "@babel/plugin-transform-object-rest-spread": "^7.24.7",
- "@babel/plugin-transform-object-super": "^7.24.7",
- "@babel/plugin-transform-optional-catch-binding": "^7.24.7",
- "@babel/plugin-transform-optional-chaining": "^7.24.8",
- "@babel/plugin-transform-parameters": "^7.24.7",
- "@babel/plugin-transform-private-methods": "^7.24.7",
- "@babel/plugin-transform-private-property-in-object": "^7.24.7",
- "@babel/plugin-transform-property-literals": "^7.24.7",
- "@babel/plugin-transform-regenerator": "^7.24.7",
- "@babel/plugin-transform-reserved-words": "^7.24.7",
- "@babel/plugin-transform-shorthand-properties": "^7.24.7",
- "@babel/plugin-transform-spread": "^7.24.7",
- "@babel/plugin-transform-sticky-regex": "^7.24.7",
- "@babel/plugin-transform-template-literals": "^7.24.7",
- "@babel/plugin-transform-typeof-symbol": "^7.24.8",
- "@babel/plugin-transform-unicode-escapes": "^7.24.7",
- "@babel/plugin-transform-unicode-property-regex": "^7.24.7",
- "@babel/plugin-transform-unicode-regex": "^7.24.7",
- "@babel/plugin-transform-unicode-sets-regex": "^7.24.7",
+ "@babel/plugin-transform-arrow-functions": "^7.25.9",
+ "@babel/plugin-transform-async-generator-functions": "^7.26.8",
+ "@babel/plugin-transform-async-to-generator": "^7.25.9",
+ "@babel/plugin-transform-block-scoped-functions": "^7.26.5",
+ "@babel/plugin-transform-block-scoping": "^7.25.9",
+ "@babel/plugin-transform-class-properties": "^7.25.9",
+ "@babel/plugin-transform-class-static-block": "^7.26.0",
+ "@babel/plugin-transform-classes": "^7.25.9",
+ "@babel/plugin-transform-computed-properties": "^7.25.9",
+ "@babel/plugin-transform-destructuring": "^7.25.9",
+ "@babel/plugin-transform-dotall-regex": "^7.25.9",
+ "@babel/plugin-transform-duplicate-keys": "^7.25.9",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9",
+ "@babel/plugin-transform-dynamic-import": "^7.25.9",
+ "@babel/plugin-transform-exponentiation-operator": "^7.26.3",
+ "@babel/plugin-transform-export-namespace-from": "^7.25.9",
+ "@babel/plugin-transform-for-of": "^7.26.9",
+ "@babel/plugin-transform-function-name": "^7.25.9",
+ "@babel/plugin-transform-json-strings": "^7.25.9",
+ "@babel/plugin-transform-literals": "^7.25.9",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.25.9",
+ "@babel/plugin-transform-member-expression-literals": "^7.25.9",
+ "@babel/plugin-transform-modules-amd": "^7.25.9",
+ "@babel/plugin-transform-modules-commonjs": "^7.26.3",
+ "@babel/plugin-transform-modules-systemjs": "^7.25.9",
+ "@babel/plugin-transform-modules-umd": "^7.25.9",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9",
+ "@babel/plugin-transform-new-target": "^7.25.9",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6",
+ "@babel/plugin-transform-numeric-separator": "^7.25.9",
+ "@babel/plugin-transform-object-rest-spread": "^7.25.9",
+ "@babel/plugin-transform-object-super": "^7.25.9",
+ "@babel/plugin-transform-optional-catch-binding": "^7.25.9",
+ "@babel/plugin-transform-optional-chaining": "^7.25.9",
+ "@babel/plugin-transform-parameters": "^7.25.9",
+ "@babel/plugin-transform-private-methods": "^7.25.9",
+ "@babel/plugin-transform-private-property-in-object": "^7.25.9",
+ "@babel/plugin-transform-property-literals": "^7.25.9",
+ "@babel/plugin-transform-regenerator": "^7.25.9",
+ "@babel/plugin-transform-regexp-modifiers": "^7.26.0",
+ "@babel/plugin-transform-reserved-words": "^7.25.9",
+ "@babel/plugin-transform-shorthand-properties": "^7.25.9",
+ "@babel/plugin-transform-spread": "^7.25.9",
+ "@babel/plugin-transform-sticky-regex": "^7.25.9",
+ "@babel/plugin-transform-template-literals": "^7.26.8",
+ "@babel/plugin-transform-typeof-symbol": "^7.26.7",
+ "@babel/plugin-transform-unicode-escapes": "^7.25.9",
+ "@babel/plugin-transform-unicode-property-regex": "^7.25.9",
+ "@babel/plugin-transform-unicode-regex": "^7.25.9",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.25.9",
"@babel/preset-modules": "0.1.6-no-external-plugins",
"babel-plugin-polyfill-corejs2": "^0.4.10",
- "babel-plugin-polyfill-corejs3": "^0.10.4",
+ "babel-plugin-polyfill-corejs3": "^0.11.0",
"babel-plugin-polyfill-regenerator": "^0.6.1",
- "core-js-compat": "^3.37.1",
+ "core-js-compat": "^3.40.0",
"semver": "^6.3.1"
},
"engines": {
@@ -3193,17 +3100,18 @@
}
},
"node_modules/@babel/preset-react": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.7.tgz",
- "integrity": "sha512-AAH4lEkpmzFWrGVlHaxJB7RLH21uPQ9+He+eFLWHmF9IuFQVugz8eAsamaW0DXRrTfco5zj1wWtpdcXJUOfsag==",
+ "version": "7.26.3",
+ "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.26.3.tgz",
+ "integrity": "sha512-Nl03d6T9ky516DGK2YMxrTqvnpUW63TnJMOMonj+Zae0JiPC5BC9xPMSL6L8fiSpA5vP88qfygavVQvnLp+6Cw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/helper-validator-option": "^7.24.7",
- "@babel/plugin-transform-react-display-name": "^7.24.7",
- "@babel/plugin-transform-react-jsx": "^7.24.7",
- "@babel/plugin-transform-react-jsx-development": "^7.24.7",
- "@babel/plugin-transform-react-pure-annotations": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.25.9",
+ "@babel/helper-validator-option": "^7.25.9",
+ "@babel/plugin-transform-react-display-name": "^7.25.9",
+ "@babel/plugin-transform-react-jsx": "^7.25.9",
+ "@babel/plugin-transform-react-jsx-development": "^7.25.9",
+ "@babel/plugin-transform-react-pure-annotations": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -3213,16 +3121,17 @@
}
},
"node_modules/@babel/preset-typescript": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.24.7.tgz",
- "integrity": "sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.0.tgz",
+ "integrity": "sha512-vxaPFfJtHhgeOVXRKuHpHPAOgymmy8V8I65T1q53R7GCZlefKeCaTyDs3zOPHTTbmquvNlQYC5klEvWsBAtrBQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.24.7",
- "@babel/helper-validator-option": "^7.24.7",
- "@babel/plugin-syntax-jsx": "^7.24.7",
- "@babel/plugin-transform-modules-commonjs": "^7.24.7",
- "@babel/plugin-transform-typescript": "^7.24.7"
+ "@babel/helper-plugin-utils": "^7.26.5",
+ "@babel/helper-validator-option": "^7.25.9",
+ "@babel/plugin-syntax-jsx": "^7.25.9",
+ "@babel/plugin-transform-modules-commonjs": "^7.26.3",
+ "@babel/plugin-transform-typescript": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
@@ -3232,10 +3141,11 @@
}
},
"node_modules/@babel/register": {
- "version": "7.24.6",
- "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.24.6.tgz",
- "integrity": "sha512-WSuFCc2wCqMeXkz/i3yfAAsxwWflEgbVkZzivgAmXl/MxrXeoYFZOOPllbC8R8WTF7u61wSRQtDVZ1879cdu6w==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.25.9.tgz",
+ "integrity": "sha512-8D43jXtGsYmEeDvm4MWHYUpWf8iiXgWYx3fW7E7Wb7Oe6FWqJPl5K6TuFW0dOwNZzEE5rjlaSJYH9JjrUKJszA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"clone-deep": "^4.0.1",
"find-cache-dir": "^2.0.0",
@@ -3250,16 +3160,11 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/regjsgen": {
- "version": "0.8.0",
- "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz",
- "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==",
- "dev": true
- },
"node_modules/@babel/runtime": {
- "version": "7.23.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
- "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
+ "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
+ "license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -3267,27 +3172,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@babel/runtime-corejs2": {
- "version": "7.16.7",
- "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.16.7.tgz",
- "integrity": "sha512-ec0BM0J/9M5Cncha++AlgvvDlk+uM+m6f7K0t74ClcYzsE8LgX4RstRreksMSCI82o3LJS//UswmA0pUWkJpqg==",
- "dev": true,
- "dependencies": {
- "core-js": "^2.6.5",
- "regenerator-runtime": "^0.13.4"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/runtime-corejs2/node_modules/core-js": {
- "version": "2.6.12",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
- "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
- "deprecated": "core-js@<3.4 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.",
- "dev": true,
- "hasInstallScript": true
- },
"node_modules/@babel/runtime-corejs3": {
"version": "7.16.8",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.16.8.tgz",
@@ -3306,32 +3190,32 @@
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
},
"node_modules/@babel/template": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
- "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
+ "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
- "@babel/parser": "^7.26.9",
- "@babel/types": "^7.26.9"
+ "@babel/parser": "^7.27.0",
+ "@babel/types": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz",
- "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
+ "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
- "@babel/generator": "^7.26.9",
- "@babel/parser": "^7.26.9",
- "@babel/template": "^7.26.9",
- "@babel/types": "^7.26.9",
+ "@babel/generator": "^7.27.0",
+ "@babel/parser": "^7.27.0",
+ "@babel/template": "^7.27.0",
+ "@babel/types": "^7.27.0",
"debug": "^4.3.1",
"globals": "^11.1.0"
},
@@ -3340,9 +3224,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
- "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
+ "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
@@ -4482,9 +4366,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
- "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz",
+ "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==",
"cpu": [
"ppc64"
],
@@ -4499,9 +4383,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
- "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz",
+ "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==",
"cpu": [
"arm"
],
@@ -4516,9 +4400,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
- "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz",
+ "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==",
"cpu": [
"arm64"
],
@@ -4533,9 +4417,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
- "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz",
+ "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==",
"cpu": [
"x64"
],
@@ -4550,9 +4434,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
- "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz",
+ "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==",
"cpu": [
"arm64"
],
@@ -4567,9 +4451,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
- "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz",
+ "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==",
"cpu": [
"x64"
],
@@ -4584,9 +4468,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
- "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz",
+ "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==",
"cpu": [
"arm64"
],
@@ -4601,9 +4485,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
- "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz",
+ "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==",
"cpu": [
"x64"
],
@@ -4618,9 +4502,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
- "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz",
+ "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==",
"cpu": [
"arm"
],
@@ -4635,9 +4519,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
- "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz",
+ "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==",
"cpu": [
"arm64"
],
@@ -4652,9 +4536,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
- "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz",
+ "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==",
"cpu": [
"ia32"
],
@@ -4669,9 +4553,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
- "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz",
+ "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==",
"cpu": [
"loong64"
],
@@ -4686,9 +4570,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
- "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz",
+ "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==",
"cpu": [
"mips64el"
],
@@ -4703,9 +4587,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
- "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz",
+ "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==",
"cpu": [
"ppc64"
],
@@ -4720,9 +4604,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
- "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz",
+ "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==",
"cpu": [
"riscv64"
],
@@ -4737,9 +4621,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
- "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz",
+ "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==",
"cpu": [
"s390x"
],
@@ -4754,9 +4638,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
- "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz",
+ "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==",
"cpu": [
"x64"
],
@@ -4771,9 +4655,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
- "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz",
+ "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==",
"cpu": [
"arm64"
],
@@ -4788,9 +4672,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
- "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz",
+ "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==",
"cpu": [
"x64"
],
@@ -4805,9 +4689,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
- "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz",
+ "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==",
"cpu": [
"arm64"
],
@@ -4822,9 +4706,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
- "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz",
+ "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==",
"cpu": [
"x64"
],
@@ -4839,9 +4723,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
- "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz",
+ "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==",
"cpu": [
"x64"
],
@@ -4856,9 +4740,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
- "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz",
+ "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==",
"cpu": [
"arm64"
],
@@ -4873,9 +4757,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
- "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz",
+ "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==",
"cpu": [
"ia32"
],
@@ -4890,9 +4774,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
- "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz",
+ "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==",
"cpu": [
"x64"
],
@@ -6342,6 +6226,7 @@
"resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
"integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==",
"dev": true,
+ "license": "MIT",
"peerDependencies": {
"react": "*"
}
@@ -6654,9 +6539,9 @@
}
},
"node_modules/@jsonjoy.com/json-pack": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.1.tgz",
- "integrity": "sha512-osjeBqMJ2lb/j/M8NCPjs1ylqWIcTRTycIhVB5pt6LgzgeRSb0YRZ7j9RfA8wIUrsr/medIuhVyonXRZWLyfdw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz",
+ "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -6700,10 +6585,11 @@
"dev": true
},
"node_modules/@leichtgewicht/ip-codec": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
- "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==",
- "dev": true
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
+ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.2.3",
@@ -7011,9 +6897,9 @@
]
},
"node_modules/@napi-rs/canvas": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.65.tgz",
- "integrity": "sha512-YcFhXQcp+b2d38zFOJNbpyPHnIL7KAEkhJQ+UeeKI5IpE9B8Cpf/M6RiHPQXSsSqnYbrfFylnW49dyh2oeSblQ==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.68.tgz",
+ "integrity": "sha512-LQESrePLEBLvhuFkXx9jjBXRC2ClYsO5mqQ1m/puth5z9SOuM3N/B3vDuqnC3RJFktDktyK9khGvo7dTkqO9uQ==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -7021,22 +6907,22 @@
"node": ">= 10"
},
"optionalDependencies": {
- "@napi-rs/canvas-android-arm64": "0.1.65",
- "@napi-rs/canvas-darwin-arm64": "0.1.65",
- "@napi-rs/canvas-darwin-x64": "0.1.65",
- "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.65",
- "@napi-rs/canvas-linux-arm64-gnu": "0.1.65",
- "@napi-rs/canvas-linux-arm64-musl": "0.1.65",
- "@napi-rs/canvas-linux-riscv64-gnu": "0.1.65",
- "@napi-rs/canvas-linux-x64-gnu": "0.1.65",
- "@napi-rs/canvas-linux-x64-musl": "0.1.65",
- "@napi-rs/canvas-win32-x64-msvc": "0.1.65"
+ "@napi-rs/canvas-android-arm64": "0.1.68",
+ "@napi-rs/canvas-darwin-arm64": "0.1.68",
+ "@napi-rs/canvas-darwin-x64": "0.1.68",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.68",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.68",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.68",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.68",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.68",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.68",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.68"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.65.tgz",
- "integrity": "sha512-ZYwqFYEKcT5Zr8lbiaJNJj/poLaeK2TncolY914r+gD2TJNeP7ZqvE7A2SX/1C9MB4E3DQEwm3YhL3WEf0x3MQ==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.68.tgz",
+ "integrity": "sha512-h1KcSR4LKLfRfzeBH65xMxbWOGa1OtMFQbCMVlxPCkN1Zr+2gK+70pXO5ktojIYcUrP6KDcOwoc8clho5ccM/w==",
"cpu": [
"arm64"
],
@@ -7051,9 +6937,9 @@
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.65.tgz",
- "integrity": "sha512-Pg1pfiJEyDIsX+V0QaJPRWvXbw5zmWAk3bivFCvt/5pwZb37/sT6E/RqPHT9NnqpDyKW6SriwY9ypjljysUA1Q==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.68.tgz",
+ "integrity": "sha512-/VURlrAD4gDoxW1GT/b0nP3fRz/fhxmHI/xznTq2FTwkQLPOlLkDLCvTmQ7v6LtGKdc2Ed6rvYpRan+JXThInQ==",
"cpu": [
"arm64"
],
@@ -7068,9 +6954,9 @@
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.65.tgz",
- "integrity": "sha512-3Tr+/HjdJN7Z/VKIcsxV2DvDIibZCExgfYTgljCkUSFuoI7iNkOE6Dc1Q6j212EB9PeO8KmfrViBqHYT6IwWkA==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.68.tgz",
+ "integrity": "sha512-tEpvGR6vCLTo1Tx9wmDnoOKROpw57wiCWwCpDOuVlj/7rqEJOUYr9ixW4aRJgmeGBrZHgevI0EURys2ER6whmg==",
"cpu": [
"x64"
],
@@ -7085,9 +6971,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.65.tgz",
- "integrity": "sha512-3KP+dYObH7CVkZMZWwk1WX9jRjL+EKdQtD43H8MOI+illf+dwqLlecdQ4d9bQRIxELKJ8dyPWY4fOp/Ngufrdg==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.68.tgz",
+ "integrity": "sha512-U9xbJsumPOiAYeAFZMlHf62b9dGs2HJ6Q5xt7xTB0uEyPeurwhgYBWGgabdsEidyj38YuzI/c3LGBbSQB3vagw==",
"cpu": [
"arm"
],
@@ -7102,9 +6988,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.65.tgz",
- "integrity": "sha512-Ka3StKz7Dq7kjTF3nNJCq43UN/VlANS7qGE3dWkn1d+tQNsCRy/wRmyt1TUFzIjRqcTFMQNRbgYq84+53UBA0A==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.68.tgz",
+ "integrity": "sha512-KFkn8wEm3mPnWD4l8+OUUkxylSJuN5q9PnJRZJgv15RtCA1bgxIwTkBhI/+xuyVMcHqON9sXq7cDkEJtHm35dg==",
"cpu": [
"arm64"
],
@@ -7119,9 +7005,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.65.tgz",
- "integrity": "sha512-O4xMASm2JrmqYoiDyxVWi+z5C14H+oVEag2rZ5iIA67dhWqYZB+iO7wCFpBYRj31JPBR29FOsu6X9zL+DwBFdw==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.68.tgz",
+ "integrity": "sha512-IQzts91rCdOALXBWQxLZRCEDrfFTGDtNRJMNu+2SKZ1uT8cmPQkPwVk5rycvFpvgAcmiFiOSCp1aRrlfU8KPpQ==",
"cpu": [
"arm64"
],
@@ -7136,9 +7022,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.65.tgz",
- "integrity": "sha512-dblWDaA59ZU8bPbkfM+riSke7sFbNZ70LEevUdI5rgiFEUzYUQlU34gSBzemTACj5rCWt1BYeu0GfkLSjNMBSw==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.68.tgz",
+ "integrity": "sha512-e9AS5UttoIKqXSmBzKZdd3NErSVyOEYzJfNOCGtafGk1//gibTwQXGlSXmAKuErqMp09pyk9aqQRSYzm1AQfBw==",
"cpu": [
"riscv64"
],
@@ -7153,9 +7039,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.65.tgz",
- "integrity": "sha512-wsp+atutw13OJXGU3DDkdngtBDoEg01IuK5xMe0L6VFPV8maGkh17CXze078OD5QJOc6kFyw3DDscMLOPF8+oA==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.68.tgz",
+ "integrity": "sha512-Pa/I36VE3j57I3Obhrr+J48KGFfkZk2cJN/2NmW/vCgmoF7kCP6aTVq5n+cGdGWLd/cN9CJ9JvNwEoMRDghu0g==",
"cpu": [
"x64"
],
@@ -7170,9 +7056,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.65.tgz",
- "integrity": "sha512-odX+nN+IozWzhdj31INcHz3Iy9+EckNw+VqsZcaUxZOTu7/3FmktRNI6aC1qe5minZNv1m05YOS1FVf7fvmjlA==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.68.tgz",
+ "integrity": "sha512-9c6rkc5195wNxuUHJdf4/mmnq433OQey9TNvQ9LspJazvHbfSkTij8wtKjASVQsJyPDva4fkWOeV/OQ7cLw0GQ==",
"cpu": [
"x64"
],
@@ -7187,9 +7073,9 @@
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
- "version": "0.1.65",
- "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.65.tgz",
- "integrity": "sha512-RZQX3luWnlNWgdMnLMQ1hyfQraeAn9lnxWWVCHuUM4tAWEV8UDdeb7cMwmJW7eyt8kAosmjeHt3cylQMHOxGFg==",
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.68.tgz",
+ "integrity": "sha512-Fc5Dez23u0FoSATurT6/w1oMytiRnKWEinHivdMvXpge6nG4YvhrASrtqMk8dGJMVQpHr8QJYF45rOrx2YU2Aw==",
"cpu": [
"x64"
],
@@ -8729,6 +8615,10 @@
"resolved": "services/clsi",
"link": true
},
+ "node_modules/@overleaf/clsi-cache": {
+ "resolved": "services/clsi-cache",
+ "link": true
+ },
"node_modules/@overleaf/clsi-perf": {
"resolved": "services/clsi-perf",
"link": true
@@ -8884,9 +8774,9 @@
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
- "version": "0.5.15",
- "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz",
- "integrity": "sha512-LFWllMA55pzB9D34w/wXUCf8+c+IYKuJDgxiZ3qMhl64KRMBHYM1I3VdGaD2BV5FNPV2/S2596bppxHbv2ZydQ==",
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.16.tgz",
+ "integrity": "sha512-kLQc9xz6QIqd2oIYyXRUiAp79kGpFBm3fEM9ahfG1HI0WI5gdZ2OVHWdmZYnwODt7ISck+QuQ6sBPrtvUBML7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8970,9 +8860,9 @@
"license": "MIT"
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin/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==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
+ "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9161,10 +9051,11 @@
"integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA="
},
"node_modules/@react-aria/ssr": {
- "version": "3.9.4",
- "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.4.tgz",
- "integrity": "sha512-4jmAigVq409qcJvQyuorsmBR4+9r3+JEC60wC+Y0MZV0HCtTmm8D9guYXlJMdx0SSkgj0hHAyFm/HvPNFofCoQ==",
+ "version": "3.9.8",
+ "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz",
+ "integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
@@ -9172,31 +9063,35 @@
"node": ">= 12"
},
"peerDependencies": {
- "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@remix-run/router": {
- "version": "1.19.1",
- "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz",
- "integrity": "sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==",
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "license": "MIT",
"engines": {
"node": ">=14.0.0"
}
@@ -9243,6 +9138,7 @@
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
"integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
},
@@ -9251,19 +9147,20 @@
}
},
"node_modules/@restart/ui": {
- "version": "1.6.9",
- "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.6.9.tgz",
- "integrity": "sha512-mUbygUsJcRurjZCt1f77gg4DpheD1D+Sc7J3JjAkysUj7t8m4EBJVOqWC9788Qtbc69cJ+HlJc6jBguKwS8Mcw==",
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz",
+ "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.21.0",
- "@popperjs/core": "^2.11.6",
+ "@babel/runtime": "^7.26.0",
+ "@popperjs/core": "^2.11.8",
"@react-aria/ssr": "^3.5.0",
- "@restart/hooks": "^0.4.9",
- "@types/warning": "^3.0.0",
+ "@restart/hooks": "^0.5.0",
+ "@types/warning": "^3.0.3",
"dequal": "^2.0.3",
"dom-helpers": "^5.2.0",
- "uncontrollable": "^8.0.1",
+ "uncontrollable": "^8.0.4",
"warning": "^4.0.3"
},
"peerDependencies": {
@@ -9271,14 +9168,17 @@
"react-dom": ">=16.14.0"
}
},
- "node_modules/@restart/ui/node_modules/dom-helpers": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
- "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "node_modules/@restart/ui/node_modules/@restart/hooks": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz",
+ "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.8.7",
- "csstype": "^3.0.2"
+ "dequal": "^2.0.3"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
}
},
"node_modules/@restart/ui/node_modules/uncontrollable": {
@@ -9286,18 +9186,290 @@
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz",
"integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==",
"dev": true,
+ "license": "MIT",
"peerDependencies": {
"react": ">=16.14.0"
}
},
- "node_modules/@restart/ui/node_modules/warning": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
- "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz",
+ "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
- "dependencies": {
- "loose-envify": "^1.0.0"
- }
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz",
+ "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz",
+ "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz",
+ "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz",
+ "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz",
+ "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz",
+ "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz",
+ "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz",
+ "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz",
+ "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz",
+ "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz",
+ "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz",
+ "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz",
+ "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz",
+ "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz",
+ "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz",
+ "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz",
+ "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz",
+ "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz",
+ "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
"node_modules/@sentry-internal/tracing": {
"version": "7.46.0",
@@ -10089,14 +10261,15 @@
}
},
"node_modules/@storybook/addon-a11y": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-8.6.4.tgz",
- "integrity": "sha512-B3/d2cRlnpAlE3kh+OBaly6qrWN9DEqwDyZsNeobaiXnNp11xoHZP2OWjEwXldc0pKls41jeOksXyXrILfvTng==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-8.6.12.tgz",
+ "integrity": "sha512-H28zHiL8uuv29XsVNf9VjNWsCeht/l66GPYHT7aom1jh+f3fS9+sutrCGEBC/T7cnRpy8ZyuHCtihUqS+RI4pg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/addon-highlight": "8.6.4",
- "@storybook/test": "8.6.4",
+ "@storybook/addon-highlight": "8.6.12",
+ "@storybook/global": "^5.0.0",
+ "@storybook/test": "8.6.12",
"axe-core": "^4.2.0"
},
"funding": {
@@ -10104,13 +10277,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-actions": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.4.tgz",
- "integrity": "sha512-mCcyfkeb19fJX0dpQqqZCnWBwjVn0/27xcpR0mbm/KW2wTByU6bKFFujgrHsX3ONl97IcIaUnmwwUwBr1ebZXw==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.12.tgz",
+ "integrity": "sha512-B5kfiRvi35oJ0NIo53CGH66H471A3XTzrfaa6SxXEJsgxxSeKScG5YeXcCvLiZfvANRQ7QDsmzPUgg0o3hdMXw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10125,7 +10298,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-actions/node_modules/uuid": {
@@ -10143,9 +10316,9 @@
}
},
"node_modules/@storybook/addon-backgrounds": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.4.tgz",
- "integrity": "sha512-lRYGumlYdd1RptQJvOTRMx/q2pDmg2MO5GX4la7VfI8KrUyeuC1ZOSRDEcXeTuAZWJztqmtymg6bB7cAAoxCFA==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.12.tgz",
+ "integrity": "sha512-lmIAma9BiiCTbJ8YfdZkXjpnAIrOUcgboLkt1f6XJ78vNEMnLNzD9gnh7Tssz1qrqvm34v9daDjIb+ggdiKp3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10158,13 +10331,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-controls": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.4.tgz",
- "integrity": "sha512-oMMP9Bj0RMfYmaitjFt6oBSjKH4titUqP+wE6PrZ3v+Om56f4buqfNKXRf80As2OrsZn0pjj95muWzVVHqIhyQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.12.tgz",
+ "integrity": "sha512-9VSRPJWQVb9wLp21uvpxDGNctYptyUX0gbvxIWOHMH3R2DslSoq41lsC/oQ4l4zSHVdL+nq8sCTkhBxIsjKqdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10177,20 +10350,20 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-docs": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.4.tgz",
- "integrity": "sha512-+kbcjvEAH0Xs+k+raAwfC0WmJilWhxBYnLLeazP3m5AkVI3sIjbzuuZ78NR0DCdRkw9BpuuXMHv5o4tIvLIUlw==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.12.tgz",
+ "integrity": "sha512-kEezQjAf/p3SpDzLABgg4fbT48B6dkT2LiZCKTRmCrJVtuReaAr4R9MMM6Jsph6XjbIj/SvOWf3CMeOPXOs9sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
- "@storybook/blocks": "8.6.4",
- "@storybook/csf-plugin": "8.6.4",
- "@storybook/react-dom-shim": "8.6.4",
+ "@storybook/blocks": "8.6.12",
+ "@storybook/csf-plugin": "8.6.12",
+ "@storybook/react-dom-shim": "8.6.12",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -10200,25 +10373,25 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-essentials": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.4.tgz",
- "integrity": "sha512-3pF0ZDl5EICqe0eOupPQq6PxeupwkLsfTWANuuJUYTJur82kvJd3Chb7P9vqw0A0QBx6106mL6PIyjrFJJMhLg==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.12.tgz",
+ "integrity": "sha512-Y/7e8KFlttaNfv7q2zoHMPdX6hPXHdsuQMAjYl5NG9HOAJREu4XBy4KZpbcozRe4ApZ78rYsN/MO1EuA+bNMIA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/addon-actions": "8.6.4",
- "@storybook/addon-backgrounds": "8.6.4",
- "@storybook/addon-controls": "8.6.4",
- "@storybook/addon-docs": "8.6.4",
- "@storybook/addon-highlight": "8.6.4",
- "@storybook/addon-measure": "8.6.4",
- "@storybook/addon-outline": "8.6.4",
- "@storybook/addon-toolbars": "8.6.4",
- "@storybook/addon-viewport": "8.6.4",
+ "@storybook/addon-actions": "8.6.12",
+ "@storybook/addon-backgrounds": "8.6.12",
+ "@storybook/addon-controls": "8.6.12",
+ "@storybook/addon-docs": "8.6.12",
+ "@storybook/addon-highlight": "8.6.12",
+ "@storybook/addon-measure": "8.6.12",
+ "@storybook/addon-outline": "8.6.12",
+ "@storybook/addon-toolbars": "8.6.12",
+ "@storybook/addon-viewport": "8.6.12",
"ts-dedent": "^2.0.0"
},
"funding": {
@@ -10226,13 +10399,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-highlight": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.4.tgz",
- "integrity": "sha512-jFREXnSE/7VuBR8kbluN+DBVkMXEV7MGuCe8Ytb1/D2Q0ohgJe395dfVgEgSMXErOwsn//NV/NgJp6JNXH2DrA==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.12.tgz",
+ "integrity": "sha512-9FITVxdoycZ+eXuAZL9ElWyML/0fPPn9UgnnAkrU7zkMi+Segq/Tx7y+WWanC5zfWZrXAuG6WTOYEXeWQdm//w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10243,19 +10416,19 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-interactions": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.4.tgz",
- "integrity": "sha512-MZAAZjyvmJXCvM35zEiPpXz7vK+fimovt+WZKAMayAbXy5fT+7El0c9dDyTQ2norNKNj9QU/8hiU/1zARSUELQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.12.tgz",
+ "integrity": "sha512-cTAJlTq6uVZBEbtwdXkXoPQ4jHOAGKQnYSezBT4pfNkdjn/FnEeaQhMBDzf14h2wr5OgBnJa6Lmd8LD9ficz4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
- "@storybook/instrumenter": "8.6.4",
- "@storybook/test": "8.6.4",
+ "@storybook/instrumenter": "8.6.12",
+ "@storybook/test": "8.6.12",
"polished": "^4.2.2",
"ts-dedent": "^2.2.0"
},
@@ -10264,13 +10437,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-links": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.6.4.tgz",
- "integrity": "sha512-TaSIteYLJ12+dVBk7fW96ZvNIFizKs+Vo/YuNAe4xTzFJRrjLkFj9htLVi/dusMfn7lYo5DHIns08LuM+po1Dg==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.6.12.tgz",
+ "integrity": "sha512-AfKujFHoAxhxq4yu+6NwylltS9lf5MPs1eLLXvOlwo3l7Y/c68OdxJ7j68vLQhs9H173WVYjKyjbjFxJWf/YYg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10283,7 +10456,7 @@
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
},
"peerDependenciesMeta": {
"react": {
@@ -10292,9 +10465,9 @@
}
},
"node_modules/@storybook/addon-measure": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.4.tgz",
- "integrity": "sha512-IpVL1rTy1tO8sy140eU3GdVB1QJ6J62+V6GSstcmqTLxDJQk5jFfg7hVbPEAZZ2sPFmeyceP9AMoBBo0EB355A==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.12.tgz",
+ "integrity": "sha512-tACmwqqOvutaQSduw8SMb62wICaT1rWaHtMN3vtWXuxgDPSdJQxLP+wdVyRYMAgpxhLyIO7YRf++Hfha9RHgFg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10306,13 +10479,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-outline": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.4.tgz",
- "integrity": "sha512-28nAslKTy0zWMdxAZcipMDYrEp1TkXVooAsqMGY5AMXMiORi1ObjhmjTLhVt1dXp+aDg0X+M3B6PqoingmHhqQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.12.tgz",
+ "integrity": "sha512-1ylwm+n1s40S91No0v9T4tCjZORu3GbnjINlyjYTDLLhQHyBQd3nWR1Y1eewU4xH4cW9SnSLcMQFS/82xHqU6A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10324,7 +10497,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-styling-webpack": {
@@ -10341,9 +10514,9 @@
}
},
"node_modules/@storybook/addon-toolbars": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.4.tgz",
- "integrity": "sha512-PU2lvgwCKDn93zpp5MEog103UUmSSugcxDf18xaoa9D15Qtr+YuQHd2hXbxA7+dnYL9lA7MLYsstfxE91ieM4Q==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.12.tgz",
+ "integrity": "sha512-HEcSzo1DyFtIu5/ikVOmh5h85C1IvK9iFKSzBR6ice33zBOaehVJK+Z5f487MOXxPsZ63uvWUytwPyViGInj+g==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10351,13 +10524,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-viewport": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.4.tgz",
- "integrity": "sha512-O5Ij+SRVg6grY6JOL5lOpsFyopZxuZEl2GHfh2SUf9hfowNS0QAgFpJupqXkwZzRSrlf9uKrLkjB6ulLgN2gOQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.12.tgz",
+ "integrity": "sha512-EXK2LArAnABsPP0leJKy78L/lbMWow+EIJfytEP5fHaW4EhMR6h7Hzaqzre6U0IMMr/jVFa1ci+m0PJ0eQc2bw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10368,13 +10541,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/addon-webpack5-compiler-babel": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-3.0.5.tgz",
- "integrity": "sha512-9dlc5PrehEFUHqkgj8x+aKtOY9XH9Zk6WBbtpgY/JCQ7waJ2VvhyDnrgJeXfek+WYlSkJElnta6SlqP+XRG0PQ==",
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-3.0.6.tgz",
+ "integrity": "sha512-J4uVxEfkd2iAxPxcT90iebt5wuLSd0EYuMJa94t1jVUGlvZZAvnmqXAqscRITNU37nOr0c9yZ2YVS/sFOZyOVw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10386,9 +10559,9 @@
}
},
"node_modules/@storybook/blocks": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.4.tgz",
- "integrity": "sha512-+oPXwT3KzJzsdkQuGEzBqOKTIFlb6qmlCWWbDwAnP0SEqYHoTVRTAIa44icFP0EZeIe+ypFVAm1E7kWTLmw1hQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.12.tgz",
+ "integrity": "sha512-DohlTq6HM1jDbHYiXL4ZvZ00VkhpUp5uftzj/CZDLY1fYHRjqtaTwWm2/OpceivMA8zDitLcq5atEZN+f+siTg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10402,7 +10575,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
},
"peerDependenciesMeta": {
"react": {
@@ -10414,13 +10587,13 @@
}
},
"node_modules/@storybook/builder-webpack5": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-8.6.4.tgz",
- "integrity": "sha512-6fhjt3uiBZeapRbF477bkJ+ln+yA8vOz0qR86XTq79VrYY5AbBL6F8swVMk9LG1t49vYPR/UuPjYBxsUNKK8MQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-8.6.12.tgz",
+ "integrity": "sha512-Z7RsQ/1+HbxdbM69JrEFcTL+pnVKUTMmeURMn5/eOvYTGjBtM18vbQTj0LjCUDIjC+v9U+uX8ZJEUVxFbGcxBw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/core-webpack": "8.6.4",
+ "@storybook/core-webpack": "8.6.12",
"@types/semver": "^7.3.4",
"browser-assert": "^1.2.1",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
@@ -10450,7 +10623,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
},
"peerDependenciesMeta": {
"typescript": {
@@ -10503,9 +10676,9 @@
"license": "MIT"
},
"node_modules/@storybook/builder-webpack5/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==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
+ "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10579,18 +10752,18 @@
}
},
"node_modules/@storybook/cli": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-8.6.4.tgz",
- "integrity": "sha512-iVw4B2Pe4/ERDkDeaXtXamFXatNgvtiA6G9p3wUpVSlxjgKW/JbjSwKAMTCsgDIj4dCMm8i0fzmiYXeg5Yprng==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-8.6.12.tgz",
+ "integrity": "sha512-USOPHgQsckHguxqYItm7+i9lohjiPsCrNKBMl40XPU3dc1LqENBOF1JtJnOLTGegjUxRox7oaqrM10b9rkafWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/types": "^7.24.0",
- "@storybook/codemod": "8.6.4",
+ "@storybook/codemod": "8.6.12",
"@types/semver": "^7.3.4",
"commander": "^12.1.0",
- "create-storybook": "8.6.4",
+ "create-storybook": "8.6.12",
"cross-spawn": "^7.0.3",
"envinfo": "^7.7.3",
"fd-package-json": "^1.2.0",
@@ -10603,7 +10776,7 @@
"p-limit": "^6.2.0",
"prompts": "^2.4.0",
"semver": "^7.3.7",
- "storybook": "8.6.4",
+ "storybook": "8.6.12",
"tiny-invariant": "^1.3.1",
"ts-dedent": "^2.0.0"
},
@@ -10904,9 +11077,9 @@
"license": "ISC"
},
"node_modules/@storybook/cli/node_modules/yocto-queue": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.0.tgz",
- "integrity": "sha512-KHBC7z61OJeaMGnF3wqNZj+GGNXOyypZviiKpQeiHirG5Ib1ImwcLBH70rbMSkKfSmUNBsdf2PwaEJtKvgmkNw==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz",
+ "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10917,16 +11090,16 @@
}
},
"node_modules/@storybook/codemod": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.6.4.tgz",
- "integrity": "sha512-HVB7py6vKB9OMzQ02aAhcqmyT/IDlYrT1960HO6LWRhcpztnBlOHAAlhM91DN8yqN0K47B+GsaN5eDzCT8ggBw==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.6.12.tgz",
+ "integrity": "sha512-RFIGUI+dkFqrcuOyxNY97WuW7dskmuGL9yGALQsNmyGC490VUJ3scLOAgBChWBjsDfnN8uSb0Kvt/fB4OOyCmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/preset-env": "^7.24.4",
"@babel/types": "^7.24.0",
- "@storybook/core": "8.6.4",
+ "@storybook/core": "8.6.12",
"@types/cross-spawn": "^6.0.2",
"cross-spawn": "^7.0.3",
"es-toolkit": "^1.22.0",
@@ -11097,9 +11270,9 @@
}
},
"node_modules/@storybook/components": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.4.tgz",
- "integrity": "sha512-91VEVFWOgHkEFoNFMk6gs1AuOE9Yp7N283BXQOW+AgP+atpzED6t/fIBPGqJ2ewAuzLJ+cFOrasSzoNwVfg3Jg==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.12.tgz",
+ "integrity": "sha512-FiaE8xvCdvKC2arYusgtlDNZ77b8ysr8njAYQZwwaIHjy27TbR2tEpLDCmUwSbANNmivtc/xGEiDDwcNppMWlQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11111,13 +11284,13 @@
}
},
"node_modules/@storybook/core": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.4.tgz",
- "integrity": "sha512-glDbjEBi3wokw1T+KQtl93irHO9N0LCwgylWfWVXYDdQjUJ7pGRQGnw73gPX7Ds9tg3myXFC83GjmY94UYSMbA==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.12.tgz",
+ "integrity": "sha512-t+ZuDzAlsXKa6tLxNZT81gEAt4GNwsKP/Id2wluhmUWD/lwYW0uum1JiPUuanw8xD6TdakCW/7ULZc7aQUBLCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/theming": "8.6.4",
+ "@storybook/theming": "8.6.12",
"better-opn": "^3.0.2",
"browser-assert": "^1.2.1",
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0",
@@ -11143,9 +11316,9 @@
}
},
"node_modules/@storybook/core-webpack": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-8.6.4.tgz",
- "integrity": "sha512-/E+NDs4Ls2KQhQJyEbqyddvcevPGCNbBIRoR691gq2lnZV7lYFfhpGfYlXL1uSoA3WUWmql/gBsa2/O3vB+HKg==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-8.6.12.tgz",
+ "integrity": "sha512-TiE+KOm0hxb/p0JxeGHKxqTNX+xnTOFsBh6uloCSuvodutJ5pR/XpxKVxwo1gtSc0Uq3qpgbMhW6qYlYQetnKA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11156,7 +11329,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/core/node_modules/semver": {
@@ -11173,9 +11346,9 @@
}
},
"node_modules/@storybook/csf-plugin": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.4.tgz",
- "integrity": "sha512-7UpEp4PFTy1iKjZiRaYMG7zvnpLIRPyD0+lUJUlLYG4UIemV3onvnIi1Je1tSZ4hfTup+ulom7JLztVSHZGRMg==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.12.tgz",
+ "integrity": "sha512-6s8CnP1aoKPb3XtC0jRLUp8M5vTA8RhGAwQDKUsFpCC7g89JR9CaKs9FY2ZSzsNbjR15uASi7b3K8BzeYumYQg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11186,7 +11359,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/global": {
@@ -11197,9 +11370,9 @@
"license": "MIT"
},
"node_modules/@storybook/icons": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.3.2.tgz",
- "integrity": "sha512-t3xcbCKkPvqyef8urBM0j/nP6sKtnlRkVgC+8JTbTAZQjaTmOjes3byEgzs89p4B/K6cJsg9wLW2k3SknLtYJw==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.4.0.tgz",
+ "integrity": "sha512-Td73IeJxOyalzvjQL+JXx72jlIYHgs+REaHiREOqfpo3A2AYYG71AUbcv+lg7mEDIweKVCxsMQ0UKo634c8XeA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11211,9 +11384,9 @@
}
},
"node_modules/@storybook/instrumenter": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.4.tgz",
- "integrity": "sha512-8OtIWLhayTUdqJEeXiPm6l3LTdSkWgQzzV2l2HIe4Adedeot+Rkwu6XHmyRDpnb0+Ish6zmMDqtJBxC2PQsy6Q==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.12.tgz",
+ "integrity": "sha512-VK5fYAF8jMwWP/u3YsmSwKGh+FeSY8WZn78flzRUwirp2Eg1WWjsqPRubAk7yTpcqcC/km9YMF3KbqfzRv2s/A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11225,13 +11398,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/manager-api": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.4.tgz",
- "integrity": "sha512-w/Nn/VznfbIg2oezDfzZNwSTDY5kBZbzxVBHLCnIcyu2AKt2Yto3pfGi60SikFcTrsClaAKT7D92kMQ9qdQNQQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.12.tgz",
+ "integrity": "sha512-O0SpISeJLNTQvhSBOsWzzkCgs8vCjOq1578rwqHlC6jWWm4QmtfdyXqnv7rR1Hk08kQ+Dzqh0uhwHx0nfwy4nQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11243,24 +11416,28 @@
}
},
"node_modules/@storybook/node-logger": {
- "version": "8.0.4",
- "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-8.0.4.tgz",
- "integrity": "sha512-cALLHuX53vLQsoJamGRlquh2pfhPq9copXou2JTmFT6mrCcipo77SzhBDfeeuhaGv6vUWPfmGjPBEHXWGPe4+g==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-8.6.12.tgz",
+ "integrity": "sha512-Jk7mQWsu60BptBwYJAd69kMmsEqBAbGDuA/fqban+8vfNiSKgR3PRkhis0DsGEk53bpAEfbkCcyvYRCrrq4M3Q==",
"dev": true,
+ "license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0"
}
},
"node_modules/@storybook/preset-react-webpack": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-8.6.4.tgz",
- "integrity": "sha512-rFd1NvSE2ZP5ZFEqH7wdXXlvnyNChSMp+w4FyGSCgFQOwQKZhhWPPyloi3gGSWztFV9qpzC/ri7TTvG6ptqPPw==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-8.6.12.tgz",
+ "integrity": "sha512-aCCHjR/jsVPVThRH7nK70wS0Od44M6hqkkakg3xr7LETZZGj99heen6t4VHvz8gcQYT9l6R/oZwCl7f/PQ3ZBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/core-webpack": "8.6.4",
- "@storybook/react": "8.6.4",
+ "@storybook/core-webpack": "8.6.12",
+ "@storybook/react": "8.6.12",
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
"@types/semver": "^7.3.4",
"find-up": "^5.0.0",
@@ -11281,7 +11458,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11318,9 +11495,9 @@
}
},
"node_modules/@storybook/preview-api": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.4.tgz",
- "integrity": "sha512-5HBfxggzxGz0dg2c61NpPiQJav7UAmzsQlzmI5SzWOS6lkaylcDG8giwKzASVCXVWBxNji9qIDFM++UH090aDg==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.12.tgz",
+ "integrity": "sha512-84FE3Hrs0AYKHqpDZOwx1S/ffOfxBdL65lhCoeI8GoWwCkzwa9zEP3kvXBo/BnEDO7nAfxvMhjASTZXbKRJh5Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11332,18 +11509,18 @@
}
},
"node_modules/@storybook/react": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.4.tgz",
- "integrity": "sha512-pfv4hMhu3AScOh0l86uIzmXLSQ0XA/e0reIVwQcxKht6miaKArhx9GkS4mMp6SO23ZoV5G/nfLgUaMVPVE0ZPg==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.12.tgz",
+ "integrity": "sha512-NzxlHLA5DkDgZM/dMwTYinuzRs6rsUPmlqP+NIv6YaciQ4NGnTYyOC7R/SqI6HHFm8ZZ5eMYvpfiFmhZ9rU+rQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/components": "8.6.4",
+ "@storybook/components": "8.6.12",
"@storybook/global": "^5.0.0",
- "@storybook/manager-api": "8.6.4",
- "@storybook/preview-api": "8.6.4",
- "@storybook/react-dom-shim": "8.6.4",
- "@storybook/theming": "8.6.4"
+ "@storybook/manager-api": "8.6.12",
+ "@storybook/preview-api": "8.6.12",
+ "@storybook/react-dom-shim": "8.6.12",
+ "@storybook/theming": "8.6.12"
},
"engines": {
"node": ">=18.0.0"
@@ -11353,10 +11530,10 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "@storybook/test": "8.6.4",
+ "@storybook/test": "8.6.12",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
- "storybook": "^8.6.4",
+ "storybook": "^8.6.12",
"typescript": ">= 4.2.x"
},
"peerDependenciesMeta": {
@@ -11423,9 +11600,9 @@
}
},
"node_modules/@storybook/react-dom-shim": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.4.tgz",
- "integrity": "sha512-kTGJ3aFdmfCFzYaDFGmZWfTXr9xhbUaf0tJ6+nEjc4tME6mFwMI+tTUT6U/J6mJhZuc2DjvIRA7bM0x77dIDqw==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.12.tgz",
+ "integrity": "sha512-51QvoimkBzYs8s3rCYnY5h0cFqLz/Mh0vRcughwYaXckWzDBV8l67WBO5Xf5nBsukCbWyqBVPpEQLww8s7mrLA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11435,19 +11612,19 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
- "storybook": "^8.6.4"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/react-webpack5": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-8.6.4.tgz",
- "integrity": "sha512-kH439Atpp94+hWF/xftOJ4ZCy7bnNWuLSni7sWvOGkYZpzzzkLXfACanvK6ZY9wUxAh0bbdGfbc3McMvIWfYlw==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-8.6.12.tgz",
+ "integrity": "sha512-wZOjPQ00gu85iQoKgwz5uvM3+bhXrQDVR0ppVAj7vVy6cvLEsJXmqNLHbXPCZuKPmvwzYr1QkslMLCIkF8OGdA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/builder-webpack5": "8.6.4",
- "@storybook/preset-react-webpack": "8.6.4",
- "@storybook/react": "8.6.4"
+ "@storybook/builder-webpack5": "8.6.12",
+ "@storybook/preset-react-webpack": "8.6.12",
+ "@storybook/react": "8.6.12"
},
"engines": {
"node": ">=18.0.0"
@@ -11459,7 +11636,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
- "storybook": "^8.6.4",
+ "storybook": "^8.6.12",
"typescript": ">= 4.2.x"
},
"peerDependenciesMeta": {
@@ -11469,14 +11646,14 @@
}
},
"node_modules/@storybook/test": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.4.tgz",
- "integrity": "sha512-JPjfbaMMuCBT47pg3/MDD9vYFF5OGPAOWEB9nJWJ9IjYAb2Nd8OYJQIDoYJQNT+aLkTVLtvzGnVNwdxpouAJcQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.12.tgz",
+ "integrity": "sha512-0BK1Eg+VD0lNMB1BtxqHE3tP9FdkUmohtvWG7cq6lWvMrbCmAmh3VWai3RMCCDOukPFpjabOr8BBRLVvhNpv2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
- "@storybook/instrumenter": "8.6.4",
+ "@storybook/instrumenter": "8.6.12",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/user-event": "14.5.2",
@@ -11488,96 +11665,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
- "storybook": "^8.6.4"
- }
- },
- "node_modules/@storybook/test/node_modules/@testing-library/dom": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
- "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.10.4",
- "@babel/runtime": "^7.12.5",
- "@types/aria-query": "^5.0.1",
- "aria-query": "5.3.0",
- "chalk": "^4.1.0",
- "dom-accessibility-api": "^0.5.9",
- "lz-string": "^1.5.0",
- "pretty-format": "^27.0.2"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@storybook/test/node_modules/@types/aria-query": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
- "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@storybook/test/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,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@storybook/test/node_modules/aria-query": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
- "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "dequal": "^2.0.3"
- }
- },
- "node_modules/@storybook/test/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,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/@storybook/test/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,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
+ "storybook": "^8.6.12"
}
},
"node_modules/@storybook/theming": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.4.tgz",
- "integrity": "sha512-g9Ns4uenC9oAWETaJ/tEKEIPMdS+CqjNWZz5Wbw1bLNhXwADZgKrVqawzZi64+bYYtQ+i8VCTjPoFa6s2eHiDQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.12.tgz",
+ "integrity": "sha512-6VjZg8HJ2Op7+KV7ihJpYrDnFtd9D1jrQnUS8LckcpuBXrIEbaut5+34ObY8ssQnSqkk2GwIZBBBQYQBCVvkOw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11612,12 +11706,13 @@
}
},
"node_modules/@swc/helpers": {
- "version": "0.5.11",
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.11.tgz",
- "integrity": "sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==",
+ "version": "0.5.17",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
+ "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
- "tslib": "^2.4.0"
+ "tslib": "^2.8.0"
}
},
"node_modules/@testing-library/cypress": {
@@ -11656,12 +11751,6 @@
"node": ">=14"
}
},
- "node_modules/@testing-library/cypress/node_modules/@types/aria-query": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.2.tgz",
- "integrity": "sha512-PHKZuMN+K5qgKIWhBodXzQslTo5P+K/6LqeKXS6O/4liIDdZqaX5RXrCK++LAw+y/nptN48YmUMFiQHRSWYwtQ==",
- "dev": true
- },
"node_modules/@testing-library/cypress/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -11706,22 +11795,23 @@
}
},
"node_modules/@testing-library/dom": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz",
- "integrity": "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
+ "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
- "@types/aria-query": "^4.2.0",
- "aria-query": "^5.0.0",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
- "lz-string": "^1.4.4",
+ "lz-string": "^1.5.0",
"pretty-format": "^27.0.2"
},
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
@@ -11739,6 +11829,16 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/@testing-library/dom/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -11839,69 +11939,33 @@
}
},
"node_modules/@testing-library/react": {
- "version": "12.1.5",
- "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz",
- "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==",
+ "version": "16.3.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
+ "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.12.5",
- "@testing-library/dom": "^8.0.0",
- "@types/react-dom": "<18.0.0"
+ "@babel/runtime": "^7.12.5"
},
"engines": {
- "node": ">=12"
+ "node": ">=18"
},
"peerDependencies": {
- "react": "<18.0.0",
- "react-dom": "<18.0.0"
- }
- },
- "node_modules/@testing-library/react-hooks": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
- "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
- "dev": true,
- "dependencies": {
- "@babel/runtime": "^7.12.5",
- "react-error-boundary": "^3.1.0"
- },
- "engines": {
- "node": ">=12"
- },
- "peerDependencies": {
- "@types/react": "^16.9.0 || ^17.0.0",
- "react": "^16.9.0 || ^17.0.0",
- "react-dom": "^16.9.0 || ^17.0.0",
- "react-test-renderer": "^16.9.0 || ^17.0.0"
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
- "react-dom": {
- "optional": true
- },
- "react-test-renderer": {
+ "@types/react-dom": {
"optional": true
}
}
},
- "node_modules/@testing-library/react-hooks/node_modules/react-error-boundary": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
- "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
- "dev": true,
- "dependencies": {
- "@babel/runtime": "^7.12.5"
- },
- "engines": {
- "node": ">=10",
- "npm": ">=6"
- },
- "peerDependencies": {
- "react": ">=16.13.1"
- }
- },
"node_modules/@testing-library/user-event": {
"version": "14.5.2",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
@@ -11924,10 +11988,11 @@
}
},
"node_modules/@transloadit/prettier-bytes": {
- "version": "0.0.7",
- "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
- "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
- "dev": true
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz",
+ "integrity": "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
@@ -11957,10 +12022,11 @@
}
},
"node_modules/@types/aria-query": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
- "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
- "dev": true
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/aws-lambda": {
"version": "8.10.119",
@@ -11982,9 +12048,9 @@
}
},
"node_modules/@types/babel__generator": {
- "version": "7.6.8",
- "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
- "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12003,9 +12069,9 @@
}
},
"node_modules/@types/babel__traverse": {
- "version": "7.20.6",
- "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
- "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+ "version": "7.20.7",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
+ "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12026,6 +12092,7 @@
"resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz",
"integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/node": "*"
}
@@ -12094,6 +12161,7 @@
"resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz",
"integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/express-serve-static-core": "*",
"@types/node": "*"
@@ -12112,7 +12180,8 @@
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
- "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "license": "MIT"
},
"node_modules/@types/cookiejar": {
"version": "2.1.2",
@@ -12191,9 +12260,9 @@
}
},
"node_modules/@types/estree": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
- "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
+ "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"dev": true,
"license": "MIT"
},
@@ -12234,6 +12303,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/glob-to-regexp": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz",
+ "integrity": "sha512-nDKoaKJYbnn1MZxUY0cA1bPmmgZbg0cTq7Rh13d0KWYNOiKbqoR+2d89SnRPszGh7ROzSwZ/GOjZ4jPbmmZ6Eg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/hapi__catbox": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/@types/hapi__catbox/-/hapi__catbox-10.2.4.tgz",
@@ -12271,9 +12347,10 @@
}
},
"node_modules/@types/hoist-non-react-statics": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
- "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz",
+ "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==",
+ "license": "MIT",
"dependencies": {
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0"
@@ -12564,9 +12641,10 @@
}
},
"node_modules/@types/prop-types": {
- "version": "15.7.4",
- "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
- "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ=="
+ "version": "15.7.14",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
+ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
+ "license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.9.7",
@@ -12579,75 +12657,73 @@
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw=="
},
"node_modules/@types/react": {
- "version": "17.0.40",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.40.tgz",
- "integrity": "sha512-UrXhD/JyLH+W70nNSufXqMZNuUD2cXHu6UjCllC6pmOQgBX4SGXOH8fjRka0O0Ee0HrFxapDD8Bwn81Kmiz6jQ==",
+ "version": "18.3.20",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
+ "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==",
+ "license": "MIT",
"dependencies": {
"@types/prop-types": "*",
- "@types/scheduler": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-bootstrap": {
- "version": "0.32.36",
- "resolved": "https://registry.npmjs.org/@types/react-bootstrap/-/react-bootstrap-0.32.36.tgz",
- "integrity": "sha512-xldfs2zixagAFEafy/XzRvZH1NtjRnLfbgL0cZ2a0Eykz+iILE/Xa46tnUFcLln6ZBq1Qp9uArhIbkkuhBU30g==",
+ "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.6",
- "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.6.tgz",
- "integrity": "sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==",
+ "version": "3.0.13",
+ "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz",
+ "integrity": "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@types/react": "*",
"@types/reactcss": "*"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "17.0.13",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.13.tgz",
- "integrity": "sha512-wEP+B8hzvy6ORDv1QBhcQia4j6ea4SFIBttHYpXKPFZRviBvknq0FRh3VrIxeXUmsPkwuXVZrVGG7KUVONmXCQ==",
- "dev": true,
- "dependencies": {
+ },
+ "peerDependencies": {
"@types/react": "*"
}
},
- "node_modules/@types/react-google-recaptcha": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.5.tgz",
- "integrity": "sha512-iWTjmVttlNgp0teyh7eBXqNOQzVq2RWNiFROWjraOptRnb1OcHJehQnji0sjqIRAk9K0z8stjyhU+OLpPb0N6w==",
+ "node_modules/@types/react-dom": {
+ "version": "18.3.6",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.6.tgz",
+ "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==",
"dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/react-google-recaptcha": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
+ "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-linkify": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz",
- "integrity": "sha512-2NKXPQGaHNfh/dCqkVC55k1tAhQyNoNZa31J50nIneMVwHqUI00FAP+Lyp8e0BarPf84kn4GRVAhtWX9XJBzSQ==",
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.4.tgz",
+ "integrity": "sha512-NOMw4X3kjvjY0lT5kXQdxZCXpPNi2hOuuqG+Kz+5EOQpi9rDUJJDitdE1j2JRNmrTnNIjrLnYG0HKyuOWN/uKA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
- "node_modules/@types/react-overlays": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@types/react-overlays/-/react-overlays-1.1.3.tgz",
- "integrity": "sha512-oOq5NWbyfNz2w2sKvjkHdvGQSMA+VDVfI5UOfGPR0wkik2welad1RDVnVgH15jKf58jrZNBa1Ee4SVBgCGFxCg==",
- "dev": true,
- "dependencies": {
- "@types/react": "*",
- "@types/react-transition-group": "*"
- }
- },
"node_modules/@types/react-redux": {
- "version": "7.1.33",
- "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz",
- "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==",
+ "version": "7.1.34",
+ "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz",
+ "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==",
+ "license": "MIT",
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
@@ -12656,20 +12732,22 @@
}
},
"node_modules/@types/react-transition-group": {
- "version": "4.4.10",
- "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
- "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==",
+ "version": "4.4.12",
+ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
+ "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"dev": true,
- "dependencies": {
+ "license": "MIT",
+ "peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/reactcss": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz",
- "integrity": "sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz",
+ "integrity": "sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==",
"dev": true,
- "dependencies": {
+ "license": "MIT",
+ "peerDependencies": {
"@types/react": "*"
}
},
@@ -12725,11 +12803,6 @@
"@types/node": "*"
}
},
- "node_modules/@types/scheduler": {
- "version": "0.16.2",
- "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
- "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
- },
"node_modules/@types/semver": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
@@ -12749,6 +12822,7 @@
"resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz",
"integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/express": "*"
}
@@ -12819,6 +12893,7 @@
"resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz",
"integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/node": "*"
}
@@ -12868,7 +12943,8 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
"integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
@@ -12893,10 +12969,11 @@
}
},
"node_modules/@types/ws": {
- "version": "8.5.12",
- "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz",
- "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==",
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/node": "*"
}
@@ -13849,6 +13926,155 @@
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
},
+ "node_modules/@uppy/companion-client": {
+ "version": "3.8.2",
+ "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-3.8.2.tgz",
+ "integrity": "sha512-WLjZ0Y6Fe7lzwU1YPvvQ/YqooejcgIZkT2TC39xr+QQ7Y1FwJECsyUdlKwgi1ee8TNpjoCrj3Q1Hjel/+p0VhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@uppy/utils": "^5.9.0",
+ "namespace-emitter": "^2.0.1",
+ "p-retry": "^6.1.0"
+ },
+ "peerDependencies": {
+ "@uppy/core": "^3.13.1"
+ }
+ },
+ "node_modules/@uppy/core": {
+ "version": "3.13.1",
+ "resolved": "https://registry.npmjs.org/@uppy/core/-/core-3.13.1.tgz",
+ "integrity": "sha512-iQGAUO4ziQRpfv7kix6tO6JOWqjI0K4vt8AynvHWzDPZxYSba3zd6RojGNPsYWSR7Xv+dRXYx+GU8oTiK1FRUA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@transloadit/prettier-bytes": "^0.3.4",
+ "@uppy/store-default": "^3.2.2",
+ "@uppy/utils": "^5.9.0",
+ "lodash": "^4.17.21",
+ "mime-match": "^1.0.2",
+ "namespace-emitter": "^2.0.1",
+ "nanoid": "^4.0.0",
+ "preact": "^10.5.13"
+ }
+ },
+ "node_modules/@uppy/informer": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@uppy/informer/-/informer-3.1.0.tgz",
+ "integrity": "sha512-vmpTLqzSLmZSuIVDZV0o19yXVqyTh5/uCbKUEiyfBhR726kQiuYQLP/ZHaKcvW3c1ESQGbNg53iNHbFBqF681w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@uppy/utils": "^5.7.4",
+ "preact": "^10.5.13"
+ },
+ "peerDependencies": {
+ "@uppy/core": "^3.9.3"
+ }
+ },
+ "node_modules/@uppy/provider-views": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/@uppy/provider-views/-/provider-views-3.13.0.tgz",
+ "integrity": "sha512-Z2oI88A+GC2zIPk8beoeFN/miHKkhtF58mYjvb5miGCMMZM7p7LRj98sgb5OOdKsGrfeiuTavtgL424BvcVd8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@uppy/utils": "^5.9.0",
+ "classnames": "^2.2.6",
+ "nanoid": "^4.0.0",
+ "p-queue": "^7.3.4",
+ "preact": "^10.5.13"
+ },
+ "peerDependencies": {
+ "@uppy/core": "^3.13.0"
+ }
+ },
+ "node_modules/@uppy/provider-views/node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@uppy/provider-views/node_modules/p-queue": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.4.1.tgz",
+ "integrity": "sha512-vRpMXmIkYF2/1hLBKisKeVYJZ8S2tZ0zEAmIJgdVKP2nq0nh4qCdf8bgw+ZgKrkh71AOCaqzwbJJk1WtdcF3VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "p-timeout": "^5.0.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@uppy/provider-views/node_modules/p-timeout": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz",
+ "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@uppy/status-bar": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/@uppy/status-bar/-/status-bar-3.3.3.tgz",
+ "integrity": "sha512-TCcnBjTDbq/AmnGOcWbCpQNsv05Z6Y36zdmTCt/xNe2/gTVAYAzGRoGOrkeb6jf/E4AAi25VyOolSqL2ibB8Kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@transloadit/prettier-bytes": "^0.3.4",
+ "@uppy/utils": "^5.9.0",
+ "classnames": "^2.2.6",
+ "preact": "^10.5.13"
+ },
+ "peerDependencies": {
+ "@uppy/core": "^3.11.2"
+ }
+ },
+ "node_modules/@uppy/store-default": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-3.2.2.tgz",
+ "integrity": "sha512-OiSgT++Jj4nLK0N9WTeod3UNjCH81OXE5BcMJCd9oWzl2d0xPNq2T/E9Y6O72XVd+6Y7+tf5vZlPElutfMB3KQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@uppy/thumbnail-generator": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@uppy/thumbnail-generator/-/thumbnail-generator-3.1.0.tgz",
+ "integrity": "sha512-tDKK/cukC0CrM0F/OlHFmvpGGUq+Db4YfakhIGPKtT7ZO8aWOiIu5JIvaYUnKRxGq3RGsk4zhkxYXuoxVzzsGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@uppy/utils": "^5.7.5",
+ "exifr": "^7.0.0"
+ },
+ "peerDependencies": {
+ "@uppy/core": "^3.10.0"
+ }
+ },
+ "node_modules/@uppy/utils": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-5.9.0.tgz",
+ "integrity": "sha512-9Ubddd3orCOLYjf0KobwgJ+aTrABSxk9t4X/QdM4qJHVZuMIftkaMplrViRUO+kvIBCXEZDIP2AmS060siDNGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21",
+ "preact": "^10.5.13"
+ }
+ },
"node_modules/@vitest/expect": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
@@ -13971,6 +14197,103 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@vitest/runner": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz",
+ "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.1.2",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz",
+ "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/@vitest/utils": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz",
+ "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.1.2",
+ "loupe": "^3.1.3",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/loupe": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
+ "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/runner/node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz",
+ "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.1.2",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz",
+ "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot/node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@vitest/spy": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz",
@@ -15498,6 +15821,12 @@
"deep-equal": "^2.0.5"
}
},
+ "node_modules/b4a": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
+ "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==",
+ "license": "Apache-2.0"
+ },
"node_modules/babel-core": {
"version": "7.0.0-bridge.0",
"resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz",
@@ -15785,13 +16114,14 @@
}
},
"node_modules/babel-plugin-polyfill-corejs3": {
- "version": "0.10.4",
- "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz",
- "integrity": "sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==",
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz",
+ "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/helper-define-polyfill-provider": "^0.6.1",
- "core-js-compat": "^3.36.1"
+ "@babel/helper-define-polyfill-provider": "^0.6.3",
+ "core-js-compat": "^3.40.0"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -15860,6 +16190,70 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
+ "node_modules/bare-events": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
+ "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/bare-fs": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.1.tgz",
+ "integrity": "sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-events": "^2.0.0",
+ "bare-path": "^3.0.0",
+ "bare-stream": "^2.0.0"
+ },
+ "engines": {
+ "bare": ">=1.7.0"
+ }
+ },
+ "node_modules/bare-os": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.0.tgz",
+ "integrity": "sha512-BUrFS5TqSBdA0LwHop4OjPJwisqxGy6JsWVqV6qaFoe965qqtaKfDzHY5T2YA1gUL0ZeeQeA+4BBc1FJTcHiPw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "bare": ">=1.14.0"
+ }
+ },
+ "node_modules/bare-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
+ "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-os": "^3.0.1"
+ }
+ },
+ "node_modules/bare-stream": {
+ "version": "2.6.5",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
+ "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "streamx": "^2.21.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*",
+ "bare-events": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ },
+ "bare-events": {
+ "optional": true
+ }
+ }
+ },
"node_modules/base": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz",
@@ -15979,8 +16373,9 @@
"node_modules/batch": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
- "integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=",
- "dev": true
+ "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/bcrypt": {
"version": "5.0.1",
@@ -16226,10 +16621,11 @@
}
},
"node_modules/bonjour-service": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz",
- "integrity": "sha512-oSzCS2zV14bh2kji6vNe7vrpJYCHGvcZnlffFQ1MEoX/WOeQ/teD8SYWKR942OI3INjq8OMNJlbPK5LLLUxFDw==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz",
+ "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"multicast-dns": "^7.2.5"
@@ -16315,9 +16711,9 @@
"license": "ISC"
},
"node_modules/browserslist": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz",
- "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==",
+ "version": "4.24.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
+ "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true,
"funding": [
{
@@ -16333,11 +16729,12 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "caniuse-lite": "^1.0.30001663",
- "electron-to-chromium": "^1.5.28",
- "node-releases": "^2.0.18",
- "update-browserslist-db": "^1.1.0"
+ "caniuse-lite": "^1.0.30001688",
+ "electron-to-chromium": "^1.5.73",
+ "node-releases": "^2.0.19",
+ "update-browserslist-db": "^1.1.1"
},
"bin": {
"browserslist": "cli.js"
@@ -16666,6 +17063,16 @@
"node": ">=10"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/cache-base": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
@@ -16800,9 +17207,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001667",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz",
- "integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==",
+ "version": "1.0.30001714",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001714.tgz",
+ "integrity": "sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==",
"dev": true,
"funding": [
{
@@ -16817,7 +17224,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
- ]
+ ],
+ "license": "CC-BY-4.0"
},
"node_modules/canvas": {
"version": "2.11.2",
@@ -17565,6 +17973,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/combobo": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/combobo/-/combobo-2.0.4.tgz",
+ "integrity": "sha512-FRaTwjtUKNAyBglThL+52VDYD7Jvodb1E1nimBwybqu5ckooZn24e65hYiXSofMp/9dsPEkoAc6v5DSaQ0ENZg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -17778,6 +18193,7 @@
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
"integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=0.8"
}
@@ -17791,9 +18207,9 @@
}
},
"node_modules/consola": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
- "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -18123,9 +18539,9 @@
}
},
"node_modules/core-js": {
- "version": "3.38.1",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz",
- "integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==",
+ "version": "3.41.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz",
+ "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -18135,12 +18551,13 @@
}
},
"node_modules/core-js-compat": {
- "version": "3.38.1",
- "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz",
- "integrity": "sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==",
+ "version": "3.41.0",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
+ "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "browserslist": "^4.23.3"
+ "browserslist": "^4.24.4"
},
"funding": {
"type": "opencollective",
@@ -18203,9 +18620,9 @@
}
},
"node_modules/create-storybook": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/create-storybook/-/create-storybook-8.6.4.tgz",
- "integrity": "sha512-YwxtA+CtGHWYvQrFh1dat3Q/kXWHekok0MAqaorD9/Mf/cpybA8afHDsdq2PDq0LXg0Od3QI4Ha04+eaB6F8gA==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/create-storybook/-/create-storybook-8.6.12.tgz",
+ "integrity": "sha512-2Yb2LuWNvOEATWjj/pDAfkGhGiTLdV1nqJZLjB6F08ItcyBSeGuFybimrac72zFq4u0GI8LkjzEg/J3B1NI4tA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -18476,7 +18893,8 @@
"node_modules/css-mediaquery": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz",
- "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="
+ "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==",
+ "license": "BSD"
},
"node_modules/css-minimizer-webpack-plugin": {
"version": "5.0.1",
@@ -18814,9 +19232,10 @@
"dev": true
},
"node_modules/csstype": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
- "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "license": "MIT"
},
"node_modules/csurf": {
"version": "1.11.0",
@@ -19672,7 +20091,8 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
@@ -19738,21 +20158,12 @@
"node": ">=8"
}
},
- "node_modules/diskusage": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.1.3.tgz",
- "integrity": "sha512-EAyaxl8hy4Ph07kzlzGTfpbZMNAAAHXSZtNEMwdlnSd1noHzvA6HsgKt4fEMSvaEXQYLSphe5rPMxN4WOj0hcQ==",
- "hasInstallScript": true,
- "dependencies": {
- "es6-promise": "^4.2.5",
- "nan": "^2.14.0"
- }
- },
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
@@ -19760,10 +20171,11 @@
}
},
"node_modules/dns-packet": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.0.tgz",
- "integrity": "sha512-rza3UH1LwdHh9qyPXp8lkwpjSNk/AMD3dPytUoRoqnypDUhY0xvbdmVhWOfxO68frEfV9BU8V12Ez7ZsHGZpCQ==",
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
+ "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@leichtgewicht/ip-codec": "^2.0.1"
},
@@ -19803,12 +20215,14 @@
}
},
"node_modules/dom-helpers": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
- "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.1.2"
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
}
},
"node_modules/dom-serializer": {
@@ -19931,21 +20345,36 @@
"integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA=="
},
"node_modules/downshift": {
- "version": "6.1.7",
- "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.7.tgz",
- "integrity": "sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.0.9.tgz",
+ "integrity": "sha512-ygOT8blgiz5liDuEFAIaPeU4dDEa+w9p6PHVUisPIjrkF5wfR59a52HpGWAVVMoWnoFO8po2mZSScKZueihS7g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.14.8",
- "compute-scroll-into-view": "^1.0.17",
- "prop-types": "^15.7.2",
- "react-is": "^17.0.2",
- "tslib": "^2.3.0"
+ "@babel/runtime": "^7.24.5",
+ "compute-scroll-into-view": "^3.1.0",
+ "prop-types": "^15.8.1",
+ "react-is": "18.2.0",
+ "tslib": "^2.6.2"
},
"peerDependencies": {
"react": ">=16.12.0"
}
},
+ "node_modules/downshift/node_modules/compute-scroll-into-view": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
+ "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/downshift/node_modules/react-is": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dropbox": {
"version": "10.34.0",
"resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz",
@@ -20113,10 +20542,11 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.34",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.34.tgz",
- "integrity": "sha512-/TZAiChbAflBNjCg+VvstbcwAtIL/VdMFO3NgRFIzBjpvPzWOTIbbO8kNb6RwU4bt9TP7K+3KqBKw/lOU+Y+GA==",
- "dev": true
+ "version": "1.5.137",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz",
+ "integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==",
+ "dev": true,
+ "license": "ISC"
},
"node_modules/email-addresses": {
"version": "5.0.0",
@@ -20293,6 +20723,7 @@
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
"integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"stackframe": "^1.3.4"
}
@@ -20397,9 +20828,9 @@
}
},
"node_modules/es-module-lexer": {
- "version": "1.5.4",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
- "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
@@ -20455,9 +20886,9 @@
}
},
"node_modules/es-toolkit": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.32.0.tgz",
- "integrity": "sha512-ZfSfHP1l6ubgW/B/FRtqb9bYdMvI6jizbOSfbwwJNcOQ1QE6TFsC3jpQkZ900uUPSR3t3SU5Ds7UWKnYz+uP8Q==",
+ "version": "1.35.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.35.0.tgz",
+ "integrity": "sha512-kVHyrRoC0eLc1hWJ6npG8nNFtOG+nWfcMI+XE0RaFO0gxd6Ions8r0O/U64QyZgY7IeidUnS5oZlRZYUgMGCAg==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -20517,9 +20948,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
- "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
+ "version": "0.25.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz",
+ "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -20530,31 +20961,31 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.0",
- "@esbuild/android-arm": "0.25.0",
- "@esbuild/android-arm64": "0.25.0",
- "@esbuild/android-x64": "0.25.0",
- "@esbuild/darwin-arm64": "0.25.0",
- "@esbuild/darwin-x64": "0.25.0",
- "@esbuild/freebsd-arm64": "0.25.0",
- "@esbuild/freebsd-x64": "0.25.0",
- "@esbuild/linux-arm": "0.25.0",
- "@esbuild/linux-arm64": "0.25.0",
- "@esbuild/linux-ia32": "0.25.0",
- "@esbuild/linux-loong64": "0.25.0",
- "@esbuild/linux-mips64el": "0.25.0",
- "@esbuild/linux-ppc64": "0.25.0",
- "@esbuild/linux-riscv64": "0.25.0",
- "@esbuild/linux-s390x": "0.25.0",
- "@esbuild/linux-x64": "0.25.0",
- "@esbuild/netbsd-arm64": "0.25.0",
- "@esbuild/netbsd-x64": "0.25.0",
- "@esbuild/openbsd-arm64": "0.25.0",
- "@esbuild/openbsd-x64": "0.25.0",
- "@esbuild/sunos-x64": "0.25.0",
- "@esbuild/win32-arm64": "0.25.0",
- "@esbuild/win32-ia32": "0.25.0",
- "@esbuild/win32-x64": "0.25.0"
+ "@esbuild/aix-ppc64": "0.25.3",
+ "@esbuild/android-arm": "0.25.3",
+ "@esbuild/android-arm64": "0.25.3",
+ "@esbuild/android-x64": "0.25.3",
+ "@esbuild/darwin-arm64": "0.25.3",
+ "@esbuild/darwin-x64": "0.25.3",
+ "@esbuild/freebsd-arm64": "0.25.3",
+ "@esbuild/freebsd-x64": "0.25.3",
+ "@esbuild/linux-arm": "0.25.3",
+ "@esbuild/linux-arm64": "0.25.3",
+ "@esbuild/linux-ia32": "0.25.3",
+ "@esbuild/linux-loong64": "0.25.3",
+ "@esbuild/linux-mips64el": "0.25.3",
+ "@esbuild/linux-ppc64": "0.25.3",
+ "@esbuild/linux-riscv64": "0.25.3",
+ "@esbuild/linux-s390x": "0.25.3",
+ "@esbuild/linux-x64": "0.25.3",
+ "@esbuild/netbsd-arm64": "0.25.3",
+ "@esbuild/netbsd-x64": "0.25.3",
+ "@esbuild/openbsd-arm64": "0.25.3",
+ "@esbuild/openbsd-x64": "0.25.3",
+ "@esbuild/sunos-x64": "0.25.3",
+ "@esbuild/win32-arm64": "0.25.3",
+ "@esbuild/win32-ia32": "0.25.3",
+ "@esbuild/win32-x64": "0.25.3"
}
},
"node_modules/esbuild-register": {
@@ -20589,9 +21020,10 @@
}
},
"node_modules/escalade": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
- "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
"engines": {
"node": ">=6"
}
@@ -22029,6 +22461,13 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
+ "node_modules/exifr": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+ "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/expand-brackets": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
@@ -22108,35 +22547,14 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true
},
- "node_modules/expect": {
- "version": "1.20.2",
- "resolved": "https://registry.npmjs.org/expect/-/expect-1.20.2.tgz",
- "integrity": "sha512-vUOB6rNLhhRgchrNzJZH72FXDgiHmmEqX07Nlb1363HyZm/GFzkNMq0X0eIygMtdc4f2okltziddtVM4D5q0Jw==",
+ "node_modules/expect-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
+ "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
"dev": true,
- "dependencies": {
- "define-properties": "~1.1.2",
- "has": "^1.0.1",
- "is-equal": "^1.5.1",
- "is-regex": "^1.0.3",
- "object-inspect": "^1.1.0",
- "object-keys": "^1.0.9",
- "tmatch": "^2.0.1"
- }
- },
- "node_modules/expect/node_modules/define-properties": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
- "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
- "dev": true,
- "dependencies": {
- "has-property-descriptors": "^1.0.0",
- "object-keys": "^1.1.1"
- },
+ "license": "Apache-2.0",
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": ">=12.0.0"
}
},
"node_modules/express": {
@@ -22569,8 +22987,7 @@
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
- "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
- "dev": true
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="
},
"node_modules/fast-glob": {
"version": "3.3.3",
@@ -22679,6 +23096,7 @@
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
"integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
"websocket-driver": ">=0.5.1"
},
@@ -22740,45 +23158,6 @@
"node": ">=4.0.0"
}
},
- "node_modules/fetch-mock": {
- "version": "9.11.0",
- "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz",
- "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==",
- "dev": true,
- "dependencies": {
- "@babel/core": "^7.0.0",
- "@babel/runtime": "^7.0.0",
- "core-js": "^3.0.0",
- "debug": "^4.1.1",
- "glob-to-regexp": "^0.4.0",
- "is-subset": "^0.1.1",
- "lodash.isequal": "^4.5.0",
- "path-to-regexp": "^2.2.1",
- "querystring": "^0.2.0",
- "whatwg-url": "^6.5.0"
- },
- "engines": {
- "node": ">=4.0.0"
- },
- "funding": {
- "type": "charity",
- "url": "https://www.justgiving.com/refugee-support-europe"
- },
- "peerDependencies": {
- "node-fetch": "*"
- },
- "peerDependenciesMeta": {
- "node-fetch": {
- "optional": true
- }
- }
- },
- "node_modules/fetch-mock/node_modules/path-to-regexp": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz",
- "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==",
- "dev": true
- },
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -23341,6 +23720,7 @@
"url": "https://opencollective.com/formik"
}
],
+ "license": "Apache-2.0",
"dependencies": {
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
@@ -23359,6 +23739,7 @@
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -23367,7 +23748,8 @@
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
- "dev": true
+ "dev": true,
+ "license": "0BSD"
},
"node_modules/forwarded": {
"version": "0.2.0",
@@ -23475,11 +23857,12 @@
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -24546,7 +24929,8 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
"integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/handlebars": {
"version": "4.7.8",
@@ -24926,8 +25310,9 @@
"node_modules/hpack.js": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
- "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=",
+ "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"inherits": "^2.0.1",
"obuf": "^1.0.0",
@@ -24938,14 +25323,16 @@
"node_modules/hpack.js/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
- "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
- "dev": true
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/hpack.js/node_modules/readable-stream": {
- "version": "2.3.7",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
- "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -24961,6 +25348,7 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -25057,6 +25445,7 @@
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
@@ -25119,8 +25508,9 @@
"node_modules/http-deceiver": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
- "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=",
- "dev": true
+ "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.0",
@@ -25189,10 +25579,11 @@
"dev": true
},
"node_modules/http-parser-js": {
- "version": "0.5.5",
- "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.5.tgz",
- "integrity": "sha512-x+JVEkO2PoM8qqpbPbOL3cqHPwerep7OwzK7Ay+sMQjKzaKCqWvjoXm5tqMP9tXWWTnTzAjIhXg+J99XYuPhPA==",
- "dev": true
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
+ "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/http-proxy": {
"version": "1.18.1",
@@ -25223,9 +25614,9 @@
}
},
"node_modules/http-proxy-middleware": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
- "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
+ "version": "2.0.9",
+ "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
+ "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -25322,9 +25713,10 @@
}
},
"node_modules/hyphenate-style-name": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
- "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
+ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
+ "license": "BSD-3-Clause"
},
"node_modules/i18next": {
"version": "23.10.0",
@@ -25676,6 +26068,7 @@
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
@@ -25855,33 +26248,6 @@
"integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=",
"dev": true
},
- "node_modules/is-arrow-function": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/is-arrow-function/-/is-arrow-function-2.0.3.tgz",
- "integrity": "sha512-iDStzcT1FJMzx+TjCOK//uDugSe/Mif/8a+T0htydQ3qkJGvSweTZpVYz4hpJH0baloSPiAFQdA8WslAgJphvQ==",
- "dev": true,
- "dependencies": {
- "is-callable": "^1.0.4"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/is-async-function": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz",
- "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==",
- "dev": true,
- "dependencies": {
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/is-bigint": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
@@ -26031,42 +26397,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/is-equal": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/is-equal/-/is-equal-1.7.0.tgz",
- "integrity": "sha512-hErktGR9jmoYXNWlbrwGjc8eHh09mbY6TWSTTFtnMcKaCuSMN8z+Ni5ma/8mkbVpe4CbB7V6kN1MkCg9bCx5bA==",
- "dev": true,
- "dependencies": {
- "es-get-iterator": "^1.1.3",
- "es-to-primitive": "^1.2.1",
- "functions-have-names": "^1.2.3",
- "has-bigints": "^1.0.2",
- "has-symbols": "^1.0.3",
- "hasown": "^2.0.0",
- "is-arrow-function": "^2.0.3",
- "is-bigint": "^1.0.4",
- "is-boolean-object": "^1.1.2",
- "is-callable": "^1.2.7",
- "is-date-object": "^1.0.5",
- "is-generator-function": "^1.0.10",
- "is-number-object": "^1.0.7",
- "is-regex": "^1.1.4",
- "is-string": "^1.0.7",
- "is-symbol": "^1.0.4",
- "isarray": "^2.0.5",
- "object-inspect": "^1.13.1",
- "object.entries": "^1.1.7",
- "object.getprototypeof": "^1.0.5",
- "which-boxed-primitive": "^1.0.2",
- "which-collection": "^1.0.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/is-expression": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz",
@@ -26096,18 +26426,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-finalizationregistry": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz",
- "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==",
- "dev": true,
- "dependencies": {
- "call-bind": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -26360,7 +26678,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-shallow-equal/-/is-shallow-equal-1.0.1.tgz",
"integrity": "sha512-lq5RvK+85Hs5J3p4oA4256M1FEffzmI533ikeDHvJd42nouRRx5wBzt36JuviiGe5dIPyHON/d0/Up+PBo6XkQ==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/is-shared-array-buffer": {
"version": "1.0.3",
@@ -26407,12 +26726,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-subset": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
- "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=",
- "dev": true
- },
"node_modules/is-symbol": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
@@ -27577,12 +27890,6 @@
"node": ">=12.0.0"
}
},
- "node_modules/keycode": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz",
- "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==",
- "dev": true
- },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -27733,10 +28040,11 @@
"link": true
},
"node_modules/launch-editor": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.8.0.tgz",
- "integrity": "sha512-vJranOAJrI/llyWGRQqiDM+adrw+k83fvmmx3+nV47g3+36xM15jE+zyZ6Ffel02+xSvuM0b2GDRosXZkbb6wA==",
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz",
+ "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
"shell-quote": "^1.8.1"
@@ -28415,12 +28723,6 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
},
- "node_modules/lodash.sortby": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
- "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=",
- "dev": true
- },
"node_modules/lodash.support": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/lodash.support/-/lodash.support-2.4.1.tgz",
@@ -28852,6 +29154,7 @@
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz",
"integrity": "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==",
+ "license": "MIT",
"dependencies": {
"css-mediaquery": "^0.1.2"
}
@@ -28860,7 +29163,8 @@
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
"integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==",
- "dev": true
+ "dev": true,
+ "license": "ISC"
},
"node_modules/mathjax": {
"version": "3.2.2",
@@ -28923,6 +29227,13 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/memoizee": {
"version": "0.4.15",
"resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz",
@@ -29492,8 +29803,9 @@
"node_modules/mime-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
- "integrity": "sha1-P4fDHprxpf1IX7nbE0Qosju7e6g=",
+ "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"wildcard": "^1.1.0"
}
@@ -29625,7 +29937,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
- "dev": true
+ "dev": true,
+ "license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
@@ -30226,6 +30539,7 @@
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"dns-packet": "^5.2.2",
"thunky": "^1.0.2"
@@ -30285,7 +30599,8 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/nan": {
"version": "2.17.0",
@@ -30298,6 +30613,25 @@
"integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==",
"dev": true
},
+ "node_modules/nanoid": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
+ "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^14 || ^16 || >=18"
+ }
+ },
"node_modules/nanomatch": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
@@ -30558,10 +30892,11 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="
},
"node_modules/node-releases": {
- "version": "2.0.18",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
- "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
- "dev": true
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
+ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.9.9",
@@ -30994,24 +31329,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/object.getprototypeof": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/object.getprototypeof/-/object.getprototypeof-1.0.5.tgz",
- "integrity": "sha512-4G0QiXpoIppBUz5efmxTm/HTbVN2ioGjk/PbsaNvwISFX+saj8muGp6vNuzIdsosFxM4V/kpUVNvy/+9+DVBZQ==",
- "dev": true,
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.2.0",
- "es-abstract": "^1.22.1",
- "reflect.getprototypeof": "^1.0.4"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "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",
@@ -31065,7 +31382,8 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/octonode": {
"version": "0.9.5",
@@ -31924,16 +32242,16 @@
}
},
"node_modules/pdfjs-dist": {
- "version": "4.10.38",
- "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
- "integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
+ "version": "5.1.91",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.1.91.tgz",
+ "integrity": "sha512-qSIADdagooJB4wWCBnrBJjRvASevmxL0BwafvOuKJG5uTQdYoFBrhrRYnucKNiSc9qS6JIk0hC5y1yktFljXkA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
- "@napi-rs/canvas": "^0.1.65"
+ "@napi-rs/canvas": "^0.1.67"
}
},
"node_modules/pend": {
@@ -32045,9 +32363,10 @@
}
},
"node_modules/picocolors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
- "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -32272,7 +32591,7 @@
"node_modules/policyfile": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/policyfile/-/policyfile-0.0.4.tgz",
- "integrity": "sha1-1rgurZiueeviKOLa9ZAzEeyYLk0=",
+ "integrity": "sha512-UfDtlscNialXfmVEwEPm0t/5qtM0xPK025eYWd/ilv89hxLIhVQmt3QIzMHincLO2MBtZyww0386pt13J4aIhQ==",
"engines": {
"node": "*"
}
@@ -32309,9 +32628,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.38",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
- "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
+ "version": "8.5.3",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
+ "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"funding": [
{
"type": "opencollective",
@@ -32326,10 +32645,11 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "nanoid": "^3.3.7",
- "picocolors": "^1.0.0",
- "source-map-js": "^1.2.0"
+ "nanoid": "^3.3.8",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -33555,15 +33875,16 @@
"dev": true
},
"node_modules/postcss/node_modules/nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -33646,6 +33967,17 @@
"node": ">= 8"
}
},
+ "node_modules/preact": {
+ "version": "10.26.5",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.5.tgz",
+ "integrity": "sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
"node_modules/precond": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz",
@@ -33869,6 +34201,7 @@
"resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz",
"integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"react-is": "^16.3.2",
"warning": "^4.0.0"
@@ -33881,16 +34214,8 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true
- },
- "node_modules/prop-types-extra/node_modules/warning": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
- "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dev": true,
- "dependencies": {
- "loose-envify": "^1.0.0"
- }
+ "license": "MIT"
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
@@ -34512,12 +34837,6 @@
}
]
},
- "node_modules/queue-tick": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
- "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==",
- "dev": true
- },
"node_modules/quick-lru": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz",
@@ -34596,12 +34915,12 @@
}
},
"node_modules/react": {
- "version": "17.0.2",
- "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
- "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
"dependencies": {
- "loose-envify": "^1.1.0",
- "object-assign": "^4.1.1"
+ "loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
@@ -34612,6 +34931,7 @@
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0",
"prop-types": "^15.5.0"
@@ -34620,28 +34940,36 @@
"react": ">=16.4.1"
}
},
- "node_modules/react-bootstrap": {
- "version": "0.33.1",
- "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz",
- "integrity": "sha512-qWTRravSds87P8WC82tETy2yIso8qDqlIm0czsrduCaYAFtHuyLu0XDbUlfLXeRzqgwm5sRk2wRaTNoiVkk/YQ==",
+ "node_modules/react-bootstrap-5": {
+ "name": "react-bootstrap",
+ "version": "2.10.5",
+ "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.5.tgz",
+ "integrity": "sha512-XueAOEn64RRkZ0s6yzUTdpFtdUXs5L5491QU//8ZcODKJNDLt/r01tNyriZccjgRImH1REynUc9pqjiRMpDLWQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/runtime-corejs2": "^7.0.0",
- "classnames": "^2.2.5",
- "dom-helpers": "^3.2.0",
+ "@babel/runtime": "^7.24.7",
+ "@restart/hooks": "^0.4.9",
+ "@restart/ui": "^1.6.9",
+ "@types/react-transition-group": "^4.4.6",
+ "classnames": "^2.3.2",
+ "dom-helpers": "^5.2.1",
"invariant": "^2.2.4",
- "keycode": "^2.2.0",
- "prop-types": "^15.6.1",
- "prop-types-extra": "^1.0.1",
- "react-overlays": "^0.9.0",
- "react-prop-types": "^0.4.0",
- "react-transition-group": "^2.0.0",
- "uncontrollable": "^7.0.2",
- "warning": "^3.0.0"
+ "prop-types": "^15.8.1",
+ "prop-types-extra": "^1.1.0",
+ "react-transition-group": "^4.4.5",
+ "uncontrollable": "^7.2.1",
+ "warning": "^4.0.3"
},
"peerDependencies": {
- "react": ">=16.3.0",
- "react-dom": ">=16.3.0"
+ "@types/react": ">=16.14.8",
+ "react": ">=16.14.0",
+ "react-dom": ">=16.14.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
"node_modules/react-chartjs-2": {
@@ -34649,6 +34977,7 @@
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.0.1.tgz",
"integrity": "sha512-u38C9OxynlNCBp+79grgXRs7DSJ9w8FuQ5/HO5FbYBbri8HSZW+9SWgjVshLkbXBfXnMGWakbHEtvN0nL2UG7Q==",
"dev": true,
+ "license": "MIT",
"peerDependencies": {
"chart.js": "^4.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
@@ -34659,6 +34988,7 @@
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
"integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@icons/material": "^0.2.4",
"lodash": "^4.17.15",
@@ -34672,6 +35002,20 @@
"react": "*"
}
},
+ "node_modules/react-cookie": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.2.0.tgz",
+ "integrity": "sha512-mqhPERUyfOljq5yJ4woDFI33bjEtigsl8JDJdPPeNhr0eSVZmBc/2Vdf8mFxOUktQxhxTR1T+uF0/FRTZyBEgw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hoist-non-react-statics": "^3.3.5",
+ "hoist-non-react-statics": "^3.3.2",
+ "universal-cookie": "^7.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0"
+ }
+ },
"node_modules/react-deep-force-update": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/react-deep-force-update/-/react-deep-force-update-1.1.2.tgz",
@@ -34683,6 +35027,7 @@
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
@@ -34713,6 +35058,7 @@
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"dnd-core": "^16.0.1"
}
@@ -34750,29 +35096,26 @@
}
},
"node_modules/react-dom": {
- "version": "17.0.2",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
- "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
- "object-assign": "^4.1.1",
- "scheduler": "^0.20.2"
+ "scheduler": "^0.23.2"
},
"peerDependencies": {
- "react": "17.0.2"
+ "react": "^18.3.1"
}
},
"node_modules/react-error-boundary": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-2.3.2.tgz",
- "integrity": "sha512-ZMzi7s4pj/6A/6i9RS4tG7g1PdF2Rgr4/7FTQ8sbKHex19uNji0j+xq0OS//c6TUgQRKoL6P51BNNNFmYpRMhw==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-5.0.0.tgz",
+ "integrity": "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.11.2"
- },
- "engines": {
- "node": ">=10",
- "npm": ">=6"
+ "@babel/runtime": "^7.12.5"
},
"peerDependencies": {
"react": ">=16.13.1"
@@ -34782,13 +35125,15 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/react-google-recaptcha": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"prop-types": "^15.5.0",
"react-async-script": "^1.2.0"
@@ -34802,6 +35147,7 @@
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.3.1.tgz",
"integrity": "sha512-JAtYREK879JXaN9GdzfBI4yJeo/XyLeXWUsRABvYXiFUakhZJ40l+kaTo+i+A/3cKIED41kS/HAbZ5BzFtq/Og==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.22.5",
"html-parse-stringify": "^3.0.1"
@@ -34828,13 +35174,15 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/react-linkify": {
"version": "1.0.0-alpha",
"resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-1.0.0-alpha.tgz",
"integrity": "sha512-7gcIUvJkAXXttt1fmBK9cwn+1jTa4hbKLGCZ9J1U6EOkyb2/+LKL1Z28d9rtDLMnpvImlNlLPdTPooorl5cpmg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"linkify-it": "^2.0.3",
"tlds": "^1.199.0"
@@ -34845,40 +35193,11 @@
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"uc.micro": "^1.0.1"
}
},
- "node_modules/react-overlays": {
- "version": "0.9.3",
- "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.3.tgz",
- "integrity": "sha512-u2T7nOLnK+Hrntho4p0Nxh+BsJl0bl4Xuwj/Y0a56xywLMetgAfyjnDVrudLXsNcKGaspoC+t3C1V80W9QQTdQ==",
- "dev": true,
- "dependencies": {
- "classnames": "^2.2.5",
- "dom-helpers": "^3.2.1",
- "prop-types": "^15.5.10",
- "prop-types-extra": "^1.0.1",
- "react-transition-group": "^2.2.1",
- "warning": "^3.0.0"
- },
- "peerDependencies": {
- "react": ">=16.3.0",
- "react-dom": ">=16.3.0"
- }
- },
- "node_modules/react-prop-types": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz",
- "integrity": "sha1-+ZsL+0AGkpya8gUefBQUpcdbk9A=",
- "dev": true,
- "dependencies": {
- "warning": "^3.0.0"
- },
- "peerDependencies": {
- "react": ">=0.14.0"
- }
- },
"node_modules/react-proxy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/react-proxy/-/react-proxy-1.1.8.tgz",
@@ -34893,6 +35212,7 @@
"version": "7.2.9",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
"integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==",
+ "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/react-redux": "^7.1.20",
@@ -34927,15 +35247,17 @@
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.1.tgz",
"integrity": "sha512-+cUV/yZBYfiBj+WJtpWDJ3NtR4zgDZfHt3+xtaETKE+FCvp+RK/NJxacDQKxMHgRUTSkfA6AnGljQ5QZNsCQoA==",
"dev": true,
+ "license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-responsive": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.0.tgz",
- "integrity": "sha512-N6/UiRLGQyGUqrarhBZmrSmHi2FXSD++N5VbSKsBBvWfG0ZV7asvUBluSv5lSzdMyEVjzZ6Y8DL4OHABiztDOg==",
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.1.tgz",
+ "integrity": "sha512-OM5/cRvbtUWEX8le8RCT8scA8y2OPtb0Q/IViEyCEM5FBN8lRrkUOZnu87I88A6njxDldvxG+rLBxWiA7/UM9g==",
+ "license": "MIT",
"dependencies": {
"hyphenate-style-name": "^1.0.0",
"matchmediaquery": "^0.4.2",
@@ -34950,11 +35272,12 @@
}
},
"node_modules/react-router": {
- "version": "6.26.1",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz",
- "integrity": "sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==",
+ "version": "6.30.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz",
+ "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==",
+ "license": "MIT",
"dependencies": {
- "@remix-run/router": "1.19.1"
+ "@remix-run/router": "1.23.0"
},
"engines": {
"node": ">=14.0.0"
@@ -34964,12 +35287,13 @@
}
},
"node_modules/react-router-dom": {
- "version": "6.26.1",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.1.tgz",
- "integrity": "sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==",
+ "version": "6.30.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz",
+ "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==",
+ "license": "MIT",
"dependencies": {
- "@remix-run/router": "1.19.1",
- "react-router": "6.26.1"
+ "@remix-run/router": "1.23.0",
+ "react-router": "6.30.0"
},
"engines": {
"node": ">=14.0.0"
@@ -34990,19 +35314,20 @@
}
},
"node_modules/react-transition-group": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
- "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dev": true,
+ "license": "BSD-3-Clause",
"dependencies": {
- "dom-helpers": "^3.4.0",
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
- "prop-types": "^15.6.2",
- "react-lifecycles-compat": "^3.0.4"
+ "prop-types": "^15.6.2"
},
"peerDependencies": {
- "react": ">=15.0.0",
- "react-dom": ">=15.0.0"
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
}
},
"node_modules/reactcss": {
@@ -35010,6 +35335,7 @@
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
"integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"lodash": "^4.0.1"
}
@@ -35247,6 +35573,15 @@
"node": ">=8"
}
},
+ "node_modules/redis": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-0.7.3.tgz",
+ "integrity": "sha512-0Pgb0jOLfn6eREtEIRn/ifyZJjl2H+wUY4F/Pe7T4UhmoSrZ/1HU5ZqiBpDk8I8Wbyv2N5DpXKzbEtMj3drprg==",
+ "optional": true,
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/redis-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
@@ -35341,37 +35676,19 @@
"node": ">=4.0.0"
}
},
- "node_modules/reflect.getprototypeof": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz",
- "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==",
- "dev": true,
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.2.0",
- "es-abstract": "^1.22.1",
- "get-intrinsic": "^1.2.1",
- "globalthis": "^1.0.3",
- "which-builtin-type": "^1.1.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
"integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/regenerate-unicode-properties": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz",
- "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==",
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz",
+ "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"regenerate": "^1.4.2"
},
@@ -35389,6 +35706,7 @@
"resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz",
"integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.4"
}
@@ -35439,6 +35757,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regexparam": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz",
+ "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/regexpp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@@ -35452,15 +35780,16 @@
}
},
"node_modules/regexpu-core": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz",
- "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==",
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz",
+ "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@babel/regjsgen": "^0.8.0",
"regenerate": "^1.4.2",
- "regenerate-unicode-properties": "^10.1.0",
- "regjsparser": "^0.9.1",
+ "regenerate-unicode-properties": "^10.2.0",
+ "regjsgen": "^0.8.0",
+ "regjsparser": "^0.12.0",
"unicode-match-property-ecmascript": "^2.0.0",
"unicode-match-property-value-ecmascript": "^2.1.0"
},
@@ -35468,25 +35797,37 @@
"node": ">=4"
}
},
- "node_modules/regjsparser": {
- "version": "0.9.1",
- "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz",
- "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==",
+ "node_modules/regjsgen": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz",
+ "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==",
"dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regjsparser": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz",
+ "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
"dependencies": {
- "jsesc": "~0.5.0"
+ "jsesc": "~3.0.2"
},
"bin": {
"regjsparser": "bin/parser"
}
},
"node_modules/regjsparser/node_modules/jsesc": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
- "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
+ "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
"dev": true,
+ "license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
}
},
"node_modules/relateurl": {
@@ -35823,6 +36164,46 @@
"resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz",
"integrity": "sha1-8z/pz7Urv9UgqhgyO8ZdsRCht2w="
},
+ "node_modules/rollup": {
+ "version": "4.40.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz",
+ "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.7"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.40.0",
+ "@rollup/rollup-android-arm64": "4.40.0",
+ "@rollup/rollup-darwin-arm64": "4.40.0",
+ "@rollup/rollup-darwin-x64": "4.40.0",
+ "@rollup/rollup-freebsd-arm64": "4.40.0",
+ "@rollup/rollup-freebsd-x64": "4.40.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.40.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.40.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.40.0",
+ "@rollup/rollup-linux-arm64-musl": "4.40.0",
+ "@rollup/rollup-linux-loongarch64-gnu": "4.40.0",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.40.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.40.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.40.0",
+ "@rollup/rollup-linux-x64-gnu": "4.40.0",
+ "@rollup/rollup-linux-x64-musl": "4.40.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.40.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.40.0",
+ "@rollup/rollup-win32-x64-msvc": "4.40.0",
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/route-recognizer": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/route-recognizer/-/route-recognizer-0.3.4.tgz",
@@ -36203,12 +36584,12 @@
}
},
"node_modules/scheduler": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
- "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
"dependencies": {
- "loose-envify": "^1.1.0",
- "object-assign": "^4.1.1"
+ "loose-envify": "^1.1.0"
}
},
"node_modules/schema-utils": {
@@ -36241,8 +36622,9 @@
"node_modules/select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
- "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=",
- "dev": true
+ "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/selfsigned": {
"version": "2.4.1",
@@ -36523,8 +36905,9 @@
"node_modules/serve-index": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz",
- "integrity": "sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=",
+ "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"batch": "0.6.1",
@@ -36543,6 +36926,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
@@ -36550,8 +36934,9 @@
"node_modules/serve-index/node_modules/http-errors": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
- "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=",
+ "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"depd": "~1.1.2",
"inherits": "2.0.3",
@@ -36565,20 +36950,23 @@
"node_modules/serve-index/node_modules/inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
- "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
- "dev": true
+ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "dev": true,
+ "license": "ISC"
},
"node_modules/serve-index/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
- "dev": true
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/serve-index/node_modules/setprototypeof": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz",
"integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==",
- "dev": true
+ "dev": true,
+ "license": "ISC"
},
"node_modules/serve-static": {
"version": "1.16.2",
@@ -36712,7 +37100,8 @@
"node_modules/shallow-equal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz",
- "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg=="
+ "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==",
+ "license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
@@ -36734,10 +37123,14 @@
}
},
"node_modules/shell-quote": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz",
- "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==",
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
+ "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -36770,6 +37163,13 @@
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
"license": "MIT"
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/sigmund": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz",
@@ -37189,8 +37589,8 @@
}
},
"node_modules/socket.io": {
- "version": "0.9.19-overleaf-10",
- "resolved": "git+ssh://git@github.com/overleaf/socket.io.git#7ac322c2a5b26a4647834868d78afbb0db1f8849",
+ "version": "0.9.19-overleaf-11",
+ "resolved": "git+ssh://git@github.com/overleaf/socket.io.git#5afa587036620afa232d0f7b778ebb1541d7e4d5",
"dependencies": {
"base64id": "0.1.0",
"policyfile": "0.0.4"
@@ -37222,20 +37622,12 @@
"ultron": "1.0.x"
}
},
- "node_modules/socket.io/node_modules/redis": {
- "version": "0.7.3",
- "resolved": "https://registry.npmjs.org/redis/-/redis-0.7.3.tgz",
- "integrity": "sha512-0Pgb0jOLfn6eREtEIRn/ifyZJjl2H+wUY4F/Pe7T4UhmoSrZ/1HU5ZqiBpDk8I8Wbyv2N5DpXKzbEtMj3drprg==",
- "optional": true,
- "engines": {
- "node": "*"
- }
- },
"node_modules/sockjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
"integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"faye-websocket": "^0.11.3",
"uuid": "^8.3.2",
@@ -37247,6 +37639,7 @@
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
+ "license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -37293,9 +37686,10 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
- "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -37380,6 +37774,7 @@
"resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz",
"integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"debug": "^4.1.0",
"handle-thing": "^2.0.0",
@@ -37396,6 +37791,7 @@
"resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz",
"integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"debug": "^4.1.0",
"detect-node": "^2.0.4",
@@ -37495,11 +37891,19 @@
"node": "*"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stackframe": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
@@ -37552,6 +37956,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/std-env": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
+ "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
@@ -37565,13 +37976,13 @@
}
},
"node_modules/storybook": {
- "version": "8.6.4",
- "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.4.tgz",
- "integrity": "sha512-XXh1Acvf1r3BQX0BDLQw6yhZ7yUGvYxIcKOBuMdetnX7iXtczipJTfw0uyFwk0ltkKEE9PpJvivYmARF3u64VQ==",
+ "version": "8.6.12",
+ "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.12.tgz",
+ "integrity": "sha512-Z/nWYEHBTLK1ZBtAWdhxC0l5zf7ioJ7G4+zYqtTdYeb67gTnxNj80gehf8o8QY9L2zA2+eyMRGLC2V5fI7Z3Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@storybook/core": "8.6.4"
+ "@storybook/core": "8.6.12"
},
"bin": {
"getstorybook": "bin/index.cjs",
@@ -37639,13 +38050,16 @@
}
},
"node_modules/streamx": {
- "version": "2.15.1",
- "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz",
- "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==",
- "dev": true,
+ "version": "2.22.0",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
+ "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
+ "license": "MIT",
"dependencies": {
- "fast-fifo": "^1.1.0",
- "queue-tick": "^1.0.1"
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ },
+ "optionalDependencies": {
+ "bare-events": "^2.2.0"
}
},
"node_modules/string_decoder": {
@@ -38721,6 +39135,31 @@
"node": ">=10"
}
},
+ "node_modules/tar-fs": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
+ "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0",
+ "tar-stream": "^3.1.5"
+ },
+ "optionalDependencies": {
+ "bare-fs": "^4.0.1",
+ "bare-path": "^3.0.0"
+ }
+ },
+ "node_modules/tar-fs/node_modules/tar-stream": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
+ "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
+ "license": "MIT",
+ "dependencies": {
+ "b4a": "^1.6.4",
+ "fast-fifo": "^1.2.0",
+ "streamx": "^2.15.0"
+ }
+ },
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
@@ -38980,6 +39419,15 @@
"node": ">=8"
}
},
+ "node_modules/text-decoder": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
+ "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -39146,7 +39594,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/tildify": {
"version": "2.0.0",
@@ -39206,13 +39655,22 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
@@ -39221,6 +39679,61 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
+ "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.4.4",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
+ "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
+ "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
@@ -39242,20 +39755,15 @@
}
},
"node_modules/tlds": {
- "version": "1.228.0",
- "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.228.0.tgz",
- "integrity": "sha512-Q0TU9zh5hDs2CpRFNM7SOW3K7OSgUgJC/cMrq9t44ei4tu+G3KV8BZyIJuYVvryJHH96mKgc9WXdhgKVvGD7jg==",
+ "version": "1.256.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.256.0.tgz",
+ "integrity": "sha512-ZmyVB9DAw+FFTmLElGYJgdZFsKLYd/I59Bg9NHkCGPwAbVZNRilFWDMAdX8UG+bHuv7kfursd5XGqo/9wi26lA==",
"dev": true,
+ "license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
- "node_modules/tmatch": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/tmatch/-/tmatch-2.0.1.tgz",
- "integrity": "sha512-OHn/lzGWAsh5MBNTXUiHc595HAbIASCs6M+hDrkMObbSzsXej0SCKrQxr4J6EmRHbdo3qwyetPzuzEktkZiy4g==",
- "dev": true
- },
"node_modules/tmp": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
@@ -39443,15 +39951,6 @@
"node": ">= 4.0.0"
}
},
- "node_modules/tr46": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
- "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
- "dev": true,
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
"node_modules/traverse": {
"version": "0.6.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz",
@@ -39533,9 +40032,10 @@
}
},
"node_modules/tslib": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz",
- "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA=="
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
},
"node_modules/tsscmp": {
"version": "1.0.6",
@@ -39735,12 +40235,13 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/ufo": {
- "version": "1.5.4",
- "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
- "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
+ "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"dev": true,
"license": "MIT"
},
@@ -39807,6 +40308,7 @@
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
"integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.6.3",
"@types/react": ">=16.9.11",
@@ -39837,10 +40339,11 @@
}
},
"node_modules/unicode-canonical-property-names-ecmascript": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
- "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+ "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=4"
}
@@ -39850,6 +40353,7 @@
"resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
"integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"unicode-canonical-property-names-ecmascript": "^2.0.0",
"unicode-property-aliases-ecmascript": "^2.0.0"
@@ -39859,10 +40363,11 @@
}
},
"node_modules/unicode-match-property-value-ecmascript": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz",
- "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz",
+ "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=4"
}
@@ -39872,6 +40377,7 @@
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
"integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=4"
}
@@ -39913,6 +40419,25 @@
"node": ">=0.10.0"
}
},
+ "node_modules/universal-cookie": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.2.tgz",
+ "integrity": "sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/cookie": "^0.6.0",
+ "cookie": "^0.7.2"
+ }
+ },
+ "node_modules/universal-cookie/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
@@ -40020,9 +40545,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
- "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"funding": [
{
@@ -40038,9 +40563,10 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "escalade": "^3.1.2",
- "picocolors": "^1.0.1"
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
@@ -40452,6 +40978,177 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
+ "node_modules/vite-node": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz",
+ "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.0",
+ "es-module-lexer": "^1.6.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite-node/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/vite-node/node_modules/fdir": {
+ "version": "6.4.4",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
+ "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node/node_modules/jiti": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
+ "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/vite-node/node_modules/picomatch": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vite-node/node_modules/vite": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz",
+ "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.3",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.12"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node/node_modules/yaml": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
+ "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -40503,10 +41200,11 @@
}
},
"node_modules/warning": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
- "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
@@ -40529,6 +41227,7 @@
"resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz",
"integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"minimalistic-assert": "^1.0.0"
}
@@ -40833,9 +41532,9 @@
}
},
"node_modules/webpack-dev-middleware/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==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
+ "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -40853,15 +41552,16 @@
}
},
"node_modules/webpack-dev-server": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz",
- "integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz",
+ "integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
"@types/express": "^4.17.21",
+ "@types/express-serve-static-core": "^4.17.21",
"@types/serve-index": "^1.9.4",
"@types/serve-static": "^1.15.5",
"@types/sockjs": "^0.3.36",
@@ -40999,9 +41699,9 @@
"license": "MIT"
},
"node_modules/webpack-dev-server/node_modules/open": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz",
- "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==",
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/open/-/open-10.1.1.tgz",
+ "integrity": "sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -41018,9 +41718,9 @@
}
},
"node_modules/webpack-dev-server/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==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
+ "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -41197,6 +41897,7 @@
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
"http-parser-js": ">=0.5.1",
"safe-buffer": ">=5.1.0",
@@ -41211,6 +41912,7 @@
"resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
"integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
"dev": true,
+ "license": "Apache-2.0",
"engines": {
"node": ">=0.8.0"
}
@@ -41248,23 +41950,6 @@
"node": ">=12"
}
},
- "node_modules/whatwg-url": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz",
- "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==",
- "dev": true,
- "dependencies": {
- "lodash.sortby": "^4.7.0",
- "tr46": "^1.0.1",
- "webidl-conversions": "^4.0.2"
- }
- },
- "node_modules/whatwg-url/node_modules/webidl-conversions": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
- "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
- "dev": true
- },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -41294,32 +41979,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/which-builtin-type": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz",
- "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==",
- "dev": true,
- "dependencies": {
- "function.prototype.name": "^1.1.5",
- "has-tostringtag": "^1.0.0",
- "is-async-function": "^2.0.0",
- "is-date-object": "^1.0.5",
- "is-finalizationregistry": "^1.0.2",
- "is-generator-function": "^1.0.10",
- "is-regex": "^1.1.4",
- "is-weakref": "^1.0.2",
- "isarray": "^2.0.5",
- "which-boxed-primitive": "^1.0.2",
- "which-collection": "^1.0.1",
- "which-typed-array": "^1.1.9"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/which-collection": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz",
@@ -41360,6 +42019,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wide-align": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
@@ -41410,8 +42086,9 @@
"node_modules/wildcard": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
- "integrity": "sha1-pwIEUwhNjNLv5wup02liY94XEKU=",
- "dev": true
+ "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/with": {
"version": "7.0.2",
@@ -42015,13 +42692,13 @@
"async": "^3.2.5",
"body-parser": "^1.20.3",
"bunyan": "^1.8.15",
- "diskusage": "^1.1.3",
"dockerode": "^4.0.5",
"express": "^4.21.2",
"lodash": "^4.17.21",
"p-limit": "^3.1.0",
"request": "^2.88.2",
"send": "^0.19.0",
+ "tar-fs": "^3.0.4",
"workerpool": "^6.1.5"
},
"devDependencies": {
@@ -42038,6 +42715,27 @@
"typescript": "^5.0.4"
}
},
+ "services/clsi-cache": {
+ "name": "@overleaf/clsi-cache",
+ "dependencies": {
+ "@overleaf/fetch-utils": "*",
+ "@overleaf/logger": "*",
+ "@overleaf/metrics": "*",
+ "@overleaf/o-error": "*",
+ "@overleaf/promise-utils": "*",
+ "@overleaf/settings": "*",
+ "body-parser": "^1.20.3",
+ "bunyan": "^1.8.15",
+ "celebrate": "^15.0.3",
+ "express": "^4.21.2",
+ "p-limit": "^3.1.0"
+ },
+ "devDependencies": {
+ "chai": "^4.3.6",
+ "chai-as-promised": "^7.1.1",
+ "mocha": "^11.1.0"
+ }
+ },
"services/clsi-perf": {
"name": "@overleaf/clsi-perf",
"dependencies": {
@@ -42136,6 +42834,18 @@
"node": ">= 8.0"
}
},
+ "services/clsi/node_modules/dockerode/node_modules/tar-fs": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
+ "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
"services/clsi/node_modules/nan": {
"version": "2.22.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz",
@@ -42215,18 +42925,6 @@
"node": ">=8"
}
},
- "services/clsi/node_modules/tar-fs": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
- "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
- "license": "MIT",
- "dependencies": {
- "chownr": "^1.1.1",
- "mkdirp-classic": "^0.5.2",
- "pump": "^3.0.0",
- "tar-stream": "^2.1.4"
- }
- },
"services/clsi/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
@@ -42912,9 +43610,9 @@
"jsonwebtoken": "^9.0.2",
"method-override": "^2.3.10",
"prop-types": "^15.8.1",
- "react": "^17.0.2",
+ "react": "^18.3.1",
"react-cookie": "^7.2.0",
- "react-dom": "^17.0.2",
+ "react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
"react-helmet": "^6.1.0",
"react-redux": "^7.2.2",
@@ -42936,12 +43634,12 @@
"@babel/preset-env": "^7.25.3",
"@babel/preset-react": "^7.24.7",
"@babel/register": "^7.24.6",
- "@testing-library/react": "^12.1.5",
+ "@testing-library/react": "^16.3.0",
+ "@vitejs/plugin-react": "^4.3.4",
"babel-loader": "^9.1.3",
"babel-plugin-react-transform": "^2.0.2",
"babel-plugin-transform-react-remove-prop-types": "^0.3.3",
- "chai": "^4.3.10",
- "chai-as-promised": "^7.1.1",
+ "combobo": "^2.0.4",
"css-loader": "^6.8.1",
"cssnano": "^6.0.0",
"eslint": "^7.21.0",
@@ -42949,11 +43647,8 @@
"eslint-config-standard": "^16.0.3",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-unicorn": "^56.0.0",
- "expect": "^1.15.2",
"file-loader": "^6.2.0",
- "jsdom": "^20.0.0",
"mini-css-extract-plugin": "^2.7.6",
- "mocha": "^11.1.0",
"nodemon": "^3.0.1",
"postcss": "^8.4.31",
"postcss-import": "^15.1.0",
@@ -42963,10 +43658,9 @@
"react-transform-hmr": "^1.0.4",
"redux-mock-store": "1.5.0",
"sandboxed-module": "^2.0.4",
- "sinon": "^19.0.2",
- "sinon-chai": "^3.7.0",
"style-loader": "^3.3.3",
"url-loader": "^4.1.1",
+ "vitest": "^3.1.2",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.2",
@@ -42986,6 +43680,197 @@
"@babel/highlight": "^7.10.4"
}
},
+ "services/latexqc/node_modules/@babel/core": {
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
+ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.26.2",
+ "@babel/generator": "^7.26.10",
+ "@babel/helper-compilation-targets": "^7.26.5",
+ "@babel/helper-module-transforms": "^7.26.0",
+ "@babel/helpers": "^7.26.10",
+ "@babel/parser": "^7.26.10",
+ "@babel/template": "^7.26.9",
+ "@babel/traverse": "^7.26.10",
+ "@babel/types": "^7.26.10",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "services/latexqc/node_modules/@babel/core/node_modules/@babel/code-frame": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
+ "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "services/latexqc/node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "services/latexqc/node_modules/@babel/generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
+ "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.27.0",
+ "@babel/types": "^7.27.0",
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "services/latexqc/node_modules/@babel/helpers": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
+ "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.27.0",
+ "@babel/types": "^7.27.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "services/latexqc/node_modules/@babel/parser": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
+ "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "services/latexqc/node_modules/@babel/template": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
+ "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "@babel/parser": "^7.27.0",
+ "@babel/types": "^7.27.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "services/latexqc/node_modules/@babel/template/node_modules/@babel/code-frame": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
+ "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "services/latexqc/node_modules/@babel/traverse": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
+ "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "@babel/generator": "^7.27.0",
+ "@babel/parser": "^7.27.0",
+ "@babel/template": "^7.27.0",
+ "@babel/types": "^7.27.0",
+ "debug": "^4.3.1",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "services/latexqc/node_modules/@babel/traverse/node_modules/@babel/code-frame": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
+ "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "services/latexqc/node_modules/@babel/traverse/node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "services/latexqc/node_modules/@babel/types": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
+ "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"services/latexqc/node_modules/@eslint/eslintrc": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz",
@@ -43021,57 +43906,120 @@
"node": ">=10.10.0"
}
},
- "services/latexqc/node_modules/@sinonjs/commons": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
- "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+ "services/latexqc/node_modules/@types/debug": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
+ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
- "type-detect": "4.0.8"
+ "@types/ms": "*"
}
},
- "services/latexqc/node_modules/@sinonjs/commons/node_modules/type-detect": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
- "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "services/latexqc/node_modules/@vitejs/plugin-react": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz",
+ "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==",
"dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.26.10",
+ "@babel/plugin-transform-react-jsx-self": "^7.25.9",
+ "@babel/plugin-transform-react-jsx-source": "^7.25.9",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
"engines": {
- "node": ">=4"
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
}
},
- "services/latexqc/node_modules/@sinonjs/fake-timers": {
- "version": "13.0.2",
- "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz",
- "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==",
+ "services/latexqc/node_modules/@vitest/expect": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz",
+ "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@sinonjs/commons": "^3.0.1"
+ "@vitest/spy": "3.1.2",
+ "@vitest/utils": "3.1.2",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "services/latexqc/node_modules/@sinonjs/samsam": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz",
- "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==",
+ "services/latexqc/node_modules/@vitest/mocker": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz",
+ "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@sinonjs/commons": "^3.0.1",
- "lodash.get": "^4.4.2",
- "type-detect": "^4.1.0"
+ "@vitest/spy": "3.1.2",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
}
},
- "services/latexqc/node_modules/@sinonjs/text-encoding": {
- "version": "0.7.3",
- "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz",
- "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==",
- "dev": true
- },
- "services/latexqc/node_modules/@types/hoist-non-react-statics": {
- "version": "3.3.5",
- "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
- "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==",
+ "services/latexqc/node_modules/@vitest/pretty-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz",
+ "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "@types/react": "*",
- "hoist-non-react-statics": "^3.3.0"
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "services/latexqc/node_modules/@vitest/spy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz",
+ "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "services/latexqc/node_modules/@vitest/utils": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz",
+ "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.1.2",
+ "loupe": "^3.1.3",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
"services/latexqc/node_modules/acorn-globals": {
@@ -43079,6 +44027,8 @@
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
"integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"acorn": "^8.1.0",
"acorn-walk": "^8.0.2"
@@ -43089,6 +44039,8 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
+ "optional": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -43101,6 +44053,8 @@
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
"integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"acorn": "^8.11.0"
},
@@ -43113,6 +44067,8 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
+ "optional": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -43144,6 +44100,16 @@
"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",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"services/latexqc/node_modules/attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
@@ -43172,21 +44138,20 @@
}
},
"services/latexqc/node_modules/chai": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
- "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
+ "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "assertion-error": "^1.1.0",
- "check-error": "^1.0.3",
- "deep-eql": "^4.1.3",
- "get-func-name": "^2.0.2",
- "loupe": "^2.3.6",
- "pathval": "^1.1.1",
- "type-detect": "^4.1.0"
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
},
"engines": {
- "node": ">=4"
+ "node": ">=12"
}
},
"services/latexqc/node_modules/chalk": {
@@ -43205,14 +44170,23 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "services/latexqc/node_modules/cookie": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
- "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "services/latexqc/node_modules/check-error": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+ "dev": true,
+ "license": "MIT",
"engines": {
- "node": ">= 0.6"
+ "node": ">= 16"
}
},
+ "services/latexqc/node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"services/latexqc/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -43239,31 +44213,22 @@
"license": "MIT"
},
"services/latexqc/node_modules/deep-eql": {
- "version": "4.1.4",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
- "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
- "dependencies": {
- "type-detect": "^4.0.0"
- },
+ "license": "MIT",
"engines": {
"node": ">=6"
}
},
- "services/latexqc/node_modules/diff": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
- "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
- "dev": true,
- "engines": {
- "node": ">=0.3.1"
- }
- },
"services/latexqc/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=0.12"
},
@@ -43390,6 +44355,21 @@
"node": ">=4"
}
},
+ "services/latexqc/node_modules/fdir": {
+ "version": "6.4.4",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
+ "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"services/latexqc/node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@@ -43418,6 +44398,8 @@
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
@@ -43435,6 +44417,18 @@
"node": ">= 4"
}
},
+ "services/latexqc/node_modules/jiti": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
+ "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "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",
@@ -43453,6 +44447,8 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
"integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"abab": "^2.0.6",
"acorn": "^8.8.1",
@@ -43498,6 +44494,8 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
+ "optional": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -43511,11 +44509,12 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
- "services/latexqc/node_modules/just-extend": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz",
- "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==",
- "dev": true
+ "services/latexqc/node_modules/loupe": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
+ "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
+ "dev": true,
+ "license": "MIT"
},
"services/latexqc/node_modules/method-override": {
"version": "2.3.10",
@@ -43544,19 +44543,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
- "services/latexqc/node_modules/nise": {
- "version": "6.1.1",
- "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz",
- "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==",
- "dev": true,
- "dependencies": {
- "@sinonjs/commons": "^3.0.1",
- "@sinonjs/fake-timers": "^13.0.1",
- "@sinonjs/text-encoding": "^0.7.3",
- "just-extend": "^6.2.0",
- "path-to-regexp": "^8.1.0"
- }
- },
"services/latexqc/node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -43579,6 +44565,8 @@
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"entities": "^4.4.0"
},
@@ -43586,26 +44574,27 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
- "services/latexqc/node_modules/path-to-regexp": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
- "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
+ "services/latexqc/node_modules/pathval": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
+ "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=16"
+ "node": ">= 14.16"
}
},
- "services/latexqc/node_modules/react-cookie": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.2.0.tgz",
- "integrity": "sha512-mqhPERUyfOljq5yJ4woDFI33bjEtigsl8JDJdPPeNhr0eSVZmBc/2Vdf8mFxOUktQxhxTR1T+uF0/FRTZyBEgw==",
- "dependencies": {
- "@types/hoist-non-react-statics": "^3.3.5",
- "hoist-non-react-statics": "^3.3.2",
- "universal-cookie": "^7.0.0"
+ "services/latexqc/node_modules/picomatch": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
},
- "peerDependencies": {
- "react": ">= 16.3.0"
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
}
},
"services/latexqc/node_modules/react-dropzone": {
@@ -43651,11 +44640,23 @@
"react": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
+ "services/latexqc/node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"services/latexqc/node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
@@ -43723,24 +44724,6 @@
"node": ">=10"
}
},
- "services/latexqc/node_modules/sinon": {
- "version": "19.0.2",
- "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz",
- "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==",
- "dev": true,
- "dependencies": {
- "@sinonjs/commons": "^3.0.1",
- "@sinonjs/fake-timers": "^13.0.2",
- "@sinonjs/samsam": "^8.0.1",
- "diff": "^7.0.0",
- "nise": "^6.1.1",
- "supports-color": "^7.2.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/sinon"
- }
- },
"services/latexqc/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -43753,11 +44736,23 @@
"node": ">=8"
}
},
+ "services/latexqc/node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"services/latexqc/node_modules/tr46": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"punycode": "^2.1.1"
},
@@ -43765,22 +44760,150 @@
"node": ">=12"
}
},
- "services/latexqc/node_modules/type-detect": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
- "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "services/latexqc/node_modules/vite": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz",
+ "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==",
"dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.3",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.12"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
"engines": {
- "node": ">=4"
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
}
},
- "services/latexqc/node_modules/universal-cookie": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.0.tgz",
- "integrity": "sha512-PvcyflJAYACJKr28HABxkGemML5vafHmiL4ICe3e+BEKXRMt0GaFLZhAwgv637kFFnnfiSJ8e6jknrKkMrU+PQ==",
+ "services/latexqc/node_modules/vitest": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz",
+ "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "@types/cookie": "^0.6.0",
- "cookie": "^0.6.0"
+ "@vitest/expect": "3.1.2",
+ "@vitest/mocker": "3.1.2",
+ "@vitest/pretty-format": "^3.1.2",
+ "@vitest/runner": "3.1.2",
+ "@vitest/snapshot": "3.1.2",
+ "@vitest/spy": "3.1.2",
+ "@vitest/utils": "3.1.2",
+ "chai": "^5.2.0",
+ "debug": "^4.4.0",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.13",
+ "tinypool": "^1.0.2",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0",
+ "vite-node": "3.1.2",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.1.2",
+ "@vitest/ui": "3.1.2",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
}
},
"services/latexqc/node_modules/w3c-xmlserializer": {
@@ -43788,6 +44911,8 @@
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
"integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"xml-name-validator": "^4.0.0"
},
@@ -43828,6 +44953,8 @@
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
"dev": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"tr46": "^3.0.0",
"webidl-conversions": "^7.0.0"
@@ -43836,6 +44963,21 @@
"node": ">=12"
}
},
+ "services/latexqc/node_modules/yaml": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
+ "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"services/notifications": {
"name": "@overleaf/notifications",
"license": "ISC",
@@ -43956,7 +45098,7 @@
"lodash": "^4.17.21",
"proxy-addr": "^2.0.7",
"request": "^2.88.2",
- "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-10",
+ "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-11",
"socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5"
},
"devDependencies": {
@@ -44420,12 +45562,12 @@
"yauzl": "^2.10.0"
},
"devDependencies": {
- "@babel/cli": "^7.24.8",
- "@babel/core": "^7.25.2",
- "@babel/preset-env": "^7.25.3",
- "@babel/preset-react": "^7.24.7",
- "@babel/preset-typescript": "^7.24.7",
- "@babel/register": "^7.24.6",
+ "@babel/cli": "^7.27.0",
+ "@babel/core": "^7.26.10",
+ "@babel/preset-env": "^7.26.9",
+ "@babel/preset-react": "^7.26.3",
+ "@babel/preset-typescript": "^7.27.0",
+ "@babel/register": "^7.25.9",
"@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-markdown": "^6.3.2",
@@ -44444,7 +45586,7 @@
"@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.3.tar.gz",
"@overleaf/ranges-tracker": "*",
"@overleaf/stream-utils": "*",
- "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
+ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.16",
"@pollyjs/adapter-node-http": "^6.0.6",
"@pollyjs/core": "^6.0.6",
"@pollyjs/persister-fs": "^6.0.6",
@@ -44452,20 +45594,19 @@
"@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161",
"@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b",
"@sentry/browser": "7.46.0",
- "@storybook/addon-a11y": "^8.6.4",
- "@storybook/addon-essentials": "^8.6.4",
- "@storybook/addon-interactions": "^8.6.4",
- "@storybook/addon-links": "^8.6.4",
+ "@storybook/addon-a11y": "^8.6.12",
+ "@storybook/addon-essentials": "^8.6.12",
+ "@storybook/addon-interactions": "^8.6.12",
+ "@storybook/addon-links": "^8.6.12",
"@storybook/addon-styling-webpack": "^1.0.1",
- "@storybook/addon-webpack5-compiler-babel": "^3.0.5",
- "@storybook/cli": "^8.6.4",
- "@storybook/react": "^8.6.4",
- "@storybook/react-webpack5": "^8.6.4",
- "@storybook/theming": "^8.6.4",
+ "@storybook/addon-webpack5-compiler-babel": "^3.0.6",
+ "@storybook/cli": "^8.6.12",
+ "@storybook/react": "^8.6.12",
+ "@storybook/react-webpack5": "^8.6.12",
+ "@storybook/theming": "^8.6.12",
"@testing-library/cypress": "^10.0.1",
- "@testing-library/dom": "^9.3.0",
- "@testing-library/react": "^12.1.5",
- "@testing-library/react-hooks": "^8.0.1",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.4.3",
"@types/bootstrap": "^5.2.10",
"@types/bootstrap-5": "npm:@types/bootstrap@^5.2.10",
@@ -44476,13 +45617,12 @@
"@types/express": "^4.17.13",
"@types/mocha": "^9.1.0",
"@types/mocha-each": "^2.0.0",
- "@types/react": "^17.0.40",
- "@types/react-bootstrap": "^0.32.36",
- "@types/react-color": "^3.0.6",
- "@types/react-dom": "^17.0.13",
- "@types/react-google-recaptcha": "^2.1.5",
- "@types/react-linkify": "^1.0.0",
- "@types/react-overlays": "^1.1.3",
+ "@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",
+ "@types/react-linkify": "^1.0.4",
"@types/recurly__recurly-js": "^4.22.0",
"@types/sinon-chai": "^3.2.8",
"@types/uuid": "^9.0.8",
@@ -44517,7 +45657,7 @@
"classnames": "^2.2.6",
"cookie-signature": "^1.2.1",
"copy-webpack-plugin": "^11.0.0",
- "core-js": "^3.38.1",
+ "core-js": "^3.41.0",
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"cypress": "13.13.2",
@@ -44526,7 +45666,7 @@
"daterangepicker": "2.1.27",
"diff": "^5.1.0",
"dompurify": "^3.2.4",
- "downshift": "^6.1.0",
+ "downshift": "^9.0.9",
"es6-promise": "^4.2.8",
"escodegen": "^2.0.0",
"eslint-config-standard-jsx": "^11.0.0",
@@ -44538,7 +45678,7 @@
"esmock": "^2.6.7",
"events": "^3.3.0",
"fake-indexeddb": "^6.0.0",
- "fetch-mock": "^9.10.2",
+ "fetch-mock": "^12.5.2",
"formik": "^2.2.9",
"fuse.js": "^3.0.0",
"glob": "^7.1.6",
@@ -44563,25 +45703,23 @@
"nock": "^13.5.6",
"nvd3": "^1.8.6",
"overleaf-editor-core": "*",
- "pdfjs-dist": "4.10.38",
+ "pdfjs-dist": "5.1.91",
"pirates": "^4.0.1",
"postcss": "^8.4.31",
"postcss-loader": "^7.3.3",
"prop-types": "^15.7.2",
"qrcode": "^1.4.4",
- "react": "^17.0.2",
- "react-bootstrap": "^0.33.1",
+ "react": "^18.3.1",
"react-bootstrap-5": "npm:react-bootstrap@^2.10.5",
"react-chartjs-2": "^5.0.1",
"react-color": "^2.19.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
- "react-dom": "^17.0.2",
- "react-error-boundary": "^2.3.1",
+ "react-dom": "^18.3.1",
+ "react-error-boundary": "^5.0.0",
"react-google-recaptcha": "^3.1.0",
"react-i18next": "^13.3.1",
"react-linkify": "^1.0.0-alpha",
- "react-overlays": "^0.9.3",
"react-refresh": "^0.14.0",
"react-resizable-panels": "^2.1.1",
"resolve-url-loader": "^5.0.0",
@@ -44593,7 +45731,7 @@
"sinon": "^7.5.0",
"sinon-chai": "^3.7.0",
"sinon-mongoose": "^2.3.0",
- "storybook": "^8.6.4",
+ "storybook": "^8.6.12",
"stylelint-config-standard-scss": "^13.1.0",
"terser-webpack-plugin": "^5.3.9",
"thread-loader": "^4.0.2",
@@ -44606,23 +45744,11 @@
"webpack": "^5.98.0",
"webpack-assets-manifest": "^5.2.1",
"webpack-cli": "^5.1.4",
- "webpack-dev-server": "^5.2.0",
+ "webpack-dev-server": "^5.2.1",
"webpack-merge": "^5.10.0",
"yup": "^0.32.11"
}
},
- "services/web/node_modules/@babel/runtime": {
- "version": "7.25.7",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz",
- "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==",
- "dev": true,
- "dependencies": {
- "regenerator-runtime": "^0.14.0"
- },
- "engines": {
- "node": ">=6.9.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",
@@ -44812,30 +45938,12 @@
"lodash": "^4.17.15"
}
},
- "services/web/node_modules/@testing-library/dom": {
- "version": "9.3.0",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.0.tgz",
- "integrity": "sha512-Dffe68pGwI6WlLRYR2I0piIkyole9cSBH5jGQKCGMRpHW5RHCqAUaqc2Kv0tUyd4dU4DLPKhJIjyKOnjv4tuUw==",
+ "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,
- "dependencies": {
- "@babel/code-frame": "^7.10.4",
- "@babel/runtime": "^7.12.5",
- "@types/aria-query": "^5.0.1",
- "aria-query": "^5.0.0",
- "chalk": "^4.1.0",
- "dom-accessibility-api": "^0.5.9",
- "lz-string": "^1.5.0",
- "pretty-format": "^27.0.2"
- },
- "engines": {
- "node": ">=14"
- }
- },
- "services/web/node_modules/@types/aria-query": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz",
- "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==",
- "dev": true
+ "license": "MIT"
},
"services/web/node_modules/@types/express": {
"version": "4.17.21",
@@ -44855,22 +45963,12 @@
"integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==",
"dev": true
},
- "services/web/node_modules/@uppy/companion-client": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-3.7.0.tgz",
- "integrity": "sha512-37qJNMkqo01SM9h2gkFbV6e+aXM02s2zAda2dGsRLRsjvl/Tx69NlmxJ3xqG/7HWRnYcbBWtspb7y0tt1i/afg==",
- "dev": true,
- "dependencies": {
- "@uppy/utils": "^5.7.0",
- "namespace-emitter": "^2.0.1",
- "p-retry": "^6.1.0"
- }
- },
"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",
@@ -44882,17 +45980,12 @@
"preact": "^10.5.13"
}
},
- "services/web/node_modules/@uppy/core/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
- },
"services/web/node_modules/@uppy/dashboard": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-3.7.1.tgz",
"integrity": "sha512-qtCMXd2Ymrw0qNGSTlEEMyyDkGUCm+wX5/VrmV9lnfT7JtlSfotUK0K6KvkBeu2v1Chsu27C6Xlq6RddZMR2xQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@transloadit/prettier-bytes": "0.0.7",
"@uppy/informer": "^3.0.4",
@@ -44911,11 +46004,19 @@
"@uppy/core": "^3.7.1"
}
},
+ "services/web/node_modules/@uppy/dashboard/node_modules/@transloadit/prettier-bytes": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
+ "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"services/web/node_modules/@uppy/drag-drop": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@uppy/drag-drop/-/drag-drop-3.0.3.tgz",
"integrity": "sha512-0bCgQKxg+9vkxQipTgrX9yQIuK9a0hZrkipm1+Ynq6jTeig49b7II1bWYnoKdiYhi6nRE4UnDJf4z09yCAU7rA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@uppy/utils": "^5.4.3",
"preact": "^10.5.13"
@@ -44929,19 +46030,7 @@
"resolved": "https://registry.npmjs.org/@uppy/file-input/-/file-input-3.0.4.tgz",
"integrity": "sha512-D7Nw9GgpABYTcC8SZluDyxd+ppe7+gJejNbPZqMpQyW1S/ME3me55dkDQaVWn8yrgv7347zO2ciue9Rfmko+rQ==",
"dev": true,
- "dependencies": {
- "@uppy/utils": "^5.5.2",
- "preact": "^10.5.13"
- },
- "peerDependencies": {
- "@uppy/core": "^3.6.0"
- }
- },
- "services/web/node_modules/@uppy/informer": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@uppy/informer/-/informer-3.0.4.tgz",
- "integrity": "sha512-gzocdxn8qAFsW2EryehwjghladaBgv6Isjte53FTBV7o/vjaHPP6huKGbYpljyuQi8i9V+KrmvNGslofssgJ4g==",
- "dev": true,
+ "license": "MIT",
"dependencies": {
"@uppy/utils": "^5.5.2",
"preact": "^10.5.13"
@@ -44955,6 +46044,7 @@
"resolved": "https://registry.npmjs.org/@uppy/progress-bar/-/progress-bar-3.0.4.tgz",
"integrity": "sha512-sxv/mG7Uc9uyTnRvfcXBhO+TWd+UqjuW5aHXCKWwTkMgDShHR0T46sEk12q+jwgbFwyeFg3p0GU3hgUxqxiEUQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@uppy/utils": "^5.5.2",
"preact": "^10.5.13"
@@ -44963,64 +46053,12 @@
"@uppy/core": "^3.6.0"
}
},
- "services/web/node_modules/@uppy/provider-views": {
- "version": "3.8.0",
- "resolved": "https://registry.npmjs.org/@uppy/provider-views/-/provider-views-3.8.0.tgz",
- "integrity": "sha512-sTtx5bgsg2WVR+MyF0gnnM3Z7g3CyFx+Stlz//AvB6g27EMqtqO4zwDR3mestMrETkWYov5bhhqUbt2BaeANpA==",
- "dev": true,
- "dependencies": {
- "@uppy/utils": "^5.7.0",
- "classnames": "^2.2.6",
- "nanoid": "^4.0.0",
- "p-queue": "^7.3.4",
- "preact": "^10.5.13"
- },
- "peerDependencies": {
- "@uppy/core": "^3.8.0"
- }
- },
- "services/web/node_modules/@uppy/provider-views/node_modules/eventemitter3": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
- "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
- "dev": true,
- "license": "MIT"
- },
- "services/web/node_modules/@uppy/provider-views/node_modules/p-queue": {
- "version": "7.4.1",
- "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.4.1.tgz",
- "integrity": "sha512-vRpMXmIkYF2/1hLBKisKeVYJZ8S2tZ0zEAmIJgdVKP2nq0nh4qCdf8bgw+ZgKrkh71AOCaqzwbJJk1WtdcF3VA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eventemitter3": "^5.0.1",
- "p-timeout": "^5.0.2"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "services/web/node_modules/@uppy/provider-views/node_modules/p-timeout": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz",
- "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"services/web/node_modules/@uppy/react": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@uppy/react/-/react-3.2.1.tgz",
"integrity": "sha512-PoLplDF6YDI7f06T8ORnJhav6CcKNSYWJETXqItZR3jcXIve6pdcCuskqd+l0yiYWf4J2IdyLQXtzgGfIJl7xQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@uppy/utils": "^5.6.0",
"prop-types": "^15.6.1"
@@ -45052,51 +46090,12 @@
}
}
},
- "services/web/node_modules/@uppy/status-bar": {
- "version": "3.2.5",
- "resolved": "https://registry.npmjs.org/@uppy/status-bar/-/status-bar-3.2.5.tgz",
- "integrity": "sha512-bRSxBPio5B+Kuf6w8ll+/i9VUwG8f0FnbZ1yQvCr8J9vxhd0Z5hvwhX4NP8uzHC6ZPJHlEQOTsxzGQ6y+Mdm0A==",
- "dev": true,
- "dependencies": {
- "@transloadit/prettier-bytes": "0.0.9",
- "@uppy/utils": "^5.5.2",
- "classnames": "^2.2.6",
- "preact": "^10.5.13"
- },
- "peerDependencies": {
- "@uppy/core": "^3.6.0"
- }
- },
- "services/web/node_modules/@uppy/status-bar/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
- },
- "services/web/node_modules/@uppy/store-default": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-3.2.0.tgz",
- "integrity": "sha512-Y7t0peUG89ZKa30vM4qlRIC6uKxIfOANeMT9Nzjwcxvzz8l7es22jG3eAj9WF2F7YSu7xdsH8ODs6SIrJJ8gow==",
- "dev": true
- },
- "services/web/node_modules/@uppy/thumbnail-generator": {
- "version": "3.0.6",
- "resolved": "https://registry.npmjs.org/@uppy/thumbnail-generator/-/thumbnail-generator-3.0.6.tgz",
- "integrity": "sha512-gsi/BQBiunHneXCbo8VglFbhEb0CoQXQjCyGNKoEq/deEcbXhBBDxkiGcgv83l5GZJl2jLiKWqXnXAXREkldrQ==",
- "dev": true,
- "dependencies": {
- "@uppy/utils": "^5.5.2",
- "exifr": "^7.0.0"
- },
- "peerDependencies": {
- "@uppy/core": "^3.6.0"
- }
- },
"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"
@@ -45107,6 +46106,7 @@
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-3.6.0.tgz",
"integrity": "sha512-HgWr+CvJzJXAp639AiZatdEWmRdhhN5LrjTZurAkvm9nPQarpi1bo0DChO+1bpkXWOR/1VarBbZOr8lNecEn7Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@uppy/companion-client": "^3.7.0",
"@uppy/utils": "^5.7.0",
@@ -45152,30 +46152,6 @@
"ajv": "^8.8.2"
}
},
- "services/web/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/web/node_modules/aria-query": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
- "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
- "dev": true,
- "dependencies": {
- "dequal": "^2.0.3"
- }
- },
"services/web/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -45207,22 +46183,6 @@
"ieee754": "^1.2.1"
}
},
- "services/web/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/web/node_modules/csv": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/csv/-/csv-6.2.5.tgz",
@@ -45269,16 +46229,6 @@
}
}
},
- "services/web/node_modules/dom-helpers": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
- "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
- "dev": true,
- "dependencies": {
- "@babel/runtime": "^7.8.7",
- "csstype": "^3.0.2"
- }
- },
"services/web/node_modules/duplexify": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz",
@@ -45307,11 +46257,21 @@
"node": ">=0.8.x"
}
},
- "services/web/node_modules/exifr": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
- "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
- "dev": true
+ "services/web/node_modules/fetch-mock": {
+ "version": "12.5.2",
+ "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.2.tgz",
+ "integrity": "sha512-b5KGDFmdmado2MPQjZl6ix3dAG3iwCitb0XQwN72y2s9VnWZ3ObaGNy+bkpm1390foiLDybdJ7yjRGKD36kATw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/glob-to-regexp": "^0.4.4",
+ "dequal": "^2.0.3",
+ "glob-to-regexp": "^0.4.1",
+ "regexparam": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18.11.0"
+ }
},
"services/web/node_modules/google-auth-library": {
"version": "8.7.0",
@@ -45435,12 +46395,6 @@
"node": ">=12"
}
},
- "services/web/node_modules/memoize-one": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
- "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
- "dev": true
- },
"services/web/node_modules/method-override": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/method-override/-/method-override-2.3.10.tgz",
@@ -45500,24 +46454,6 @@
"node": ">= 6.0.0"
}
},
- "services/web/node_modules/nanoid": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
- "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "bin": {
- "nanoid": "bin/nanoid.js"
- },
- "engines": {
- "node": "^14 || ^16 || >=18"
- }
- },
"services/web/node_modules/nise": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz",
@@ -45568,23 +46504,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "services/web/node_modules/p-retry": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.0.tgz",
- "integrity": "sha512-JA6nkq6hKyWLLasXQXUrO4z8BUZGUt/LjlJxx8Gb2+2ntodU/SS63YZ8b0LUTbQ8ZB9iwOfhEPhg4ykKnn2KsA==",
- "dev": true,
- "dependencies": {
- "@types/retry": "0.12.2",
- "is-network-error": "^1.0.0",
- "retry": "^0.13.1"
- },
- "engines": {
- "node": ">=16.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"services/web/node_modules/path-to-regexp": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz",
@@ -45594,69 +46513,6 @@
"isarray": "0.0.1"
}
},
- "services/web/node_modules/preact": {
- "version": "10.19.3",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz",
- "integrity": "sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==",
- "dev": true,
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/preact"
- }
- },
- "services/web/node_modules/react-bootstrap-5": {
- "name": "react-bootstrap",
- "version": "2.10.5",
- "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.5.tgz",
- "integrity": "sha512-XueAOEn64RRkZ0s6yzUTdpFtdUXs5L5491QU//8ZcODKJNDLt/r01tNyriZccjgRImH1REynUc9pqjiRMpDLWQ==",
- "dev": true,
- "dependencies": {
- "@babel/runtime": "^7.24.7",
- "@restart/hooks": "^0.4.9",
- "@restart/ui": "^1.6.9",
- "@types/react-transition-group": "^4.4.6",
- "classnames": "^2.3.2",
- "dom-helpers": "^5.2.1",
- "invariant": "^2.2.4",
- "prop-types": "^15.8.1",
- "prop-types-extra": "^1.1.0",
- "react-transition-group": "^4.4.5",
- "uncontrollable": "^7.2.1",
- "warning": "^4.0.3"
- },
- "peerDependencies": {
- "@types/react": ">=16.14.8",
- "react": ">=16.14.0",
- "react-dom": ">=16.14.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "services/web/node_modules/react-bootstrap-5/node_modules/react-transition-group": {
- "version": "4.4.5",
- "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
- "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
- "dev": true,
- "dependencies": {
- "@babel/runtime": "^7.5.5",
- "dom-helpers": "^5.0.1",
- "loose-envify": "^1.4.0",
- "prop-types": "^15.6.2"
- },
- "peerDependencies": {
- "react": ">=16.6.0",
- "react-dom": ">=16.6.0"
- }
- },
- "services/web/node_modules/regenerator-runtime": {
- "version": "0.14.1",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
- "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
- "dev": true
- },
"services/web/node_modules/retry-request": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/retry-request/-/retry-request-5.0.2.tgz",
@@ -45768,18 +46624,6 @@
"resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.1.tgz",
"integrity": "sha512-ApK+WTJ5bCOf0A2tlec1qhvr8bGEBM/sgXXB7mysdCYgZJO5DZeaV3h3G+g0HnAQ372P5IhiGqnW29zoLOfTzQ=="
},
- "services/web/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/web/node_modules/teeny-request": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-8.0.2.tgz",
@@ -45867,15 +46711,6 @@
"uuid": "dist/bin/uuid"
}
},
- "services/web/node_modules/warning": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
- "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
- "dev": true,
- "dependencies": {
- "loose-envify": "^1.0.0"
- }
- },
"services/web/node_modules/xml-crypto": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-2.1.6.tgz",
diff --git a/package.json b/package.json
index ae25e19029..7dc9a63e29 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
"services/analytics",
"services/chat",
"services/clsi",
+ "services/clsi-cache",
"services/clsi-perf",
"services/contacts",
"services/docstore",
diff --git a/patches/pdfjs-dist+5.1.91.patch b/patches/pdfjs-dist+5.1.91.patch
new file mode 100644
index 0000000000..24f6e2adc9
--- /dev/null
+++ b/patches/pdfjs-dist+5.1.91.patch
@@ -0,0 +1,22 @@
+diff --git a/node_modules/pdfjs-dist/build/pdf.worker.mjs b/node_modules/pdfjs-dist/build/pdf.worker.mjs
+index 6c5c6f1..bb6b7d1 100644
+--- a/node_modules/pdfjs-dist/build/pdf.worker.mjs
++++ b/node_modules/pdfjs-dist/build/pdf.worker.mjs
+@@ -1830,7 +1830,7 @@ async function __wbg_init(module_or_path) {
+ }
+ }
+ if (typeof module_or_path === 'undefined') {
+- module_or_path = new URL('qcms_bg.wasm', import.meta.url);
++ module_or_path = new URL(/* webpackIgnore: true */ 'qcms_bg.wasm', import.meta.url);
+ }
+ const imports = __wbg_get_imports();
+ if (typeof module_or_path === 'string' || typeof Request === 'function' && module_or_path instanceof Request || typeof URL === 'function' && module_or_path instanceof URL) {
+@@ -5358,7 +5358,7 @@ var OpenJPEG = (() => {
+ if (Module["locateFile"]) {
+ return locateFile("openjpeg.wasm");
+ }
+- return new URL("openjpeg.wasm", import.meta.url).href;
++ return new URL(/* webpackIgnore: true */ "openjpeg.wasm", import.meta.url).href;
+ }
+ function getBinarySync(file) {
+ if (file == wasmBinaryFile && wasmBinary) {
diff --git a/server-ce/config/custom-environment-variables.json b/server-ce/config/custom-environment-variables.json
index 450e92fed9..f65f74ae2b 100644
--- a/server-ce/config/custom-environment-variables.json
+++ b/server-ce/config/custom-environment-variables.json
@@ -45,5 +45,17 @@
"clusterWorkers": "CLUSTER_WORKERS",
"maxFileUploadSize": "MAX_FILE_UPLOAD_SIZE",
"httpsOnly": "HTTPS_ONLY",
- "httpRequestTimeout": "OVERLEAF_HISTORY_V1_HTTP_REQUEST_TIMEOUT"
+ "httpRequestTimeout": "OVERLEAF_HISTORY_V1_HTTP_REQUEST_TIMEOUT",
+ "redis": {
+ "history": {
+ "host": "OVERLEAF_REDIS_HOST",
+ "password": "OVERLEAF_REDIS_PASS",
+ "port": "OVERLEAF_REDIS_PORT"
+ },
+ "lock": {
+ "host": "OVERLEAF_REDIS_HOST",
+ "password": "OVERLEAF_REDIS_PASS",
+ "port": "OVERLEAF_REDIS_PORT"
+ }
+ }
}
diff --git a/server-ce/test/accounts.spec.ts b/server-ce/test/accounts.spec.ts
index eeeb104087..85d545535a 100644
--- a/server-ce/test/accounts.spec.ts
+++ b/server-ce/test/accounts.spec.ts
@@ -9,7 +9,7 @@ describe('Accounts', function () {
it('can log in and out', function () {
login('user@example.com')
cy.visit('/project')
- cy.findByText('Account').click()
+ cy.findByRole('menuitem', { name: 'Account' }).click()
cy.findByText('Log Out').click()
cy.url().should('include', '/login')
cy.visit('/project')
diff --git a/server-ce/test/admin.spec.ts b/server-ce/test/admin.spec.ts
index 18b33c6932..8020deeb4b 100644
--- a/server-ce/test/admin.spec.ts
+++ b/server-ce/test/admin.spec.ts
@@ -293,7 +293,7 @@ describe('admin panel', function () {
cy.findByText(deletedProjectName).should('not.exist')
cy.log('navigate to thrashed projects and delete the project')
- cy.get('.project-list-sidebar-react').within(() => {
+ cy.get('.project-list-sidebar-scroll').within(() => {
cy.findByText('Trashed Projects').click()
})
findProjectRow(deletedProjectName).within(() =>
@@ -318,7 +318,7 @@ describe('admin panel', function () {
cy.log('login as the user and verify the project is restored')
login(user1)
cy.visit('/project')
- cy.get('.project-list-sidebar-react').within(() => {
+ cy.get('.project-list-sidebar-scroll').within(() => {
cy.findByText('Trashed Projects').click()
})
cy.findByText(`${deletedProjectName} (Restored)`)
diff --git a/server-ce/test/create-and-compile-project.spec.ts b/server-ce/test/create-and-compile-project.spec.ts
index 20f8f0dd6d..2be4f208e2 100644
--- a/server-ce/test/create-and-compile-project.spec.ts
+++ b/server-ce/test/create-and-compile-project.spec.ts
@@ -102,10 +102,6 @@ describe('Project creation and compilation', function () {
cy.findByText('Invite not yet accepted.')
})
- cy.visit('/project')
- cy.findByText('Account').click()
- cy.findByText('Log Out').click()
-
login('collaborator@example.com')
openProjectViaInviteNotification(targetProjectName)
cy.get('@targetProjectId').then(targetProjectId => {
diff --git a/server-ce/test/host-admin.js b/server-ce/test/host-admin.js
index 9e4cd5d360..f73209d58f 100644
--- a/server-ce/test/host-admin.js
+++ b/server-ce/test/host-admin.js
@@ -131,9 +131,7 @@ const allowedVars = Joi.object(
'GIT_BRIDGE_HOST',
'GIT_BRIDGE_PORT',
'V1_HISTORY_URL',
- 'DOCKER_RUNNER',
'SANDBOXED_COMPILES',
- 'SANDBOXED_COMPILES_SIBLING_CONTAINERS',
'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES',
'OVERLEAF_TEMPLATES_USER_ID',
'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS',
@@ -196,10 +194,7 @@ function setVarsDockerCompose({ pro, vars, version, withDataDir }) {
)
}
- if (
- cfg.services.sharelatex.environment
- .SANDBOXED_COMPILES_SIBLING_CONTAINERS === 'true'
- ) {
+ if (cfg.services.sharelatex.environment.SANDBOXED_COMPILES === 'true') {
cfg.services.sharelatex.environment.SANDBOXED_COMPILES_HOST_DIR =
PATHS.SANDBOXED_COMPILES_HOST_DIR
cfg.services.sharelatex.environment.TEX_LIVE_DOCKER_IMAGE =
diff --git a/server-ce/test/sandboxed-compiles.spec.ts b/server-ce/test/sandboxed-compiles.spec.ts
index 505f8cffd2..f39a00161b 100644
--- a/server-ce/test/sandboxed-compiles.spec.ts
+++ b/server-ce/test/sandboxed-compiles.spec.ts
@@ -10,9 +10,7 @@ const LABEL_TEX_LIVE_VERSION = 'TeX Live version'
describe('SandboxedCompiles', function () {
const enabledVars = {
- DOCKER_RUNNER: 'true',
SANDBOXED_COMPILES: 'true',
- SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true',
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: '2023,2022',
}
diff --git a/server-ce/test/templates.spec.ts b/server-ce/test/templates.spec.ts
index bb581650a2..e36e99315d 100644
--- a/server-ce/test/templates.spec.ts
+++ b/server-ce/test/templates.spec.ts
@@ -96,12 +96,12 @@ describe('Templates', () => {
.parent()
.parent()
.within(() => cy.get('input[type="checkbox"]').first().check())
- cy.get('.project-list-sidebar-react').within(() => {
+ cy.get('.project-list-sidebar-scroll').within(() => {
cy.findAllByText('New Tag').first().click()
})
cy.focused().type(tagName)
cy.findByText('Create').click()
- cy.get('.project-list-sidebar-react').within(() => {
+ cy.get('.project-list-sidebar-scroll').within(() => {
cy.findByText(tagName)
.parent()
.within(() => cy.get('.name').should('have.text', `${tagName} (1)`))
diff --git a/services/chat/buildscript.txt b/services/chat/buildscript.txt
index e8020de0d4..1dc88e9fa6 100644
--- a/services/chat/buildscript.txt
+++ b/services/chat/buildscript.txt
@@ -6,4 +6,4 @@ chat
--esmock-loader=False
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/services/clsi/README.md b/services/clsi/README.md
index 33a9c95c1c..16e40b8990 100644
--- a/services/clsi/README.md
+++ b/services/clsi/README.md
@@ -20,6 +20,7 @@ The CLSI can be configured through the following environment variables:
* `CATCH_ERRORS` - Set to `true` to log uncaught exceptions
* `COMPILE_GROUP_DOCKER_CONFIGS` - JSON string of Docker configs for compile groups
* `COMPILES_HOST_DIR` - Working directory for LaTeX compiles
+* `OUTPUT_HOST_DIR` - Output directory for LaTeX compiles
* `COMPILE_SIZE_LIMIT` - Sets the body-parser [limit](https://github.com/expressjs/body-parser#limit)
* `DOCKER_RUNNER` - Set to true to use sibling containers
* `DOCKER_RUNTIME` -
diff --git a/services/clsi/app.js b/services/clsi/app.js
index 8715802a0e..8de9d89b9b 100644
--- a/services/clsi/app.js
+++ b/services/clsi/app.js
@@ -258,6 +258,8 @@ app.use(function (error, req, res, next) {
if (error instanceof Errors.NotFoundError) {
logger.debug({ err: error, url: req.url }, 'not found error')
res.sendStatus(404)
+ } else if (error instanceof Errors.InvalidParameter) {
+ res.status(400).send(error.message)
} else if (error.code === 'EPIPE') {
// inspect container returns EPIPE when shutting down
res.sendStatus(503) // send 503 Unavailable response
diff --git a/services/clsi/app/js/CLSICacheHandler.js b/services/clsi/app/js/CLSICacheHandler.js
new file mode 100644
index 0000000000..de6f512987
--- /dev/null
+++ b/services/clsi/app/js/CLSICacheHandler.js
@@ -0,0 +1,276 @@
+const crypto = require('node:crypto')
+const fs = require('node:fs')
+const Path = require('node:path')
+const { pipeline } = require('node:stream/promises')
+const { createGzip, createGunzip } = require('node:zlib')
+const tarFs = require('tar-fs')
+const _ = require('lodash')
+const {
+ fetchNothing,
+ fetchStream,
+ RequestFailedError,
+} = require('@overleaf/fetch-utils')
+const logger = require('@overleaf/logger')
+const Metrics = require('@overleaf/metrics')
+const Settings = require('@overleaf/settings')
+const { CACHE_SUBDIR } = require('./OutputCacheManager')
+const { isExtraneousFile } = require('./ResourceWriter')
+
+const TIMING_BUCKETS = [
+ 0, 10, 100, 1000, 2000, 5000, 10000, 15000, 20000, 30000,
+]
+const MAX_ENTRIES_IN_OUTPUT_TAR = 100
+
+/**
+ * @param {string} projectId
+ * @param {string} userId
+ * @param {string} buildId
+ * @param {string} editorId
+ * @param {[{path: string}]} outputFiles
+ * @param {string} compileGroup
+ * @param {Record} options
+ */
+function notifyCLSICacheAboutBuild({
+ projectId,
+ userId,
+ buildId,
+ editorId,
+ outputFiles,
+ compileGroup,
+ options,
+}) {
+ if (!Settings.apis.clsiCache.enabled) return
+
+ /**
+ * @param {[{path: string}]} files
+ */
+ const enqueue = files => {
+ Metrics.count('clsi_cache_enqueue_files', files.length)
+ fetchNothing(`${Settings.apis.clsiCache.url}/enqueue`, {
+ method: 'POST',
+ json: {
+ projectId,
+ userId,
+ buildId,
+ editorId,
+ files,
+ downloadHost: Settings.apis.clsi.downloadHost,
+ clsiServerId: Settings.apis.clsi.clsiServerId,
+ compileGroup,
+ options,
+ },
+ signal: AbortSignal.timeout(15_000),
+ }).catch(err => {
+ logger.warn(
+ { err, projectId, userId, buildId },
+ 'enqueue for clsi cache failed'
+ )
+ })
+ }
+
+ // PDF preview
+ enqueue(
+ outputFiles
+ .filter(
+ f =>
+ f.path === 'output.pdf' ||
+ f.path === 'output.log' ||
+ f.path === 'output.synctex.gz' ||
+ f.path.endsWith('.blg')
+ )
+ .map(f => {
+ if (f.path === 'output.pdf') {
+ return _.pick(f, 'path', 'size', 'contentId', 'ranges')
+ }
+ return _.pick(f, 'path')
+ })
+ )
+
+ // Compile Cache
+ buildTarball({ projectId, userId, buildId, outputFiles })
+ .then(() => {
+ enqueue([{ path: 'output.tar.gz' }])
+ })
+ .catch(err => {
+ logger.warn(
+ { err, projectId, userId, buildId },
+ 'build output.tar.gz for clsi cache failed'
+ )
+ })
+}
+
+/**
+ * @param {string} projectId
+ * @param {string} userId
+ * @param {string} buildId
+ * @param {[{path: string}]} outputFiles
+ * @return {Promise}
+ */
+async function buildTarball({ projectId, userId, buildId, outputFiles }) {
+ const timer = new Metrics.Timer('clsi_cache_build', 1, {}, TIMING_BUCKETS)
+ const outputDir = Path.join(
+ Settings.path.outputDir,
+ userId ? `${projectId}-${userId}` : projectId,
+ CACHE_SUBDIR,
+ buildId
+ )
+
+ const files = outputFiles.filter(f => !isExtraneousFile(f.path))
+ if (files.length > MAX_ENTRIES_IN_OUTPUT_TAR) {
+ Metrics.inc('clsi_cache_build_too_many_entries')
+ throw new Error('too many output files for output.tar.gz')
+ }
+ Metrics.count('clsi_cache_build_files', files.length)
+
+ const path = Path.join(outputDir, 'output.tar.gz')
+ try {
+ await pipeline(
+ tarFs.pack(outputDir, { entries: files.map(f => f.path) }),
+ createGzip(),
+ fs.createWriteStream(path)
+ )
+ } catch (err) {
+ try {
+ await fs.promises.unlink(path)
+ } catch (e) {}
+ throw err
+ } finally {
+ timer.done()
+ }
+}
+
+/**
+ * @param {string} projectId
+ * @param {string} userId
+ * @param {string} editorId
+ * @param {string} buildId
+ * @param {string} outputDir
+ * @return {Promise}
+ */
+async function downloadOutputDotSynctexFromCompileCache(
+ projectId,
+ userId,
+ editorId,
+ buildId,
+ outputDir
+) {
+ if (!Settings.apis.clsiCache.enabled) return false
+
+ const timer = new Metrics.Timer(
+ 'clsi_cache_download',
+ 1,
+ { method: 'synctex' },
+ TIMING_BUCKETS
+ )
+ let stream
+ try {
+ stream = await fetchStream(
+ `${Settings.apis.clsiCache.url}/project/${projectId}/${
+ userId ? `user/${userId}/` : ''
+ }build/${editorId}-${buildId}/search/output/output.synctex.gz`,
+ {
+ method: 'GET',
+ signal: AbortSignal.timeout(10_000),
+ }
+ )
+ } catch (err) {
+ if (err instanceof RequestFailedError && err.response.status === 404) {
+ timer.done({ status: 'not-found' })
+ return false
+ }
+ timer.done({ status: 'error' })
+ throw err
+ }
+ await fs.promises.mkdir(outputDir, { recursive: true })
+ const dst = Path.join(outputDir, 'output.synctex.gz')
+ const tmp = dst + crypto.randomUUID()
+ try {
+ await pipeline(stream, fs.createWriteStream(tmp))
+ await fs.promises.rename(tmp, dst)
+ } catch (err) {
+ try {
+ await fs.promises.unlink(tmp)
+ } catch {}
+ throw err
+ }
+ timer.done({ status: 'success' })
+ return true
+}
+
+/**
+ * @param {string} projectId
+ * @param {string} userId
+ * @param {string} compileDir
+ * @return {Promise}
+ */
+async function downloadLatestCompileCache(projectId, userId, compileDir) {
+ if (!Settings.apis.clsiCache.enabled) return false
+
+ const url = `${Settings.apis.clsiCache.url}/project/${projectId}/${
+ userId ? `user/${userId}/` : ''
+ }latest/output/output.tar.gz`
+ const timer = new Metrics.Timer(
+ 'clsi_cache_download',
+ 1,
+ { method: 'tar' },
+ TIMING_BUCKETS
+ )
+ let stream
+ try {
+ stream = await fetchStream(url, {
+ method: 'GET',
+ signal: AbortSignal.timeout(10_000),
+ })
+ } catch (err) {
+ if (err instanceof RequestFailedError && err.response.status === 404) {
+ timer.done({ status: 'not-found' })
+ return false
+ }
+ timer.done({ status: 'error' })
+ throw err
+ }
+ let n = 0
+ let abort = false
+ await pipeline(
+ stream,
+ createGunzip(),
+ tarFs.extract(compileDir, {
+ // use ignore hook for counting entries (files+folders) and validation.
+ // Include folders as they incur mkdir calls.
+ ignore(_, header) {
+ if (abort) return true // log once
+ n++
+ if (n > MAX_ENTRIES_IN_OUTPUT_TAR) {
+ abort = true
+ logger.warn(
+ {
+ url,
+ compileDir,
+ },
+ 'too many entries in tar-ball from clsi-cache'
+ )
+ } else if (header.type !== 'file' && header.type !== 'directory') {
+ abort = true
+ logger.warn(
+ {
+ url,
+ compileDir,
+ entryType: header.type,
+ },
+ 'unexpected entry in tar-ball from clsi-cache'
+ )
+ }
+ return abort
+ },
+ })
+ )
+ Metrics.count('clsi_cache_download_entries', n)
+ timer.done({ status: 'success' })
+ return !abort
+}
+
+module.exports = {
+ notifyCLSICacheAboutBuild,
+ downloadLatestCompileCache,
+ downloadOutputDotSynctexFromCompileCache,
+}
diff --git a/services/clsi/app/js/CompileController.js b/services/clsi/app/js/CompileController.js
index 8091055afe..87a7db6ec2 100644
--- a/services/clsi/app/js/CompileController.js
+++ b/services/clsi/app/js/CompileController.js
@@ -1,3 +1,4 @@
+const Path = require('node:path')
const RequestParser = require('./RequestParser')
const CompileManager = require('./CompileManager')
const Settings = require('@overleaf/settings')
@@ -5,6 +6,7 @@ const Metrics = require('./Metrics')
const ProjectPersistenceManager = require('./ProjectPersistenceManager')
const logger = require('@overleaf/logger')
const Errors = require('./Errors')
+const { notifyCLSICacheAboutBuild } = require('./CLSICacheHandler')
let lastSuccessfulCompileTimestamp = 0
@@ -29,100 +31,133 @@ function compile(req, res, next) {
if (error) {
return next(error)
}
- CompileManager.doCompileWithLock(request, (error, result) => {
- let { buildId, outputFiles, stats, timings } = result || {}
- let code, status
- if (outputFiles == null) {
- outputFiles = []
- }
- if (error instanceof Errors.AlreadyCompilingError) {
- code = 423 // Http 423 Locked
- status = 'compile-in-progress'
- } else if (error instanceof Errors.FilesOutOfSyncError) {
- code = 409 // Http 409 Conflict
- status = 'retry'
- logger.warn(
- {
+ const stats = {}
+ const timings = {}
+ CompileManager.doCompileWithLock(
+ request,
+ stats,
+ timings,
+ (error, result) => {
+ let { buildId, outputFiles } = result || {}
+ let code, status
+ if (outputFiles == null) {
+ outputFiles = []
+ }
+ if (error instanceof Errors.AlreadyCompilingError) {
+ code = 423 // Http 423 Locked
+ status = 'compile-in-progress'
+ } else if (error instanceof Errors.FilesOutOfSyncError) {
+ code = 409 // Http 409 Conflict
+ status = 'retry'
+ logger.warn(
+ {
+ projectId: request.project_id,
+ userId: request.user_id,
+ },
+ 'files out of sync, please retry'
+ )
+ } else if (
+ error?.code === 'EPIPE' ||
+ error instanceof Errors.TooManyCompileRequestsError
+ ) {
+ // docker returns EPIPE when shutting down
+ code = 503 // send 503 Unavailable response
+ status = 'unavailable'
+ } else if (error?.terminated) {
+ status = 'terminated'
+ } else if (error?.validate) {
+ status = `validation-${error.validate}`
+ } else if (error?.timedout) {
+ status = 'timedout'
+ logger.debug(
+ { err: error, projectId: request.project_id },
+ 'timeout running compile'
+ )
+ } else if (error) {
+ status = 'error'
+ code = 500
+ logger.error(
+ { err: error, projectId: request.project_id },
+ 'error running compile'
+ )
+ } else {
+ if (
+ outputFiles.some(
+ file => file.path === 'output.pdf' && file.size > 0
+ )
+ ) {
+ status = 'success'
+ lastSuccessfulCompileTimestamp = Date.now()
+ } else if (request.stopOnFirstError) {
+ status = 'stopped-on-first-error'
+ } else {
+ status = 'failure'
+ logger.warn(
+ { projectId: request.project_id, outputFiles },
+ 'project failed to compile successfully, no output.pdf generated'
+ )
+ }
+
+ // log an error if any core files are found
+ if (outputFiles.some(file => file.path === 'core')) {
+ logger.error(
+ { projectId: request.project_id, req, outputFiles },
+ 'core file found in output'
+ )
+ }
+ }
+
+ if (error) {
+ outputFiles = error.outputFiles || []
+ buildId = error.buildId
+ }
+
+ if (
+ status === 'success' &&
+ request.editorId &&
+ request.populateClsiCache
+ ) {
+ notifyCLSICacheAboutBuild({
projectId: request.project_id,
userId: request.user_id,
+ buildId: outputFiles[0].build,
+ editorId: request.editorId,
+ outputFiles,
+ compileGroup: request.compileGroup,
+ options: {
+ compiler: request.compiler,
+ draft: request.draft,
+ imageName: request.imageName
+ ? Path.basename(request.imageName)
+ : undefined,
+ rootResourcePath: request.rootResourcePath,
+ stopOnFirstError: request.stopOnFirstError,
+ },
+ })
+ }
+
+ timer.done()
+ res.status(code || 200).send({
+ compile: {
+ status,
+ error: error?.message || error,
+ stats,
+ timings,
+ buildId,
+ outputUrlPrefix: Settings.apis.clsi.outputUrlPrefix,
+ outputFiles: outputFiles.map(file => ({
+ url:
+ `${Settings.apis.clsi.url}/project/${request.project_id}` +
+ (request.user_id != null
+ ? `/user/${request.user_id}`
+ : '') +
+ `/build/${file.build}/output/${file.path}`,
+ ...file,
+ })),
},
- 'files out of sync, please retry'
- )
- } else if (
- error?.code === 'EPIPE' ||
- error instanceof Errors.TooManyCompileRequestsError
- ) {
- // docker returns EPIPE when shutting down
- code = 503 // send 503 Unavailable response
- status = 'unavailable'
- } else if (error?.terminated) {
- status = 'terminated'
- } else if (error?.validate) {
- status = `validation-${error.validate}`
- } else if (error?.timedout) {
- status = 'timedout'
- logger.debug(
- { err: error, projectId: request.project_id },
- 'timeout running compile'
- )
- } else if (error) {
- status = 'error'
- code = 500
- logger.error(
- { err: error, projectId: request.project_id },
- 'error running compile'
- )
- } else {
- if (
- outputFiles.some(
- file => file.path === 'output.pdf' && file.size > 0
- )
- ) {
- status = 'success'
- lastSuccessfulCompileTimestamp = Date.now()
- } else if (request.stopOnFirstError) {
- status = 'stopped-on-first-error'
- } else {
- status = 'failure'
- logger.warn(
- { projectId: request.project_id, outputFiles },
- 'project failed to compile successfully, no output.pdf generated'
- )
- }
-
- // log an error if any core files are found
- if (outputFiles.some(file => file.path === 'core')) {
- logger.error(
- { projectId: request.project_id, req, outputFiles },
- 'core file found in output'
- )
- }
+ })
}
-
- if (error) {
- outputFiles = error.outputFiles || []
- buildId = error.buildId
- }
-
- timer.done()
- res.status(code || 200).send({
- compile: {
- status,
- error: error?.message || error,
- stats,
- timings,
- buildId,
- outputUrlPrefix: Settings.apis.clsi.outputUrlPrefix,
- outputFiles: outputFiles.map(file => ({
- url:
- `${Settings.apis.clsi.url}/project/${request.project_id}` +
- (request.user_id != null ? `/user/${request.user_id}` : '') +
- `/build/${file.build}/output/${file.path}`,
- ...file,
- })),
- },
- })
- })
+ )
}
)
})
@@ -153,24 +188,19 @@ function clearCache(req, res, next) {
}
function syncFromCode(req, res, next) {
- const { file } = req.query
+ const { file, editorId, buildId, compileFromClsiCache } = req.query
const line = parseInt(req.query.line, 10)
const column = parseInt(req.query.column, 10)
const { imageName } = req.query
const projectId = req.params.project_id
const userId = req.params.user_id
-
- if (imageName && !_isImageNameAllowed(imageName)) {
- return res.status(400).send('invalid image')
- }
-
CompileManager.syncFromCode(
projectId,
userId,
file,
line,
column,
- imageName,
+ { imageName, editorId, buildId, compileFromClsiCache },
function (error, pdfPositions) {
if (error) {
return next(error)
@@ -186,20 +216,16 @@ function syncFromPdf(req, res, next) {
const page = parseInt(req.query.page, 10)
const h = parseFloat(req.query.h)
const v = parseFloat(req.query.v)
- const { imageName } = req.query
+ const { imageName, editorId, buildId, compileFromClsiCache } = req.query
const projectId = req.params.project_id
const userId = req.params.user_id
-
- if (imageName && !_isImageNameAllowed(imageName)) {
- return res.status(400).send('invalid image')
- }
CompileManager.syncFromPdf(
projectId,
userId,
page,
h,
v,
- imageName,
+ { imageName, editorId, buildId, compileFromClsiCache },
function (error, codePositions) {
if (error) {
return next(error)
@@ -216,9 +242,6 @@ function wordcount(req, res, next) {
const projectId = req.params.project_id
const userId = req.params.user_id
const { image } = req.query
- if (image && !_isImageNameAllowed(image)) {
- return res.status(400).send('invalid image')
- }
logger.debug({ image, file, projectId }, 'word count request')
CompileManager.wordcount(
@@ -241,12 +264,6 @@ function status(req, res, next) {
res.send('OK')
}
-function _isImageNameAllowed(imageName) {
- const ALLOWED_IMAGES =
- Settings.clsi && Settings.clsi.docker && Settings.clsi.docker.allowedImages
- return !ALLOWED_IMAGES || ALLOWED_IMAGES.includes(imageName)
-}
-
module.exports = {
compile,
stopCompile,
diff --git a/services/clsi/app/js/CompileManager.js b/services/clsi/app/js/CompileManager.js
index a98b138d0a..b65fb3cd02 100644
--- a/services/clsi/app/js/CompileManager.js
+++ b/services/clsi/app/js/CompileManager.js
@@ -19,6 +19,10 @@ const Errors = require('./Errors')
const CommandRunner = require('./CommandRunner')
const { emitPdfStats } = require('./ContentCacheMetrics')
const SynctexOutputParser = require('./SynctexOutputParser')
+const {
+ downloadLatestCompileCache,
+ downloadOutputDotSynctexFromCompileCache,
+} = require('./CLSICacheHandler')
const COMPILE_TIME_BUCKETS = [
// NOTE: These buckets are locked in per metric name.
@@ -42,22 +46,22 @@ function getOutputDir(projectId, userId) {
return Path.join(Settings.path.outputDir, getCompileName(projectId, userId))
}
-async function doCompileWithLock(request) {
+async function doCompileWithLock(request, stats, timings) {
const compileDir = getCompileDir(request.project_id, request.user_id)
- await fsPromises.mkdir(compileDir, { recursive: true })
+ request.isInitialCompile =
+ (await fsPromises.mkdir(compileDir, { recursive: true })) === compileDir
// prevent simultaneous compiles
const lock = LockManager.acquire(compileDir)
try {
- return await doCompile(request)
+ return await doCompile(request, stats, timings)
} finally {
lock.release()
}
}
-async function doCompile(request) {
+async function doCompile(request, stats, timings) {
+ const { project_id: projectId, user_id: userId } = request
const compileDir = getCompileDir(request.project_id, request.user_id)
- const stats = {}
- const timings = {}
const timerE2E = new Metrics.Timer(
'compile-e2e-v2',
@@ -65,6 +69,25 @@ async function doCompile(request) {
request.metricsOpts,
COMPILE_TIME_BUCKETS
)
+ if (request.isInitialCompile) {
+ stats.isInitialCompile = 1
+ request.metricsOpts.compile = 'initial'
+ if (request.compileFromClsiCache) {
+ try {
+ if (await downloadLatestCompileCache(projectId, userId, compileDir)) {
+ stats.restoredClsiCache = 1
+ request.metricsOpts.compile = 'from-clsi-cache'
+ }
+ } catch (err) {
+ logger.warn(
+ { err, projectId, userId },
+ 'failed to populate compile dir from cache'
+ )
+ }
+ }
+ } else {
+ request.metricsOpts.compile = 'recompile'
+ }
const writeToDiskTimer = new Metrics.Timer(
'write-to-disk',
1,
@@ -296,7 +319,7 @@ async function doCompile(request) {
emitPdfStats(stats, timings, request)
}
- return { outputFiles, stats, timings, buildId }
+ return { outputFiles, buildId }
}
async function _saveOutputFiles({
@@ -408,14 +431,7 @@ async function _checkDirectory(compileDir) {
return true
}
-async function syncFromCode(
- projectId,
- userId,
- filename,
- line,
- column,
- imageName
-) {
+async function syncFromCode(projectId, userId, filename, line, column, opts) {
// If LaTeX was run in a virtual environment, the file path that synctex expects
// might not match the file path on the host. The .synctex.gz file however, will be accessed
// wherever it is on the host.
@@ -431,7 +447,7 @@ async function syncFromCode(
'-o',
outputFilePath,
]
- const stdout = await _runSynctex(projectId, userId, command, imageName)
+ const stdout = await _runSynctex(projectId, userId, command, opts)
logger.debug(
{ projectId, userId, filename, line, column, command, stdout },
'synctex code output'
@@ -439,7 +455,7 @@ async function syncFromCode(
return SynctexOutputParser.parseViewOutput(stdout)
}
-async function syncFromPdf(projectId, userId, page, h, v, imageName) {
+async function syncFromPdf(projectId, userId, page, h, v, opts) {
const compileName = getCompileName(projectId, userId)
const baseDir = Settings.path.synctexBaseDir(compileName)
const outputFilePath = `${baseDir}/output.pdf`
@@ -449,7 +465,7 @@ async function syncFromPdf(projectId, userId, page, h, v, imageName) {
'-o',
`${page}:${h}:${v}:${outputFilePath}`,
]
- const stdout = await _runSynctex(projectId, userId, command, imageName)
+ const stdout = await _runSynctex(projectId, userId, command, opts)
logger.debug({ projectId, userId, page, h, v, stdout }, 'synctex pdf output')
return SynctexOutputParser.parseEditOutput(stdout, baseDir)
}
@@ -478,32 +494,85 @@ async function _checkFileExists(dir, filename) {
}
}
-async function _runSynctex(projectId, userId, command, imageName) {
- const directory = getCompileDir(projectId, userId)
+async function _runSynctex(projectId, userId, command, opts) {
+ const { imageName, editorId, buildId, compileFromClsiCache } = opts
+
+ if (imageName && !_isImageNameAllowed(imageName)) {
+ throw new Errors.InvalidParameter('invalid image')
+ }
+ if (editorId && !/^[a-f0-9-]+$/.test(editorId)) {
+ throw new Errors.InvalidParameter('invalid editorId')
+ }
+ if (buildId && !OutputCacheManager.BUILD_REGEX.test(buildId)) {
+ throw new Errors.InvalidParameter('invalid buildId')
+ }
+
+ const outputDir = getOutputDir(projectId, userId)
+ const runInOutputDir = buildId && CommandRunner.canRunSyncTeXInOutputDir()
+
+ const directory = runInOutputDir
+ ? Path.join(outputDir, OutputCacheManager.CACHE_SUBDIR, buildId)
+ : getCompileDir(projectId, userId)
const timeout = 60 * 1000 // increased to allow for large projects
const compileName = getCompileName(projectId, userId)
- const compileGroup = 'synctex'
+ const compileGroup = runInOutputDir ? 'synctex-output' : 'synctex'
const defaultImageName =
Settings.clsi && Settings.clsi.docker && Settings.clsi.docker.image
- await _checkFileExists(directory, 'output.synctex.gz')
- try {
- const output = await CommandRunner.promises.run(
- compileName,
- command,
- directory,
- imageName || defaultImageName,
- timeout,
- {},
- compileGroup
- )
- return output.stdout
- } catch (error) {
- throw OError.tag(error, 'error running synctex', {
- command,
- projectId,
- userId,
- })
- }
+ // eslint-disable-next-line @typescript-eslint/return-await
+ return await OutputCacheManager.promises.queueDirOperation(
+ outputDir,
+ /**
+ * @return {Promise}
+ */
+ async () => {
+ try {
+ await _checkFileExists(directory, 'output.synctex.gz')
+ } catch (err) {
+ if (
+ err instanceof Errors.NotFoundError &&
+ compileFromClsiCache &&
+ editorId &&
+ buildId
+ ) {
+ try {
+ await downloadOutputDotSynctexFromCompileCache(
+ projectId,
+ userId,
+ editorId,
+ buildId,
+ directory
+ )
+ } catch (err) {
+ logger.warn(
+ { err, projectId, userId, editorId, buildId },
+ 'failed to download output.synctex.gz from clsi-cache'
+ )
+ }
+ await _checkFileExists(directory, 'output.synctex.gz')
+ } else {
+ throw err
+ }
+ }
+ try {
+ const output = await CommandRunner.promises.run(
+ compileName,
+ command,
+ directory,
+ imageName || defaultImageName,
+ timeout,
+ {},
+ compileGroup
+ )
+ return output.stdout
+ } catch (error) {
+ throw OError.tag(error, 'error running synctex', {
+ command,
+ projectId,
+ userId,
+ })
+ }
+ }
+ )
}
async function wordcount(projectId, userId, filename, image) {
@@ -515,6 +584,10 @@ async function wordcount(projectId, userId, filename, image) {
const compileName = getCompileName(projectId, userId)
const compileGroup = 'wordcount'
+ if (image && !_isImageNameAllowed(image)) {
+ throw new Errors.InvalidParameter('invalid image')
+ }
+
try {
await fsPromises.mkdir(compileDir, { recursive: true })
} catch (err) {
@@ -602,6 +675,12 @@ function _parseWordcountFromOutput(output) {
return results
}
+function _isImageNameAllowed(imageName) {
+ const ALLOWED_IMAGES =
+ Settings.clsi && Settings.clsi.docker && Settings.clsi.docker.allowedImages
+ return !ALLOWED_IMAGES || ALLOWED_IMAGES.includes(imageName)
+}
+
module.exports = {
doCompileWithLock: callbackify(doCompileWithLock),
stopCompile: callbackify(stopCompile),
diff --git a/services/clsi/app/js/DockerRunner.js b/services/clsi/app/js/DockerRunner.js
index 7aac613db4..def02eaf5b 100644
--- a/services/clsi/app/js/DockerRunner.js
+++ b/services/clsi/app/js/DockerRunner.js
@@ -6,21 +6,12 @@ const dockerode = new Docker()
const crypto = require('node:crypto')
const async = require('async')
const LockManager = require('./DockerLockManager')
-const fs = require('node:fs')
const Path = require('node:path')
const _ = require('lodash')
const ONE_HOUR_IN_MS = 60 * 60 * 1000
logger.debug('using docker runner')
-function usingSiblingContainers() {
- return (
- Settings != null &&
- Settings.path != null &&
- Settings.path.sandboxedCompilesHostDir != null
- )
-}
-
let containerMonitorTimeout
let containerMonitorInterval
@@ -35,24 +26,6 @@ const DockerRunner = {
compileGroup,
callback
) {
- if (usingSiblingContainers()) {
- const _newPath = Settings.path.sandboxedCompilesHostDir
- logger.debug(
- { path: _newPath },
- 'altering bind path for sibling containers'
- )
- // Server Pro, example:
- // '/var/lib/overleaf/data/compiles/'
- // ... becomes ...
- // '/opt/overleaf_data/data/compiles/'
- directory = Path.join(
- Settings.path.sandboxedCompilesHostDir,
- Path.basename(directory)
- )
- }
-
- const volumes = { [directory]: '/compile' }
-
command = command.map(arg =>
arg.toString().replace('$COMPILE_DIR', '/compile')
)
@@ -72,7 +45,32 @@ const DockerRunner = {
image = `${Settings.texliveImageNameOveride}/${img[2]}`
}
- if (compileGroup === 'synctex' || compileGroup === 'wordcount') {
+ if (compileGroup === 'synctex-output') {
+ // In: directory = '/overleaf/services/clsi/output/projectId-userId/generated-files/buildId'
+ // directory.split('/').slice(-3) === 'projectId-userId/generated-files/buildId'
+ // sandboxedCompilesHostDirOutput = '/host/output'
+ // Out: directory = '/host/output/projectId-userId/generated-files/buildId'
+ directory = Path.join(
+ Settings.path.sandboxedCompilesHostDirOutput,
+ ...directory.split('/').slice(-3)
+ )
+ } else {
+ // In: directory = '/overleaf/services/clsi/compiles/projectId-userId'
+ // Path.basename(directory) === 'projectId-userId'
+ // sandboxedCompilesHostDirCompiles = '/host/compiles'
+ // Out: directory = '/host/compiles/projectId-userId'
+ directory = Path.join(
+ Settings.path.sandboxedCompilesHostDirCompiles,
+ Path.basename(directory)
+ )
+ }
+
+ const volumes = { [directory]: '/compile' }
+ if (
+ compileGroup === 'synctex' ||
+ compileGroup === 'synctex-output' ||
+ compileGroup === 'wordcount'
+ ) {
volumes[directory] += ':ro'
}
@@ -309,50 +307,17 @@ const DockerRunner = {
LockManager.runWithLock(
options.name,
releaseLock =>
- // Check that volumes exist before starting the container.
- // When a container is started with volume pointing to a
- // non-existent directory then docker creates the directory but
- // with root ownership.
- DockerRunner._checkVolumes(options, volumes, err => {
- if (err != null) {
- return releaseLock(err)
- }
- DockerRunner._startContainer(
- options,
- volumes,
- attachStreamHandler,
- releaseLock
- )
- }),
-
+ DockerRunner._startContainer(
+ options,
+ volumes,
+ attachStreamHandler,
+ releaseLock
+ ),
callback
)
},
// Check that volumes exist and are directories
- _checkVolumes(options, volumes, callback) {
- if (usingSiblingContainers()) {
- // Server Pro, with sibling-containers active, skip checks
- return callback(null)
- }
-
- const checkVolume = (path, cb) =>
- fs.stat(path, (err, stats) => {
- if (err != null) {
- return cb(err)
- }
- if (!stats.isDirectory()) {
- return cb(new Error('not a directory'))
- }
- cb()
- })
- const jobs = []
- for (const vol in volumes) {
- jobs.push(cb => checkVolume(vol, cb))
- }
- async.series(jobs, callback)
- },
-
_startContainer(options, volumes, attachStreamHandler, callback) {
callback = _.once(callback)
const { name } = options
@@ -617,6 +582,10 @@ const DockerRunner = {
containerMonitorInterval = undefined
}
},
+
+ canRunSyncTeXInOutputDir() {
+ return Boolean(Settings.path.sandboxedCompilesHostDirOutput)
+ },
}
DockerRunner.startContainerMonitor()
diff --git a/services/clsi/app/js/Errors.js b/services/clsi/app/js/Errors.js
index 5c5fd3745a..64c3c7b59a 100644
--- a/services/clsi/app/js/Errors.js
+++ b/services/clsi/app/js/Errors.js
@@ -35,6 +35,7 @@ class QueueLimitReachedError extends OError {}
class TimedOutError extends OError {}
class NoXrefTableError extends OError {}
class TooManyCompileRequestsError extends OError {}
+class InvalidParameter extends OError {}
module.exports = Errors = {
QueueLimitReachedError,
@@ -44,4 +45,5 @@ module.exports = Errors = {
AlreadyCompilingError,
NoXrefTableError,
TooManyCompileRequestsError,
+ InvalidParameter,
}
diff --git a/services/clsi/app/js/LocalCommandRunner.js b/services/clsi/app/js/LocalCommandRunner.js
index bac7d39400..ce27473358 100644
--- a/services/clsi/app/js/LocalCommandRunner.js
+++ b/services/clsi/app/js/LocalCommandRunner.js
@@ -99,6 +99,10 @@ module.exports = CommandRunner = {
}
return callback()
},
+
+ canRunSyncTeXInOutputDir() {
+ return true
+ },
}
module.exports.promises = {
diff --git a/services/clsi/app/js/OutputCacheManager.js b/services/clsi/app/js/OutputCacheManager.js
index 1e9a10c921..a1a0a89aa7 100644
--- a/services/clsi/app/js/OutputCacheManager.js
+++ b/services/clsi/app/js/OutputCacheManager.js
@@ -83,6 +83,13 @@ async function cleanupDirectory(dir, options) {
})
}
+/**
+ * @template T
+ *
+ * @param {string} dir
+ * @param {() => Promise} fn
+ * @return {Promise}
+ */
async function queueDirOperation(dir, fn) {
const pending = PENDING_PROJECT_ACTIONS.get(dir) || Promise.resolve()
const p = pending.then(fn, fn).finally(() => {
@@ -677,4 +684,5 @@ OutputCacheManager.promises = {
saveOutputFilesInBuildDir: promisify(
OutputCacheManager.saveOutputFilesInBuildDir
),
+ queueDirOperation,
}
diff --git a/services/clsi/app/js/OutputFileArchiveManager.js b/services/clsi/app/js/OutputFileArchiveManager.js
index a64d634e12..64c5198392 100644
--- a/services/clsi/app/js/OutputFileArchiveManager.js
+++ b/services/clsi/app/js/OutputFileArchiveManager.js
@@ -93,8 +93,11 @@ module.exports = {
)
return outputFiles.filter(
- // Ignore the pdf and also ignore the files ignored by the frontend.
- ({ path }) => path !== 'output.pdf' && !ignoreFiles.includes(path)
+ // Ignore the pdf, clsi-cache tar-ball and also ignore the files ignored by the frontend.
+ ({ path }) =>
+ path !== 'output.pdf' &&
+ path !== 'output.tar.gz' &&
+ !ignoreFiles.includes(path)
)
} catch (error) {
if (
diff --git a/services/clsi/app/js/ProjectPersistenceManager.js b/services/clsi/app/js/ProjectPersistenceManager.js
index 7d4f071d2c..e96a4591c3 100644
--- a/services/clsi/app/js/ProjectPersistenceManager.js
+++ b/services/clsi/app/js/ProjectPersistenceManager.js
@@ -15,7 +15,6 @@ const logger = require('@overleaf/logger')
const oneDay = 24 * 60 * 60 * 1000
const Metrics = require('@overleaf/metrics')
const Settings = require('@overleaf/settings')
-const diskusage = require('diskusage')
const { callbackify } = require('node:util')
const Path = require('node:path')
const fs = require('node:fs')
@@ -33,7 +32,13 @@ async function collectDiskStats() {
const diskStats = {}
for (const path of paths) {
try {
- const stats = await diskusage.check(path)
+ const { blocks, bavail, bsize } = await fs.promises.statfs(path)
+ const stats = {
+ // Warning: these values will be wrong by a factor in Docker-for-Mac.
+ // See https://github.com/docker/for-mac/issues/2136
+ total: blocks * bsize, // Total size of the file system in bytes
+ available: bavail * bsize, // Free space available to unprivileged users.
+ }
const diskAvailablePercent = (stats.available / stats.total) * 100
Metrics.gauge('disk_available_percent', diskAvailablePercent, 1, {
path,
diff --git a/services/clsi/app/js/RequestParser.js b/services/clsi/app/js/RequestParser.js
index f5c07d3bcf..4e9d722921 100644
--- a/services/clsi/app/js/RequestParser.js
+++ b/services/clsi/app/js/RequestParser.js
@@ -3,6 +3,7 @@ const OutputCacheManager = require('./OutputCacheManager')
const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
const MAX_TIMEOUT = 600
+const EDITOR_ID_REGEX = /^[a-f0-9-]{36}$/ // UUID
function parse(body, callback) {
const response = {}
@@ -28,12 +29,24 @@ function parse(body, callback) {
default: '',
type: 'string',
}),
+ // Will be populated later. Must always be populated for prom library.
+ compile: 'initial',
}
response.compiler = _parseAttribute('compiler', compile.options.compiler, {
validValues: VALID_COMPILERS,
default: 'pdflatex',
type: 'string',
})
+ response.compileFromClsiCache = _parseAttribute(
+ 'compileFromClsiCache',
+ compile.options.compileFromClsiCache,
+ { default: false, type: 'boolean' }
+ )
+ response.populateClsiCache = _parseAttribute(
+ 'populateClsiCache',
+ compile.options.populateClsiCache,
+ { default: false, type: 'boolean' }
+ )
response.enablePdfCaching = _parseAttribute(
'enablePdfCaching',
compile.options.enablePdfCaching,
@@ -137,6 +150,10 @@ function parse(body, callback) {
)
response.rootResourcePath = _checkPath(rootResourcePath)
+ response.editorId = _parseAttribute('editorId', compile.options.editorId, {
+ type: 'string',
+ regex: EDITOR_ID_REGEX,
+ })
response.buildId = _parseAttribute('buildId', compile.options.buildId, {
type: 'string',
regex: OutputCacheManager.BUILD_REGEX,
diff --git a/services/clsi/app/js/ResourceWriter.js b/services/clsi/app/js/ResourceWriter.js
index 1db1c2baac..bf88538746 100644
--- a/services/clsi/app/js/ResourceWriter.js
+++ b/services/clsi/app/js/ResourceWriter.js
@@ -262,6 +262,7 @@ module.exports = ResourceWriter = {
shouldDelete = false
}
if (
+ path === 'output.tar.gz' ||
path === 'output.synctex.gz' ||
path === 'output.pdfxref' ||
path === 'output.pdf' ||
diff --git a/services/clsi/buildscript.txt b/services/clsi/buildscript.txt
index 756d5c3bb6..1834ac9648 100644
--- a/services/clsi/buildscript.txt
+++ b/services/clsi/buildscript.txt
@@ -2,10 +2,10 @@ clsi
--data-dirs=cache,compiles,output
--dependencies=
--docker-repos=gcr.io/overleaf-ops,us-east1-docker.pkg.dev/overleaf-ops/ol-docker
---env-add=ENABLE_PDF_CACHING="true",PDF_CACHING_ENABLE_WORKER_POOL="true",ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2017.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=gcr.io/overleaf-ops,TEXLIVE_IMAGE_USER="tex",DOCKER_RUNNER="true",COMPILES_HOST_DIR=$PWD/compiles
+--env-add=ENABLE_PDF_CACHING="true",PDF_CACHING_ENABLE_WORKER_POOL="true",ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2017.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=gcr.io/overleaf-ops,TEXLIVE_IMAGE_USER="tex",DOCKER_RUNNER="true",COMPILES_HOST_DIR=$PWD/compiles,OUTPUT_HOST_DIR=$PWD/output
--env-pass-through=
--esmock-loader=False
--node-version=20.18.2
--public-repo=True
---script-version=4.5.0
+--script-version=4.7.0
--use-large-ci-runner=True
diff --git a/services/clsi/config/settings.defaults.js b/services/clsi/config/settings.defaults.js
index 0f4111dc62..6f16e01a89 100644
--- a/services/clsi/config/settings.defaults.js
+++ b/services/clsi/config/settings.defaults.js
@@ -1,10 +1,12 @@
const Path = require('node:path')
const http = require('node:http')
const https = require('node:https')
+const os = require('node:os')
http.globalAgent.keepAlive = false
https.globalAgent.keepAlive = false
const isPreEmptible = process.env.PREEMPTIBLE === 'TRUE'
+const CLSI_SERVER_ID = os.hostname().replace('-ctr', '')
module.exports = {
compileSizeLimit: process.env.COMPILE_SIZE_LIMIT || '7mb',
@@ -48,12 +50,20 @@ module.exports = {
url: `http://${process.env.CLSI_HOST || '127.0.0.1'}:3013`,
// External url prefix for output files, e.g. for requests via load-balancers.
outputUrlPrefix: `${process.env.ZONE ? `/zone/${process.env.ZONE}` : ''}`,
+ clsiServerId: process.env.CLSI_SERVER_ID || CLSI_SERVER_ID,
+
+ downloadHost: process.env.DOWNLOAD_HOST || 'http://localhost:3013',
},
clsiPerf: {
host: `${process.env.CLSI_PERF_HOST || '127.0.0.1'}:${
process.env.CLSI_PERF_PORT || '3043'
}`,
},
+ clsiCache: {
+ enabled: !!process.env.CLSI_CACHE_HOST,
+ url: `http://${process.env.CLSI_CACHE_HOST}:3044`,
+ downloadURL: `http://${process.env.CLSI_CACHE_NGINX_HOST || process.env.CLSI_CACHE_HOST}:8080`,
+ },
},
smokeTest: process.env.SMOKE_TEST || false,
@@ -88,14 +98,15 @@ if (process.env.ALLOWED_COMPILE_GROUPS) {
}
}
-if (process.env.DOCKER_RUNNER) {
- let seccompProfilePath
+if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') {
module.exports.clsi = {
- dockerRunner: process.env.DOCKER_RUNNER === 'true',
+ dockerRunner: true,
docker: {
runtime: process.env.DOCKER_RUNTIME,
image:
- process.env.TEXLIVE_IMAGE || 'quay.io/sharelatex/texlive-full:2017.1',
+ process.env.TEXLIVE_IMAGE ||
+ process.env.TEX_LIVE_DOCKER_IMAGE ||
+ 'quay.io/sharelatex/texlive-full:2017.1',
env: {
HOME: '/tmp',
CLSI: 1,
@@ -121,6 +132,7 @@ if (process.env.DOCKER_RUNNER) {
const defaultCompileGroupConfig = {
wordcount: { 'HostConfig.AutoRemove': true },
synctex: { 'HostConfig.AutoRemove': true },
+ 'synctex-output': { 'HostConfig.AutoRemove': true },
}
module.exports.clsi.docker.compileGroupConfig = Object.assign(
defaultCompileGroupConfig,
@@ -131,6 +143,7 @@ if (process.env.DOCKER_RUNNER) {
process.exit(1)
}
+ let seccompProfilePath
try {
seccompProfilePath = Path.resolve(__dirname, '../seccomp/clsi-profile.json')
module.exports.clsi.docker.seccomp_profile = JSON.stringify(
@@ -165,5 +178,23 @@ if (process.env.DOCKER_RUNNER) {
module.exports.path.synctexBaseDir = () => '/compile'
- module.exports.path.sandboxedCompilesHostDir = process.env.COMPILES_HOST_DIR
+ module.exports.path.sandboxedCompilesHostDirCompiles =
+ process.env.SANDBOXED_COMPILES_HOST_DIR_COMPILES ||
+ process.env.SANDBOXED_COMPILES_HOST_DIR ||
+ process.env.COMPILES_HOST_DIR
+ if (!module.exports.path.sandboxedCompilesHostDirCompiles) {
+ throw new Error(
+ 'SANDBOXED_COMPILES enabled, but SANDBOXED_COMPILES_HOST_DIR_COMPILES not set'
+ )
+ }
+
+ module.exports.path.sandboxedCompilesHostDirOutput =
+ process.env.SANDBOXED_COMPILES_HOST_DIR_OUTPUT ||
+ process.env.OUTPUT_HOST_DIR
+ if (!module.exports.path.sandboxedCompilesHostDirOutput) {
+ // TODO(das7pad): Enforce in a future major version of Server Pro.
+ // throw new Error(
+ // 'SANDBOXED_COMPILES enabled, but SANDBOXED_COMPILES_HOST_DIR_OUTPUT not set'
+ // )
+ }
}
diff --git a/services/clsi/docker-compose.ci.yml b/services/clsi/docker-compose.ci.yml
index 00f54c6e72..1754a3a916 100644
--- a/services/clsi/docker-compose.ci.yml
+++ b/services/clsi/docker-compose.ci.yml
@@ -31,6 +31,7 @@ services:
TEXLIVE_IMAGE_USER: "tex"
DOCKER_RUNNER: "true"
COMPILES_HOST_DIR: $PWD/compiles
+ OUTPUT_HOST_DIR: $PWD/output
volumes:
- ./compiles:/overleaf/services/clsi/compiles
- /var/run/docker.sock:/var/run/docker.sock
diff --git a/services/clsi/docker-compose.yml b/services/clsi/docker-compose.yml
index a525c6029e..3e70c256ea 100644
--- a/services/clsi/docker-compose.yml
+++ b/services/clsi/docker-compose.yml
@@ -49,5 +49,6 @@ services:
TEXLIVE_IMAGE_USER: "tex"
DOCKER_RUNNER: "true"
COMPILES_HOST_DIR: $PWD/compiles
+ OUTPUT_HOST_DIR: $PWD/output
command: npm run --silent test:acceptance
diff --git a/services/clsi/package.json b/services/clsi/package.json
index 3f05ab543d..86566e0f59 100644
--- a/services/clsi/package.json
+++ b/services/clsi/package.json
@@ -27,13 +27,13 @@
"async": "^3.2.5",
"body-parser": "^1.20.3",
"bunyan": "^1.8.15",
- "diskusage": "^1.1.3",
"dockerode": "^4.0.5",
"express": "^4.21.2",
"lodash": "^4.17.21",
"p-limit": "^3.1.0",
"request": "^2.88.2",
"send": "^0.19.0",
+ "tar-fs": "^3.0.4",
"workerpool": "^6.1.5"
},
"devDependencies": {
diff --git a/services/clsi/test/acceptance/js/BrokenLatexFileTests.js b/services/clsi/test/acceptance/js/BrokenLatexFileTests.js
index d22c142cff..46d07da092 100644
--- a/services/clsi/test/acceptance/js/BrokenLatexFileTests.js
+++ b/services/clsi/test/acceptance/js/BrokenLatexFileTests.js
@@ -11,6 +11,7 @@
const Client = require('./helpers/Client')
const request = require('request')
const ClsiApp = require('./helpers/ClsiApp')
+const { expect } = require('chai')
describe('Broken LaTeX file', function () {
before(function (done) {
@@ -62,6 +63,10 @@ Hello world
return this.body.compile.status.should.equal('failure')
})
+ it('should return isInitialCompile flag', function () {
+ expect(this.body.compile.stats.isInitialCompile).to.equal(1)
+ })
+
it('should return output files', function () {
// NOTE: No output.pdf file.
this.body.compile.outputFiles
@@ -98,6 +103,10 @@ Hello world
return this.body.compile.status.should.equal('failure')
})
+ it('should not return isInitialCompile flag', function () {
+ expect(this.body.compile.stats.isInitialCompile).to.not.exist
+ })
+
it('should return output files', function () {
// NOTE: No output.pdf file.
this.body.compile.outputFiles
diff --git a/services/clsi/test/acceptance/js/TimeoutTests.js b/services/clsi/test/acceptance/js/TimeoutTests.js
index bca8ae71d2..e9175d223c 100644
--- a/services/clsi/test/acceptance/js/TimeoutTests.js
+++ b/services/clsi/test/acceptance/js/TimeoutTests.js
@@ -11,6 +11,7 @@
const Client = require('./helpers/Client')
const request = require('request')
const ClsiApp = require('./helpers/ClsiApp')
+const { expect } = require('chai')
describe('Timed out compile', function () {
before(function (done) {
@@ -54,6 +55,10 @@ describe('Timed out compile', function () {
return this.body.compile.status.should.equal('timedout')
})
+ it('should return isInitialCompile flag', function () {
+ expect(this.body.compile.stats.isInitialCompile).to.equal(1)
+ })
+
return it('should return the log output file name', function () {
const outputFilePaths = this.body.compile.outputFiles.map(x => x.path)
return outputFilePaths.should.include('output.log')
diff --git a/services/clsi/test/setup.js b/services/clsi/test/setup.js
index 19e1ae7165..b17507bf92 100644
--- a/services/clsi/test/setup.js
+++ b/services/clsi/test/setup.js
@@ -20,7 +20,7 @@ SandboxedModule.configure({
err() {},
},
},
- globals: { Buffer, console, process, URL },
+ globals: { Buffer, console, process, URL, Math },
sourceTransformers: {
removeNodePrefix: function (source) {
return source.replace(/require\(['"]node:/g, "require('")
diff --git a/services/clsi/test/unit/js/CompileControllerTests.js b/services/clsi/test/unit/js/CompileControllerTests.js
index d97e433f29..e6d21aed9f 100644
--- a/services/clsi/test/unit/js/CompileControllerTests.js
+++ b/services/clsi/test/unit/js/CompileControllerTests.js
@@ -1,54 +1,11 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
-const { expect } = require('chai')
const modulePath = require('node:path').join(
__dirname,
'../../../app/js/CompileController'
)
const Errors = require('../../../app/js/Errors')
-function tryImageNameValidation(method, imageNameField) {
- describe('when allowedImages is set', function () {
- beforeEach(function () {
- this.Settings.clsi = { docker: {} }
- this.Settings.clsi.docker.allowedImages = [
- 'repo/image:tag1',
- 'repo/image:tag2',
- ]
- this.res.send = sinon.stub()
- this.res.status = sinon.stub().returns({ send: this.res.send })
-
- this.CompileManager[method].reset()
- })
-
- describe('with an invalid image', function () {
- beforeEach(function () {
- this.req.query[imageNameField] = 'something/evil:1337'
- this.CompileController[method](this.req, this.res, this.next)
- })
- it('should return a 400', function () {
- expect(this.res.status.calledWith(400)).to.equal(true)
- })
- it('should not run the query', function () {
- expect(this.CompileManager[method].called).to.equal(false)
- })
- })
-
- describe('with a valid image', function () {
- beforeEach(function () {
- this.req.query[imageNameField] = 'repo/image:tag1'
- this.CompileController[method](this.req, this.res, this.next)
- })
- it('should not return a 400', function () {
- expect(this.res.status.calledWith(400)).to.equal(false)
- })
- it('should run the query', function () {
- expect(this.CompileManager[method].called).to.equal(true)
- })
- })
- })
-}
-
describe('CompileController', function () {
beforeEach(function () {
this.buildId = 'build-id-123'
@@ -61,6 +18,11 @@ describe('CompileController', function () {
clsi: {
url: 'http://clsi.example.com',
outputUrlPrefix: '/zone/b',
+ downloadHost: 'http://localhost:3013',
+ },
+ clsiCache: {
+ enabled: false,
+ url: 'http://localhost:3044',
},
},
}),
@@ -68,6 +30,11 @@ describe('CompileController', function () {
Timer: sinon.stub().returns({ done: sinon.stub() }),
},
'./ProjectPersistenceManager': (this.ProjectPersistenceManager = {}),
+ './CLSICacheHandler': {
+ notifyCLSICacheAboutBuild: sinon.stub(),
+ downloadLatestCompileCache: sinon.stub().resolves(),
+ downloadOutputDotSynctexFromCompileCache: sinon.stub().resolves(),
+ },
'./Errors': (this.Erros = Errors),
},
})
@@ -113,16 +80,21 @@ describe('CompileController', function () {
this.timings = { bar: 2 }
this.res.status = sinon.stub().returnsThis()
this.res.send = sinon.stub()
+
+ this.CompileManager.doCompileWithLock = sinon
+ .stub()
+ .callsFake((_req, stats, timings, cb) => {
+ Object.assign(stats, this.stats)
+ Object.assign(timings, this.timings)
+ cb(null, {
+ outputFiles: this.output_files,
+ buildId: this.buildId,
+ })
+ })
})
describe('successfully', function () {
beforeEach(function () {
- this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
- outputFiles: this.output_files,
- stats: this.stats,
- timings: this.timings,
- buildId: this.buildId,
- })
this.CompileController.compile(this.req, this.res)
})
@@ -166,12 +138,6 @@ describe('CompileController', function () {
describe('without a outputUrlPrefix', function () {
beforeEach(function () {
this.Settings.apis.clsi.outputUrlPrefix = ''
- this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
- outputFiles: this.output_files,
- stats: this.stats,
- timings: this.timings,
- buildId: this.buildId,
- })
this.CompileController.compile(this.req, this.res)
})
@@ -210,33 +176,35 @@ describe('CompileController', function () {
build: 1234,
},
]
- this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
- outputFiles: this.output_files,
- stats: this.stats,
- timings: this.timings,
- buildId: this.buildId,
- })
+ this.CompileManager.doCompileWithLock = sinon
+ .stub()
+ .callsFake((_req, stats, timings, cb) => {
+ Object.assign(stats, this.stats)
+ Object.assign(timings, this.timings)
+ cb(null, {
+ outputFiles: this.output_files,
+ buildId: this.buildId,
+ })
+ })
this.CompileController.compile(this.req, this.res)
})
it('should return the JSON response with status failure', function () {
this.res.status.calledWith(200).should.equal(true)
- this.res.send
- .calledWith({
- compile: {
- status: 'failure',
- error: null,
- stats: this.stats,
- timings: this.timings,
- outputUrlPrefix: '/zone/b',
- buildId: this.buildId,
- outputFiles: this.output_files.map(file => ({
- url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
- ...file,
- })),
- },
- })
- .should.equal(true)
+ this.res.send.should.have.been.calledWith({
+ compile: {
+ status: 'failure',
+ error: null,
+ stats: this.stats,
+ timings: this.timings,
+ outputUrlPrefix: '/zone/b',
+ buildId: this.buildId,
+ outputFiles: this.output_files.map(file => ({
+ url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
+ ...file,
+ })),
+ },
+ })
})
})
@@ -255,33 +223,35 @@ describe('CompileController', function () {
build: 1234,
},
]
- this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
- outputFiles: this.output_files,
- stats: this.stats,
- timings: this.timings,
- buildId: this.buildId,
- })
+ this.CompileManager.doCompileWithLock = sinon
+ .stub()
+ .callsFake((_req, stats, timings, cb) => {
+ Object.assign(stats, this.stats)
+ Object.assign(timings, this.timings)
+ cb(null, {
+ outputFiles: this.output_files,
+ buildId: this.buildId,
+ })
+ })
this.CompileController.compile(this.req, this.res)
})
it('should return the JSON response with status failure', function () {
this.res.status.calledWith(200).should.equal(true)
- this.res.send
- .calledWith({
- compile: {
- status: 'failure',
- error: null,
- stats: this.stats,
- buildId: this.buildId,
- timings: this.timings,
- outputUrlPrefix: '/zone/b',
- outputFiles: this.output_files.map(file => ({
- url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
- ...file,
- })),
- },
- })
- .should.equal(true)
+ this.res.send.should.have.been.calledWith({
+ compile: {
+ status: 'failure',
+ error: null,
+ stats: this.stats,
+ buildId: this.buildId,
+ timings: this.timings,
+ outputUrlPrefix: '/zone/b',
+ outputFiles: this.output_files.map(file => ({
+ url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
+ ...file,
+ })),
+ },
+ })
})
})
@@ -291,7 +261,11 @@ describe('CompileController', function () {
error.buildId = this.buildId
this.CompileManager.doCompileWithLock = sinon
.stub()
- .callsArgWith(1, error, null)
+ .callsFake((_req, stats, timings, cb) => {
+ Object.assign(stats, this.stats)
+ Object.assign(timings, this.timings)
+ cb(error)
+ })
this.CompileController.compile(this.req, this.res)
})
@@ -305,9 +279,8 @@ describe('CompileController', function () {
outputUrlPrefix: '/zone/b',
outputFiles: [],
buildId: this.buildId,
- // JSON.stringify will omit these
- stats: undefined,
- timings: undefined,
+ stats: this.stats,
+ timings: this.timings,
},
})
.should.equal(true)
@@ -321,7 +294,11 @@ describe('CompileController', function () {
)
this.CompileManager.doCompileWithLock = sinon
.stub()
- .callsArgWith(1, error, null)
+ .callsFake((_req, stats, timings, cb) => {
+ Object.assign(stats, this.stats)
+ Object.assign(timings, this.timings)
+ cb(error)
+ })
this.CompileController.compile(this.req, this.res)
})
@@ -334,9 +311,10 @@ describe('CompileController', function () {
error: 'too many concurrent compile requests',
outputUrlPrefix: '/zone/b',
outputFiles: [],
+ stats: this.stats,
+ timings: this.timings,
+ // JSON.stringify will omit these undefined values
buildId: undefined,
- stats: undefined,
- timings: undefined,
},
})
.should.equal(true)
@@ -349,7 +327,11 @@ describe('CompileController', function () {
this.error.timedout = true
this.CompileManager.doCompileWithLock = sinon
.stub()
- .callsArgWith(1, this.error, null)
+ .callsFake((_req, stats, timings, cb) => {
+ Object.assign(stats, this.stats)
+ Object.assign(timings, this.timings)
+ cb(this.error)
+ })
this.CompileController.compile(this.req, this.res)
})
@@ -362,10 +344,10 @@ describe('CompileController', function () {
error: this.message,
outputUrlPrefix: '/zone/b',
outputFiles: [],
- // JSON.stringify will omit these
+ stats: this.stats,
+ timings: this.timings,
+ // JSON.stringify will omit these undefined values
buildId: undefined,
- stats: undefined,
- timings: undefined,
},
})
.should.equal(true)
@@ -376,7 +358,11 @@ describe('CompileController', function () {
beforeEach(function () {
this.CompileManager.doCompileWithLock = sinon
.stub()
- .callsArgWith(1, null, [])
+ .callsFake((_req, stats, timings, cb) => {
+ Object.assign(stats, this.stats)
+ Object.assign(timings, this.timings)
+ cb(null, {})
+ })
this.CompileController.compile(this.req, this.res)
})
@@ -389,10 +375,10 @@ describe('CompileController', function () {
status: 'failure',
outputUrlPrefix: '/zone/b',
outputFiles: [],
- // JSON.stringify will omit these
+ stats: this.stats,
+ timings: this.timings,
+ // JSON.stringify will omit these undefined values
buildId: undefined,
- stats: undefined,
- timings: undefined,
},
})
.should.equal(true)
@@ -439,8 +425,6 @@ describe('CompileController', function () {
})
.should.equal(true)
})
-
- tryImageNameValidation('syncFromCode', 'imageName')
})
describe('syncFromPdf', function () {
@@ -476,8 +460,6 @@ describe('CompileController', function () {
})
.should.equal(true)
})
-
- tryImageNameValidation('syncFromPdf', 'imageName')
})
describe('wordcount', function () {
@@ -511,7 +493,5 @@ describe('CompileController', function () {
})
.should.equal(true)
})
-
- tryImageNameValidation('wordcount', 'image')
})
})
diff --git a/services/clsi/test/unit/js/CompileManagerTests.js b/services/clsi/test/unit/js/CompileManagerTests.js
index 8d7aff4910..33a43ae029 100644
--- a/services/clsi/test/unit/js/CompileManagerTests.js
+++ b/services/clsi/test/unit/js/CompileManagerTests.js
@@ -62,6 +62,7 @@ describe('CompileManager', function () {
}
this.OutputCacheManager = {
promises: {
+ queueDirOperation: sinon.stub().callsArg(1),
saveOutputFiles: sinon
.stub()
.resolves({ outputFiles: this.buildFiles, buildId: this.buildId }),
@@ -160,6 +161,11 @@ describe('CompileManager', function () {
'./LockManager': this.LockManager,
'./SynctexOutputParser': this.SynctexOutputParser,
'fs/promises': this.fsPromises,
+ './CLSICacheHandler': {
+ notifyCLSICacheAboutBuild: sinon.stub(),
+ downloadLatestCompileCache: sinon.stub().resolves(),
+ downloadOutputDotSynctexFromCompileCache: sinon.stub().resolves(),
+ },
},
})
})
@@ -177,6 +183,11 @@ describe('CompileManager', function () {
flags: (this.flags = ['-file-line-error']),
compileGroup: (this.compileGroup = 'compile-group'),
stopOnFirstError: false,
+ metricsOpts: {
+ path: 'clsi-perf',
+ method: 'minimal',
+ compile: 'initial',
+ },
}
this.env = {
OVERLEAF_PROJECT_ID: this.projectId,
@@ -188,7 +199,7 @@ describe('CompileManager', function () {
const error = new Error('locked')
this.LockManager.acquire.throws(error)
await expect(
- this.CompileManager.promises.doCompileWithLock(this.request)
+ this.CompileManager.promises.doCompileWithLock(this.request, {}, {})
).to.be.rejectedWith(error)
})
@@ -206,7 +217,9 @@ describe('CompileManager', function () {
describe('normally', function () {
beforeEach(async function () {
this.result = await this.CompileManager.promises.doCompileWithLock(
- this.request
+ this.request,
+ {},
+ {}
)
})
@@ -260,7 +273,11 @@ describe('CompileManager', function () {
describe('with draft mode', function () {
beforeEach(async function () {
this.request.draft = true
- await this.CompileManager.promises.doCompileWithLock(this.request)
+ await this.CompileManager.promises.doCompileWithLock(
+ this.request,
+ {},
+ {}
+ )
})
it('should inject the draft mode header', function () {
@@ -273,7 +290,11 @@ describe('CompileManager', function () {
describe('with a check option', function () {
beforeEach(async function () {
this.request.check = 'error'
- await this.CompileManager.promises.doCompileWithLock(this.request)
+ await this.CompileManager.promises.doCompileWithLock(
+ this.request,
+ {},
+ {}
+ )
})
it('should run chktex', function () {
@@ -305,7 +326,11 @@ describe('CompileManager', function () {
beforeEach(async function () {
this.request.rootResourcePath = 'main.Rtex'
this.request.check = 'error'
- await this.CompileManager.promises.doCompileWithLock(this.request)
+ await this.CompileManager.promises.doCompileWithLock(
+ this.request,
+ {},
+ {}
+ )
})
it('should not run chktex', function () {
@@ -334,7 +359,7 @@ describe('CompileManager', function () {
error.timedout = true
this.LatexRunner.promises.runLatex.rejects(error)
await expect(
- this.CompileManager.promises.doCompileWithLock(this.request)
+ this.CompileManager.promises.doCompileWithLock(this.request, {}, {})
).to.be.rejected
})
@@ -357,7 +382,7 @@ describe('CompileManager', function () {
error.terminated = true
this.LatexRunner.promises.runLatex.rejects(error)
await expect(
- this.CompileManager.promises.doCompileWithLock(this.request)
+ this.CompileManager.promises.doCompileWithLock(this.request, {}, {})
).to.be.rejected
})
@@ -455,7 +480,7 @@ describe('CompileManager', function () {
this.filename,
this.line,
this.column,
- customImageName
+ { imageName: customImageName }
)
})
@@ -497,7 +522,7 @@ describe('CompileManager', function () {
this.page,
this.h,
this.v,
- ''
+ { imageName: '' }
)
})
@@ -532,7 +557,7 @@ describe('CompileManager', function () {
this.page,
this.h,
this.v,
- customImageName
+ { imageName: customImageName }
)
})
diff --git a/services/clsi/test/unit/js/DockerRunnerTests.js b/services/clsi/test/unit/js/DockerRunnerTests.js
index 6c377d102b..d70aab52c7 100644
--- a/services/clsi/test/unit/js/DockerRunnerTests.js
+++ b/services/clsi/test/unit/js/DockerRunnerTests.js
@@ -76,8 +76,11 @@ describe('DockerRunner', function () {
this.env = {}
this.callback = sinon.stub()
this.project_id = 'project-id-123'
- this.volumes = { '/local/compile/directory': '/compile' }
+ this.volumes = { '/some/host/dir/compiles/directory': '/compile' }
this.Settings.clsi.docker.image = this.defaultImage = 'default-image'
+ this.Settings.path.sandboxedCompilesHostDirCompiles =
+ '/some/host/dir/compiles'
+ this.Settings.path.sandboxedCompilesHostDirOutput = '/some/host/dir/output'
this.compileGroup = 'compile-group'
return (this.Settings.clsi.docker.env = { PATH: 'mock-path' })
})
@@ -151,9 +154,8 @@ describe('DockerRunner', function () {
})
})
- describe('when path.sandboxedCompilesHostDir is set', function () {
+ describe('standard compile', function () {
beforeEach(function () {
- this.Settings.path.sandboxedCompilesHostDir = '/some/host/dir/compiles'
this.directory = '/var/lib/overleaf/data/compiles/xyz'
this.DockerRunner._runAndWaitForContainer = sinon
.stub()
@@ -183,6 +185,99 @@ describe('DockerRunner', function () {
})
})
+ describe('synctex-output', function () {
+ beforeEach(function () {
+ this.directory = '/var/lib/overleaf/data/output/xyz/generated-files/id'
+ this.DockerRunner._runAndWaitForContainer = sinon
+ .stub()
+ .callsArgWith(3, null, (this.output = 'mock-output'))
+ this.DockerRunner.run(
+ this.project_id,
+ this.command,
+ this.directory,
+ this.image,
+ this.timeout,
+ this.env,
+ 'synctex-output',
+ this.callback
+ )
+ })
+
+ it('should re-write the bind directory and set ro flag', function () {
+ const volumes =
+ this.DockerRunner._runAndWaitForContainer.lastCall.args[1]
+ expect(volumes).to.deep.equal({
+ '/some/host/dir/output/xyz/generated-files/id': '/compile:ro',
+ })
+ })
+
+ it('should call the callback', function () {
+ this.callback.calledWith(null, this.output).should.equal(true)
+ })
+ })
+
+ describe('synctex', function () {
+ beforeEach(function () {
+ this.directory = '/var/lib/overleaf/data/compile/xyz'
+ this.DockerRunner._runAndWaitForContainer = sinon
+ .stub()
+ .callsArgWith(3, null, (this.output = 'mock-output'))
+ this.DockerRunner.run(
+ this.project_id,
+ this.command,
+ this.directory,
+ this.image,
+ this.timeout,
+ this.env,
+ 'synctex',
+ this.callback
+ )
+ })
+
+ it('should re-write the bind directory', function () {
+ const volumes =
+ this.DockerRunner._runAndWaitForContainer.lastCall.args[1]
+ expect(volumes).to.deep.equal({
+ '/some/host/dir/compiles/xyz': '/compile:ro',
+ })
+ })
+
+ it('should call the callback', function () {
+ this.callback.calledWith(null, this.output).should.equal(true)
+ })
+ })
+
+ describe('wordcount', function () {
+ beforeEach(function () {
+ this.directory = '/var/lib/overleaf/data/compile/xyz'
+ this.DockerRunner._runAndWaitForContainer = sinon
+ .stub()
+ .callsArgWith(3, null, (this.output = 'mock-output'))
+ this.DockerRunner.run(
+ this.project_id,
+ this.command,
+ this.directory,
+ this.image,
+ this.timeout,
+ this.env,
+ 'wordcount',
+ this.callback
+ )
+ })
+
+ it('should re-write the bind directory', function () {
+ const volumes =
+ this.DockerRunner._runAndWaitForContainer.lastCall.args[1]
+ expect(volumes).to.deep.equal({
+ '/some/host/dir/compiles/xyz': '/compile:ro',
+ })
+ })
+
+ it('should call the callback', function () {
+ this.callback.calledWith(null, this.output).should.equal(true)
+ })
+ })
+
describe('when the run throws an error', function () {
beforeEach(function () {
let firstTime = true
@@ -390,7 +485,7 @@ describe('DockerRunner', function () {
const options =
this.DockerRunner._runAndWaitForContainer.lastCall.args[0]
return expect(options.HostConfig).to.deep.include({
- Binds: ['/local/compile/directory:/compile:rw'],
+ Binds: ['/some/host/dir/compiles/directory:/compile:rw'],
LogConfig: { Type: 'none', Config: {} },
CapDrop: 'ALL',
SecurityOpt: ['no-new-privileges'],
@@ -562,82 +657,6 @@ describe('DockerRunner', function () {
})
})
- describe('when a volume does not exist', function () {
- beforeEach(function () {
- this.fs.stat = sinon.stub().yields(new Error('no such path'))
- return this.DockerRunner.startContainer(
- this.options,
- this.volumes,
- this.attachStreamHandler,
- this.callback
- )
- })
-
- it('should not try to create the container', function () {
- return this.createContainer.called.should.equal(false)
- })
-
- it('should call the callback with an error', function () {
- this.callback.calledWith(sinon.match(Error)).should.equal(true)
- })
- })
-
- describe('when a volume exists but is not a directory', function () {
- beforeEach(function () {
- this.fs.stat = sinon.stub().yields(null, {
- isDirectory() {
- return false
- },
- })
- return this.DockerRunner.startContainer(
- this.options,
- this.volumes,
- this.attachStreamHandler,
- this.callback
- )
- })
-
- it('should not try to create the container', function () {
- return this.createContainer.called.should.equal(false)
- })
-
- it('should call the callback with an error', function () {
- this.callback.calledWith(sinon.match(Error)).should.equal(true)
- })
- })
-
- describe('when a volume does not exist, but sibling-containers are used', function () {
- beforeEach(function () {
- this.fs.stat = sinon.stub().yields(new Error('no such path'))
- this.Settings.path.sandboxedCompilesHostDir = '/some/path'
- this.container.start = sinon.stub().yields()
- return this.DockerRunner.startContainer(
- this.options,
- this.volumes,
- () => {},
- this.callback
- )
- })
-
- afterEach(function () {
- return delete this.Settings.path.sandboxedCompilesHostDir
- })
-
- it('should start the container with the given name', function () {
- this.getContainer.calledWith(this.options.name).should.equal(true)
- return this.container.start.called.should.equal(true)
- })
-
- it('should not try to create the container', function () {
- return this.createContainer.called.should.equal(false)
- })
-
- return it('should call the callback', function () {
- this.callback.called.should.equal(true)
- return this.callback.calledWith(new Error()).should.equal(false)
- })
- })
-
return describe('when the container tries to be created, but already has been (race condition)', function () {})
})
diff --git a/services/clsi/test/unit/js/ProjectPersistenceManagerTests.js b/services/clsi/test/unit/js/ProjectPersistenceManagerTests.js
index 2504d266ca..4f42411fba 100644
--- a/services/clsi/test/unit/js/ProjectPersistenceManagerTests.js
+++ b/services/clsi/test/unit/js/ProjectPersistenceManagerTests.js
@@ -21,12 +21,16 @@ const tk = require('timekeeper')
describe('ProjectPersistenceManager', function () {
beforeEach(function () {
+ this.fsPromises = {
+ statfs: sinon.stub(),
+ }
+
this.ProjectPersistenceManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/metrics': (this.Metrics = { gauge: sinon.stub() }),
'./UrlCache': (this.UrlCache = {}),
'./CompileManager': (this.CompileManager = {}),
- diskusage: (this.diskusage = { check: sinon.stub() }),
+ fs: { promises: this.fsPromises },
'@overleaf/settings': (this.settings = {
project_cache_length_ms: 1000,
path: {
@@ -44,9 +48,10 @@ describe('ProjectPersistenceManager', function () {
describe('refreshExpiryTimeout', function () {
it('should leave expiry alone if plenty of disk', function (done) {
- this.diskusage.check.resolves({
- available: 40,
- total: 100,
+ this.fsPromises.statfs.resolves({
+ blocks: 100,
+ bsize: 1,
+ bavail: 40,
})
this.ProjectPersistenceManager.refreshExpiryTimeout(() => {
@@ -62,9 +67,10 @@ describe('ProjectPersistenceManager', function () {
})
it('should drop EXPIRY_TIMEOUT 10% if low disk usage', function (done) {
- this.diskusage.check.resolves({
- available: 5,
- total: 100,
+ this.fsPromises.statfs.resolves({
+ blocks: 100,
+ bsize: 1,
+ bavail: 5,
})
this.ProjectPersistenceManager.refreshExpiryTimeout(() => {
@@ -78,9 +84,10 @@ describe('ProjectPersistenceManager', function () {
})
it('should not drop EXPIRY_TIMEOUT to below 50% of project_cache_length_ms', function (done) {
- this.diskusage.check.resolves({
- available: 5,
- total: 100,
+ this.fsPromises.statfs.resolves({
+ blocks: 100,
+ bsize: 1,
+ bavail: 5,
})
this.ProjectPersistenceManager.EXPIRY_TIMEOUT = 500
this.ProjectPersistenceManager.refreshExpiryTimeout(() => {
@@ -94,7 +101,7 @@ describe('ProjectPersistenceManager', function () {
})
it('should not modify EXPIRY_TIMEOUT if there is an error getting disk values', function (done) {
- this.diskusage.check.throws(new Error())
+ this.fsPromises.statfs.rejects(new Error())
this.ProjectPersistenceManager.refreshExpiryTimeout(() => {
this.ProjectPersistenceManager.EXPIRY_TIMEOUT.should.equal(1000)
done()
diff --git a/services/contacts/buildscript.txt b/services/contacts/buildscript.txt
index 0f4f2e643d..8563d1b71e 100644
--- a/services/contacts/buildscript.txt
+++ b/services/contacts/buildscript.txt
@@ -6,4 +6,4 @@ contacts
--esmock-loader=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/services/docstore/buildscript.txt b/services/docstore/buildscript.txt
index 0fb0267ca2..c329d7b571 100644
--- a/services/docstore/buildscript.txt
+++ b/services/docstore/buildscript.txt
@@ -6,4 +6,4 @@ docstore
--esmock-loader=False
--node-version=20.18.2
--public-repo=True
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/services/document-updater/app/js/DocumentManager.js b/services/document-updater/app/js/DocumentManager.js
index 540a8a254c..dc20c27d7f 100644
--- a/services/document-updater/app/js/DocumentManager.js
+++ b/services/document-updater/app/js/DocumentManager.js
@@ -160,14 +160,6 @@ const DocumentManager = {
alreadyLoaded,
} = await DocumentManager.getDoc(projectId, docId)
- if (oldLines != null && oldLines.length > 0 && oldLines[0].text != null) {
- logger.debug(
- { docId, projectId, oldLines, newLines },
- 'document is JSON so not updating'
- )
- return
- }
-
logger.debug(
{ docId, projectId, oldLines, newLines },
'setting a document via http'
diff --git a/services/document-updater/buildscript.txt b/services/document-updater/buildscript.txt
index f303583bcb..ee013ebea9 100644
--- a/services/document-updater/buildscript.txt
+++ b/services/document-updater/buildscript.txt
@@ -6,4 +6,4 @@ document-updater
--esmock-loader=False
--node-version=20.18.2
--public-repo=True
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml
index bdf10c9732..6deaad433d 100644
--- a/services/document-updater/docker-compose.ci.yml
+++ b/services/document-updater/docker-compose.ci.yml
@@ -21,6 +21,7 @@ services:
ELASTIC_SEARCH_DSN: es:9200
REDIS_HOST: redis
QUEUES_REDIS_HOST: redis
+ HISTORY_REDIS_HOST: redis
ANALYTICS_QUEUES_REDIS_HOST: redis
MONGO_HOST: mongo
POSTGRES_HOST: postgres
diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml
index 7dd27c9a39..e33174f9e2 100644
--- a/services/document-updater/docker-compose.yml
+++ b/services/document-updater/docker-compose.yml
@@ -30,6 +30,7 @@ services:
environment:
ELASTIC_SEARCH_DSN: es:9200
REDIS_HOST: redis
+ HISTORY_REDIS_HOST: redis
QUEUES_REDIS_HOST: redis
ANALYTICS_QUEUES_REDIS_HOST: redis
MONGO_HOST: mongo
diff --git a/services/document-updater/scripts/flush_projects_with_no_history_id.js b/services/document-updater/scripts/flush_projects_with_no_history_id.js
new file mode 100644
index 0000000000..aa912b4b66
--- /dev/null
+++ b/services/document-updater/scripts/flush_projects_with_no_history_id.js
@@ -0,0 +1,211 @@
+// @ts-check
+
+const Settings = require('@overleaf/settings')
+const logger = require('@overleaf/logger')
+const RedisManager = require('../app/js/RedisManager')
+const minimist = require('minimist')
+const { db, ObjectId } = require('../app/js/mongodb')
+const ProjectManager = require('../app/js/ProjectManager')
+const OError = require('@overleaf/o-error')
+
+const docUpdaterKeys = Settings.redis.documentupdater.key_schema
+
+const rclient = RedisManager.rclient
+
+const { verbose, commit, ...args } = minimist(process.argv.slice(2), {
+ boolean: ['verbose', 'commit'],
+ string: ['batchSize'],
+ default: {
+ batchSize: '1000',
+ },
+})
+
+logger.logger.level(verbose ? 'debug' : 'warn')
+
+const batchSize = parseInt(args.batchSize, 10)
+
+/**
+ * @typedef {import('ioredis').Redis} Redis
+ */
+
+/**
+ *
+ * @param {string} key
+ * @return {string|void}
+ */
+function extractDocId(key) {
+ const matches = key.match(/ProjectHistoryId:\{(.*?)\}/)
+ if (matches) {
+ return matches[1]
+ }
+}
+
+/**
+ *
+ * @param {string} docId
+ * @return {Promise<{projectId: string, historyId: string}>}
+ */
+async function getHistoryId(docId) {
+ const doc = await db.docs.findOne(
+ { _id: new ObjectId(docId) },
+ { projection: { project_id: 1 }, readPreference: 'secondaryPreferred' }
+ )
+
+ if (!doc) {
+ throw new OError('Doc not present in mongo', { docId })
+ }
+
+ const project = await db.projects.findOne(
+ { _id: doc.project_id },
+ {
+ projection: { 'overleaf.history': 1 },
+ readPreference: 'secondaryPreferred',
+ }
+ )
+
+ if (!project?.overleaf?.history?.id) {
+ throw new OError('Project not present in mongo (or has no history id)', {
+ docId,
+ project,
+ doc,
+ })
+ }
+
+ return {
+ historyId: project?.overleaf?.history?.id,
+ projectId: doc.project_id.toString(),
+ }
+}
+
+/**
+ * @typedef {Object} UpdateableDoc
+ * @property {string} docId
+ * @property {string} projectId
+ * @property {string} historyId
+ */
+
+/**
+ *
+ * @param {Redis} node
+ * @param {Array} docIds
+ * @return {Promise>}
+ */
+async function findDocsWithMissingHistoryIds(node, docIds) {
+ const historyIds = await node.mget(
+ docIds.map(docId => docUpdaterKeys.projectHistoryId({ doc_id: docId }))
+ )
+
+ const results = []
+
+ for (const index in docIds) {
+ const historyId = historyIds[index]
+ const docId = docIds[index]
+ if (!historyId) {
+ try {
+ const { projectId, historyId } = await getHistoryId(docId)
+ results.push({ projectId, historyId, docId })
+ } catch (error) {
+ logger.warn(
+ { error },
+ 'Error gathering data for doc with missing history id'
+ )
+ }
+ }
+ }
+ return results
+}
+
+/**
+ *
+ * @param {Array} updates
+ * @return {Promise}
+ */
+async function fixAndFlushProjects(updates) {
+ for (const update of updates) {
+ if (commit) {
+ try {
+ await rclient.set(
+ docUpdaterKeys.projectHistoryId({ doc_id: update.docId }),
+ update.historyId
+ )
+ logger.debug({ ...update }, 'Set history id in redis')
+ await ProjectManager.promises.flushAndDeleteProjectWithLocks(
+ update.projectId,
+ {}
+ )
+ logger.debug({ ...update }, 'Flushed project')
+ } catch (err) {
+ logger.error({ err, ...update }, 'Error fixing and flushing project')
+ }
+ } else {
+ logger.debug(
+ { ...update },
+ 'Would have set history id in redis and flushed'
+ )
+ }
+ }
+}
+
+/**
+ *
+ * @param {Array} nodes
+ * @param {number} batchSize
+ * @return {Promise}
+ */
+async function scanNodes(nodes, batchSize = 1000) {
+ let scanned = 0
+
+ for (const node of nodes) {
+ const stream = node.scanStream({
+ match: docUpdaterKeys.projectHistoryId({ doc_id: '*' }),
+ count: batchSize,
+ })
+
+ for await (const docKeys of stream) {
+ if (docKeys.length === 0) {
+ continue
+ }
+ stream.pause()
+ scanned += docKeys.length
+
+ const docIds = docKeys
+ .map((/** @type {string} */ docKey) => extractDocId(docKey))
+ .filter(Boolean)
+
+ try {
+ const updates = await findDocsWithMissingHistoryIds(node, docIds)
+ if (updates.length > 0) {
+ logger.info({ updates }, 'Found doc(s) with missing history ids')
+ await fixAndFlushProjects(updates)
+ }
+ } catch (error) {
+ logger.error({ docKeys }, 'Error processing batch')
+ } finally {
+ stream.resume()
+ }
+ }
+
+ logger.info({ scanned, server: node.serverInfo.role }, 'Scanned node')
+ }
+}
+
+async function main({ batchSize }) {
+ const nodes = (typeof rclient.nodes === 'function'
+ ? rclient.nodes('master')
+ : undefined) || [rclient]
+ await scanNodes(nodes, batchSize)
+}
+
+let code = 0
+
+main({ batchSize })
+ .then(() => {
+ logger.info({}, 'done')
+ })
+ .catch(error => {
+ logger.error({ error }, 'error')
+ code = 1
+ })
+ .finally(() => {
+ rclient.quit().then(() => process.exit(code))
+ })
diff --git a/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js b/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js
index 73e22aace7..0df2e72a08 100644
--- a/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js
+++ b/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js
@@ -16,13 +16,16 @@ const DocUpdaterClient = require('./helpers/DocUpdaterClient')
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
describe('Applying updates to a doc', function () {
- before(function (done) {
+ beforeEach(function (done) {
+ sinon.spy(MockWebApi, 'getDocument')
this.lines = ['one', 'two', 'three']
this.version = 42
this.op = {
i: 'one and a half\n',
p: 4,
}
+ this.project_id = DocUpdaterClient.randomId()
+ this.doc_id = DocUpdaterClient.randomId()
this.update = {
doc: this.doc_id,
op: [this.op],
@@ -31,12 +34,12 @@ describe('Applying updates to a doc', function () {
this.result = ['one', 'one and a half', 'two', 'three']
DocUpdaterApp.ensureRunning(done)
})
+ afterEach(function () {
+ sinon.restore()
+ })
describe('when the document is not loaded', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
- sinon.spy(MockWebApi, 'getDocument')
+ beforeEach(function (done) {
this.startTime = Date.now()
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines: this.lines,
@@ -50,15 +53,25 @@ describe('Applying updates to a doc', function () {
if (error != null) {
throw error
}
- setTimeout(done, 200)
+ setTimeout(() => {
+ rclientProjectHistory.get(
+ ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
+ project_id: this.project_id,
+ }),
+ (error, result) => {
+ if (error != null) {
+ throw error
+ }
+ result = parseInt(result, 10)
+ this.firstOpTimestamp = result
+ done()
+ }
+ )
+ }, 200)
}
)
})
- after(function () {
- MockWebApi.getDocument.restore()
- })
-
it('should load the document from the web API', function () {
MockWebApi.getDocument
.calledWith(this.project_id, this.doc_id)
@@ -92,21 +105,8 @@ describe('Applying updates to a doc', function () {
)
})
- it('should set the first op timestamp', function (done) {
- rclientProjectHistory.get(
- ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
- project_id: this.project_id,
- }),
- (error, result) => {
- if (error != null) {
- throw error
- }
- result = parseInt(result, 10)
- result.should.be.within(this.startTime, Date.now())
- this.firstOpTimestamp = result
- done()
- }
- )
+ it('should set the first op timestamp', function () {
+ this.firstOpTimestamp.should.be.within(this.startTime, Date.now())
})
it('should yield last updated time', function (done) {
@@ -138,7 +138,7 @@ describe('Applying updates to a doc', function () {
})
describe('when sending another update', function () {
- before(function (done) {
+ beforeEach(function (done) {
this.timeout(10000)
this.second_update = Object.assign({}, this.update)
this.second_update.v = this.version + 1
@@ -207,13 +207,85 @@ describe('Applying updates to a doc', function () {
)
})
})
+
+ describe('when another client is sending a concurrent update', function () {
+ beforeEach(function (done) {
+ this.timeout(10000)
+ this.otherUpdate = {
+ doc: this.doc_id,
+ op: [{ p: 8, i: 'two and a half\n' }],
+ v: this.version,
+ meta: { source: 'other-random-publicId' },
+ }
+ this.secondStartTime = Date.now()
+ DocUpdaterClient.sendUpdate(
+ this.project_id,
+ this.doc_id,
+ this.otherUpdate,
+ error => {
+ if (error != null) {
+ throw error
+ }
+ setTimeout(done, 200)
+ }
+ )
+ })
+
+ it('should update the doc', function (done) {
+ DocUpdaterClient.getDoc(
+ this.project_id,
+ this.doc_id,
+ (error, res, doc) => {
+ if (error) done(error)
+ doc.lines.should.deep.equal([
+ 'one',
+ 'one and a half',
+ 'two',
+ 'two and a half',
+ 'three',
+ ])
+ done()
+ }
+ )
+ })
+
+ it('should not change the first op timestamp', function (done) {
+ rclientProjectHistory.get(
+ ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
+ project_id: this.project_id,
+ }),
+ (error, result) => {
+ if (error != null) {
+ throw error
+ }
+ result = parseInt(result, 10)
+ result.should.equal(this.firstOpTimestamp)
+ done()
+ }
+ )
+ })
+
+ it('should yield last updated time', function (done) {
+ DocUpdaterClient.getProjectLastUpdatedAt(
+ this.project_id,
+ (error, res, body) => {
+ if (error != null) {
+ throw error
+ }
+ res.statusCode.should.equal(200)
+ body.lastUpdatedAt.should.be.within(
+ this.secondStartTime,
+ Date.now()
+ )
+ done()
+ }
+ )
+ })
+ })
})
describe('when the document is loaded', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
-
+ beforeEach(function (done) {
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines: this.lines,
version: this.version,
@@ -222,7 +294,7 @@ describe('Applying updates to a doc', function () {
if (error != null) {
throw error
}
- sinon.spy(MockWebApi, 'getDocument')
+ sinon.resetHistory()
DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
@@ -237,10 +309,6 @@ describe('Applying updates to a doc', function () {
})
})
- after(function () {
- MockWebApi.getDocument.restore()
- })
-
it('should not need to call the web api', function () {
MockWebApi.getDocument.called.should.equal(false)
})
@@ -272,10 +340,7 @@ describe('Applying updates to a doc', function () {
})
describe('when the document is loaded and is using project-history only', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
-
+ beforeEach(function (done) {
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines: this.lines,
version: this.version,
@@ -284,7 +349,7 @@ describe('Applying updates to a doc', function () {
if (error != null) {
throw error
}
- sinon.spy(MockWebApi, 'getDocument')
+ sinon.resetHistory()
DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
@@ -299,10 +364,6 @@ describe('Applying updates to a doc', function () {
})
})
- after(function () {
- MockWebApi.getDocument.restore()
- })
-
it('should update the doc', function (done) {
DocUpdaterClient.getDoc(
this.project_id,
@@ -331,9 +392,7 @@ describe('Applying updates to a doc', function () {
describe('when the document has been deleted', function () {
describe('when the ops come in a single linear order', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
+ beforeEach(function (done) {
const lines = ['', '', '']
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines,
@@ -353,54 +412,49 @@ describe('Applying updates to a doc', function () {
{ doc_id: this.doc_id, v: 10, op: [{ i: 'd', p: 10 }] },
]
this.my_result = ['hello world', '', '']
- done()
- })
-
- it('should be able to continue applying updates when the project has been deleted', function (done) {
- let update
const actions = []
- for (update of this.updates.slice(0, 6)) {
- ;(update => {
- actions.push(callback =>
- DocUpdaterClient.sendUpdate(
- this.project_id,
- this.doc_id,
- update,
- callback
- )
+ for (const update of this.updates.slice(0, 6)) {
+ actions.push(callback =>
+ DocUpdaterClient.sendUpdate(
+ this.project_id,
+ this.doc_id,
+ update,
+ callback
)
- })(update)
+ )
}
actions.push(callback =>
DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
)
- for (update of this.updates.slice(6)) {
- ;(update => {
- actions.push(callback =>
- DocUpdaterClient.sendUpdate(
- this.project_id,
- this.doc_id,
- update,
- callback
- )
+ for (const update of this.updates.slice(6)) {
+ actions.push(callback =>
+ DocUpdaterClient.sendUpdate(
+ this.project_id,
+ this.doc_id,
+ update,
+ callback
)
- })(update)
+ )
}
- async.series(actions, error => {
- if (error != null) {
- throw error
+ // process updates
+ actions.push(cb =>
+ DocUpdaterClient.getDoc(this.project_id, this.doc_id, cb)
+ )
+
+ async.series(actions, done)
+ })
+
+ it('should be able to continue applying updates when the project has been deleted', function (done) {
+ DocUpdaterClient.getDoc(
+ this.project_id,
+ this.doc_id,
+ (error, res, doc) => {
+ if (error) return done(error)
+ doc.lines.should.deep.equal(this.my_result)
+ done()
}
- DocUpdaterClient.getDoc(
- this.project_id,
- this.doc_id,
- (error, res, doc) => {
- if (error) return done(error)
- doc.lines.should.deep.equal(this.my_result)
- done()
- }
- )
- })
+ )
})
it('should store the doc ops in the correct order', function (done) {
@@ -422,9 +476,7 @@ describe('Applying updates to a doc', function () {
})
describe('when older ops come in after the delete', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
+ beforeEach(function (done) {
const lines = ['', '', '']
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines,
@@ -492,11 +544,9 @@ describe('Applying updates to a doc', function () {
})
describe('with a broken update', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
+ beforeEach(function (done) {
this.broken_update = {
- doc_id: this.doc_id,
+ doc: this.doc_id,
v: this.version,
op: [{ d: 'not the correct content', p: 0 }],
}
@@ -547,9 +597,7 @@ describe('Applying updates to a doc', function () {
})
describe('when there is no version in Mongo', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
+ beforeEach(function (done) {
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines: this.lines,
})
@@ -586,9 +634,7 @@ describe('Applying updates to a doc', function () {
})
describe('when the sending duplicate ops', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
+ beforeEach(function (done) {
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines: this.lines,
version: this.version,
@@ -671,11 +717,9 @@ describe('Applying updates to a doc', function () {
})
describe('when sending updates for a non-existing doc id', function () {
- before(function (done) {
- this.project_id = DocUpdaterClient.randomId()
- this.doc_id = DocUpdaterClient.randomId()
+ beforeEach(function (done) {
this.non_existing = {
- doc_id: this.doc_id,
+ doc: this.doc_id,
v: this.version,
op: [{ d: 'content', p: 0 }],
}
diff --git a/services/filestore/app.js b/services/filestore/app.js
index 23d01f1ca3..24741e079c 100644
--- a/services/filestore/app.js
+++ b/services/filestore/app.js
@@ -50,64 +50,68 @@ app.use((req, res, next) => {
Metrics.injectMetricsRoute(app)
-app.head(
- '/project/:project_id/file/:file_id',
- keyBuilder.userFileKeyMiddleware,
- fileController.getFileHead
-)
-app.get(
- '/project/:project_id/file/:file_id',
- keyBuilder.userFileKeyMiddleware,
- fileController.getFile
-)
-app.post(
- '/project/:project_id/file/:file_id',
- keyBuilder.userFileKeyMiddleware,
- fileController.insertFile
-)
-app.put(
- '/project/:project_id/file/:file_id',
- keyBuilder.userFileKeyMiddleware,
- bodyParser.json(),
- fileController.copyFile
-)
-app.delete(
- '/project/:project_id/file/:file_id',
- keyBuilder.userFileKeyMiddleware,
- fileController.deleteFile
-)
-app.delete(
- '/project/:project_id',
- keyBuilder.userProjectKeyMiddleware,
- fileController.deleteProject
-)
+if (settings.filestore.stores.user_files) {
+ app.head(
+ '/project/:project_id/file/:file_id',
+ keyBuilder.userFileKeyMiddleware,
+ fileController.getFileHead
+ )
+ app.get(
+ '/project/:project_id/file/:file_id',
+ keyBuilder.userFileKeyMiddleware,
+ fileController.getFile
+ )
+ app.post(
+ '/project/:project_id/file/:file_id',
+ keyBuilder.userFileKeyMiddleware,
+ fileController.insertFile
+ )
+ app.put(
+ '/project/:project_id/file/:file_id',
+ keyBuilder.userFileKeyMiddleware,
+ bodyParser.json(),
+ fileController.copyFile
+ )
+ app.delete(
+ '/project/:project_id/file/:file_id',
+ keyBuilder.userFileKeyMiddleware,
+ fileController.deleteFile
+ )
+ app.delete(
+ '/project/:project_id',
+ keyBuilder.userProjectKeyMiddleware,
+ fileController.deleteProject
+ )
-app.get(
- '/project/:project_id/size',
- keyBuilder.userProjectKeyMiddleware,
- fileController.directorySize
-)
+ app.get(
+ '/project/:project_id/size',
+ keyBuilder.userProjectKeyMiddleware,
+ fileController.directorySize
+ )
+}
-app.head(
- '/template/:template_id/v/:version/:format',
- keyBuilder.templateFileKeyMiddleware,
- fileController.getFileHead
-)
-app.get(
- '/template/:template_id/v/:version/:format',
- keyBuilder.templateFileKeyMiddleware,
- fileController.getFile
-)
-app.get(
- '/template/:template_id/v/:version/:format/:sub_type',
- keyBuilder.templateFileKeyMiddleware,
- fileController.getFile
-)
-app.post(
- '/template/:template_id/v/:version/:format',
- keyBuilder.templateFileKeyMiddleware,
- fileController.insertFile
-)
+if (settings.filestore.stores.template_files) {
+ app.head(
+ '/template/:template_id/v/:version/:format',
+ keyBuilder.templateFileKeyMiddleware,
+ fileController.getFileHead
+ )
+ app.get(
+ '/template/:template_id/v/:version/:format',
+ keyBuilder.templateFileKeyMiddleware,
+ fileController.getFile
+ )
+ app.get(
+ '/template/:template_id/v/:version/:format/:sub_type',
+ keyBuilder.templateFileKeyMiddleware,
+ fileController.getFile
+ )
+ app.post(
+ '/template/:template_id/v/:version/:format',
+ keyBuilder.templateFileKeyMiddleware,
+ fileController.insertFile
+ )
+}
app.get(
'/bucket/:bucket/key/*',
diff --git a/services/filestore/buildscript.txt b/services/filestore/buildscript.txt
index d40b7669c9..75a491c18a 100644
--- a/services/filestore/buildscript.txt
+++ b/services/filestore/buildscript.txt
@@ -7,6 +7,6 @@ filestore
--esmock-loader=False
--node-version=20.18.2
--public-repo=True
---script-version=4.5.0
+--script-version=4.7.0
--test-acceptance-shards=SHARD_01_,SHARD_02_,SHARD_03_
--use-large-ci-runner=True
diff --git a/services/history-v1/api/controllers/projects.js b/services/history-v1/api/controllers/projects.js
index 7dbeb53fae..d42e29ab81 100644
--- a/services/history-v1/api/controllers/projects.js
+++ b/services/history-v1/api/controllers/projects.js
@@ -138,6 +138,45 @@ async function getHistoryBefore(req, res, next) {
}
}
+/**
+ * Get all changes since the beginning of history or since a given version
+ */
+async function getChanges(req, res, next) {
+ const projectId = req.swagger.params.project_id.value
+ const since = req.swagger.params.since.value ?? 0
+
+ if (since < 0) {
+ // Negative values would cause an infinite loop
+ return res.status(400).json({
+ error: `Version out of bounds: ${since}`,
+ })
+ }
+
+ const changes = []
+ let chunk = await chunkStore.loadLatest(projectId)
+
+ if (since > chunk.getEndVersion()) {
+ return res.status(400).json({
+ error: `Version out of bounds: ${since}`,
+ })
+ }
+
+ // Fetch all chunks that come after the chunk that contains the start version
+ while (chunk.getStartVersion() > since) {
+ const changesInChunk = chunk.getChanges()
+ changes.unshift(...changesInChunk)
+ chunk = await chunkStore.loadAtVersion(projectId, chunk.getStartVersion())
+ }
+
+ // Extract the relevant changes from the chunk that contains the start version
+ const changesInChunk = chunk
+ .getChanges()
+ .slice(since - chunk.getStartVersion())
+ changes.unshift(...changesInChunk)
+
+ res.json(changes.map(change => change.toRaw()))
+}
+
async function getZip(req, res, next) {
const projectId = req.swagger.params.project_id.value
const version = req.swagger.params.version.value
@@ -337,6 +376,7 @@ module.exports = {
getLatestHistoryRaw: expressify(getLatestHistoryRaw),
getHistory: expressify(getHistory),
getHistoryBefore: expressify(getHistoryBefore),
+ getChanges: expressify(getChanges),
getZip: expressify(getZip),
createZip: expressify(createZip),
deleteProject: expressify(deleteProject),
diff --git a/services/history-v1/api/swagger/project_import.js b/services/history-v1/api/swagger/project_import.js
index 60eb47fce4..a93f42d27e 100644
--- a/services/history-v1/api/swagger/project_import.js
+++ b/services/history-v1/api/swagger/project_import.js
@@ -100,9 +100,48 @@ const importChanges = {
],
}
+const getChanges = {
+ 'x-swagger-router-controller': 'projects',
+ operationId: 'getChanges',
+ tags: ['Project'],
+ description: 'Get changes applied to a project',
+ parameters: [
+ {
+ name: 'project_id',
+ in: 'path',
+ description: 'project id',
+ required: true,
+ type: 'string',
+ },
+ {
+ name: 'since',
+ in: 'query',
+ description: 'start version',
+ required: false,
+ type: 'number',
+ },
+ ],
+ responses: {
+ 200: {
+ description: 'Success',
+ schema: {
+ type: 'array',
+ items: {
+ $ref: '#/definitions/Change',
+ },
+ },
+ },
+ },
+ security: [
+ {
+ basic: [],
+ },
+ ],
+}
+
exports.paths = {
'/projects/{project_id}/import': { post: importSnapshot },
'/projects/{project_id}/legacy_import': { post: importSnapshot },
- '/projects/{project_id}/changes': { post: importChanges },
+ '/projects/{project_id}/changes': { get: getChanges, post: importChanges },
'/projects/{project_id}/legacy_changes': { post: importChanges },
}
diff --git a/services/history-v1/backup-verifier-app.mjs b/services/history-v1/backup-verifier-app.mjs
index 3949e6a62d..856a15dd53 100644
--- a/services/history-v1/backup-verifier-app.mjs
+++ b/services/history-v1/backup-verifier-app.mjs
@@ -90,15 +90,16 @@ process.on('SIGINT', () => {
/**
* @param {number} port
+ * @param {boolean} enableVerificationLoop
* @return {Promise}
*/
-export async function startApp(port) {
+export async function startApp(port, enableVerificationLoop = true) {
await mongodb.client.connect()
await loadGlobalBlobs()
await healthCheck()
const server = http.createServer(app)
await promisify(server.listen.bind(server, port))()
- loopRandomProjects(shutdownEmitter)
+ enableVerificationLoop && loopRandomProjects(shutdownEmitter)
return server
}
diff --git a/services/history-v1/buildscript.txt b/services/history-v1/buildscript.txt
index f8c895901b..f3e029ba33 100644
--- a/services/history-v1/buildscript.txt
+++ b/services/history-v1/buildscript.txt
@@ -6,5 +6,5 @@ history-v1
--esmock-loader=False
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
--tsconfig-extra-includes=backup-deletion-app.mjs,backup-verifier-app.mjs,backup-worker-app.mjs,api/**/*,migrations/**/*,storage/**/*
diff --git a/services/history-v1/config/custom-environment-variables.json b/services/history-v1/config/custom-environment-variables.json
index daf804251c..d07ae2925a 100644
--- a/services/history-v1/config/custom-environment-variables.json
+++ b/services/history-v1/config/custom-environment-variables.json
@@ -89,6 +89,16 @@
"host": "QUEUES_REDIS_HOST",
"password": "QUEUES_REDIS_PASSWORD",
"port": "QUEUES_REDIS_PORT"
+ },
+ "history": {
+ "host": "HISTORY_REDIS_HOST",
+ "password": "HISTORY_REDIS_PASSWORD",
+ "port": "HISTORY_REDIS_PORT"
+ },
+ "lock": {
+ "host": "REDIS_HOST",
+ "password": "REDIS_PASSWORD",
+ "port": "REDIS_PORT"
}
}
}
diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml
index 7245ef14e2..06d5d55161 100644
--- a/services/history-v1/docker-compose.ci.yml
+++ b/services/history-v1/docker-compose.ci.yml
@@ -21,6 +21,7 @@ services:
ELASTIC_SEARCH_DSN: es:9200
REDIS_HOST: redis
QUEUES_REDIS_HOST: redis
+ HISTORY_REDIS_HOST: redis
ANALYTICS_QUEUES_REDIS_HOST: redis
MONGO_HOST: mongo
POSTGRES_HOST: postgres
diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml
index 608dd1d325..f4c885d467 100644
--- a/services/history-v1/docker-compose.yml
+++ b/services/history-v1/docker-compose.yml
@@ -37,6 +37,7 @@ services:
environment:
ELASTIC_SEARCH_DSN: es:9200
REDIS_HOST: redis
+ HISTORY_REDIS_HOST: redis
QUEUES_REDIS_HOST: redis
ANALYTICS_QUEUES_REDIS_HOST: redis
MONGO_HOST: mongo
diff --git a/services/history-v1/migrations/20250415210802_add_chunks_closed.js b/services/history-v1/migrations/20250415210802_add_chunks_closed.js
new file mode 100644
index 0000000000..b5c1d577f9
--- /dev/null
+++ b/services/history-v1/migrations/20250415210802_add_chunks_closed.js
@@ -0,0 +1,27 @@
+// @ts-check
+
+/**
+ * @import { Knex } from "knex"
+ */
+
+/**
+ * @param { Knex } knex
+ * @returns { Promise }
+ */
+exports.up = async function (knex) {
+ await knex.raw(`
+ ALTER TABLE chunks
+ ADD COLUMN closed BOOLEAN NOT NULL DEFAULT FALSE
+ `)
+}
+
+/**
+ * @param { Knex } knex
+ * @returns { Promise }
+ */
+exports.down = async function (knex) {
+ await knex.raw(`
+ ALTER TABLE chunks
+ DROP COLUMN closed
+ `)
+}
diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js
index 238cd12a38..5fe283a34c 100644
--- a/services/history-v1/storage/index.js
+++ b/services/history-v1/storage/index.js
@@ -1,10 +1,12 @@
exports.BatchBlobStore = require('./lib/batch_blob_store')
exports.blobHash = require('./lib/blob_hash')
exports.HashCheckBlobStore = require('./lib/hash_check_blob_store')
+exports.chunkBuffer = require('./lib/chunk_buffer')
exports.chunkStore = require('./lib/chunk_store')
exports.historyStore = require('./lib/history_store').historyStore
exports.knex = require('./lib/knex')
exports.mongodb = require('./lib/mongodb')
+exports.redis = require('./lib/redis')
exports.persistChanges = require('./lib/persist_changes')
exports.persistor = require('./lib/persistor')
exports.ProjectArchive = require('./lib/project_archive')
@@ -18,3 +20,6 @@ exports.loadGlobalBlobs = loadGlobalBlobs
const { InvalidChangeError } = require('./lib/errors')
exports.InvalidChangeError = InvalidChangeError
+
+const { ChunkVersionConflictError } = require('./lib/chunk_store/errors')
+exports.ChunkVersionConflictError = ChunkVersionConflictError
diff --git a/services/history-v1/storage/lib/assert.js b/services/history-v1/storage/lib/assert.js
index d0ce318b4d..91f24da7e0 100644
--- a/services/history-v1/storage/lib/assert.js
+++ b/services/history-v1/storage/lib/assert.js
@@ -1,5 +1,7 @@
'use strict'
+const OError = require('@overleaf/o-error')
+
const check = require('check-types')
const { Blob } = require('overleaf-editor-core')
@@ -7,41 +9,58 @@ const assert = check.assert
const MONGO_ID_REGEXP = /^[0-9a-f]{24}$/
const POSTGRES_ID_REGEXP = /^[1-9][0-9]{0,9}$/
-const PROJECT_ID_REGEXP = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/
+const MONGO_OR_POSTGRES_ID_REGEXP = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/
function transaction(transaction, message) {
assert.function(transaction, message)
}
function blobHash(arg, message) {
- assert.match(arg, Blob.HEX_HASH_RX, message)
+ try {
+ assert.match(arg, Blob.HEX_HASH_RX, message)
+ } catch (error) {
+ throw OError.tag(error, message, { arg })
+ }
+}
+
+/**
+ * A project id is a string that contains either an integer (for projects stored in Postgres) or 24
+ * hex digits (for projects stored in Mongo)
+ */
+function projectId(arg, message) {
+ try {
+ assert.match(arg, MONGO_OR_POSTGRES_ID_REGEXP, message)
+ } catch (error) {
+ throw OError.tag(error, message, { arg })
+ }
}
/**
* A chunk id is a string that contains either an integer (for projects stored in Postgres) or 24
* hex digits (for projects stored in Mongo)
*/
-function projectId(arg, message) {
- assert.match(arg, PROJECT_ID_REGEXP, message)
-}
-
-/**
- * A chunk id is either a number (for projects stored in Postgres) or a 24
- * character string (for projects stored in Mongo)
- */
function chunkId(arg, message) {
- const valid = check.integer(arg) || check.match(arg, MONGO_ID_REGEXP)
- if (!valid) {
- throw new TypeError(message)
+ try {
+ assert.match(arg, MONGO_OR_POSTGRES_ID_REGEXP, message)
+ } catch (error) {
+ throw OError.tag(error, message, { arg })
}
}
function mongoId(arg, message) {
- assert.match(arg, MONGO_ID_REGEXP)
+ try {
+ assert.match(arg, MONGO_ID_REGEXP, message)
+ } catch (error) {
+ throw OError.tag(error, message, { arg })
+ }
}
function postgresId(arg, message) {
- assert.match(arg, POSTGRES_ID_REGEXP, message)
+ try {
+ assert.match(arg, POSTGRES_ID_REGEXP, message)
+ } catch (error) {
+ throw OError.tag(error, message, { arg })
+ }
}
module.exports = {
diff --git a/services/history-v1/storage/lib/backupGenerator.mjs b/services/history-v1/storage/lib/backupGenerator.mjs
index f6b1b01a31..4c18929d54 100644
--- a/services/history-v1/storage/lib/backupGenerator.mjs
+++ b/services/history-v1/storage/lib/backupGenerator.mjs
@@ -2,11 +2,7 @@
* Provides a generator function to back up project chunks and blobs.
*/
-import {
- getProjectChunksFromVersion,
- loadAtVersion,
- loadByChunkRecord,
-} from './chunk_store/index.js'
+import chunkStore from './chunk_store/index.js'
import {
GLOBAL_BLOBS, // NOTE: must call loadGlobalBlobs() before using this
@@ -33,7 +29,10 @@ async function lookBehindForSeenBlobs(
) {
// the snapshot in this chunk has not been backed up
// so we find the set of backed up blobs from the previous chunk
- const previousChunk = await loadAtVersion(projectId, lastBackedUpVersion)
+ const previousChunk = await chunkStore.loadAtVersion(
+ projectId,
+ lastBackedUpVersion
+ )
const previousChunkHistory = previousChunk.getHistory()
previousChunkHistory.findBlobHashes(seenBlobs)
}
@@ -115,13 +114,13 @@ export async function* backupGenerator(projectId, lastBackedUpVersion) {
lastBackedUpVersion >= 0 ? lastBackedUpVersion + 1 : 0
let isStartingChunk = true
let currentBackedUpVersion = lastBackedUpVersion
- const chunkRecordIterator = getProjectChunksFromVersion(
+ const chunkRecordIterator = chunkStore.getProjectChunksFromVersion(
projectId,
firstPendingVersion
)
for await (const chunkRecord of chunkRecordIterator) {
- const { chunk, chunkBuffer } = await loadByChunkRecord(
+ const { chunk, chunkBuffer } = await chunkStore.loadByChunkRecord(
projectId,
chunkRecord
)
diff --git a/services/history-v1/storage/lib/blob_store/postgres.js b/services/history-v1/storage/lib/blob_store/postgres.js
index 7f66d2d24d..1cedeec5d7 100644
--- a/services/history-v1/storage/lib/blob_store/postgres.js
+++ b/services/history-v1/storage/lib/blob_store/postgres.js
@@ -13,7 +13,7 @@ async function initialize(projectId) {
* Return blob metadata for the given project and hash
*/
async function findBlob(projectId, hash) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
+ assert.postgresId(projectId, 'bad projectId')
projectId = parseInt(projectId, 10)
assert.blobHash(hash, 'bad hash')
@@ -35,7 +35,7 @@ async function findBlob(projectId, hash) {
* @return {Promise.>} no guarantee on order
*/
async function findBlobs(projectId, hashes) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
+ assert.postgresId(projectId, 'bad projectId')
projectId = parseInt(projectId, 10)
assert.array(hashes, 'bad hashes: not array')
hashes.forEach(function (hash) {
@@ -57,7 +57,7 @@ async function findBlobs(projectId, hashes) {
* Return metadata for all blobs in the given project
*/
async function getProjectBlobs(projectId) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
+ assert.postgresId(projectId, 'bad projectId')
projectId = parseInt(projectId, 10)
const records = await knex('project_blobs')
@@ -103,7 +103,7 @@ async function getProjectBlobsBatch(projectIds) {
* Add a blob's metadata to the blobs table after it has been uploaded.
*/
async function insertBlob(projectId, blob) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
+ assert.postgresId(projectId, 'bad projectId')
projectId = parseInt(projectId, 10)
await knex('project_blobs')
@@ -116,7 +116,7 @@ async function insertBlob(projectId, blob) {
* Deletes all blobs for a given project
*/
async function deleteBlobs(projectId) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
+ assert.postgresId(projectId, 'bad projectId')
projectId = parseInt(projectId, 10)
await knex('project_blobs').where('project_id', projectId).delete()
diff --git a/services/history-v1/storage/lib/chunk_buffer/index.js b/services/history-v1/storage/lib/chunk_buffer/index.js
new file mode 100644
index 0000000000..5ef533ddba
--- /dev/null
+++ b/services/history-v1/storage/lib/chunk_buffer/index.js
@@ -0,0 +1,39 @@
+'use strict'
+
+/**
+ * @module storage/lib/chunk_buffer
+ */
+
+const chunkStore = require('../chunk_store')
+const redisBackend = require('../chunk_store/redis')
+const metrics = require('@overleaf/metrics')
+/**
+ * Load the latest Chunk stored for a project, including blob metadata.
+ *
+ * @param {string} projectId
+ * @return {Promise.}
+ */
+async function loadLatest(projectId) {
+ const chunkRecord = await chunkStore.loadLatestRaw(projectId)
+ const cachedChunk = await redisBackend.getCurrentChunkIfValid(
+ projectId,
+ chunkRecord
+ )
+ if (cachedChunk) {
+ metrics.inc('chunk_buffer.loadLatest', 1, {
+ status: 'cache-hit',
+ })
+ return cachedChunk
+ } else {
+ metrics.inc('chunk_buffer.loadLatest', 1, {
+ status: 'cache-miss',
+ })
+ const chunk = await chunkStore.loadLatest(projectId)
+ await redisBackend.setCurrentChunk(projectId, chunk)
+ return chunk
+ }
+}
+
+module.exports = {
+ loadLatest,
+}
diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js
index c1fbb9d607..f75c017552 100644
--- a/services/history-v1/storage/lib/chunk_store/index.js
+++ b/services/history-v1/storage/lib/chunk_store/index.js
@@ -1,3 +1,5 @@
+// @ts-check
+
'use strict'
/**
@@ -156,7 +158,6 @@ async function loadAtTimestamp(projectId, timestamp) {
* @param {string} projectId
* @param {Chunk} chunk
* @param {Date} [earliestChangeTimestamp]
- * @return {Promise.} for the chunkId of the inserted chunk
*/
async function create(projectId, chunk, earliestChangeTimestamp) {
assert.projectId(projectId, 'bad projectId')
@@ -164,13 +165,18 @@ async function create(projectId, chunk, earliestChangeTimestamp) {
assert.maybe.date(earliestChangeTimestamp, 'bad timestamp')
const backend = getBackend(projectId)
+ const chunkStart = chunk.getStartVersion()
const chunkId = await uploadChunk(projectId, chunk)
- await backend.confirmCreate(
- projectId,
- chunk,
- chunkId,
- earliestChangeTimestamp
- )
+
+ const opts = {}
+ if (chunkStart > 0) {
+ opts.oldChunkId = await getChunkIdForVersion(projectId, chunkStart - 1)
+ }
+ if (earliestChangeTimestamp != null) {
+ opts.earliestChangeTimestamp = earliestChangeTimestamp
+ }
+
+ await backend.confirmCreate(projectId, chunk, chunkId, opts)
}
/**
@@ -220,13 +226,12 @@ async function update(
const oldChunkId = await getChunkIdForVersion(projectId, oldEndVersion)
const newChunkId = await uploadChunk(projectId, newChunk)
- await backend.confirmUpdate(
- projectId,
- oldChunkId,
- newChunk,
- newChunkId,
- earliestChangeTimestamp
- )
+ const opts = {}
+ if (earliestChangeTimestamp != null) {
+ opts.earliestChangeTimestamp = earliestChangeTimestamp
+ }
+
+ await backend.confirmUpdate(projectId, oldChunkId, newChunk, newChunkId, opts)
}
/**
@@ -234,7 +239,7 @@ async function update(
*
* @param {string} projectId
* @param {number} version
- * @return {Promise.}
+ * @return {Promise.}
*/
async function getChunkIdForVersion(projectId, version) {
const backend = getBackend(projectId)
@@ -343,10 +348,14 @@ async function deleteProjectChunks(projectId) {
* Delete a given number of old chunks from both the database
* and from object storage.
*
- * @param {number} count - number of chunks to delete
- * @param {number} minAgeSecs - how many seconds ago must chunks have been
- * deleted
- * @return {Promise}
+ * @param {object} options
+ * @param {number} [options.batchSize] - number of chunks to delete in each
+ * batch
+ * @param {number} [options.maxBatches] - maximum number of batches to process
+ * @param {number} [options.minAgeSecs] - minimum age of chunks to delete
+ * @param {number} [options.timeout] - maximum time to spend deleting chunks
+ *
+ * @return {Promise} number of chunks deleted
*/
async function deleteOldChunks(options = {}) {
const batchSize = options.batchSize ?? DEFAULT_DELETE_BATCH_SIZE
diff --git a/services/history-v1/storage/lib/chunk_store/mongo.js b/services/history-v1/storage/lib/chunk_store/mongo.js
index bb93679fec..a34b7194af 100644
--- a/services/history-v1/storage/lib/chunk_store/mongo.js
+++ b/services/history-v1/storage/lib/chunk_store/mongo.js
@@ -1,4 +1,6 @@
-const { ObjectId, ReadPreference } = require('mongodb')
+// @ts-check
+
+const { ObjectId, ReadPreference, MongoError } = require('mongodb')
const { Chunk } = require('overleaf-editor-core')
const OError = require('@overleaf/o-error')
const assert = require('../assert')
@@ -7,6 +9,10 @@ const { ChunkVersionConflictError } = require('./errors')
const DUPLICATE_KEY_ERROR_CODE = 11000
+/**
+ * @import { ClientSession } from 'mongodb'
+ */
+
/**
* Get the latest chunk's metadata from the database
* @param {string} projectId
@@ -18,7 +24,10 @@ async function getLatestChunk(projectId, opts = {}) {
const { readOnly = false } = opts
const record = await mongodb.chunks.findOne(
- { projectId: new ObjectId(projectId), state: 'active' },
+ {
+ projectId: new ObjectId(projectId),
+ state: { $in: ['active', 'closed'] },
+ },
{
sort: { startVersion: -1 },
readPreference: readOnly
@@ -42,7 +51,7 @@ async function getChunkForVersion(projectId, version) {
const record = await mongodb.chunks.findOne(
{
projectId: new ObjectId(projectId),
- state: 'active',
+ state: { $in: ['active', 'closed'] },
startVersion: { $lte: version },
endVersion: { $gte: version },
},
@@ -94,7 +103,7 @@ async function getChunkForTimestamp(projectId, timestamp) {
const record = await mongodb.chunks.findOne(
{
projectId: new ObjectId(projectId),
- state: 'active',
+ state: { $in: ['active', 'closed'] },
endTimestamp: { $gte: timestamp },
},
// We use the index on the startVersion for sorting records. This assumes
@@ -126,7 +135,7 @@ async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) {
const record = await mongodb.chunks.findOne(
{
projectId: new ObjectId(projectId),
- state: 'active',
+ state: { $in: ['active', 'closed'] },
$or: [
{
endTimestamp: {
@@ -155,7 +164,10 @@ async function getProjectChunkIds(projectId) {
assert.mongoId(projectId, 'bad projectId')
const cursor = mongodb.chunks.find(
- { projectId: new ObjectId(projectId), state: 'active' },
+ {
+ projectId: new ObjectId(projectId),
+ state: { $in: ['active', 'closed'] },
+ },
{ projection: { _id: 1 } }
)
return await cursor.map(record => record._id).toArray()
@@ -169,7 +181,10 @@ async function getProjectChunks(projectId) {
const cursor = mongodb.chunks
.find(
- { projectId: new ObjectId(projectId), state: 'active' },
+ {
+ projectId: new ObjectId(projectId),
+ state: { $in: ['active', 'closed'] },
+ },
{ projection: { state: 0 } }
)
.sort({ startVersion: 1 })
@@ -198,48 +213,35 @@ async function insertPendingChunk(projectId, chunk) {
/**
* Record that a new chunk was created.
+ *
+ * @param {string} projectId
+ * @param {Chunk} chunk
+ * @param {string} chunkId
+ * @param {object} opts
+ * @param {Date} [opts.earliestChangeTimestamp]
+ * @param {string} [opts.oldChunkId]
*/
-async function confirmCreate(
- projectId,
- chunk,
- chunkId,
- earliestChangeTimestamp,
- mongoOpts = {}
-) {
+async function confirmCreate(projectId, chunk, chunkId, opts = {}) {
assert.mongoId(projectId, 'bad projectId')
- assert.instance(chunk, Chunk, 'bad chunk')
- assert.mongoId(chunkId, 'bad chunkId')
+ assert.instance(chunk, Chunk, 'bad newChunk')
+ assert.mongoId(chunkId, 'bad newChunkId')
- let result
- try {
- result = await mongodb.chunks.updateOne(
- {
- _id: new ObjectId(chunkId),
- projectId: new ObjectId(projectId),
- state: 'pending',
- },
- { $set: { state: 'active', updatedAt: new Date() } },
- mongoOpts
- )
- } catch (err) {
- if (err.code === DUPLICATE_KEY_ERROR_CODE) {
- throw new ChunkVersionConflictError('chunk start version is not unique', {
+ await mongodb.client.withSession(async session => {
+ await session.withTransaction(async () => {
+ if (opts.oldChunkId != null) {
+ await closeChunk(projectId, opts.oldChunkId, { session })
+ }
+
+ await activateChunk(projectId, chunkId, { session })
+
+ await updateProjectRecord(
projectId,
- chunkId,
- })
- } else {
- throw err
- }
- }
- if (result.matchedCount === 0) {
- throw new OError('pending chunk not found', { projectId, chunkId })
- }
- await updateProjectRecord(
- projectId,
- chunk,
- earliestChangeTimestamp,
- mongoOpts
- )
+ chunk,
+ opts.earliestChangeTimestamp,
+ { session }
+ )
+ })
+ })
}
/**
@@ -276,41 +278,145 @@ async function updateProjectRecord(
/**
* Record that a chunk was replaced by a new one.
+ *
+ * @param {string} projectId
+ * @param {string} oldChunkId
+ * @param {Chunk} newChunk
+ * @param {string} newChunkId
+ * @param {object} [opts]
+ * @param {Date} [opts.earliestChangeTimestamp]
*/
async function confirmUpdate(
projectId,
oldChunkId,
newChunk,
newChunkId,
- earliestChangeTimestamp
+ opts = {}
) {
assert.mongoId(projectId, 'bad projectId')
assert.mongoId(oldChunkId, 'bad oldChunkId')
assert.instance(newChunk, Chunk, 'bad newChunk')
assert.mongoId(newChunkId, 'bad newChunkId')
- const session = mongodb.client.startSession()
- try {
+ await mongodb.client.withSession(async session => {
await session.withTransaction(async () => {
- await deleteChunk(projectId, oldChunkId, { session })
- await confirmCreate(
+ await deleteActiveChunk(projectId, oldChunkId, { session })
+
+ await activateChunk(projectId, newChunkId, { session })
+
+ await updateProjectRecord(
projectId,
newChunk,
- newChunkId,
- earliestChangeTimestamp,
+ opts.earliestChangeTimestamp,
{ session }
)
})
- } finally {
- await session.endSession()
+ })
+}
+
+/**
+ * Activate a pending chunk
+ *
+ * @param {string} projectId
+ * @param {string} chunkId
+ * @param {object} [opts]
+ * @param {ClientSession} [opts.session]
+ */
+async function activateChunk(projectId, chunkId, opts = {}) {
+ assert.mongoId(projectId, 'bad projectId')
+ assert.mongoId(chunkId, 'bad chunkId')
+
+ let result
+ try {
+ result = await mongodb.chunks.updateOne(
+ {
+ _id: new ObjectId(chunkId),
+ projectId: new ObjectId(projectId),
+ state: 'pending',
+ },
+ { $set: { state: 'active', updatedAt: new Date() } },
+ opts
+ )
+ } catch (err) {
+ if (err instanceof MongoError && err.code === DUPLICATE_KEY_ERROR_CODE) {
+ throw new ChunkVersionConflictError('chunk start version is not unique', {
+ projectId,
+ chunkId,
+ })
+ } else {
+ throw err
+ }
+ }
+ if (result.matchedCount === 0) {
+ throw new OError('pending chunk not found', { projectId, chunkId })
+ }
+}
+
+/**
+ * Close a chunk
+ *
+ * A closed chunk is one that can't be extended anymore.
+ *
+ * @param {string} projectId
+ * @param {string} chunkId
+ * @param {object} [opts]
+ * @param {ClientSession} [opts.session]
+ */
+async function closeChunk(projectId, chunkId, opts = {}) {
+ const result = await mongodb.chunks.updateOne(
+ {
+ _id: new ObjectId(chunkId),
+ projectId: new ObjectId(projectId),
+ state: 'active',
+ },
+ { $set: { state: 'closed' } },
+ opts
+ )
+
+ if (result.matchedCount === 0) {
+ throw new ChunkVersionConflictError('unable to close chunk', {
+ projectId,
+ chunkId,
+ })
+ }
+}
+
+/**
+ * Delete an active chunk
+ *
+ * This is used to delete chunks that are in the process of being extended. It
+ * will refuse to delete chunks that are already closed and can therefore not be
+ * extended.
+ *
+ * @param {string} projectId
+ * @param {string} chunkId
+ * @param {object} [opts]
+ * @param {ClientSession} [opts.session]
+ */
+async function deleteActiveChunk(projectId, chunkId, opts = {}) {
+ const updateResult = await mongodb.chunks.updateOne(
+ {
+ _id: new ObjectId(chunkId),
+ projectId: new ObjectId(projectId),
+ state: 'active',
+ },
+ { $set: { state: 'deleted', updatedAt: new Date() } },
+ opts
+ )
+
+ if (updateResult.matchedCount === 0) {
+ throw new ChunkVersionConflictError('unable to delete active chunk', {
+ projectId,
+ chunkId,
+ })
}
}
/**
* Delete a chunk.
*
- * @param {number} projectId
- * @param {number} chunkId
+ * @param {string} projectId
+ * @param {string} chunkId
* @return {Promise}
*/
async function deleteChunk(projectId, chunkId, mongoOpts = {}) {
@@ -331,7 +437,10 @@ async function deleteProjectChunks(projectId) {
assert.mongoId(projectId, 'bad projectId')
await mongodb.chunks.updateMany(
- { projectId: new ObjectId(projectId), state: 'active' },
+ {
+ projectId: new ObjectId(projectId),
+ state: { $in: ['active', 'closed'] },
+ },
{ $set: { state: 'deleted', updatedAt: new Date() } }
)
}
diff --git a/services/history-v1/storage/lib/chunk_store/postgres.js b/services/history-v1/storage/lib/chunk_store/postgres.js
index 0964b0ecca..0c33c0fd82 100644
--- a/services/history-v1/storage/lib/chunk_store/postgres.js
+++ b/services/history-v1/storage/lib/chunk_store/postgres.js
@@ -1,3 +1,5 @@
+// @ts-check
+
const { Chunk } = require('overleaf-editor-core')
const assert = require('../assert')
const knex = require('../knex')
@@ -7,6 +9,10 @@ const { updateProjectRecord } = require('./mongo')
const DUPLICATE_KEY_ERROR_CODE = '23505'
+/**
+ * @import { Knex } from 'knex'
+ */
+
/**
* Get the latest chunk's metadata from the database
* @param {string} projectId
@@ -14,12 +20,11 @@ const DUPLICATE_KEY_ERROR_CODE = '23505'
* @param {boolean} [opts.readOnly]
*/
async function getLatestChunk(projectId, opts = {}) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
const { readOnly = false } = opts
const record = await (readOnly ? knexReadOnly : knex)('chunks')
- .where('doc_id', projectId)
+ .where('doc_id', parseInt(projectId, 10))
.orderBy('end_version', 'desc')
.first()
if (record == null) {
@@ -30,13 +35,15 @@ async function getLatestChunk(projectId, opts = {}) {
/**
* Get the metadata for the chunk that contains the given version.
+ *
+ * @param {string} projectId
+ * @param {number} version
*/
async function getChunkForVersion(projectId, version) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
const record = await knex('chunks')
- .where('doc_id', projectId)
+ .where('doc_id', parseInt(projectId, 10))
.where('end_version', '>=', version)
.orderBy('end_version')
.first()
@@ -48,20 +55,23 @@ async function getChunkForVersion(projectId, version) {
/**
* Get the metadata for the chunk that contains the given version.
+ *
+ * @param {string} projectId
+ * @param {Date} timestamp
*/
async function getFirstChunkBeforeTimestamp(projectId, timestamp) {
assert.date(timestamp, 'bad timestamp')
const recordActive = await getChunkForVersion(projectId, 0)
+
// projectId must be valid if getChunkForVersion did not throw
- projectId = parseInt(projectId, 10)
if (recordActive && recordActive.endTimestamp <= timestamp) {
return recordActive
}
// fallback to deleted chunk
const recordDeleted = await knex('old_chunks')
- .where('doc_id', projectId)
+ .where('doc_id', parseInt(projectId, 10))
.where('start_version', '=', 0)
.where('end_timestamp', '<=', timestamp)
.orderBy('end_version', 'desc')
@@ -75,14 +85,16 @@ async function getFirstChunkBeforeTimestamp(projectId, timestamp) {
/**
* Get the metadata for the chunk that contains the version that was current at
* the given timestamp.
+ *
+ * @param {string} projectId
+ * @param {Date} timestamp
*/
async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) {
assert.date(timestamp, 'bad timestamp')
assert.postgresId(projectId, 'bad projectId')
- projectId = parseInt(projectId, 10)
const query = knex('chunks')
- .where('doc_id', projectId)
+ .where('doc_id', parseInt(projectId, 10))
.where(function () {
this.where('end_timestamp', '<=', timestamp).orWhere(
'end_timestamp',
@@ -102,10 +114,12 @@ async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) {
/**
* Get the metadata for the chunk that contains the version that was current at
* the given timestamp.
+ *
+ * @param {string} projectId
+ * @param {Date} timestamp
*/
async function getChunkForTimestamp(projectId, timestamp) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
// This query will find the latest chunk after the timestamp (query orders
// in reverse chronological order), OR the latest chunk
@@ -118,11 +132,11 @@ async function getChunkForTimestamp(projectId, timestamp) {
'WHERE doc_id = ? ' +
'ORDER BY end_version desc LIMIT 1' +
')',
- [timestamp, projectId]
+ [timestamp, parseInt(projectId, 10)]
)
const record = await knex('chunks')
- .where('doc_id', projectId)
+ .where('doc_id', parseInt(projectId, 10))
.where(whereAfterEndTimestampOrLatestChunk)
.orderBy('end_version')
.first()
@@ -137,7 +151,7 @@ async function getChunkForTimestamp(projectId, timestamp) {
*/
function chunkFromRecord(record) {
return {
- id: record.id,
+ id: record.id.toString(),
startVersion: record.start_version,
endVersion: record.end_version,
endTimestamp: record.end_timestamp,
@@ -146,35 +160,41 @@ function chunkFromRecord(record) {
/**
* Get all of a project's chunk ids
+ *
+ * @param {string} projectId
*/
async function getProjectChunkIds(projectId) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
- const records = await knex('chunks').select('id').where('doc_id', projectId)
+ const records = await knex('chunks')
+ .select('id')
+ .where('doc_id', parseInt(projectId, 10))
return records.map(record => record.id)
}
/**
* Get all of a projects chunks directly
+ *
+ * @param {string} projectId
*/
async function getProjectChunks(projectId) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
const records = await knex('chunks')
.select()
- .where('doc_id', projectId)
+ .where('doc_id', parseInt(projectId, 10))
.orderBy('end_version')
return records.map(chunkFromRecord)
}
/**
* Insert a pending chunk before sending it to object storage.
+ *
+ * @param {string} projectId
+ * @param {Chunk} chunk
*/
async function insertPendingChunk(projectId, chunk) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
const result = await knex.first(
knex.raw("nextval('chunks_id_seq'::regclass)::integer as chunkid")
@@ -182,80 +202,119 @@ async function insertPendingChunk(projectId, chunk) {
const chunkId = result.chunkid
await knex('pending_chunks').insert({
id: chunkId,
- doc_id: projectId,
+ doc_id: parseInt(projectId, 10),
end_version: chunk.getEndVersion(),
start_version: chunk.getStartVersion(),
end_timestamp: chunk.getEndTimestamp(),
})
- return chunkId
+ return chunkId.toString()
}
/**
* Record that a new chunk was created.
+ *
+ * @param {string} projectId
+ * @param {Chunk} chunk
+ * @param {string} chunkId
+ * @param {object} opts
+ * @param {Date} [opts.earliestChangeTimestamp]
+ * @param {string} [opts.oldChunkId]
*/
-async function confirmCreate(
- projectId,
- chunk,
- chunkId,
- earliestChangeTimestamp
-) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+async function confirmCreate(projectId, chunk, chunkId, opts = {}) {
+ assert.postgresId(projectId, 'bad projectId')
await knex.transaction(async tx => {
+ if (opts.oldChunkId != null) {
+ await _assertChunkIsNotClosed(tx, projectId, opts.oldChunkId)
+ await _closeChunk(tx, projectId, opts.oldChunkId)
+ }
await Promise.all([
_deletePendingChunk(tx, projectId, chunkId),
_insertChunk(tx, projectId, chunk, chunkId),
])
- await updateProjectRecord(projectId, chunk, earliestChangeTimestamp)
+ await updateProjectRecord(
+ // The history id in Mongo is an integer for Postgres projects
+ parseInt(projectId, 10),
+ chunk,
+ opts.earliestChangeTimestamp
+ )
})
}
/**
* Record that a chunk was replaced by a new one.
+ *
+ * @param {string} projectId
+ * @param {string} oldChunkId
+ * @param {Chunk} newChunk
+ * @param {string} newChunkId
*/
async function confirmUpdate(
projectId,
oldChunkId,
newChunk,
newChunkId,
- earliestChangeTimestamp
+ opts = {}
) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
await knex.transaction(async tx => {
+ await _assertChunkIsNotClosed(tx, projectId, oldChunkId)
await _deleteChunks(tx, { doc_id: projectId, id: oldChunkId })
await Promise.all([
_deletePendingChunk(tx, projectId, newChunkId),
_insertChunk(tx, projectId, newChunk, newChunkId),
])
- await updateProjectRecord(projectId, newChunk, earliestChangeTimestamp)
+ await updateProjectRecord(
+ // The history id in Mongo is an integer for Postgres projects
+ parseInt(projectId, 10),
+ newChunk,
+ opts.earliestChangeTimestamp
+ )
})
}
+/**
+ * Delete a pending chunk
+ *
+ * @param {Knex} tx
+ * @param {string} projectId
+ * @param {string} chunkId
+ */
async function _deletePendingChunk(tx, projectId, chunkId) {
await tx('pending_chunks')
.where({
- doc_id: projectId,
- id: chunkId,
+ doc_id: parseInt(projectId, 10),
+ id: parseInt(chunkId, 10),
})
.del()
}
+/**
+ * Adds an active chunk
+ *
+ * @param {Knex} tx
+ * @param {string} projectId
+ * @param {Chunk} chunk
+ * @param {string} chunkId
+ */
async function _insertChunk(tx, projectId, chunk, chunkId) {
const startVersion = chunk.getStartVersion()
const endVersion = chunk.getEndVersion()
try {
await tx('chunks').insert({
- id: chunkId,
- doc_id: projectId,
+ id: parseInt(chunkId, 10),
+ doc_id: parseInt(projectId, 10),
start_version: startVersion,
end_version: endVersion,
end_timestamp: chunk.getEndTimestamp(),
})
} catch (err) {
- if (err.code === DUPLICATE_KEY_ERROR_CODE) {
+ if (
+ err instanceof Error &&
+ 'code' in err &&
+ err.code === DUPLICATE_KEY_ERROR_CODE
+ ) {
throw new ChunkVersionConflictError(
'chunk start or end version is not unique',
{ projectId, chunkId, startVersion, endVersion }
@@ -265,35 +324,92 @@ async function _insertChunk(tx, projectId, chunk, chunkId) {
}
}
+/**
+ * Check that a chunk is not closed
+ *
+ * This is used to synchronize chunk creations and extensions.
+ *
+ * @param {Knex} tx
+ * @param {string} projectId
+ * @param {string} chunkId
+ */
+async function _assertChunkIsNotClosed(tx, projectId, chunkId) {
+ const record = await tx('chunks')
+ .forUpdate()
+ .select('closed')
+ .where('doc_id', parseInt(projectId, 10))
+ .where('id', parseInt(chunkId, 10))
+ .first()
+ if (!record) {
+ throw new ChunkVersionConflictError('unable to close chunk: not found', {
+ projectId,
+ chunkId,
+ })
+ }
+ if (record.closed) {
+ throw new ChunkVersionConflictError(
+ 'unable to close chunk: already closed',
+ {
+ projectId,
+ chunkId,
+ }
+ )
+ }
+}
+
+/**
+ * Close a chunk
+ *
+ * A closed chunk can no longer be extended.
+ *
+ * @param {Knex} tx
+ * @param {string} projectId
+ * @param {string} chunkId
+ */
+async function _closeChunk(tx, projectId, chunkId) {
+ await tx('chunks')
+ .update({ closed: true })
+ .where('doc_id', parseInt(projectId, 10))
+ .where('id', parseInt(chunkId, 10))
+}
+
/**
* Delete a chunk.
*
- * @param {number} projectId
- * @param {number} chunkId
- * @return {Promise}
+ * @param {string} projectId
+ * @param {string} chunkId
*/
async function deleteChunk(projectId, chunkId) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
assert.integer(chunkId, 'bad chunkId')
- await _deleteChunks(knex, { doc_id: projectId, id: chunkId })
+ await _deleteChunks(knex, {
+ doc_id: parseInt(projectId, 10),
+ id: parseInt(chunkId, 10),
+ })
}
/**
* Delete all of a project's chunks
+ *
+ * @param {string} projectId
*/
async function deleteProjectChunks(projectId) {
- assert.postgresId(projectId, `bad projectId ${projectId}`)
- projectId = parseInt(projectId, 10)
+ assert.postgresId(projectId, 'bad projectId')
await knex.transaction(async tx => {
- await _deleteChunks(knex, { doc_id: projectId })
+ await _deleteChunks(knex, { doc_id: parseInt(projectId, 10) })
})
}
+/**
+ * Delete many chunks
+ *
+ * @param {Knex} tx
+ * @param {any} whereClause
+ */
async function _deleteChunks(tx, whereClause) {
- const rows = await tx('chunks').returning('*').where(whereClause).del()
+ const rows = await tx('chunks').where(whereClause).del().returning('*')
if (rows.length === 0) {
return
}
@@ -311,6 +427,9 @@ async function _deleteChunks(tx, whereClause) {
/**
* Get a batch of old chunks for deletion
+ *
+ * @param {number} count
+ * @param {number} minAgeSecs
*/
async function getOldChunksBatch(count, minAgeSecs) {
const maxDeletedAt = new Date(Date.now() - minAgeSecs * 1000)
@@ -321,15 +440,22 @@ async function getOldChunksBatch(count, minAgeSecs) {
.limit(count)
return records.map(oldChunk => ({
projectId: oldChunk.doc_id.toString(),
- chunkId: oldChunk.chunk_id,
+ chunkId: oldChunk.chunk_id.toString(),
}))
}
/**
* Delete a batch of old chunks from the database
+ *
+ * @param {string[]} chunkIds
*/
async function deleteOldChunks(chunkIds) {
- await knex('old_chunks').whereIn('chunk_id', chunkIds).del()
+ await knex('old_chunks')
+ .whereIn(
+ 'chunk_id',
+ chunkIds.map(id => parseInt(id, 10))
+ )
+ .del()
}
/**
diff --git a/services/history-v1/storage/lib/chunk_store/redis.js b/services/history-v1/storage/lib/chunk_store/redis.js
new file mode 100644
index 0000000000..d9c423861d
--- /dev/null
+++ b/services/history-v1/storage/lib/chunk_store/redis.js
@@ -0,0 +1,478 @@
+const metrics = require('@overleaf/metrics')
+const logger = require('@overleaf/logger')
+const redis = require('../redis')
+const rclient = redis.rclientHistory //
+const { Snapshot, Change, History, Chunk } = require('overleaf-editor-core')
+
+const TEMPORARY_CACHE_LIFETIME = 300 // 5 minutes
+
+const keySchema = {
+ snapshot({ projectId }) {
+ return `snapshot:{${projectId}}`
+ },
+ startVersion({ projectId }) {
+ return `snapshot-version:{${projectId}}`
+ },
+ changes({ projectId }) {
+ return `changes:{${projectId}}`
+ },
+ expireTime({ projectId }) {
+ return `expire-time:{${projectId}}`
+ },
+ persistTime({ projectId }) {
+ return `persist-time:{${projectId}}`
+ },
+}
+
+rclient.defineCommand('get_current_chunk', {
+ numberOfKeys: 3,
+ lua: `
+ local startVersionValue = redis.call('GET', KEYS[2])
+ if not startVersionValue then
+ return nil -- this is a cache-miss
+ end
+ local snapshotValue = redis.call('GET', KEYS[1])
+ local changesValues = redis.call('LRANGE', KEYS[3], 0, -1)
+ return {snapshotValue, startVersionValue, changesValues}
+ `,
+})
+
+/**
+ * Retrieves the current chunk of project history from Redis storage
+ * @param {string} projectId - The unique identifier of the project
+ * @returns {Promise} A Promise that resolves to a Chunk object containing project history,
+ * or null if retrieval fails
+ * @throws {Error} If Redis operations fail
+ */
+async function getCurrentChunk(projectId) {
+ try {
+ const result = await rclient.get_current_chunk(
+ keySchema.snapshot({ projectId }),
+ keySchema.startVersion({ projectId }),
+ keySchema.changes({ projectId })
+ )
+ if (!result) {
+ return null // cache-miss
+ }
+ const snapshot = Snapshot.fromRaw(JSON.parse(result[0]))
+ const startVersion = JSON.parse(result[1])
+ const changes = result[2].map(c => Change.fromRaw(JSON.parse(c)))
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, startVersion)
+ metrics.inc('chunk_store.redis.get_current_chunk', 1, { status: 'success' })
+ return chunk
+ } catch (err) {
+ logger.error({ err, projectId }, 'error getting current chunk from redis')
+ metrics.inc('chunk_store.redis.get_current_chunk', 1, { status: 'error' })
+ return null
+ }
+}
+
+rclient.defineCommand('get_current_chunk_if_valid', {
+ numberOfKeys: 3,
+ lua: `
+ local expectedStartVersion = ARGV[1]
+ local expectedChangesCount = tonumber(ARGV[2])
+ local startVersionValue = redis.call('GET', KEYS[2])
+ if not startVersionValue then
+ return nil -- this is a cache-miss
+ end
+ if startVersionValue ~= expectedStartVersion then
+ return nil -- this is a cache-miss
+ end
+ local changesCount = redis.call('LLEN', KEYS[3])
+ if changesCount ~= expectedChangesCount then
+ return nil -- this is a cache-miss
+ end
+ local snapshotValue = redis.call('GET', KEYS[1])
+ local changesValues = redis.call('LRANGE', KEYS[3], 0, -1)
+ return {snapshotValue, startVersionValue, changesValues}
+ `,
+})
+
+async function getCurrentChunkIfValid(projectId, chunkRecord) {
+ try {
+ const changesCount = chunkRecord.endVersion - chunkRecord.startVersion
+ const result = await rclient.get_current_chunk_if_valid(
+ keySchema.snapshot({ projectId }),
+ keySchema.startVersion({ projectId }),
+ keySchema.changes({ projectId }),
+ chunkRecord.startVersion,
+ changesCount
+ )
+ if (!result) {
+ return null // cache-miss
+ }
+ const snapshot = Snapshot.fromRaw(JSON.parse(result[0]))
+ const startVersion = parseInt(result[1], 10)
+ const changes = result[2].map(c => Change.fromRaw(JSON.parse(c)))
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, startVersion)
+ metrics.inc('chunk_store.redis.get_current_chunk_if_valid', 1, {
+ status: 'success',
+ })
+ return chunk
+ } catch (err) {
+ logger.error(
+ { err, projectId, chunkRecord },
+ 'error getting current chunk from redis'
+ )
+ metrics.inc('chunk_store.redis.get_current_chunk_if_valid', 1, {
+ status: 'error',
+ })
+ return null
+ }
+}
+
+rclient.defineCommand('get_current_chunk_metadata', {
+ numberOfKeys: 2,
+ lua: `
+ local startVersionValue = redis.call('GET', KEYS[1])
+ if not startVersionValue then
+ return nil -- this is a cache-miss
+ end
+ local changesCount = redis.call('LLEN', KEYS[2])
+ return {startVersionValue, changesCount}
+ `,
+})
+
+/**
+ * Retrieves the current chunk metadata for a given project from Redis
+ * @param {string} projectId - The ID of the project to get metadata for
+ * @returns {Promise} Object containing startVersion and changesCount if found, null on error or cache miss
+ * @property {number} startVersion - The starting version information
+ * @property {number} changesCount - The number of changes in the chunk
+ */
+async function getCurrentChunkMetadata(projectId) {
+ try {
+ const result = await rclient.get_current_chunk_metadata(
+ keySchema.startVersion({ projectId }),
+ keySchema.changes({ projectId })
+ )
+ if (!result) {
+ return null // cache-miss
+ }
+ const startVersion = JSON.parse(result[0])
+ const changesCount = parseInt(result[1], 10)
+ return { startVersion, changesCount }
+ } catch (err) {
+ return null
+ }
+}
+
+rclient.defineCommand('set_current_chunk', {
+ numberOfKeys: 4,
+ lua: `
+ local snapshotValue = ARGV[1]
+ local startVersionValue = ARGV[2]
+ local expireTime = ARGV[3]
+ redis.call('SET', KEYS[1], snapshotValue)
+ redis.call('SET', KEYS[2], startVersionValue)
+ redis.call('SET', KEYS[3], expireTime)
+ redis.call('DEL', KEYS[4]) -- clear the old changes list
+ if #ARGV >= 4 then
+ redis.call('RPUSH', KEYS[4], unpack(ARGV, 4))
+ end
+
+ `,
+})
+
+/**
+ * Stores the current chunk of project history in Redis
+ * @param {string} projectId - The ID of the project
+ * @param {Chunk} chunk - The chunk object containing history data
+ * @returns {Promise<*>} Returns the result of the Redis operation, or null if an error occurs
+ * @throws {Error} May throw Redis-related errors which are caught internally
+ */
+async function setCurrentChunk(projectId, chunk) {
+ try {
+ const snapshotKey = keySchema.snapshot({ projectId })
+ const startVersionKey = keySchema.startVersion({ projectId })
+ const changesKey = keySchema.changes({ projectId })
+ const expireTimeKey = keySchema.expireTime({ projectId })
+
+ const snapshot = chunk.history.snapshot
+ const startVersion = chunk.startVersion
+ const changes = chunk.history.changes
+ const expireTime = Date.now() + TEMPORARY_CACHE_LIFETIME * 1000
+
+ await rclient.set_current_chunk(
+ snapshotKey, // KEYS[1]
+ startVersionKey, // KEYS[2]
+ expireTimeKey, // KEYS[3]
+ changesKey, // KEYS[4]
+ JSON.stringify(snapshot.toRaw()), // ARGV[1]
+ startVersion, // ARGV[2]
+ expireTime, // ARGV[3]
+ ...changes.map(c => JSON.stringify(c.toRaw())) // ARGV[4..]
+ )
+ metrics.inc('chunk_store.redis.set_current_chunk', 1, { status: 'success' })
+ } catch (err) {
+ logger.error(
+ { err, projectId, chunk },
+ 'error setting current chunk in redis'
+ )
+ metrics.inc('chunk_store.redis.set_current_chunk', 1, { status: 'error' })
+ return null // while testing we will suppress any errors
+ }
+}
+
+/**
+ * Checks whether a cached chunk's version metadata matches the current chunk's metadata
+ * @param {Chunk} cachedChunk - The chunk retrieved from cache
+ * @param {Chunk} currentChunk - The current chunk to compare against
+ * @returns {boolean} - Returns true if the chunks have matching start and end versions, false otherwise
+ */
+function checkCacheValidity(cachedChunk, currentChunk) {
+ return Boolean(
+ cachedChunk &&
+ cachedChunk.getStartVersion() === currentChunk.getStartVersion() &&
+ cachedChunk.getEndVersion() === currentChunk.getEndVersion()
+ )
+}
+
+/**
+ * Validates if a cached chunk matches the current chunk metadata by comparing versions
+ * @param {Object} cachedChunk - The cached chunk object to validate
+ * @param {Object} currentChunkMetadata - The current chunk metadata to compare against
+ * @param {number} currentChunkMetadata.startVersion - The starting version number
+ * @param {number} currentChunkMetadata.endVersion - The ending version number
+ * @returns {boolean} - True if the cached chunk is valid, false otherwise
+ */
+function checkCacheValidityWithMetadata(cachedChunk, currentChunkMetadata) {
+ return Boolean(
+ cachedChunk &&
+ cachedChunk.getStartVersion() === currentChunkMetadata.startVersion &&
+ cachedChunk.getEndVersion() === currentChunkMetadata.endVersion
+ )
+}
+
+/**
+ * Compares two chunks for equality using stringified JSON comparison
+ * @param {string} projectId - The ID of the project
+ * @param {Chunk} cachedChunk - The cached chunk to compare
+ * @param {Chunk} currentChunk - The current chunk to compare against
+ * @returns {boolean} - Returns false if either chunk is null/undefined, otherwise returns the comparison result
+ */
+function compareChunks(projectId, cachedChunk, currentChunk) {
+ if (!cachedChunk || !currentChunk) {
+ return false
+ }
+ const identical = JSON.stringify(cachedChunk) === JSON.stringify(currentChunk)
+ if (!identical) {
+ try {
+ logger.error(
+ {
+ projectId,
+ cachedChunkStartVersion: cachedChunk.getStartVersion(),
+ cachedChunkEndVersion: cachedChunk.getEndVersion(),
+ currentChunkStartVersion: currentChunk.getStartVersion(),
+ currentChunkEndVersion: currentChunk.getEndVersion(),
+ },
+ 'chunk cache mismatch'
+ )
+ } catch (err) {
+ // ignore errors while logging
+ }
+ }
+ metrics.inc('chunk_store.redis.compare_chunks', 1, {
+ status: identical ? 'success' : 'fail',
+ })
+ return identical
+}
+
+// Define Lua script for atomic cache clearing
+rclient.defineCommand('expire_chunk_cache', {
+ numberOfKeys: 5,
+ lua: `
+ local persistTimeExists = redis.call('EXISTS', KEYS[5])
+ if persistTimeExists == 1 then
+ return nil -- chunk has changes pending, do not expire
+ end
+ local currentTime = tonumber(ARGV[1])
+ local expireTimeValue = redis.call('GET', KEYS[4])
+ if not expireTimeValue then
+ return nil -- this is a cache-miss
+ end
+ local expireTime = tonumber(expireTimeValue)
+ if currentTime < expireTime then
+ return nil -- cache is still valid
+ end
+ -- Cache is expired and all changes are persisted, proceed to delete the keys atomically
+ redis.call('DEL', KEYS[1]) -- snapshot key
+ redis.call('DEL', KEYS[2]) -- startVersion key
+ redis.call('DEL', KEYS[3]) -- changes key
+ redis.call('DEL', KEYS[4]) -- expireTime key
+ return 1
+ `,
+})
+
+/**
+ * Expire cache entries for a project's chunk data if needed
+ * @param {string} projectId - The ID of the project whose cache should be cleared
+ * @returns {Promise} A promise that resolves to true if successful, false on error
+ */
+async function expireCurrentChunk(projectId, currentTime) {
+ try {
+ const snapshotKey = keySchema.snapshot({ projectId })
+ const startVersionKey = keySchema.startVersion({ projectId })
+ const changesKey = keySchema.changes({ projectId })
+ const expireTimeKey = keySchema.expireTime({ projectId })
+ const persistTimeKey = keySchema.persistTime({ projectId })
+ const result = await rclient.expire_chunk_cache(
+ snapshotKey,
+ startVersionKey,
+ changesKey,
+ expireTimeKey,
+ persistTimeKey,
+ currentTime || Date.now()
+ )
+ if (!result) {
+ logger.debug(
+ { projectId },
+ 'chunk cache not expired due to pending changes'
+ )
+ metrics.inc('chunk_store.redis.expire_cache', 1, {
+ status: 'skip-due-to-pending-changes',
+ })
+ return false // not expired
+ }
+ metrics.inc('chunk_store.redis.expire_cache', 1, { status: 'success' })
+ return true
+ } catch (err) {
+ logger.error({ err, projectId }, 'error clearing chunk cache from redis')
+ metrics.inc('chunk_store.redis.expire_cache', 1, { status: 'error' })
+ return false
+ }
+}
+
+// Define Lua script for atomic cache clearing
+rclient.defineCommand('clear_chunk_cache', {
+ numberOfKeys: 5,
+ lua: `
+ local persistTimeExists = redis.call('EXISTS', KEYS[5])
+ if persistTimeExists == 1 then
+ return nil -- chunk has changes pending, do not clear
+ end
+ -- Delete all keys related to a project's chunk cache atomically
+ redis.call('DEL', KEYS[1]) -- snapshot key
+ redis.call('DEL', KEYS[2]) -- startVersion key
+ redis.call('DEL', KEYS[3]) -- changes key
+ redis.call('DEL', KEYS[4]) -- expireTime key
+ return 1
+ `,
+})
+
+/**
+ * Clears all cache entries for a project's chunk data
+ * @param {string} projectId - The ID of the project whose cache should be cleared
+ * @returns {Promise} A promise that resolves to true if successful, false on error
+ */
+async function clearCache(projectId) {
+ try {
+ const snapshotKey = keySchema.snapshot({ projectId })
+ const startVersionKey = keySchema.startVersion({ projectId })
+ const changesKey = keySchema.changes({ projectId })
+ const expireTimeKey = keySchema.expireTime({ projectId })
+ const persistTimeKey = keySchema.persistTime({ projectId }) // Add persistTimeKey
+
+ const result = await rclient.clear_chunk_cache(
+ snapshotKey,
+ startVersionKey,
+ changesKey,
+ expireTimeKey,
+ persistTimeKey
+ )
+ if (result === null) {
+ logger.debug(
+ { projectId },
+ 'chunk cache not cleared due to pending changes'
+ )
+ metrics.inc('chunk_store.redis.clear_cache', 1, {
+ status: 'skip-due-to-pending-changes',
+ })
+ return false
+ }
+ metrics.inc('chunk_store.redis.clear_cache', 1, { status: 'success' })
+ return true
+ } catch (err) {
+ logger.error({ err, projectId }, 'error clearing chunk cache from redis')
+ metrics.inc('chunk_store.redis.clear_cache', 1, { status: 'error' })
+ return false
+ }
+}
+
+// Define Lua script for getting chunk status
+rclient.defineCommand('get_chunk_status', {
+ numberOfKeys: 2, // expireTimeKey, persistTimeKey
+ lua: `
+ local expireTimeValue = redis.call('GET', KEYS[1])
+ local persistTimeValue = redis.call('GET', KEYS[2])
+ return {expireTimeValue, persistTimeValue}
+ `,
+})
+
+/**
+ * Retrieves the current chunk status for a given project from Redis
+ * @param {string} projectId - The ID of the project to get status for
+ * @returns {Promise} Object containing expireTime and persistTime, or nulls on error
+ * @property {number|null} expireTime - The expiration time of the chunk
+ * @property {number|null} persistTime - The persistence time of the chunk
+ */
+async function getCurrentChunkStatus(projectId) {
+ try {
+ const expireTimeKey = keySchema.expireTime({ projectId })
+ const persistTimeKey = keySchema.persistTime({ projectId })
+
+ const result = await rclient.get_chunk_status(expireTimeKey, persistTimeKey)
+
+ // Lua script returns an array [expireTimeValue, persistTimeValue]
+ // Redis nil replies are converted to null by ioredis
+ const [expireTime, persistTime] = result
+
+ return {
+ expireTime: expireTime ? parseInt(expireTime, 10) : null, // Parse to number or null
+ persistTime: persistTime ? parseInt(persistTime, 10) : null, // Parse to number or null
+ }
+ } catch (err) {
+ logger.warn({ err, projectId }, 'error getting chunk status from redis')
+ return { expireTime: null, persistTime: null } // Return nulls on error
+ }
+}
+
+/**
+ * Sets the persist time for a project's chunk cache.
+ * This is primarily intended for testing purposes.
+ * @param {string} projectId - The ID of the project.
+ * @param {number} timestamp - The timestamp to set as the persist time.
+ * @returns {Promise}
+ */
+async function setPersistTime(projectId, timestamp) {
+ try {
+ const persistTimeKey = keySchema.persistTime({ projectId })
+ await rclient.set(persistTimeKey, timestamp)
+ metrics.inc('chunk_store.redis.set_persist_time', 1, { status: 'success' })
+ } catch (err) {
+ logger.error(
+ { err, projectId, timestamp },
+ 'error setting persist time in redis'
+ )
+ metrics.inc('chunk_store.redis.set_persist_time', 1, { status: 'error' })
+ // Re-throw the error so the test fails if setting fails
+ throw err
+ }
+}
+
+module.exports = {
+ getCurrentChunk,
+ getCurrentChunkIfValid,
+ setCurrentChunk,
+ getCurrentChunkMetadata,
+ checkCacheValidity,
+ checkCacheValidityWithMetadata,
+ compareChunks,
+ expireCurrentChunk,
+ clearCache,
+ getCurrentChunkStatus,
+ setPersistTime, // Export the new function
+}
diff --git a/services/history-v1/storage/lib/history_store.js b/services/history-v1/storage/lib/history_store.js
index d16820d74c..e51bdc25c5 100644
--- a/services/history-v1/storage/lib/history_store.js
+++ b/services/history-v1/storage/lib/history_store.js
@@ -25,8 +25,8 @@ const gunzip = promisify(zlib.gunzip)
class LoadError extends OError {
/**
- * @param {number|string} projectId
- * @param {number|string} chunkId
+ * @param {string} projectId
+ * @param {string} chunkId
* @param {any} cause
*/
constructor(projectId, chunkId, cause) {
@@ -42,8 +42,8 @@ class LoadError extends OError {
class StoreError extends OError {
/**
- * @param {number|string} projectId
- * @param {number|string} chunkId
+ * @param {string} projectId
+ * @param {string} chunkId
* @param {any} cause
*/
constructor(projectId, chunkId, cause) {
@@ -58,8 +58,8 @@ class StoreError extends OError {
}
/**
- * @param {number|string} projectId
- * @param {number|string} chunkId
+ * @param {string} projectId
+ * @param {string} chunkId
* @return {string}
*/
function getKey(projectId, chunkId) {
@@ -89,8 +89,8 @@ class HistoryStore {
/**
* Load the raw object for a History.
*
- * @param {number|string} projectId
- * @param {number|string} chunkId
+ * @param {string} projectId
+ * @param {string} chunkId
* @return {Promise}
*/
async loadRaw(projectId, chunkId) {
@@ -144,8 +144,8 @@ class HistoryStore {
/**
* Compress and store a {@link History}.
*
- * @param {number|string} projectId
- * @param {number|string} chunkId
+ * @param {string} projectId
+ * @param {string} chunkId
* @param {import('overleaf-editor-core/lib/types').RawHistory} rawHistory
*/
async storeRaw(projectId, chunkId, rawHistory) {
diff --git a/services/history-v1/storage/lib/knex.js b/services/history-v1/storage/lib/knex.js
index 5cdc85e2ab..7000fe034c 100644
--- a/services/history-v1/storage/lib/knex.js
+++ b/services/history-v1/storage/lib/knex.js
@@ -1,6 +1,8 @@
+// @ts-check
+
'use strict'
const env = process.env.NODE_ENV || 'development'
const knexfile = require('../../knexfile')
-module.exports = require('knex')(knexfile[env])
+module.exports = require('knex').default(knexfile[env])
diff --git a/services/history-v1/storage/lib/redis.js b/services/history-v1/storage/lib/redis.js
new file mode 100644
index 0000000000..9b00cc0a26
--- /dev/null
+++ b/services/history-v1/storage/lib/redis.js
@@ -0,0 +1,19 @@
+const config = require('config')
+const redis = require('@overleaf/redis-wrapper')
+
+const historyRedisOptions = config.get('redis.history')
+const rclientHistory = redis.createClient(historyRedisOptions)
+
+const lockRedisOptions = config.get('redis.history')
+const rclientLock = redis.createClient(lockRedisOptions)
+
+async function disconnect() {
+ await Promise.all([rclientHistory.disconnect(), rclientLock.disconnect()])
+}
+
+module.exports = {
+ rclientHistory,
+ rclientLock,
+ redis,
+ disconnect,
+}
diff --git a/services/history-v1/storage/lib/scan.js b/services/history-v1/storage/lib/scan.js
new file mode 100644
index 0000000000..45d0c327fe
--- /dev/null
+++ b/services/history-v1/storage/lib/scan.js
@@ -0,0 +1,52 @@
+const BATCH_SIZE = 1000 // Default batch size for SCAN
+
+/**
+ * Asynchronously scans a Redis instance or cluster for keys matching a pattern.
+ *
+ * This function handles both standalone Redis instances and Redis clusters.
+ * For clusters, it iterates over all master nodes. It yields keys in batches
+ * as they are found by the SCAN command.
+ *
+ * @param {object} redisClient - The Redis client instance (from @overleaf/redis-wrapper).
+ * @param {string} pattern - The pattern to match keys against (e.g., 'user:*').
+ * @param {number} [count=BATCH_SIZE] - Optional hint for Redis SCAN count per iteration.
+ * @yields {string[]} A batch of matching keys.
+ */
+async function* scanRedisCluster(redisClient, pattern, count = BATCH_SIZE) {
+ const nodes = redisClient.nodes ? redisClient.nodes('master') : [redisClient]
+
+ for (const node of nodes) {
+ let cursor = '0'
+ do {
+ // redisClient from @overleaf/redis-wrapper uses ioredis style commands
+ const [nextCursor, keys] = await node.scan(
+ cursor,
+ 'MATCH',
+ pattern,
+ 'COUNT',
+ count
+ )
+ cursor = nextCursor
+ if (keys.length > 0) {
+ yield keys
+ }
+ } while (cursor !== '0')
+ }
+}
+
+/**
+ * Extracts the content within the first pair of curly braces {} from a string.
+ * This is used to extract a user ID or project ID from a Redis key.
+ *
+ * @param {string} key - The input string containing content within curly braces.
+ * @returns {string | null} The extracted content (the key ID) if found, otherwise null.
+ */
+function extractKeyId(key) {
+ const match = key.match(/\{(.*?)\}/)
+ if (match && match[1]) {
+ return match[1]
+ }
+ return null
+}
+
+module.exports = { scanRedisCluster, extractKeyId }
diff --git a/services/history-v1/storage/scripts/backup.mjs b/services/history-v1/storage/scripts/backup.mjs
index 474192dc74..9ae6101105 100644
--- a/services/history-v1/storage/scripts/backup.mjs
+++ b/services/history-v1/storage/scripts/backup.mjs
@@ -9,6 +9,7 @@ import {
create,
} from '../lib/chunk_store/index.js'
import { client } from '../lib/mongodb.js'
+import redis from '../lib/redis.js'
import knex from '../lib/knex.js'
import { historyStore } from '../lib/history_store.js'
import pLimit from 'p-limit'
@@ -1091,5 +1092,13 @@ if (import.meta.url === `file://${process.argv[1]}`) {
.catch(err => {
console.error('Error closing MongoDB connection:', err)
})
+ redis
+ .disconnect()
+ .then(() => {
+ console.log('Redis connection closed')
+ })
+ .catch(err => {
+ console.error('Error closing Redis connection:', err)
+ })
})
}
diff --git a/services/history-v1/storage/scripts/backup_blob.mjs b/services/history-v1/storage/scripts/backup_blob.mjs
index 2a777d0074..314b05313e 100644
--- a/services/history-v1/storage/scripts/backup_blob.mjs
+++ b/services/history-v1/storage/scripts/backup_blob.mjs
@@ -10,6 +10,7 @@ import {
import assert from '../lib/assert.js'
import knex from '../lib/knex.js'
import { client } from '../lib/mongodb.js'
+import redis from '../lib/redis.js'
import { setTimeout } from 'node:timers/promises'
import fs from 'node:fs'
@@ -23,6 +24,7 @@ async function gracefulShutdown() {
console.log('Gracefully shutting down')
await knex.destroy()
await client.close()
+ await redis.disconnect()
await setTimeout(100)
process.exit()
}
diff --git a/services/history-v1/storage/scripts/backup_scheduler.mjs b/services/history-v1/storage/scripts/backup_scheduler.mjs
index 164512701e..3fac053f12 100644
--- a/services/history-v1/storage/scripts/backup_scheduler.mjs
+++ b/services/history-v1/storage/scripts/backup_scheduler.mjs
@@ -240,17 +240,25 @@ async function processPendingProjects(
changeTimes.push(pendingAt)
const pendingAge = Math.floor((Date.now() - pendingAt.getTime()) / 1000)
if (pendingAge > WARN_THRESHOLD) {
- const backupStatus = await getBackupStatus(projectId)
- logger.warn(
- {
- projectId,
- pendingAt,
- pendingAge,
- backupStatus,
- warnThreshold: WARN_THRESHOLD,
- },
- `pending change exceeds rpo warning threshold`
- )
+ try {
+ const backupStatus = await getBackupStatus(projectId)
+ logger.warn(
+ {
+ projectId,
+ pendingAt,
+ pendingAge,
+ backupStatus,
+ warnThreshold: WARN_THRESHOLD,
+ },
+ `pending change exceeds rpo warning threshold`
+ )
+ } catch (err) {
+ logger.error(
+ { projectId, pendingAt, pendingAge },
+ 'Error getting backup status'
+ )
+ throw err
+ }
}
}
if (showOnly && verbose) {
@@ -290,10 +298,11 @@ async function processPendingProjects(
)
}
}
-
- const oldestChange = changeTimes.reduce((min, time) =>
- time < min ? time : min
- )
+ // Set oldestChange to undefined if there are no changes
+ const oldestChange =
+ changeTimes.length > 0
+ ? changeTimes.reduce((min, time) => (time < min ? time : min))
+ : undefined
if (showOnly) {
console.log(
@@ -303,7 +312,9 @@ async function processPendingProjects(
console.log(`Found ${count} projects with pending changes:`)
console.log(` ${addedCount} jobs added to queue`)
console.log(` ${existingCount} jobs already existed in queue`)
- console.log(` Oldest pending change: ${formatPendingTime(oldestChange)}`)
+ if (oldestChange) {
+ console.log(` Oldest pending change: ${formatPendingTime(oldestChange)}`)
+ }
}
}
diff --git a/services/history-v1/storage/scripts/expire_redis_chunks.js b/services/history-v1/storage/scripts/expire_redis_chunks.js
new file mode 100644
index 0000000000..11b34101da
--- /dev/null
+++ b/services/history-v1/storage/scripts/expire_redis_chunks.js
@@ -0,0 +1,98 @@
+const logger = require('@overleaf/logger')
+const commandLineArgs = require('command-line-args') // Add this line
+const redis = require('../lib/redis')
+const { scanRedisCluster, extractKeyId } = require('../lib/scan')
+const { expireCurrentChunk } = require('../lib/chunk_store/redis')
+
+const rclient = redis.rclientHistory
+const EXPIRE_TIME_KEY_PATTERN = `expire-time:{*}`
+
+const optionDefinitions = [{ name: 'dry-run', alias: 'd', type: Boolean }]
+const options = commandLineArgs(optionDefinitions)
+const DRY_RUN = options['dry-run'] || false
+
+logger.initialize('expire-redis-chunks')
+
+function isExpiredKey(expireTimestamp, currentTime) {
+ const expireTime = parseInt(expireTimestamp, 10)
+ if (isNaN(expireTime)) {
+ return false
+ }
+ logger.debug(
+ {
+ expireTime,
+ currentTime,
+ expireIn: expireTime - currentTime,
+ expired: currentTime > expireTime,
+ },
+ 'Checking if key is expired'
+ )
+ return currentTime > expireTime
+}
+
+async function processKeysBatch(keysBatch, rclient) {
+ let clearedKeyCount = 0
+ if (keysBatch.length === 0) {
+ return 0
+ }
+ // For efficiency, we use MGET to fetch all the timestamps in a single request
+ const expireTimestamps = await rclient.mget(keysBatch)
+ const currentTime = Date.now()
+ for (let i = 0; i < keysBatch.length; i++) {
+ const key = keysBatch[i]
+ // For each key, do a quick check to see if the key is expired before calling
+ // the LUA script to expire the chunk atomically.
+ if (isExpiredKey(expireTimestamps[i], currentTime)) {
+ const projectId = extractKeyId(key)
+ if (DRY_RUN) {
+ logger.info({ projectId }, '[Dry Run] Would expire chunk for project')
+ } else {
+ await expireCurrentChunk(projectId)
+ }
+ clearedKeyCount++
+ }
+ }
+ return clearedKeyCount
+}
+
+async function expireRedisChunks() {
+ let scannedKeyCount = 0
+ let clearedKeyCount = 0
+ const START_TIME = Date.now()
+
+ if (DRY_RUN) {
+ // Use global DRY_RUN
+ logger.info({}, 'starting expireRedisChunks scan in DRY RUN mode')
+ } else {
+ logger.info({}, 'starting expireRedisChunks scan')
+ }
+
+ for await (const keysBatch of scanRedisCluster(
+ rclient,
+ EXPIRE_TIME_KEY_PATTERN
+ )) {
+ scannedKeyCount += keysBatch.length
+ clearedKeyCount += await processKeysBatch(keysBatch, rclient)
+ if (scannedKeyCount % 1000 === 0) {
+ logger.info(
+ { scannedKeyCount, clearedKeyCount },
+ 'expireRedisChunks scan progress'
+ )
+ }
+ }
+ logger.info(
+ {
+ scannedKeyCount,
+ clearedKeyCount,
+ elapsedTimeInSeconds: Math.floor((Date.now() - START_TIME) / 1000),
+ dryRun: DRY_RUN,
+ },
+ 'expireRedisChunks scan complete'
+ )
+ await redis.disconnect()
+}
+
+expireRedisChunks().catch(err => {
+ logger.fatal({ err }, 'unhandled error in expireRedisChunks')
+ process.exit(1)
+})
diff --git a/services/history-v1/storage/scripts/list_redis_buffer_stats.js b/services/history-v1/storage/scripts/list_redis_buffer_stats.js
new file mode 100644
index 0000000000..a53a939e44
--- /dev/null
+++ b/services/history-v1/storage/scripts/list_redis_buffer_stats.js
@@ -0,0 +1,145 @@
+const { rclientHistory, disconnect } = require('../lib/redis')
+const { scanRedisCluster } = require('../lib/scan')
+
+// Lua script to get snapshot length, change lengths, and change timestamps
+// Assumes snapshot key is a string and changes key is a list.
+const LUA_SCRIPT = `
+ -- local cjson = require('cjson')
+ local snapshotKey = KEYS[1]
+ local changesKey = KEYS[2]
+
+ -- Get snapshot length (returns 0 if key does not exist)
+ local snapshotLen = redis.call('STRLEN', snapshotKey)
+
+ -- Return nil if snapshot is empty
+ if snapshotLen == 0 then
+ return nil
+ end
+
+ local changeLengths = {}
+ local changeTimestamps = {}
+
+ -- Get all changes (returns empty list if key does not exist)
+ local changes = redis.call('LRANGE', changesKey, 0, -1)
+
+ -- FIXME: it would be better to send all the changes back and do the processing
+ -- in JS to avoid blocking redis, if we need to run this script regularly
+ for i, change in ipairs(changes) do
+ -- Calculate length
+ table.insert(changeLengths, string.len(change))
+
+ -- Attempt to decode JSON and extract timestamp
+ local ok, decoded = pcall(cjson.decode, change)
+ if ok and type(decoded) == 'table' and decoded.timestamp then
+ table.insert(changeTimestamps, decoded.timestamp)
+ else
+ -- Handle cases where decoding fails or timestamp is missing
+ -- Log or insert a placeholder like nil if needed, otherwise skip
+ table.insert(changeTimestamps, nil) -- Keep placeholder for consistency
+ end
+ end
+
+ -- Return snapshot length, list of change lengths, and list of change timestamps
+ return {snapshotLen, changeLengths, changeTimestamps}
+`
+
+// Define the command if it doesn't exist
+if (!rclientHistory.getProjectBufferStats) {
+ rclientHistory.defineCommand('getProjectBufferStats', {
+ numberOfKeys: 2,
+ lua: LUA_SCRIPT,
+ })
+}
+
+/**
+ * Processes a single project ID: fetches its buffer stats from Redis
+ * and writes the results to the output stream in CSV format.
+ *
+ * @param {string} projectId The project ID to process.
+ * @param {WritableStream} outputStream The stream to write CSV output to.
+ */
+async function processProject(projectId, outputStream) {
+ try {
+ // Get current time in milliseconds *before* fetching data
+ const nowMs = Date.now()
+
+ // Execute the Lua script
+ const result = await rclientHistory.getProjectBufferStats(
+ `snapshot:${projectId}`,
+ `changes:${projectId}`
+ )
+
+ // Check if the result is null (e.g., snapshot is empty)
+ if (result === null) {
+ console.log(
+ `Skipping project ${projectId}: Snapshot is empty or does not exist.`
+ )
+ return
+ }
+
+ const [snapshotSize, changeSizes, changeTimestamps] = result
+
+ // Output snapshot size
+ outputStream.write(`${projectId},snapshotSize,${snapshotSize}\n`)
+ outputStream.write(`${projectId},changeCount,${changeSizes.length}\n`)
+
+ const changes = changeSizes.map((size, index) => [
+ size,
+ changeTimestamps[index],
+ ])
+
+ let totalChangeSize = 0
+ // Output change sizes
+ for (const [changeSize, changeTimestamp] of changes) {
+ totalChangeSize += parseInt(changeSize, 10)
+ const age = nowMs - new Date(changeTimestamp)
+ const ageInSeconds = Math.floor(age / 1000)
+ outputStream.write(`${projectId},change,${changeSize},${ageInSeconds}\n`)
+ }
+ outputStream.write(`${projectId},totalChangeSize,${totalChangeSize}\n`)
+ } catch (err) {
+ // Log error for this specific project but continue with others
+ console.error(`Error processing project ${projectId}:`, err)
+ }
+}
+
+async function main() {
+ const outputStream = process.stdout
+
+ // Write CSV header
+ outputStream.write('projectId,type,size,age\n')
+
+ try {
+ const scanPattern = 'snapshot:*'
+ console.log(`Scanning Redis for keys matching "${scanPattern}"...`)
+
+ for await (const keysBatch of scanRedisCluster(
+ rclientHistory,
+ scanPattern
+ )) {
+ for (const key of keysBatch) {
+ const parts = key.split(':')
+ if (parts.length !== 2 || parts[0] !== 'snapshot') {
+ console.warn(`Skipping malformed key: ${key}`)
+ continue
+ }
+ const projectId = parts[1]
+
+ // Call processProject directly and await it sequentially
+ await processProject(projectId, outputStream)
+ }
+ }
+
+ console.log('Finished processing keys.')
+ } catch (error) {
+ console.error('Error during Redis scan:', error)
+ } finally {
+ await disconnect()
+ console.log('Redis connections closed.')
+ }
+}
+
+main().catch(err => {
+ console.error('Unhandled error in main:', err)
+ process.exit(1)
+})
diff --git a/services/history-v1/storage/scripts/redis.mjs b/services/history-v1/storage/scripts/redis.mjs
index bf46452ba4..ce9a39891f 100644
--- a/services/history-v1/storage/scripts/redis.mjs
+++ b/services/history-v1/storage/scripts/redis.mjs
@@ -1,11 +1,28 @@
import redis from '@overleaf/redis-wrapper'
import config from 'config'
-const redisOptions = config.get('redis.queue')
+// Get allowed Redis dbs from config
+const redisConfig = config.get('redis')
+const allowedDbs = Object.keys(redisConfig)
+// Get the Redis db from command line argument or use the first available db as default
+const db = process.argv[2]
+
+// Validate redis db
+if (!allowedDbs.includes(db)) {
+ if (db) {
+ console.error('Invalid redis db:', db)
+ }
+ console.error(`Usage: node redis.mjs [${allowedDbs.join('|')}]`)
+ process.exit(1)
+}
+
+// Get redis options based on command line argument
+const redisOptions = config.get(`redis.${db}`)
+console.log('Using redis db:', db)
console.log('REDIS CONFIG', {
...redisOptions,
- password: '*'.repeat(redisOptions.password.length),
+ password: '*'.repeat(redisOptions.password?.length),
})
const rclient = redis.createClient(redisOptions)
diff --git a/services/history-v1/storage/scripts/show.mjs b/services/history-v1/storage/scripts/show.mjs
index 04ec6c61a8..b4ae1664e3 100644
--- a/services/history-v1/storage/scripts/show.mjs
+++ b/services/history-v1/storage/scripts/show.mjs
@@ -6,6 +6,7 @@ import {
} from '../lib/chunk_store/index.js'
import { client } from '../lib/mongodb.js'
import knex from '../lib/knex.js'
+import redis from '../lib/redis.js'
import {
loadGlobalBlobs,
BlobStore,
@@ -247,4 +248,7 @@ main()
.finally(() => {
knex.destroy().catch(err => console.error('Error closing Postgres:', err))
client.close().catch(err => console.error('Error closing MongoDB:', err))
+ redis
+ .disconnect()
+ .catch(err => console.error('Error disconnecting Redis:', err))
})
diff --git a/services/history-v1/storage/scripts/verify_backed_up_blobs.mjs b/services/history-v1/storage/scripts/verify_backed_up_blobs.mjs
index f4778c382f..257238aad4 100644
--- a/services/history-v1/storage/scripts/verify_backed_up_blobs.mjs
+++ b/services/history-v1/storage/scripts/verify_backed_up_blobs.mjs
@@ -16,6 +16,7 @@ import {
db,
client,
} from '../lib/mongodb.js'
+import redis from '../lib/redis.js'
import commandLineArgs from 'command-line-args'
import fs from 'node:fs'
@@ -146,4 +147,7 @@ main()
console.error('Error closing Postgres connection:', err)
})
client.close().catch(err => console.error('Error closing MongoDB:', err))
+ redis.disconnect().catch(err => {
+ console.error('Error disconnecting Redis:', err)
+ })
})
diff --git a/services/history-v1/storage/scripts/verify_project.mjs b/services/history-v1/storage/scripts/verify_project.mjs
index 6e1cb9de89..3c26f9b5da 100644
--- a/services/history-v1/storage/scripts/verify_project.mjs
+++ b/services/history-v1/storage/scripts/verify_project.mjs
@@ -2,6 +2,7 @@ import commandLineArgs from 'command-line-args'
import { verifyProjectWithErrorContext } from '../lib/backupVerifier.mjs'
import knex from '../lib/knex.js'
import { client } from '../lib/mongodb.js'
+import redis from '../lib/redis.js'
import { setTimeout } from 'node:timers/promises'
import { loadGlobalBlobs } from '../lib/blob_store/index.js'
@@ -10,6 +11,7 @@ const { historyId } = commandLineArgs([{ name: 'historyId', type: String }])
async function gracefulShutdown(code = process.exitCode) {
await knex.destroy()
await client.close()
+ await redis.disconnect()
await setTimeout(1_000)
process.exit(code)
}
diff --git a/services/history-v1/storage/scripts/verify_sampled_projects.mjs b/services/history-v1/storage/scripts/verify_sampled_projects.mjs
index e5b2d0c347..a74a8b9798 100644
--- a/services/history-v1/storage/scripts/verify_sampled_projects.mjs
+++ b/services/history-v1/storage/scripts/verify_sampled_projects.mjs
@@ -14,6 +14,7 @@ import { loadGlobalBlobs } from '../lib/blob_store/index.js'
import { getDatesBeforeRPO } from '../../backupVerifier/utils.mjs'
import { EventEmitter } from 'node:events'
import { mongodb } from '../index.js'
+import redis from '../lib/redis.js'
logger.logger.level('fatal')
@@ -30,6 +31,7 @@ const usageMessage = [
async function gracefulShutdown(code = process.exitCode) {
await knex.destroy()
await client.close()
+ await redis.disconnect()
await setTimeout(1_000)
process.exit(code)
}
diff --git a/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs b/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs
index fe3a4d1591..2b4001a9f0 100644
--- a/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs
+++ b/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs
@@ -30,14 +30,17 @@ import { historyStore } from '../../../../storage/lib/history_store.js'
* @typedef {import("overleaf-editor-core").Blob} Blob
*/
-async function verifyProjectScript(historyId) {
+// Timeout for script execution, increased to avoid flaky tests
+const SCRIPT_TIMEOUT = 15_000
+
+async function verifyProjectScript(historyId, expectFail = true) {
try {
const result = await promisify(execFile)(
process.argv0,
['storage/scripts/verify_project.mjs', `--historyId=${historyId}`],
{
encoding: 'utf-8',
- timeout: 5_000,
+ timeout: SCRIPT_TIMEOUT,
env: {
...process.env,
LOG_LEVEL: 'warn',
@@ -53,6 +56,9 @@ async function verifyProjectScript(historyId) {
'code' in err &&
'stderr' in err
) {
+ if (!expectFail) {
+ console.log(err)
+ }
return {
stdout: typeof err.stdout === 'string' ? err.stdout : '',
status: typeof err.code === 'number' ? err.code : -1,
@@ -68,7 +74,7 @@ async function verifyProjectScript(historyId) {
* @param {string} hash
* @return {Promise<{stdout: string, status:number }>}
*/
-async function verifyBlobScript(historyId, hash) {
+async function verifyBlobScript(historyId, hash, expectFail = true) {
try {
const result = await promisify(execFile)(
process.argv0,
@@ -79,7 +85,7 @@ async function verifyBlobScript(historyId, hash) {
],
{
encoding: 'utf-8',
- timeout: 5_000,
+ timeout: SCRIPT_TIMEOUT,
env: {
...process.env,
LOG_LEVEL: 'warn',
@@ -89,6 +95,9 @@ async function verifyBlobScript(historyId, hash) {
return { status: 0, stdout: result.stdout }
} catch (err) {
if (err && typeof err === 'object' && 'stdout' in err && 'code' in err) {
+ if (!expectFail) {
+ console.log(err)
+ }
return {
stdout: typeof err.stdout === 'string' ? err.stdout : '',
status: typeof err.code === 'number' ? err.code : -1,
@@ -202,6 +211,7 @@ async function checkDEKExists(historyId) {
}
describe('backupVerifier', function () {
+ this.timeout(5_000 + SCRIPT_TIMEOUT) // allow time for external scripts to run
const historyIdPostgres = '42'
const historyIdMongo = '000000000000000000000042'
let blobHashPG, blobHashMongo, blobPathPG
@@ -228,7 +238,7 @@ describe('backupVerifier', function () {
describe('storage/scripts/verify_project.mjs', function () {
describe('when the project is appropriately backed up', function () {
it('should return 0', async function () {
- const response = await verifyProjectScript(historyIdPostgres)
+ const response = await verifyProjectScript(historyIdPostgres, false)
expect(response.status).to.equal(0)
})
})
@@ -306,12 +316,20 @@ describe('backupVerifier', function () {
expect(result.stdout).to.include('hash mismatch for backed up blob')
})
it('should successfully verify from postgres', async function () {
- const result = await verifyBlobScript(historyIdPostgres, blobHashPG)
+ const result = await verifyBlobScript(
+ historyIdPostgres,
+ blobHashPG,
+ false
+ )
expect(result.status).to.equal(0)
expect(result.stdout.split('\n')).to.include('OK')
})
it('should successfully verify from mongo', async function () {
- const result = await verifyBlobScript(historyIdMongo, blobHashMongo)
+ const result = await verifyBlobScript(
+ historyIdMongo,
+ blobHashMongo,
+ false
+ )
expect(result.status).to.equal(0)
expect(result.stdout.split('\n')).to.include('OK')
})
diff --git a/services/history-v1/test/acceptance/js/api/project_updates.test.js b/services/history-v1/test/acceptance/js/api/project_updates.test.js
index d67000245a..eb7b1703a7 100644
--- a/services/history-v1/test/acceptance/js/api/project_updates.test.js
+++ b/services/history-v1/test/acceptance/js/api/project_updates.test.js
@@ -22,6 +22,7 @@ const TextOperation = core.TextOperation
const V2DocVersions = core.V2DocVersions
const knex = require('../../../../storage').knex
+const redis = require('../../../../storage/lib/chunk_store/redis')
describe('history import', function () {
beforeEach(cleanup.everything)
@@ -580,7 +581,7 @@ describe('history import', function () {
.catch(expectResponse.unprocessableEntity)
.then(getLatestContent)
.then(response => {
- // Check that no chaes were made
+ // Check that no changes were made
const snapshot = Snapshot.fromRaw(response.obj)
expect(snapshot.countFiles()).to.equal(1)
expect(snapshot.getFile(mainFilePathname).getHash()).to.equal(
@@ -594,6 +595,10 @@ describe('history import', function () {
testFiles.NULL_CHARACTERS_TXT_BYTE_LENGTH
)
})
+ .then(() => {
+ // Now clear the cache because we have changed the string length in the database
+ return redis.clearCache(testProjectId)
+ })
.then(importChanges)
.then(getLatestContent)
.then(response => {
diff --git a/services/history-v1/test/acceptance/js/api/projects.test.js b/services/history-v1/test/acceptance/js/api/projects.test.js
index 7af8b0172f..3c333d8698 100644
--- a/services/history-v1/test/acceptance/js/api/projects.test.js
+++ b/services/history-v1/test/acceptance/js/api/projects.test.js
@@ -21,6 +21,8 @@ const {
Snapshot,
Change,
AddFileOperation,
+ EditFileOperation,
+ TextOperation,
} = require('overleaf-editor-core')
const testProjects = require('./support/test_projects')
@@ -103,56 +105,176 @@ describe('project controller', function () {
// https://github.com/overleaf/write_latex/pull/5120#discussion_r244291862
})
- describe('getLatestHashedContent', function () {
- let limitsToPersistImmediately
+ describe('project with changes', function () {
+ let projectId
- before(function () {
+ beforeEach(async function () {
// used to provide a limit which forces us to persist all of the changes.
const farFuture = new Date()
farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
- limitsToPersistImmediately = {
+ const limits = {
minChangeTimestamp: farFuture,
maxChangeTimestamp: farFuture,
}
- })
-
- it('returns a snaphot', async function () {
const changes = [
new Change(
[new AddFileOperation('test.tex', File.fromString('ab'))],
new Date(),
[]
),
+ new Change(
+ [new AddFileOperation('other.tex', File.fromString('hello'))],
+ new Date(),
+ []
+ ),
]
- const projectId = await createEmptyProject()
- await persistChanges(projectId, changes, limitsToPersistImmediately, 0)
- const response =
- await testServer.basicAuthClient.apis.Project.getLatestHashedContent({
- project_id: projectId,
- })
- expect(response.status).to.equal(HTTPStatus.OK)
- const snapshot = Snapshot.fromRaw(response.obj)
- expect(snapshot.countFiles()).to.equal(1)
- expect(snapshot.getFile('test.tex').getHash()).to.equal(
- testFiles.STRING_AB_HASH
- )
+ projectId = await createEmptyProject()
+ await persistChanges(projectId, changes, limits, 0)
})
- describe('getLatestHistoryRaw', function () {
- it('should handles read', async function () {
- const projectId = fixtures.docs.initializedProject.id
+
+ describe('getLatestHashedContent', function () {
+ it('returns a snapshot', async function () {
const response =
- await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistoryRaw(
- {
- project_id: projectId,
- readOnly: 'true',
- }
+ await testServer.basicAuthClient.apis.Project.getLatestHashedContent({
+ project_id: projectId,
+ })
+ expect(response.status).to.equal(HTTPStatus.OK)
+ const snapshot = Snapshot.fromRaw(response.obj)
+ expect(snapshot.countFiles()).to.equal(2)
+ expect(snapshot.getFile('test.tex').getHash()).to.equal(
+ testFiles.STRING_AB_HASH
+ )
+ })
+ })
+
+ describe('getChanges', function () {
+ it('returns all changes when not given a limit', async function () {
+ const response =
+ await testServer.basicAuthClient.apis.Project.getChanges({
+ project_id: projectId,
+ })
+ expect(response.status).to.equal(HTTPStatus.OK)
+ const changes = response.obj
+ expect(changes.length).to.equal(2)
+ const filenames = changes
+ .flatMap(change => change.operations)
+ .map(operation => operation.pathname)
+ expect(filenames).to.deep.equal(['test.tex', 'other.tex'])
+ })
+
+ it('returns only requested changes', async function () {
+ const response =
+ await testServer.basicAuthClient.apis.Project.getChanges({
+ project_id: projectId,
+ since: 1,
+ })
+ expect(response.status).to.equal(HTTPStatus.OK)
+ const changes = response.obj
+ expect(changes.length).to.equal(1)
+ const filenames = changes
+ .flatMap(change => change.operations)
+ .map(operation => operation.pathname)
+ expect(filenames).to.deep.equal(['other.tex'])
+ })
+
+ it('rejects negative versions', async function () {
+ await expect(
+ testServer.basicAuthClient.apis.Project.getChanges({
+ project_id: projectId,
+ since: -1,
+ })
+ ).to.be.rejectedWith('Bad Request')
+ })
+
+ it('rejects out of bounds versions', async function () {
+ await expect(
+ testServer.basicAuthClient.apis.Project.getChanges({
+ project_id: projectId,
+ since: 20,
+ })
+ ).to.be.rejectedWith('Bad Request')
+ })
+ })
+ })
+
+ describe('project with many chunks', function () {
+ let projectId
+
+ beforeEach(async function () {
+ // used to provide a limit which forces us to persist all of the changes.
+ const farFuture = new Date()
+ farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
+ const limits = {
+ minChangeTimestamp: farFuture,
+ maxChangeTimestamp: farFuture,
+ maxChunkChanges: 5,
+ }
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString(''))],
+ new Date(),
+ []
+ ),
+ ]
+
+ for (let i = 0; i < 20; i++) {
+ const textOperation = new TextOperation()
+ textOperation.retain(i)
+ textOperation.insert('x')
+ changes.push(
+ new Change(
+ [new EditFileOperation('test.tex', textOperation)],
+ new Date(),
+ []
)
- expect(response.body).to.deep.equal({
- startVersion: 0,
- endVersion: 1,
- endTimestamp: '2032-01-01T00:00:00.000Z',
- })
+ )
+ }
+
+ projectId = await createEmptyProject()
+ await persistChanges(projectId, changes, limits, 0)
+ })
+
+ it('returns all changes when not given a limit', async function () {
+ const response = await testServer.basicAuthClient.apis.Project.getChanges(
+ {
+ project_id: projectId,
+ }
+ )
+ expect(response.status).to.equal(HTTPStatus.OK)
+ const changes = response.obj
+ expect(changes.length).to.equal(21)
+ expect(changes[10].operations[0].textOperation).to.deep.equal([9, 'x'])
+ })
+
+ it('returns only requested changes', async function () {
+ const response = await testServer.basicAuthClient.apis.Project.getChanges(
+ {
+ project_id: projectId,
+ since: 10,
+ }
+ )
+ expect(response.status).to.equal(HTTPStatus.OK)
+ const changes = response.obj
+ expect(changes.length).to.equal(11)
+ expect(changes[2].operations[0].textOperation).to.deep.equal([11, 'x'])
+ })
+ })
+
+ describe('getLatestHistoryRaw', function () {
+ it('should handles read', async function () {
+ const projectId = fixtures.docs.initializedProject.id
+ const response =
+ await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistoryRaw(
+ {
+ project_id: projectId,
+ readOnly: 'true',
+ }
+ )
+ expect(response.body).to.deep.equal({
+ startVersion: 0,
+ endVersion: 1,
+ endTimestamp: '2032-01-01T00:00:00.000Z',
})
})
})
diff --git a/services/history-v1/test/acceptance/js/api/support/test_backup_verifier_server.mjs b/services/history-v1/test/acceptance/js/api/support/test_backup_verifier_server.mjs
index 10d6dbc6c1..57a805e334 100644
--- a/services/history-v1/test/acceptance/js/api/support/test_backup_verifier_server.mjs
+++ b/services/history-v1/test/acceptance/js/api/support/test_backup_verifier_server.mjs
@@ -26,7 +26,7 @@ async function listenOnRandomPort() {
return
} catch {}
}
- server = await startApp(0)
+ server = await startApp(0, false)
}
after('close server', function (done) {
diff --git a/services/history-v1/test/acceptance/js/storage/assert.test.js b/services/history-v1/test/acceptance/js/storage/assert.test.js
new file mode 100644
index 0000000000..6ba30e2562
--- /dev/null
+++ b/services/history-v1/test/acceptance/js/storage/assert.test.js
@@ -0,0 +1,248 @@
+'use strict'
+
+const OError = require('@overleaf/o-error')
+const { expect } = require('chai')
+const assert = require('../../../../storage/lib/assert')
+
+describe('assert', function () {
+ describe('blobHash', function () {
+ it('should not throw for valid blob hashes', function () {
+ expect(() =>
+ assert.blobHash(
+ 'aad321caf77ca6c5ab09e6c638c237705f93b001',
+ 'should be a blob hash'
+ )
+ ).to.not.throw()
+ })
+
+ it('should throw for invalid blob hashes', function () {
+ try {
+ assert.blobHash('invalid-hash', 'should be a blob hash')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a blob hash')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-hash' })
+ }
+ })
+
+ it('should throw for string integer blob hashes', function () {
+ try {
+ assert.blobHash('123', 'should be a blob hash')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a blob hash')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: '123' })
+ }
+ })
+ })
+
+ describe('projectId', function () {
+ it('should not throw for valid mongo project ids', function () {
+ expect(() =>
+ assert.projectId('507f1f77bcf86cd799439011', 'should be a project id')
+ ).to.not.throw()
+ })
+
+ it('should not throw for valid postgres project ids', function () {
+ expect(() =>
+ assert.projectId('123456789', 'should be a project id')
+ ).to.not.throw()
+ })
+
+ it('should throw for invalid project ids', function () {
+ try {
+ assert.projectId('invalid-id', 'should be a project id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a project id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' })
+ }
+ })
+
+ it('should throw for non-numeric project ids', function () {
+ try {
+ assert.projectId('12345x', 'should be a project id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a project id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345x' })
+ }
+ })
+
+ it('should throw for postgres ids starting with 0', function () {
+ try {
+ assert.projectId('0123456', 'should be a project id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a project id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: '0123456' })
+ }
+ })
+ })
+
+ describe('chunkId', function () {
+ it('should not throw for valid mongo chunk ids', function () {
+ expect(() =>
+ assert.chunkId('507f1f77bcf86cd799439011', 'should be a chunk id')
+ ).to.not.throw()
+ })
+
+ it('should not throw for valid postgres chunk ids', function () {
+ expect(() =>
+ assert.chunkId('123456789', 'should be a chunk id')
+ ).to.not.throw()
+ })
+
+ it('should throw for invalid chunk ids', function () {
+ try {
+ assert.chunkId('invalid-id', 'should be a chunk id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a chunk id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' })
+ }
+ })
+
+ it('should throw for integer chunk ids', function () {
+ try {
+ assert.chunkId(12345, 'should be a chunk id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a chunk id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: 12345 })
+ }
+ })
+ })
+
+ describe('mongoId', function () {
+ it('should not throw for valid mongo ids', function () {
+ expect(() =>
+ assert.mongoId('507f1f77bcf86cd799439011', 'should be a mongo id')
+ ).to.not.throw()
+ })
+
+ it('should throw for invalid mongo ids', function () {
+ try {
+ assert.mongoId('invalid-id', 'should be a mongo id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a mongo id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' })
+ }
+ })
+
+ it('should throw for numeric mongo ids', function () {
+ try {
+ assert.mongoId('12345', 'should be a mongo id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a mongo id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345' })
+ }
+ })
+
+ it('should throw for mongo ids that are too short', function () {
+ try {
+ assert.mongoId('507f1f77bcf86cd79943901', 'should be a mongo id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a mongo id')
+ expect(OError.getFullInfo(error)).to.deep.equal({
+ arg: '507f1f77bcf86cd79943901',
+ })
+ }
+ })
+
+ it('should throw for mongo ids that are too long', function () {
+ try {
+ assert.mongoId('507f1f77bcf86cd7994390111', 'should be a mongo id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a mongo id')
+ expect(OError.getFullInfo(error)).to.deep.equal({
+ arg: '507f1f77bcf86cd7994390111',
+ })
+ }
+ })
+ })
+
+ describe('postgresId', function () {
+ it('should not throw for valid postgres ids', function () {
+ expect(() =>
+ assert.postgresId('123456789', 'should be a postgres id')
+ ).to.not.throw()
+ expect(() =>
+ assert.postgresId('1', 'should be a postgres id')
+ ).to.not.throw()
+ })
+
+ it('should throw for invalid postgres ids', function () {
+ try {
+ assert.postgresId('invalid-id', 'should be a postgres id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a postgres id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' })
+ }
+ })
+
+ it('should throw for postgres ids starting with 0', function () {
+ try {
+ assert.postgresId('0123456', 'should be a postgres id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a postgres id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: '0123456' })
+ }
+ })
+
+ it('should throw for postgres ids that are too long', function () {
+ try {
+ assert.postgresId('12345678901', 'should be a postgres id')
+ expect.fail()
+ } catch (error) {
+ expect(error).to.be.instanceOf(TypeError)
+ expect(error.message).to.equal('should be a postgres id')
+ expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345678901' })
+ }
+ })
+ })
+
+ describe('regex constants', function () {
+ it('MONGO_ID_REGEXP should match valid mongo ids', function () {
+ expect('507f1f77bcf86cd799439011').to.match(assert.MONGO_ID_REGEXP)
+ expect('abcdef0123456789abcdef01').to.match(assert.MONGO_ID_REGEXP)
+ })
+
+ it('MONGO_ID_REGEXP should not match invalid mongo ids', function () {
+ expect('invalid-id').to.not.match(assert.MONGO_ID_REGEXP)
+ expect('507f1f77bcf86cd79943901').to.not.match(assert.MONGO_ID_REGEXP) // too short
+ expect('507f1f77bcf86cd7994390111').to.not.match(assert.MONGO_ID_REGEXP) // too long
+ expect('507F1F77BCF86CD799439011').to.not.match(assert.MONGO_ID_REGEXP) // uppercase
+ })
+
+ it('POSTGRES_ID_REGEXP should match valid postgres ids', function () {
+ expect('123456789').to.match(assert.POSTGRES_ID_REGEXP)
+ expect('1').to.match(assert.POSTGRES_ID_REGEXP)
+ })
+
+ it('POSTGRES_ID_REGEXP should not match invalid postgres ids', function () {
+ expect('invalid-id').to.not.match(assert.POSTGRES_ID_REGEXP)
+ expect('0123456').to.not.match(assert.POSTGRES_ID_REGEXP) // starts with 0
+ expect('12345678901').to.not.match(assert.POSTGRES_ID_REGEXP) // too long (> 10 digits)
+ })
+ })
+})
diff --git a/services/history-v1/test/acceptance/js/storage/blob_store_postgres.test.js b/services/history-v1/test/acceptance/js/storage/blob_store_postgres.test.js
index 0add4fa901..e762c33569 100644
--- a/services/history-v1/test/acceptance/js/storage/blob_store_postgres.test.js
+++ b/services/history-v1/test/acceptance/js/storage/blob_store_postgres.test.js
@@ -8,20 +8,20 @@ describe('BlobStore postgres backend', function () {
const projectId = new ObjectId().toString()
await expect(
postgresBackend.insertBlob(projectId, 'hash', 123, 99)
- ).to.be.rejectedWith(`bad projectId ${projectId}`)
+ ).to.be.rejectedWith('bad projectId')
})
it('deleteBlobs rejects when called with bad projectId', async function () {
const projectId = new ObjectId().toString()
await expect(postgresBackend.deleteBlobs(projectId)).to.be.rejectedWith(
- `bad projectId ${projectId}`
+ 'bad projectId'
)
})
it('findBlobs rejects when called with bad projectId', async function () {
const projectId = new ObjectId().toString()
await expect(postgresBackend.findBlobs(projectId)).to.be.rejectedWith(
- `bad projectId ${projectId}`
+ 'bad projectId'
)
})
@@ -29,14 +29,14 @@ describe('BlobStore postgres backend', function () {
const projectId = new ObjectId().toString()
await expect(
postgresBackend.findBlob(projectId, 'hash')
- ).to.be.rejectedWith(`bad projectId ${projectId}`)
+ ).to.be.rejectedWith('bad projectId')
})
it('getProjectBlobs rejects when called with bad projectId', async function () {
const projectId = new ObjectId().toString()
await expect(
postgresBackend.getProjectBlobs(projectId)
- ).to.be.rejectedWith(`bad projectId ${projectId}`)
+ ).to.be.rejectedWith('bad projectId')
})
})
})
diff --git a/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js b/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js
new file mode 100644
index 0000000000..841282a8e4
--- /dev/null
+++ b/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js
@@ -0,0 +1,351 @@
+'use strict'
+
+const { expect } = require('chai')
+const sinon = require('sinon')
+const {
+ Chunk,
+ Snapshot,
+ History,
+ File,
+ AddFileOperation,
+ EditFileOperation,
+ AddCommentOperation,
+ TextOperation,
+ Range,
+ TrackingProps,
+ Change,
+} = require('overleaf-editor-core')
+const cleanup = require('./support/cleanup')
+const fixtures = require('./support/fixtures')
+const chunkBuffer = require('../../../../storage/lib/chunk_buffer')
+const chunkStore = require('../../../../storage/lib/chunk_store')
+const redisBackend = require('../../../../storage/lib/chunk_store/redis')
+const metrics = require('@overleaf/metrics')
+
+describe('chunk buffer', function () {
+ beforeEach(cleanup.everything)
+ beforeEach(fixtures.create)
+ beforeEach(function () {
+ sinon.spy(metrics, 'inc')
+ })
+ afterEach(function () {
+ metrics.inc.restore()
+ })
+
+ const projectId = '123456'
+
+ describe('loadLatest', function () {
+ // Initialize project and create a test chunk
+ beforeEach(async function () {
+ // Initialize project in chunk store
+ await chunkStore.initializeProject(projectId)
+ })
+
+ describe('with an existing chunk', function () {
+ beforeEach(async function () {
+ // Create a sample chunk with some content
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date(),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 1) // startVersion 1
+
+ // Store the chunk directly in the chunk store using create method
+ // which internally calls uploadChunk
+ await chunkStore.create(projectId, chunk)
+
+ // Clear any existing cache
+ await redisBackend.clearCache(projectId)
+ })
+
+ it('should load from chunk store and update cache on first access (cache miss)', async function () {
+ // Load the underlying chunk from the chunk store for verification
+ const storedChunk = await chunkStore.loadLatest(projectId)
+
+ // First access should load from chunk store and populate cache
+ const firstResult = await chunkBuffer.loadLatest(projectId)
+
+ // Verify the chunk is correct
+ expect(firstResult).to.not.be.null
+ expect(firstResult.getStartVersion()).to.equal(1)
+ expect(firstResult.getEndVersion()).to.equal(2)
+
+ // Verify the chunk is the same as the one in the store
+ expect(firstResult).to.deep.equal(storedChunk)
+
+ // Verify that we got a cache miss metric
+ expect(
+ metrics.inc.calledWith('chunk_buffer.loadLatest', 1, {
+ status: 'cache-miss',
+ })
+ ).to.be.true
+
+ // Reset the metrics spy
+ metrics.inc.resetHistory()
+
+ // Second access should hit the cache
+ const secondResult = await chunkBuffer.loadLatest(projectId)
+
+ // Verify we got the same chunk
+ expect(secondResult).to.not.be.null
+ expect(secondResult.getStartVersion()).to.equal(1)
+ expect(secondResult.getEndVersion()).to.equal(2)
+
+ // Verify the chunk is the same as the one in the store
+ expect(secondResult).to.deep.equal(storedChunk)
+
+ // Verify that we got a cache hit metric
+ expect(
+ metrics.inc.calledWith('chunk_buffer.loadLatest', 1, {
+ status: 'cache-hit',
+ })
+ ).to.be.true
+
+ // Verify both chunks are equivalent
+ expect(secondResult.getStartVersion()).to.equal(
+ firstResult.getStartVersion()
+ )
+ expect(secondResult.getEndVersion()).to.equal(
+ firstResult.getEndVersion()
+ )
+ expect(secondResult).to.deep.equal(firstResult)
+ })
+
+ it('should refresh the cache when chunk changes in the store', async function () {
+ // First access to load into cache
+ const firstResult = await chunkBuffer.loadLatest(projectId)
+ expect(firstResult.getStartVersion()).to.equal(1)
+
+ // Reset metrics spy
+ metrics.inc.resetHistory()
+
+ // Create a new chunk with different content
+ const newSnapshot = new Snapshot()
+ const newChanges = [
+ new Change(
+ [
+ new AddFileOperation(
+ 'updated.tex',
+ File.fromString('Updated content')
+ ),
+ ],
+ new Date(),
+ []
+ ),
+ ]
+ const newHistory = new History(newSnapshot, newChanges)
+ const newChunk = new Chunk(newHistory, 2) // Different start version
+
+ // Store the new chunk directly in the chunk store
+ await chunkStore.create(projectId, newChunk)
+
+ // Load the underlying chunk from the chunk store for verification
+ const storedChunk = await chunkStore.loadLatest(projectId)
+
+ // Access again - should detect the change and refresh cache
+ const secondResult = await chunkBuffer.loadLatest(projectId)
+
+ // Verify we got the updated chunk
+ expect(secondResult.getStartVersion()).to.equal(2)
+ expect(secondResult.getEndVersion()).to.equal(3)
+ // Verify that the chunk content is the same
+ expect(secondResult).to.deep.equal(storedChunk)
+
+ // Verify that we got a cache miss metric (since the cached chunk was invalidated)
+ expect(
+ metrics.inc.calledWith('chunk_buffer.loadLatest', 1, {
+ status: 'cache-miss',
+ })
+ ).to.be.true
+ })
+
+ it('should continue using cache when chunk in store has not changed', async function () {
+ // Load the underlying chunk from the chunk store for verification
+ const storedChunk = await chunkStore.loadLatest(projectId)
+
+ // First access to load into cache
+ await chunkBuffer.loadLatest(projectId)
+
+ // Reset metrics spy
+ metrics.inc.resetHistory()
+
+ // Access again without changing the underlying chunk
+ const result = await chunkBuffer.loadLatest(projectId)
+
+ // Verify we got the same chunk
+ expect(result.getStartVersion()).to.equal(1)
+ expect(result.getEndVersion()).to.equal(2)
+ expect(result).to.deep.equal(storedChunk)
+
+ // Verify that we got a cache hit metric
+ expect(
+ metrics.inc.calledWith('chunk_buffer.loadLatest', 1, {
+ status: 'cache-hit',
+ })
+ ).to.be.true
+ })
+ })
+
+ it('should handle a chunk with metadata, comments and tracked changes', async function () {
+ // Create a snapshot and initial file
+ const snapshot = new Snapshot()
+ const initialFileOp = new AddFileOperation(
+ 'test.tex',
+ File.fromString('Initial line.\\nSecond line.', {
+ meta1: 'abc',
+ meta2: 'def',
+ })
+ )
+ const initialChange = new Change([initialFileOp], new Date(), [])
+
+ // Add a comment
+ const commentOp = new AddCommentOperation(
+ 'comment1',
+ [new Range(0, 7)] // Range for "Initial"
+ )
+ const commentChange = new Change(
+ [new EditFileOperation('test.tex', commentOp)],
+ new Date(),
+ []
+ )
+
+ // Tracked insert
+ const trackedInsertOp = new TextOperation()
+ .retain(14)
+ .insert('Hello', {
+ commentIds: ['comment1'],
+ tracking: TrackingProps.fromRaw({
+ ts: '2024-01-01T00:00:00.000Z',
+ type: 'insert',
+ userId: 'user1',
+ }),
+ })
+ .retain(12)
+ const insertChange = new Change(
+ [new EditFileOperation('test.tex', trackedInsertOp)],
+ new Date(),
+ []
+ )
+
+ // Tracked delete
+ const trackedDeleteOp = new TextOperation().retain(14, {
+ tracking: TrackingProps.fromRaw({
+ ts: '2024-01-01T00:00:00.000Z',
+ type: 'delete',
+ userId: 'user1',
+ }),
+ })
+ const deleteChange = new Change(
+ [new EditFileOperation('test.tex', trackedDeleteOp)],
+ new Date(),
+ []
+ )
+
+ // Combine changes into history and create chunk
+ const history = new History(snapshot, [
+ initialChange,
+ commentChange,
+ insertChange,
+ deleteChange,
+ ])
+ const chunk = new Chunk(history, 1) // Start version 0
+ // Store the chunk
+ await chunkStore.create(projectId, chunk)
+ // Clear the cache
+ await redisBackend.clearCache(projectId)
+ metrics.inc.resetHistory()
+
+ // Load the underlying chunk from the chunk store for verification
+ const storedChunk = await chunkStore.loadLatest(projectId)
+
+ // Load the chunk via buffer (cache miss)
+ const firstResult = await chunkBuffer.loadLatest(projectId)
+
+ // Verify chunk details
+ expect(firstResult.getStartVersion()).to.equal(1)
+ expect(firstResult.getEndVersion()).to.equal(5) // 4 changes
+ expect(firstResult.history.changes.length).to.equal(4)
+ expect(firstResult).to.deep.equal(storedChunk)
+
+ // Verify cache miss metric
+ expect(
+ metrics.inc.calledWith('chunk_buffer.loadLatest', 1, {
+ status: 'cache-miss',
+ })
+ ).to.be.true
+
+ // Reset metrics
+ metrics.inc.resetHistory()
+
+ // Second access should hit the cache
+ const secondResult = await chunkBuffer.loadLatest(projectId)
+
+ // Verify we got the same chunk
+ expect(secondResult.getStartVersion()).to.equal(1)
+ expect(secondResult.getEndVersion()).to.equal(5)
+ expect(secondResult.history.changes.length).to.equal(4)
+ expect(secondResult).to.deep.equal(storedChunk)
+
+ // Verify cache hit metric
+ expect(
+ metrics.inc.calledWith('chunk_buffer.loadLatest', 1, {
+ status: 'cache-hit',
+ })
+ ).to.be.true
+ })
+
+ describe('with an empty project', function () {
+ it('should handle a case with empty chunks (no changes)', async function () {
+ // Clear the cache
+ await redisBackend.clearCache(projectId)
+
+ // Load the underlying chunk from the chunk store for verification
+ const storedChunk = await chunkStore.loadLatest(projectId)
+
+ // Load the initial empty chunk via buffer
+ const result = await chunkBuffer.loadLatest(projectId)
+
+ // Verify we got the empty chunk
+ expect(result.getStartVersion()).to.equal(0)
+ expect(result.getEndVersion()).to.equal(0) // Start equals end for empty chunks
+ expect(result.history.changes.length).to.equal(0)
+
+ // Verify that the chunk is the same as the one in the store
+ expect(result).to.deep.equal(storedChunk)
+
+ // Verify cache miss metric
+ expect(
+ metrics.inc.calledWith('chunk_buffer.loadLatest', 1, {
+ status: 'cache-miss',
+ })
+ ).to.be.true
+
+ // Reset metrics
+ metrics.inc.resetHistory()
+
+ // Second access should hit the cache
+ const secondResult = await chunkBuffer.loadLatest(projectId)
+
+ // Verify we got the same empty chunk
+ expect(secondResult.getStartVersion()).to.equal(0)
+ expect(secondResult.getEndVersion()).to.equal(0)
+ expect(secondResult.history.changes.length).to.equal(0)
+
+ // Verify that the chunk is the same as the one in the store
+ expect(secondResult).to.deep.equal(storedChunk)
+
+ // Verify cache hit metric
+ expect(
+ metrics.inc.calledWith('chunk_buffer.loadLatest', 1, {
+ status: 'cache-hit',
+ })
+ ).to.be.true
+ })
+ })
+ })
+})
diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_mongo_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_mongo_backend.test.js
index 61d80810f1..98cdd2db4d 100644
--- a/services/history-v1/test/acceptance/js/storage/chunk_store_mongo_backend.test.js
+++ b/services/history-v1/test/acceptance/js/storage/chunk_store_mongo_backend.test.js
@@ -1,8 +1,16 @@
const { expect } = require('chai')
const { ObjectId } = require('mongodb')
-const { Chunk, Snapshot, History } = require('overleaf-editor-core')
+const {
+ Chunk,
+ Snapshot,
+ History,
+ Change,
+ AddFileOperation,
+ File,
+} = require('overleaf-editor-core')
const cleanup = require('./support/cleanup')
const backend = require('../../../../storage/lib/chunk_store/mongo')
+const { ChunkVersionConflictError } = require('../../../../storage')
describe('chunk store Mongo backend', function () {
beforeEach(cleanup.everything)
@@ -42,11 +50,86 @@ describe('chunk store Mongo backend', function () {
expect(oldChunks).to.deep.equal([])
})
})
+
+ describe('concurrency handling', function () {
+ it('prevents chunks from being created with the same start version', async function () {
+ const projectId = new ObjectId().toString()
+ const chunks = [makeChunk([], 10), makeChunk([], 10)]
+
+ const chunkIds = []
+ for (const chunk of chunks) {
+ const chunkId = await backend.insertPendingChunk(projectId, chunk)
+ chunkIds.push(chunkId)
+ }
+
+ await backend.confirmCreate(projectId, chunks[0], chunkIds[0])
+ await expect(
+ backend.confirmCreate(projectId, chunks[1], chunkIds[1])
+ ).to.be.rejectedWith(ChunkVersionConflictError)
+ })
+
+ describe('conflicts between chunk extension and chunk creation', function () {
+ let projectId,
+ baseChunkId,
+ updatedChunkId,
+ newChunkId,
+ updatedChunk,
+ newChunk
+
+ beforeEach(async function () {
+ projectId = new ObjectId().toString()
+ const baseChunk = makeChunk([], 0)
+ baseChunkId = await backend.insertPendingChunk(projectId, baseChunk)
+ await backend.confirmCreate(projectId, baseChunk, baseChunkId)
+
+ const change = new Change(
+ [new AddFileOperation('main.tex', File.fromString('hello'))],
+ new Date()
+ )
+
+ updatedChunk = makeChunk([change], 0)
+ updatedChunkId = await backend.insertPendingChunk(
+ projectId,
+ updatedChunk
+ )
+ newChunk = makeChunk([change], 1)
+ newChunkId = await backend.insertPendingChunk(projectId, newChunk)
+ })
+
+ it('prevents creation after extension', async function () {
+ await backend.confirmUpdate(
+ projectId,
+ baseChunkId,
+ updatedChunk,
+ updatedChunkId
+ )
+ await expect(
+ backend.confirmCreate(projectId, newChunk, newChunkId, {
+ oldChunkId: baseChunkId,
+ })
+ ).to.be.rejectedWith(ChunkVersionConflictError)
+ })
+
+ it('prevents extension after creation', async function () {
+ await backend.confirmCreate(projectId, newChunk, newChunkId, {
+ oldChunkId: baseChunkId,
+ })
+ await expect(
+ backend.confirmUpdate(
+ projectId,
+ baseChunkId,
+ updatedChunk,
+ updatedChunkId
+ )
+ ).to.be.rejectedWith(ChunkVersionConflictError)
+ })
+ })
+ })
})
function makeChunk(changes, versionNumber) {
const snapshot = Snapshot.fromRaw({ files: {} })
- const history = new History(snapshot, [])
+ const history = new History(snapshot, changes)
const chunk = new Chunk(history, versionNumber)
return chunk
}
diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_postgres_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_postgres_backend.test.js
index 20a24de3eb..cd1d705bdc 100644
--- a/services/history-v1/test/acceptance/js/storage/chunk_store_postgres_backend.test.js
+++ b/services/history-v1/test/acceptance/js/storage/chunk_store_postgres_backend.test.js
@@ -1,7 +1,15 @@
const { expect } = require('chai')
const { ObjectId } = require('mongodb')
-const { Chunk, Snapshot, History } = require('overleaf-editor-core')
+const {
+ Chunk,
+ Snapshot,
+ History,
+ Change,
+ AddFileOperation,
+ File,
+} = require('overleaf-editor-core')
const cleanup = require('./support/cleanup')
+const { ChunkVersionConflictError } = require('../../../../storage')
const backend = require('../../../../storage/lib/chunk_store/postgres')
describe('chunk store Postgres backend', function () {
@@ -11,32 +19,86 @@ describe('chunk store Postgres backend', function () {
const invalidProjectId = new ObjectId().toString()
await expect(backend.getLatestChunk(invalidProjectId)).to.be.rejectedWith(
- `bad projectId ${invalidProjectId}`
+ 'bad projectId'
)
await expect(
backend.getChunkForVersion(invalidProjectId, 1)
- ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
+ ).to.be.rejectedWith('bad projectId')
await expect(
backend.getChunkForTimestamp(invalidProjectId, new Date())
- ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
+ ).to.be.rejectedWith('bad projectId')
await expect(
backend.getProjectChunkIds(invalidProjectId)
- ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
+ ).to.be.rejectedWith('bad projectId')
await expect(
backend.insertPendingChunk(invalidProjectId, makeChunk([], 0))
- ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
+ ).to.be.rejectedWith('bad projectId')
await expect(
backend.confirmCreate(invalidProjectId, makeChunk([], 0), 1)
- ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
+ ).to.be.rejectedWith('bad projectId')
await expect(
backend.confirmUpdate(invalidProjectId, 1, makeChunk([], 0), 2)
- ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
+ ).to.be.rejectedWith('bad projectId')
await expect(backend.deleteChunk(invalidProjectId, 1)).to.be.rejectedWith(
- `bad projectId ${invalidProjectId}`
+ 'bad projectId'
)
await expect(
backend.deleteProjectChunks(invalidProjectId)
- ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
+ ).to.be.rejectedWith('bad projectId')
+ })
+
+ describe('conflicts between chunk extension and chunk creation', function () {
+ let projectId,
+ baseChunkId,
+ updatedChunkId,
+ newChunkId,
+ updatedChunk,
+ newChunk
+
+ beforeEach(async function () {
+ projectId = '1234'
+ const baseChunk = makeChunk([], 0)
+ baseChunkId = await backend.insertPendingChunk(projectId, baseChunk)
+ await backend.confirmCreate(projectId, baseChunk, baseChunkId)
+
+ const change = new Change(
+ [new AddFileOperation('main.tex', File.fromString('hello'))],
+ new Date()
+ )
+
+ updatedChunk = makeChunk([change], 0)
+ updatedChunkId = await backend.insertPendingChunk(projectId, updatedChunk)
+ newChunk = makeChunk([change], 1)
+ newChunkId = await backend.insertPendingChunk(projectId, newChunk)
+ })
+
+ it('prevents creation after extension', async function () {
+ await backend.confirmUpdate(
+ projectId,
+ baseChunkId,
+ updatedChunk,
+ updatedChunkId
+ )
+ await expect(
+ backend.confirmCreate(projectId, newChunk, newChunkId, {
+ oldChunkId: baseChunkId,
+ })
+ ).to.be.rejectedWith(ChunkVersionConflictError)
+ })
+
+ it('prevents extension after creation', async function () {
+ await backend.confirmCreate(projectId, newChunk, newChunkId, {
+ oldChunkId: baseChunkId,
+ })
+ await expect(
+ backend.confirmUpdate(
+ projectId,
+ baseChunkId,
+ updatedChunk,
+ updatedChunkId
+ )
+ ).to.be.rejectedWith(ChunkVersionConflictError)
+ })
})
})
diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js
new file mode 100644
index 0000000000..612e802ff1
--- /dev/null
+++ b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js
@@ -0,0 +1,924 @@
+'use strict'
+
+const { expect } = require('chai')
+const {
+ Chunk,
+ Snapshot,
+ History,
+ File,
+ AddFileOperation,
+ Origin,
+ Change,
+ V2DocVersions,
+} = require('overleaf-editor-core')
+const cleanup = require('./support/cleanup')
+const redisBackend = require('../../../../storage/lib/chunk_store/redis')
+
+describe('chunk store Redis backend', function () {
+ beforeEach(cleanup.everything)
+ const projectId = '123456'
+
+ describe('getCurrentChunk', function () {
+ it('should return null on cache miss', async function () {
+ const chunk = await redisBackend.getCurrentChunk(projectId)
+ expect(chunk).to.be.null
+ })
+
+ it('should return the cached chunk', async function () {
+ // Create a sample chunk
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date(),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 5) // startVersion 5
+
+ // Cache the chunk
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Retrieve the cached chunk
+ const cachedChunk = await redisBackend.getCurrentChunk(projectId)
+
+ expect(cachedChunk).to.not.be.null
+ expect(cachedChunk.getStartVersion()).to.equal(5)
+ expect(cachedChunk.getEndVersion()).to.equal(6)
+ expect(cachedChunk).to.deep.equal(chunk)
+ })
+ })
+
+ describe('setCurrentChunk', function () {
+ it('should successfully cache a chunk', async function () {
+ // Create a sample chunk
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date(),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 5) // startVersion 5
+
+ // Cache the chunk
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Verify the chunk was cached correctly by retrieving it
+ const cachedChunk = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunk).to.not.be.null
+ expect(cachedChunk.getStartVersion()).to.equal(5)
+ expect(cachedChunk.getEndVersion()).to.equal(6)
+ expect(cachedChunk).to.deep.equal(chunk)
+
+ // Verify that the chunk was stored correctly using the chunk metadata
+ const chunkMetadata =
+ await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(chunkMetadata).to.not.be.null
+ expect(chunkMetadata.startVersion).to.equal(5)
+ expect(chunkMetadata.changesCount).to.equal(1)
+ })
+
+ it('should correctly handle a chunk with zero changes', async function () {
+ // Create a sample chunk with no changes
+ const snapshot = new Snapshot()
+ const changes = []
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 10) // startVersion 10
+
+ // Cache the chunk
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Retrieve the cached chunk
+ const cachedChunk = await redisBackend.getCurrentChunk(projectId)
+
+ expect(cachedChunk).to.not.be.null
+ expect(cachedChunk.getStartVersion()).to.equal(10)
+ expect(cachedChunk.getEndVersion()).to.equal(10) // End version should equal start version with no changes
+ expect(cachedChunk.history.changes.length).to.equal(0)
+ expect(cachedChunk).to.deep.equal(chunk)
+ })
+ })
+
+ describe('updating already cached chunks', function () {
+ it('should replace a chunk with a longer chunk', async function () {
+ // Set initial chunk with one change
+ const snapshotA = new Snapshot()
+ const changesA = [
+ new Change(
+ [
+ new AddFileOperation(
+ 'test.tex',
+ File.fromString('Initial content')
+ ),
+ ],
+ new Date(),
+ []
+ ),
+ ]
+ const historyA = new History(snapshotA, changesA)
+ const chunkA = new Chunk(historyA, 10)
+
+ await redisBackend.setCurrentChunk(projectId, chunkA)
+
+ // Verify the initial chunk was cached
+ const cachedChunkA = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunkA.getStartVersion()).to.equal(10)
+ expect(cachedChunkA.getEndVersion()).to.equal(11)
+ expect(cachedChunkA.history.changes.length).to.equal(1)
+
+ // Create a longer chunk (with more changes)
+ const snapshotB = new Snapshot()
+ const changesB = [
+ new Change(
+ [new AddFileOperation('test1.tex', File.fromString('Content 1'))],
+ new Date(),
+ []
+ ),
+ new Change(
+ [new AddFileOperation('test2.tex', File.fromString('Content 2'))],
+ new Date(),
+ []
+ ),
+ new Change(
+ [new AddFileOperation('test3.tex', File.fromString('Content 3'))],
+ new Date(),
+ []
+ ),
+ ]
+ const historyB = new History(snapshotB, changesB)
+ const chunkB = new Chunk(historyB, 15)
+
+ // Replace the cached chunk
+ await redisBackend.setCurrentChunk(projectId, chunkB)
+
+ // Verify the new chunk replaced the old one
+ const cachedChunkB = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunkB).to.not.be.null
+ expect(cachedChunkB.getStartVersion()).to.equal(15)
+ expect(cachedChunkB.getEndVersion()).to.equal(18)
+ expect(cachedChunkB.history.changes.length).to.equal(3)
+ expect(cachedChunkB).to.deep.equal(chunkB)
+
+ // Verify the metadata was updated
+ const updatedMetadata =
+ await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(updatedMetadata.startVersion).to.equal(15)
+ expect(updatedMetadata.changesCount).to.equal(3)
+ })
+
+ it('should replace a chunk with a shorter chunk', async function () {
+ // Set initial chunk with three changes
+ const snapshotA = new Snapshot()
+ const changesA = [
+ new Change(
+ [new AddFileOperation('file1.tex', File.fromString('Content 1'))],
+ new Date(),
+ []
+ ),
+ new Change(
+ [new AddFileOperation('file2.tex', File.fromString('Content 2'))],
+ new Date(),
+ []
+ ),
+ new Change(
+ [new AddFileOperation('file3.tex', File.fromString('Content 3'))],
+ new Date(),
+ []
+ ),
+ ]
+ const historyA = new History(snapshotA, changesA)
+ const chunkA = new Chunk(historyA, 20)
+
+ await redisBackend.setCurrentChunk(projectId, chunkA)
+
+ // Verify the initial chunk was cached
+ const cachedChunkA = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunkA.getStartVersion()).to.equal(20)
+ expect(cachedChunkA.getEndVersion()).to.equal(23)
+ expect(cachedChunkA.history.changes.length).to.equal(3)
+
+ // Create a shorter chunk (with fewer changes)
+ const snapshotB = new Snapshot()
+ const changesB = [
+ new Change(
+ [new AddFileOperation('new.tex', File.fromString('New content'))],
+ new Date(),
+ []
+ ),
+ ]
+ const historyB = new History(snapshotB, changesB)
+ const chunkB = new Chunk(historyB, 30)
+
+ // Replace the cached chunk
+ await redisBackend.setCurrentChunk(projectId, chunkB)
+
+ // Verify the new chunk replaced the old one
+ const cachedChunkB = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunkB).to.not.be.null
+ expect(cachedChunkB.getStartVersion()).to.equal(30)
+ expect(cachedChunkB.getEndVersion()).to.equal(31)
+ expect(cachedChunkB.history.changes.length).to.equal(1)
+ expect(cachedChunkB).to.deep.equal(chunkB)
+
+ // Verify the metadata was updated
+ const updatedMetadata =
+ await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(updatedMetadata.startVersion).to.equal(30)
+ expect(updatedMetadata.changesCount).to.equal(1)
+ })
+
+ it('should replace a chunk with a zero-length chunk', async function () {
+ // Set initial chunk with changes
+ const snapshotA = new Snapshot()
+ const changesA = [
+ new Change(
+ [new AddFileOperation('file1.tex', File.fromString('Content 1'))],
+ new Date(),
+ []
+ ),
+ new Change(
+ [new AddFileOperation('file2.tex', File.fromString('Content 2'))],
+ new Date(),
+ []
+ ),
+ ]
+ const historyA = new History(snapshotA, changesA)
+ const chunkA = new Chunk(historyA, 25)
+
+ await redisBackend.setCurrentChunk(projectId, chunkA)
+
+ // Verify the initial chunk was cached
+ const cachedChunkA = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunkA.getStartVersion()).to.equal(25)
+ expect(cachedChunkA.getEndVersion()).to.equal(27)
+ expect(cachedChunkA.history.changes.length).to.equal(2)
+
+ // Create a zero-length chunk (with no changes)
+ const snapshotB = new Snapshot()
+ const changesB = []
+ const historyB = new History(snapshotB, changesB)
+ const chunkB = new Chunk(historyB, 40)
+
+ // Replace the cached chunk
+ await redisBackend.setCurrentChunk(projectId, chunkB)
+
+ // Verify the new chunk replaced the old one
+ const cachedChunkB = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunkB).to.not.be.null
+ expect(cachedChunkB.getStartVersion()).to.equal(40)
+ expect(cachedChunkB.getEndVersion()).to.equal(40) // Start version equals end version with no changes
+ expect(cachedChunkB.history.changes.length).to.equal(0)
+ expect(cachedChunkB).to.deep.equal(chunkB)
+
+ // Verify the metadata was updated
+ const updatedMetadata =
+ await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(updatedMetadata.startVersion).to.equal(40)
+ expect(updatedMetadata.changesCount).to.equal(0)
+ })
+
+ it('should replace a zero-length chunk with a non-empty chunk', async function () {
+ // Set initial empty chunk
+ const snapshotA = new Snapshot()
+ const changesA = []
+ const historyA = new History(snapshotA, changesA)
+ const chunkA = new Chunk(historyA, 50)
+
+ await redisBackend.setCurrentChunk(projectId, chunkA)
+
+ // Verify the initial chunk was cached
+ const cachedChunkA = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunkA.getStartVersion()).to.equal(50)
+ expect(cachedChunkA.getEndVersion()).to.equal(50)
+ expect(cachedChunkA.history.changes.length).to.equal(0)
+
+ // Create a non-empty chunk
+ const snapshotB = new Snapshot()
+ const changesB = [
+ new Change(
+ [new AddFileOperation('newfile.tex', File.fromString('New content'))],
+ new Date(),
+ []
+ ),
+ new Change(
+ [
+ new AddFileOperation(
+ 'another.tex',
+ File.fromString('Another file')
+ ),
+ ],
+ new Date(),
+ []
+ ),
+ ]
+ const historyB = new History(snapshotB, changesB)
+ const chunkB = new Chunk(historyB, 60)
+
+ // Replace the cached chunk
+ await redisBackend.setCurrentChunk(projectId, chunkB)
+
+ // Verify the new chunk replaced the old one
+ const cachedChunkB = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunkB).to.not.be.null
+ expect(cachedChunkB.getStartVersion()).to.equal(60)
+ expect(cachedChunkB.getEndVersion()).to.equal(62)
+ expect(cachedChunkB.history.changes.length).to.equal(2)
+ expect(cachedChunkB).to.deep.equal(chunkB)
+
+ // Verify the metadata was updated
+ const updatedMetadata =
+ await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(updatedMetadata.startVersion).to.equal(60)
+ expect(updatedMetadata.changesCount).to.equal(2)
+ })
+ })
+
+ describe('checkCacheValidity', function () {
+ it('should return true when versions match', function () {
+ const snapshotA = new Snapshot()
+ const historyA = new History(snapshotA, [])
+ const chunkA = new Chunk(historyA, 10)
+ chunkA.pushChanges([
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello'))],
+ new Date(),
+ []
+ ),
+ ])
+
+ const snapshotB = new Snapshot()
+ const historyB = new History(snapshotB, [])
+ const chunkB = new Chunk(historyB, 10)
+ chunkB.pushChanges([
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello'))],
+ new Date(),
+ []
+ ),
+ ])
+
+ const isValid = redisBackend.checkCacheValidity(chunkA, chunkB)
+ expect(isValid).to.be.true
+ })
+
+ it('should return false when start versions differ', function () {
+ const snapshotA = new Snapshot()
+ const historyA = new History(snapshotA, [])
+ const chunkA = new Chunk(historyA, 10)
+
+ const snapshotB = new Snapshot()
+ const historyB = new History(snapshotB, [])
+ const chunkB = new Chunk(historyB, 11)
+
+ const isValid = redisBackend.checkCacheValidity(chunkA, chunkB)
+ expect(isValid).to.be.false
+ })
+
+ it('should return false when end versions differ', function () {
+ const snapshotA = new Snapshot()
+ const historyA = new History(snapshotA, [])
+ const chunkA = new Chunk(historyA, 10)
+ chunkA.pushChanges([
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello'))],
+ new Date(),
+ []
+ ),
+ ])
+
+ const snapshotB = new Snapshot()
+ const historyB = new History(snapshotB, [])
+ const chunkB = new Chunk(historyB, 10)
+ chunkB.pushChanges([
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello'))],
+ new Date(),
+ []
+ ),
+ new Change(
+ [new AddFileOperation('other.tex', File.fromString('World'))],
+ new Date(),
+ []
+ ),
+ ])
+
+ const isValid = redisBackend.checkCacheValidity(chunkA, chunkB)
+ expect(isValid).to.be.false
+ })
+
+ it('should return false when cached chunk is null', function () {
+ const snapshotB = new Snapshot()
+ const historyB = new History(snapshotB, [])
+ const chunkB = new Chunk(historyB, 10)
+
+ const isValid = redisBackend.checkCacheValidity(null, chunkB)
+ expect(isValid).to.be.false
+ })
+ })
+
+ describe('compareChunks', function () {
+ it('should return true when chunks are identical', function () {
+ // Create two identical chunks
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date('2025-04-10T12:00:00Z'), // Using fixed date for consistent comparison
+ []
+ ),
+ ]
+ const history1 = new History(snapshot, changes)
+ const chunk1 = new Chunk(history1, 5)
+
+ // Create a separate but identical chunk
+ const snapshot2 = new Snapshot()
+ const changes2 = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date('2025-04-10T12:00:00Z'), // Using same fixed date
+ []
+ ),
+ ]
+ const history2 = new History(snapshot2, changes2)
+ const chunk2 = new Chunk(history2, 5)
+
+ const result = redisBackend.compareChunks(projectId, chunk1, chunk2)
+ expect(result).to.be.true
+ })
+
+ it('should return false when chunks differ', function () {
+ // Create first chunk
+ const snapshot1 = new Snapshot()
+ const changes1 = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date('2025-04-10T12:00:00Z'),
+ []
+ ),
+ ]
+ const history1 = new History(snapshot1, changes1)
+ const chunk1 = new Chunk(history1, 5)
+
+ // Create a different chunk (different content)
+ const snapshot2 = new Snapshot()
+ const changes2 = [
+ new Change(
+ [
+ new AddFileOperation(
+ 'test.tex',
+ File.fromString('Different content')
+ ),
+ ],
+ new Date('2025-04-10T12:00:00Z'),
+ []
+ ),
+ ]
+ const history2 = new History(snapshot2, changes2)
+ const chunk2 = new Chunk(history2, 5)
+
+ const result = redisBackend.compareChunks(projectId, chunk1, chunk2)
+ expect(result).to.be.false
+ })
+
+ it('should return false when one chunk is null', function () {
+ // Create a chunk
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date('2025-04-10T12:00:00Z'),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 5)
+
+ const resultWithNullCached = redisBackend.compareChunks(
+ projectId,
+ null,
+ chunk
+ )
+ expect(resultWithNullCached).to.be.false
+
+ const resultWithNullCurrent = redisBackend.compareChunks(
+ projectId,
+ chunk,
+ null
+ )
+ expect(resultWithNullCurrent).to.be.false
+ })
+
+ it('should return false when chunks have different start versions', function () {
+ // Create first chunk with start version 5
+ const snapshot1 = new Snapshot()
+ const changes1 = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date('2025-04-10T12:00:00Z'),
+ []
+ ),
+ ]
+ const history1 = new History(snapshot1, changes1)
+ const chunk1 = new Chunk(history1, 5)
+
+ // Create second chunk with identical content but different start version (10)
+ const snapshot2 = new Snapshot()
+ const changes2 = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello World'))],
+ new Date('2025-04-10T12:00:00Z'),
+ []
+ ),
+ ]
+ const history2 = new History(snapshot2, changes2)
+ const chunk2 = new Chunk(history2, 10)
+
+ const result = redisBackend.compareChunks(projectId, chunk1, chunk2)
+ expect(result).to.be.false
+ })
+ })
+
+ describe('integration with redis', function () {
+ it('should store and retrieve complex chunks correctly', async function () {
+ // Create a more complex chunk
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('file1.tex', File.fromString('Content 1'))],
+ new Date(),
+ [1234]
+ ),
+ new Change(
+ [new AddFileOperation('file2.tex', File.fromString('Content 2'))],
+ new Date(),
+ null,
+ new Origin('test-origin'),
+ ['5a296963ad5e82432674c839', null],
+ '123.4',
+ new V2DocVersions({
+ 'random-doc-id': { pathname: 'file2.tex', v: 123 },
+ })
+ ),
+ new Change(
+ [new AddFileOperation('file3.tex', File.fromString('Content 3'))],
+ new Date(),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 20)
+
+ // Cache the chunk
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Retrieve the cached chunk
+ const cachedChunk = await redisBackend.getCurrentChunk(projectId)
+
+ expect(cachedChunk.getStartVersion()).to.equal(20)
+ expect(cachedChunk.getEndVersion()).to.equal(23)
+ expect(cachedChunk).to.deep.equal(chunk)
+ expect(cachedChunk.history.changes.length).to.equal(3)
+
+ // Check that the operations were preserved correctly
+ const retrievedChanges = cachedChunk.history.changes
+ expect(retrievedChanges[0].getOperations()[0].getPathname()).to.equal(
+ 'file1.tex'
+ )
+ expect(retrievedChanges[1].getOperations()[0].getPathname()).to.equal(
+ 'file2.tex'
+ )
+ expect(retrievedChanges[2].getOperations()[0].getPathname()).to.equal(
+ 'file3.tex'
+ )
+
+ // Check that the chunk was stored correctly using the chunk metadata
+ const chunkMetadata =
+ await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(chunkMetadata).to.not.be.null
+ expect(chunkMetadata.startVersion).to.equal(20)
+ expect(chunkMetadata.changesCount).to.equal(3)
+ })
+ })
+
+ describe('getCurrentChunkIfValid', function () {
+ it('should return the chunk when versions and changes count match', async function () {
+ // Create and cache a sample chunk
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Valid content'))],
+ new Date(),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 7) // startVersion 7, endVersion 8
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Prepare chunkRecord matching the cached chunk
+ const chunkRecord = { startVersion: 7, endVersion: 8 }
+
+ // Retrieve using getCurrentChunkIfValid
+ const validChunk = await redisBackend.getCurrentChunkIfValid(
+ projectId,
+ chunkRecord
+ )
+
+ expect(validChunk).to.not.be.null
+ expect(validChunk.getStartVersion()).to.equal(7)
+ expect(validChunk.getEndVersion()).to.equal(8)
+ expect(validChunk).to.deep.equal(chunk)
+ })
+
+ it('should return null when no chunk is cached', async function () {
+ // No chunk is cached for this projectId yet
+ const chunkRecord = { startVersion: 1, endVersion: 2 }
+ const validChunk = await redisBackend.getCurrentChunkIfValid(
+ projectId,
+ chunkRecord
+ )
+ expect(validChunk).to.be.null
+ })
+
+ it('should return null when start version mismatches', async function () {
+ // Cache a chunk with startVersion 10
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Content'))],
+ new Date(),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 10) // startVersion 10, endVersion 11
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Attempt to retrieve with a different startVersion
+ const chunkRecord = { startVersion: 9, endVersion: 10 } // Incorrect startVersion
+ const validChunk = await redisBackend.getCurrentChunkIfValid(
+ projectId,
+ chunkRecord
+ )
+ expect(validChunk).to.be.null
+ })
+
+ it('should return null when changes count mismatches', async function () {
+ // Cache a chunk with one change (startVersion 15, endVersion 16)
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Content'))],
+ new Date(),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 15)
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Attempt to retrieve with correct startVersion but incorrect endVersion (implying wrong changes count)
+ const chunkRecord = { startVersion: 15, endVersion: 17 } // Incorrect endVersion (implies 2 changes)
+ const validChunk = await redisBackend.getCurrentChunkIfValid(
+ projectId,
+ chunkRecord
+ )
+ expect(validChunk).to.be.null
+ })
+
+ it('should return the chunk when versions and changes count match for a zero-change chunk', async function () {
+ // Cache a chunk with zero changes
+ const snapshot = new Snapshot()
+ const changes = []
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 20) // startVersion 20, endVersion 20
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Prepare chunkRecord matching the zero-change chunk
+ const chunkRecord = { startVersion: 20, endVersion: 20 }
+
+ // Retrieve using getCurrentChunkIfValid
+ const validChunk = await redisBackend.getCurrentChunkIfValid(
+ projectId,
+ chunkRecord
+ )
+
+ expect(validChunk).to.not.be.null
+ expect(validChunk.getStartVersion()).to.equal(20)
+ expect(validChunk.getEndVersion()).to.equal(20)
+ expect(validChunk.history.changes.length).to.equal(0)
+ expect(validChunk).to.deep.equal(chunk)
+ })
+
+ it('should return null when start version matches but changes count is wrong for zero-change chunk', async function () {
+ // Cache a chunk with zero changes
+ const snapshot = new Snapshot()
+ const changes = []
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 25) // startVersion 25, endVersion 25
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Attempt to retrieve with correct startVersion but incorrect endVersion
+ const chunkRecord = { startVersion: 25, endVersion: 26 } // Incorrect endVersion (implies 1 change)
+ const validChunk = await redisBackend.getCurrentChunkIfValid(
+ projectId,
+ chunkRecord
+ )
+ expect(validChunk).to.be.null
+ })
+ })
+
+ describe('getCurrentChunkMetadata', function () {
+ it('should return metadata for a cached chunk', async function () {
+ // Cache a chunk
+ const snapshot = new Snapshot()
+ const history = new History(snapshot, [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Hello'))],
+ new Date(),
+ []
+ ),
+ new Change(
+ [new AddFileOperation('other.tex', File.fromString('Bonjour'))],
+ new Date(),
+ []
+ ),
+ ])
+ const chunk = new Chunk(history, 10)
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ const metadata = await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(metadata).to.deep.equal({ startVersion: 10, changesCount: 2 })
+ })
+
+ it('should return null if no chunk is cached for the project', async function () {
+ const metadata = await redisBackend.getCurrentChunkMetadata(
+ 'non-existent-project-id'
+ )
+ expect(metadata).to.be.null
+ })
+
+ it('should return metadata with zero changes for a zero-change chunk', async function () {
+ // Cache a chunk with no changes
+ const snapshot = new Snapshot()
+ const history = new History(snapshot, [])
+ const chunk = new Chunk(history, 5)
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ const metadata = await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(metadata).to.deep.equal({ startVersion: 5, changesCount: 0 })
+ })
+ })
+
+ describe('expireCurrentChunk', function () {
+ const TEMPORARY_CACHE_LIFETIME_MS = 300 * 1000 // Match the value in redis.js
+
+ it('should return false and not expire a non-expired chunk', async function () {
+ // Cache a chunk
+ const snapshot = new Snapshot()
+ const history = new History(snapshot, [])
+ const chunk = new Chunk(history, 10)
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Attempt to expire immediately (should not be expired yet)
+ const expired = await redisBackend.expireCurrentChunk(projectId)
+ expect(expired).to.be.false
+
+ // Verify the chunk still exists
+ const cachedChunk = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunk).to.not.be.null
+ expect(cachedChunk.getStartVersion()).to.equal(10)
+ })
+
+ it('should return true and expire an expired chunk using currentTime', async function () {
+ // Cache a chunk
+ const snapshot = new Snapshot()
+ const history = new History(snapshot, [])
+ const chunk = new Chunk(history, 10)
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Calculate a time far enough in the future to ensure expiry
+ const futureTime = Date.now() + TEMPORARY_CACHE_LIFETIME_MS + 5000 // 5 seconds past expiry
+
+ // Attempt to expire using the future time
+ const expired = await redisBackend.expireCurrentChunk(
+ projectId,
+ futureTime
+ )
+ expect(expired).to.be.true
+
+ // Verify the chunk is gone
+ const cachedChunk = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunk).to.be.null
+
+ // Verify metadata is also gone
+ const metadata = await redisBackend.getCurrentChunkMetadata(projectId)
+ expect(metadata).to.be.null
+ })
+
+ it('should return false if no chunk is cached for the project', async function () {
+ const expired = await redisBackend.expireCurrentChunk(
+ 'non-existent-project'
+ )
+ expect(expired).to.be.false
+ })
+
+ it('should return false if called with a currentTime before the expiry time', async function () {
+ // Cache a chunk
+ const snapshot = new Snapshot()
+ const history = new History(snapshot, [])
+ const chunk = new Chunk(history, 10)
+ await redisBackend.setCurrentChunk(projectId, chunk)
+
+ // Use a time *before* the cache would normally expire
+ const pastTime = Date.now() - 10000 // 10 seconds ago
+
+ // Attempt to expire using the past time
+ const expired = await redisBackend.expireCurrentChunk(projectId, pastTime)
+ expect(expired).to.be.false
+
+ // Verify the chunk still exists
+ const cachedChunk = await redisBackend.getCurrentChunk(projectId)
+ expect(cachedChunk).to.not.be.null
+ })
+ })
+
+ describe('with a persist-time timestamp', function () {
+ const persistTimestamp = Date.now() + 1000 * 60 * 60 // 1 hour in the future
+
+ beforeEach(async function () {
+ // Ensure a chunk exists before each test in this block
+ const snapshot = new Snapshot()
+ const changes = [
+ new Change(
+ [new AddFileOperation('test.tex', File.fromString('Persist Test'))],
+ new Date(),
+ []
+ ),
+ ]
+ const history = new History(snapshot, changes)
+ const chunk = new Chunk(history, 100)
+ await redisBackend.setCurrentChunk(projectId, chunk)
+ })
+
+ it('should not clear a chunk if persist-time is set', async function () {
+ // Set persist time
+ await redisBackend.setPersistTime(projectId, persistTimestamp)
+
+ // Attempt to clear the cache
+ const cleared = await redisBackend.clearCache(projectId)
+ expect(cleared).to.be.false // Expect clearCache to return false
+
+ // Verify the chunk still exists
+ const chunk = await redisBackend.getCurrentChunk(projectId)
+ expect(chunk).to.not.be.null
+ expect(chunk.getStartVersion()).to.equal(100)
+ })
+
+ it('should not expire a chunk if persist-time is set, even if expire-time has passed', async function () {
+ // Set persist time
+ await redisBackend.setPersistTime(projectId, persistTimestamp)
+
+ // Attempt to expire the chunk with a time far in the future
+ const farFutureTime = Date.now() + 1000 * 60 * 60 * 24 // 24 hours in the future
+ const expired = await redisBackend.expireCurrentChunk(
+ projectId,
+ farFutureTime
+ )
+ expect(expired).to.be.false // Expect expireCurrentChunk to return false
+
+ // Verify the chunk still exists
+ const chunk = await redisBackend.getCurrentChunk(projectId)
+ expect(chunk).to.not.be.null
+ expect(chunk.getStartVersion()).to.equal(100)
+ })
+
+ it('getCurrentChunkStatus should return persist-time when set', async function () {
+ // Set persist time
+ await redisBackend.setPersistTime(projectId, persistTimestamp)
+
+ const status = await redisBackend.getCurrentChunkStatus(projectId)
+ expect(status.persistTime).to.equal(persistTimestamp)
+ expect(status.expireTime).to.be.a('number') // expireTime is set by setCurrentChunk
+ })
+
+ it('getCurrentChunkStatus should return null for persist-time when not set', async function () {
+ const status = await redisBackend.getCurrentChunkStatus(projectId)
+ expect(status.persistTime).to.be.null
+ expect(status.expireTime).to.be.a('number')
+ })
+
+ it('getCurrentChunkStatus should return nulls after cache is cleared (without persist-time)', async function () {
+ // Clear cache (persistTime is not set here)
+ await redisBackend.clearCache(projectId)
+
+ const status = await redisBackend.getCurrentChunkStatus(projectId)
+ expect(status.persistTime).to.be.null
+ expect(status.expireTime).to.be.null
+ })
+ })
+})
diff --git a/services/history-v1/test/acceptance/js/storage/fixtures/chunks.js b/services/history-v1/test/acceptance/js/storage/fixtures/chunks.js
index 8f67c17d71..0fb50e49e9 100644
--- a/services/history-v1/test/acceptance/js/storage/fixtures/chunks.js
+++ b/services/history-v1/test/acceptance/js/storage/fixtures/chunks.js
@@ -15,7 +15,7 @@ exports.chunks = {
exports.histories = {
chunkOne: {
projectId: DocFixtures.initializedProject.id,
- chunkId: 1000000,
+ chunkId: '1000000',
json: { snapshot: { files: {} }, changes: [] },
},
}
diff --git a/services/history-v1/test/acceptance/js/storage/support/cleanup.js b/services/history-v1/test/acceptance/js/storage/support/cleanup.js
index fd0fcbbcb6..55829bef13 100644
--- a/services/history-v1/test/acceptance/js/storage/support/cleanup.js
+++ b/services/history-v1/test/acceptance/js/storage/support/cleanup.js
@@ -1,6 +1,6 @@
const config = require('config')
-const { knex, persistor, mongodb } = require('../../../../../storage')
+const { knex, persistor, mongodb, redis } = require('../../../../../storage')
const { S3Persistor } = require('@overleaf/object-persistor/src/S3Persistor')
const POSTGRES_TABLES = [
@@ -43,6 +43,11 @@ async function cleanupMongo() {
}
}
+async function cleanupRedis() {
+ await redis.rclientHistory.flushdb()
+ await redis.rclientLock.flushdb()
+}
+
async function cleanupPersistor() {
await Promise.all([
clearBucket(config.get('blobStore.globalBucket')),
@@ -82,6 +87,7 @@ async function cleanupEverything() {
cleanupMongo(),
cleanupPersistor(),
cleanupBackup(),
+ cleanupRedis(),
])
}
@@ -90,5 +96,6 @@ module.exports = {
mongo: cleanupMongo,
persistor: cleanupPersistor,
backup: cleanupBackup,
+ redis: cleanupRedis,
everything: cleanupEverything,
}
diff --git a/services/history-v1/test/acceptance/js/storage/tasks.test.js b/services/history-v1/test/acceptance/js/storage/tasks.test.js
index 04f9cd12c3..e43bdac79f 100644
--- a/services/history-v1/test/acceptance/js/storage/tasks.test.js
+++ b/services/history-v1/test/acceptance/js/storage/tasks.test.js
@@ -76,9 +76,13 @@ describe('tasks', function () {
await mongodb.chunks.insertMany(mongoChunks)
await Promise.all([
...postgresChunks.map(chunk =>
- historyStore.storeRaw(postgresProjectId.toString(), chunk.chunk_id, {
- history: 'raw history',
- })
+ historyStore.storeRaw(
+ postgresProjectId.toString(),
+ chunk.chunk_id.toString(),
+ {
+ history: 'raw history',
+ }
+ )
),
...mongoChunks.map(chunk =>
historyStore.storeRaw(mongoProjectId.toString(), chunk._id.toString(), {
diff --git a/services/history-v1/test/setup.js b/services/history-v1/test/setup.js
index 38c1b283ad..60974173de 100644
--- a/services/history-v1/test/setup.js
+++ b/services/history-v1/test/setup.js
@@ -2,12 +2,13 @@ const chai = require('chai')
const chaiAsPromised = require('chai-as-promised')
const config = require('config')
const fetch = require('node-fetch')
-const { knex, mongodb } = require('../storage')
+const { knex, mongodb, redis } = require('../storage')
// ensure every ObjectId has the id string as a property for correct comparisons
require('mongodb').ObjectId.cacheHexString = true
chai.use(chaiAsPromised)
+chai.config.truncateThreshold = 0
async function setupPostgresDatabase() {
this.timeout(60_000)
@@ -20,7 +21,7 @@ async function setupMongoDatabase() {
{
key: { projectId: 1, startVersion: 1 },
name: 'projectId_1_startVersion_1',
- partialFilterExpression: { state: 'active' },
+ partialFilterExpression: { state: { $in: ['active', 'closed'] } },
unique: true,
},
{
@@ -52,6 +53,7 @@ async function createGcsBuckets() {
// can exit.
async function tearDownConnectionPool() {
await knex.destroy()
+ await redis.disconnect()
}
module.exports = {
diff --git a/services/notifications/buildscript.txt b/services/notifications/buildscript.txt
index 10e6804ead..c52e316ffe 100644
--- a/services/notifications/buildscript.txt
+++ b/services/notifications/buildscript.txt
@@ -6,4 +6,4 @@ notifications
--esmock-loader=False
--node-version=20.18.2
--public-repo=True
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/services/project-history/app/js/HttpController.js b/services/project-history/app/js/HttpController.js
index 766fb4a414..927248726f 100644
--- a/services/project-history/app/js/HttpController.js
+++ b/services/project-history/app/js/HttpController.js
@@ -238,22 +238,6 @@ export function getFileMetadataSnapshot(req, res, next) {
)
}
-export function getMostRecentChunk(req, res, next) {
- const { project_id: projectId } = req.params
- WebApiManager.getHistoryId(projectId, (error, historyId) => {
- if (error) return next(OError.tag(error))
-
- HistoryStoreManager.getMostRecentChunk(
- projectId,
- historyId,
- (err, data) => {
- if (err) return next(OError.tag(err))
- res.json(data)
- }
- )
- })
-}
-
export function getLatestSnapshot(req, res, next) {
const { project_id: projectId } = req.params
WebApiManager.getHistoryId(projectId, (error, historyId) => {
@@ -272,25 +256,6 @@ export function getLatestSnapshot(req, res, next) {
})
}
-export function getChangesSince(req, res, next) {
- const { project_id: projectId } = req.params
- const { since } = req.query
- WebApiManager.getHistoryId(projectId, (error, historyId) => {
- if (error) return next(OError.tag(error))
- SnapshotManager.getChangesSince(
- projectId,
- historyId,
- since,
- (error, changes) => {
- if (error != null) {
- return next(error)
- }
- res.json(changes.map(c => c.toRaw()))
- }
- )
- })
-}
-
export function getChangesInChunkSince(req, res, next) {
const { project_id: projectId } = req.params
const { since } = req.query
diff --git a/services/project-history/app/js/Router.js b/services/project-history/app/js/Router.js
index d7233a511b..ec9a4f0582 100644
--- a/services/project-history/app/js/Router.js
+++ b/services/project-history/app/js/Router.js
@@ -22,10 +22,6 @@ export function initialize(app) {
app.delete('/project/:project_id', HttpController.deleteProject)
app.get('/project/:project_id/snapshot', HttpController.getLatestSnapshot)
- app.get(
- '/project/:project_id/latest/history',
- HttpController.getMostRecentChunk
- )
app.get(
'/project/:project_id/diff',
@@ -61,16 +57,6 @@ export function initialize(app) {
HttpController.getUpdates
)
- app.get(
- '/project/:project_id/changes',
- validate({
- query: {
- since: Joi.number().integer().min(0),
- },
- }),
- HttpController.getChangesSince
- )
-
app.get(
'/project/:project_id/changes-in-chunk',
validate({
diff --git a/services/project-history/app/js/SnapshotManager.js b/services/project-history/app/js/SnapshotManager.js
index e735fd334b..ed316743cf 100644
--- a/services/project-history/app/js/SnapshotManager.js
+++ b/services/project-history/app/js/SnapshotManager.js
@@ -341,44 +341,6 @@ function getLatestSnapshotFromChunk(data) {
}
}
-async function getChangesSince(projectId, historyId, sinceVersion) {
- const allChanges = []
- let nextVersion
- while (true) {
- let data
- if (nextVersion) {
- data = await HistoryStoreManager.promises.getChunkAtVersion(
- projectId,
- historyId,
- nextVersion
- )
- } else {
- data = await HistoryStoreManager.promises.getMostRecentChunk(
- projectId,
- historyId
- )
- }
- if (data == null || data.chunk == null) {
- throw new OError('undefined chunk')
- }
- const chunk = Core.Chunk.fromRaw(data.chunk)
- if (sinceVersion > chunk.getEndVersion()) {
- throw new OError('requested version past the end')
- }
- const changes = chunk.getChanges()
- if (chunk.getStartVersion() > sinceVersion) {
- allChanges.unshift(...changes)
- nextVersion = chunk.getStartVersion()
- } else {
- allChanges.unshift(
- ...changes.slice(sinceVersion - chunk.getStartVersion())
- )
- break
- }
- }
- return allChanges
-}
-
async function getChangesInChunkSince(projectId, historyId, sinceVersion) {
const latestChunk = Core.Chunk.fromRaw(
(
@@ -426,7 +388,6 @@ async function _loadFilesLimit(snapshot, kind, blobStore) {
// EXPORTS
-const getChangesSinceCb = callbackify(getChangesSince)
const getChangesInChunkSinceCb = callbackify(getChangesInChunkSince)
const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
const getProjectSnapshotCb = callbackify(getProjectSnapshot)
@@ -441,7 +402,6 @@ const getPathsAtVersionCb = callbackify(getPathsAtVersion)
export {
getLatestSnapshotFromChunk,
- getChangesSinceCb as getChangesSince,
getChangesInChunkSinceCb as getChangesInChunkSince,
getFileSnapshotStreamCb as getFileSnapshotStream,
getProjectSnapshotCb as getProjectSnapshot,
@@ -454,7 +414,6 @@ export {
}
export const promises = {
- getChangesSince,
getChangesInChunkSince,
getFileSnapshotStream,
getProjectSnapshot,
diff --git a/services/project-history/buildscript.txt b/services/project-history/buildscript.txt
index 89ed021aa3..be5e751759 100644
--- a/services/project-history/buildscript.txt
+++ b/services/project-history/buildscript.txt
@@ -6,4 +6,4 @@ project-history
--esmock-loader=True
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml
index bdf10c9732..6deaad433d 100644
--- a/services/project-history/docker-compose.ci.yml
+++ b/services/project-history/docker-compose.ci.yml
@@ -21,6 +21,7 @@ services:
ELASTIC_SEARCH_DSN: es:9200
REDIS_HOST: redis
QUEUES_REDIS_HOST: redis
+ HISTORY_REDIS_HOST: redis
ANALYTICS_QUEUES_REDIS_HOST: redis
MONGO_HOST: mongo
POSTGRES_HOST: postgres
diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml
index 39c7ed9009..deed9c5033 100644
--- a/services/project-history/docker-compose.yml
+++ b/services/project-history/docker-compose.yml
@@ -30,6 +30,7 @@ services:
environment:
ELASTIC_SEARCH_DSN: es:9200
REDIS_HOST: redis
+ HISTORY_REDIS_HOST: redis
QUEUES_REDIS_HOST: redis
ANALYTICS_QUEUES_REDIS_HOST: redis
MONGO_HOST: mongo
diff --git a/services/project-history/test/acceptance/js/GetChangesSince.js b/services/project-history/test/acceptance/js/GetChangesSince.js
deleted file mode 100644
index 559432fc73..0000000000
--- a/services/project-history/test/acceptance/js/GetChangesSince.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import { expect } from 'chai'
-import mongodb from 'mongodb-legacy'
-import nock from 'nock'
-import Core from 'overleaf-editor-core'
-import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
-import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
-import latestChunk from '../fixtures/chunks/7-8.json' with { type: 'json' }
-import previousChunk from '../fixtures/chunks/4-6.json' with { type: 'json' }
-import firstChunk from '../fixtures/chunks/0-3.json' with { type: 'json' }
-const { ObjectId } = mongodb
-
-const MockHistoryStore = () => nock('http://127.0.0.1:3100')
-const MockWeb = () => nock('http://127.0.0.1:3000')
-
-const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
-
-describe('GetChangesSince', function () {
- let projectId, historyId
- beforeEach(function (done) {
- projectId = new ObjectId().toString()
- historyId = new ObjectId().toString()
- ProjectHistoryApp.ensureRunning(error => {
- if (error) throw error
-
- MockHistoryStore().post('/api/projects').reply(200, {
- projectId: historyId,
- })
-
- ProjectHistoryClient.initializeProject(historyId, (error, olProject) => {
- if (error) throw error
- MockWeb()
- .get(`/project/${projectId}/details`)
- .reply(200, {
- name: 'Test Project',
- overleaf: { history: { id: olProject.id } },
- })
-
- MockHistoryStore()
- .get(`/api/projects/${historyId}/latest/history`)
- .replyWithFile(200, fixture('chunks/7-8.json'))
- MockHistoryStore()
- .get(`/api/projects/${historyId}/versions/6/history`)
- .replyWithFile(200, fixture('chunks/4-6.json'))
- MockHistoryStore()
- .get(`/api/projects/${historyId}/versions/3/history`)
- .replyWithFile(200, fixture('chunks/0-3.json'))
-
- done()
- })
- })
- })
-
- afterEach(function () {
- nock.cleanAll()
- })
-
- function expectChangesSince(version, changes, done) {
- ProjectHistoryClient.getChangesSince(
- projectId,
- version,
- {},
- (error, got) => {
- if (error) throw error
- expect(got.map(c => Core.Change.fromRaw(c))).to.deep.equal(
- changes.map(c => Core.Change.fromRaw(c))
- )
- done()
- }
- )
- }
-
- it('should return zero changes since the latest version', function (done) {
- expectChangesSince(8, [], done)
- })
-
- it('should return one change when behind one version', function (done) {
- expectChangesSince(7, [latestChunk.chunk.history.changes[1]], done)
- })
-
- it('should return changes when at the chunk boundary', function (done) {
- expect(latestChunk.chunk.startVersion).to.equal(6)
- expectChangesSince(6, latestChunk.chunk.history.changes, done)
- })
-
- it('should return changes spanning multiple chunks', function (done) {
- expectChangesSince(
- 1,
- [
- ...firstChunk.chunk.history.changes.slice(1),
- ...previousChunk.chunk.history.changes,
- ...latestChunk.chunk.history.changes,
- ],
- done
- )
- })
-
- it('should return all changes when going back to the beginning', function (done) {
- expectChangesSince(
- 0,
- [
- ...firstChunk.chunk.history.changes,
- ...previousChunk.chunk.history.changes,
- ...latestChunk.chunk.history.changes,
- ],
- done
- )
- })
-
- it('should return an error when past the end version', function (done) {
- ProjectHistoryClient.getChangesSince(
- projectId,
- 9,
- { allowErrors: true },
- (error, body, statusCode) => {
- if (error) throw error
- expect(statusCode).to.equal(500)
- expect(body).to.deep.equal({ message: 'an internal error occurred' })
- done()
- }
- )
- })
-})
diff --git a/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js b/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js
index 7a30b27740..92caa4bd0e 100644
--- a/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js
+++ b/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js
@@ -108,25 +108,6 @@ export function getFileTreeDiff(projectId, from, to, callback) {
)
}
-export function getChangesSince(projectId, since, options, callback) {
- request.get(
- {
- url: `http://127.0.0.1:3054/project/${projectId}/changes`,
- qs: {
- since,
- },
- json: true,
- },
- (error, res, body) => {
- if (error) return callback(error)
- if (!options.allowErrors) {
- expect(res.statusCode).to.equal(200)
- }
- callback(null, body, res.statusCode)
- }
- )
-}
-
export function getChangesInChunkSince(projectId, since, options, callback) {
request.get(
{
diff --git a/services/real-time/app.js b/services/real-time/app.js
index 85b469bf90..38cb3caec4 100644
--- a/services/real-time/app.js
+++ b/services/real-time/app.js
@@ -83,6 +83,39 @@ io.configure(function () {
io.set('match origin protocol', true)
io.set('transports', ['websocket', 'xhr-polling'])
+
+ if (Settings.allowedCorsOrigins) {
+ // Create a regex for matching origins, allowing wildcard subdomains
+ const allowedCorsOriginsRegex = new RegExp(
+ `^${Settings.allowedCorsOrigins.replaceAll('.', '\\.').replace('://*', '://[^.]+')}(?::443)?$`
+ )
+
+ io.set('origins', function (origin, req) {
+ const normalizedOrigin = URL.parse(origin).origin
+ const originIsValid = allowedCorsOriginsRegex.test(normalizedOrigin)
+
+ if (req.headers.origin) {
+ if (!originIsValid) {
+ logger.warn(
+ { normalizedOrigin, origin, req },
+ 'Origin header does not match allowed origins'
+ )
+ }
+ return originIsValid
+ }
+
+ if (!originIsValid) {
+ // There is no Origin header and the Referrer does not satisfy the
+ // constraints. We're going to pass this anyway for now but log it
+ logger.warn(
+ { req, referer: req.headers.referer },
+ 'Referrer header does not match allowed origins'
+ )
+ }
+
+ return true
+ })
+ }
})
// Serve socket.io.js client file from imported dist folder
diff --git a/services/real-time/buildscript.txt b/services/real-time/buildscript.txt
index 79980f013f..292fde8b4c 100644
--- a/services/real-time/buildscript.txt
+++ b/services/real-time/buildscript.txt
@@ -6,4 +6,4 @@ real-time
--esmock-loader=False
--node-version=20.18.2
--public-repo=False
---script-version=4.5.0
+--script-version=4.7.0
diff --git a/services/real-time/config/settings.defaults.js b/services/real-time/config/settings.defaults.js
index 96c116fb2e..57b0a50a42 100644
--- a/services/real-time/config/settings.defaults.js
+++ b/services/real-time/config/settings.defaults.js
@@ -173,6 +173,7 @@ const settings = {
behindProxy: process.env.BEHIND_PROXY === 'true',
trustedProxyIps: process.env.TRUSTED_PROXY_IPS,
keepAliveTimeoutMs: parseInt(process.env.KEEPALIVE_TIMEOUT_MS ?? '5000', 10),
+ allowedCorsOrigins: process.env.REAL_TIME_ALLOWED_CORS_ORIGINS,
}
// console.log settings.redis
diff --git a/services/real-time/docker-compose.ci.yml b/services/real-time/docker-compose.ci.yml
index ca9813adad..9011627c06 100644
--- a/services/real-time/docker-compose.ci.yml
+++ b/services/real-time/docker-compose.ci.yml
@@ -21,6 +21,7 @@ services:
ELASTIC_SEARCH_DSN: es:9200
REDIS_HOST: redis
QUEUES_REDIS_HOST: redis
+ HISTORY_REDIS_HOST: redis
ANALYTICS_QUEUES_REDIS_HOST: redis
MONGO_HOST: mongo
POSTGRES_HOST: postgres
diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml
index 5ff5afe513..d40fada758 100644
--- a/services/real-time/docker-compose.yml
+++ b/services/real-time/docker-compose.yml
@@ -30,6 +30,7 @@ services:
environment:
ELASTIC_SEARCH_DSN: es:9200
REDIS_HOST: redis
+ HISTORY_REDIS_HOST: redis
QUEUES_REDIS_HOST: redis
ANALYTICS_QUEUES_REDIS_HOST: redis
MONGO_HOST: mongo
diff --git a/services/real-time/package.json b/services/real-time/package.json
index 33c75ecf9f..2d5f87a109 100644
--- a/services/real-time/package.json
+++ b/services/real-time/package.json
@@ -34,7 +34,7 @@
"lodash": "^4.17.21",
"proxy-addr": "^2.0.7",
"request": "^2.88.2",
- "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-10",
+ "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-11",
"socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5"
},
"devDependencies": {
diff --git a/services/real-time/test/unit/js/ConnectedUsersManagerTests.js b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js
index dd4aeb35c9..a6864075e0 100644
--- a/services/real-time/test/unit/js/ConnectedUsersManagerTests.js
+++ b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js
@@ -20,6 +20,7 @@ const tk = require('timekeeper')
describe('ConnectedUsersManager', function () {
beforeEach(function () {
+ tk.freeze(new Date())
this.settings = {
redis: {
realtime: {
@@ -56,7 +57,6 @@ describe('ConnectedUsersManager', function () {
return this.rClient
},
}
- tk.freeze(new Date())
this.Metrics = {
inc: sinon.stub(),
histogram: sinon.stub(),
diff --git a/services/web/.storybook/preview.tsx b/services/web/.storybook/preview.tsx
index ed67abc3c4..e3838a6f97 100644
--- a/services/web/.storybook/preview.tsx
+++ b/services/web/.storybook/preview.tsx
@@ -9,14 +9,10 @@ import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
// @ts-ignore
import en from '../../../services/web/locales/en.json'
-import { bootstrapVersionArg } from './utils/with-bootstrap-switcher'
-function resetMeta(bootstrapVersion?: 3 | 5) {
+function resetMeta() {
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' })
- if (bootstrapVersion) {
- window.metaAttributesCache.set('ol-bootstrapVersion', bootstrapVersion)
- }
window.metaAttributesCache.set('ol-ExposedSettings', {
adminEmail: 'placeholder@example.com',
appName: 'Overleaf',
@@ -126,8 +122,6 @@ const preview: Preview = {
// render stories in iframes, to isolate modals
inlineStories: false,
},
- // Default to Bootstrap 5 styles
- bootstrap5: true,
},
globalTypes: {
theme: {
@@ -144,17 +138,9 @@ const preview: Preview = {
},
},
loaders: [
- async ({ globals }) => {
- const { theme } = globals
-
+ async () => {
return {
- // NOTE: this uses `${theme}style.less` rather than `${theme}.less`
- // so that webpack only bundles files ending with "style.less"
- bootstrap3Style: await import(
- `!!to-string-loader!css-loader!less-loader!../../../services/web/frontend/stylesheets/${theme}style.less`
- ),
- // Themes are applied differently in Bootstrap 5 code
- bootstrap5Style: await import(
+ mainStyle: await import(
// @ts-ignore
`!!to-string-loader!css-loader!resolve-url-loader!sass-loader!../../../services/web/frontend/stylesheets/bootstrap-5/main-style.scss`
),
@@ -163,15 +149,9 @@ const preview: Preview = {
],
decorators: [
(Story, context) => {
- const { bootstrap3Style, bootstrap5Style } = context.loaded
- const bootstrapVersion = Number(
- context.args[bootstrapVersionArg] ||
- (context.parameters.bootstrap5 === false ? 3 : 5)
- ) as 3 | 5
- const activeStyle =
- bootstrapVersion === 3 ? bootstrap3Style : bootstrap5Style
+ const { mainStyle } = context.loaded
- resetMeta(bootstrapVersion)
+ resetMeta()
return (
- {activeStyle && }
-
+ {mainStyle && }
+
)
},
diff --git a/services/web/.storybook/utils/with-bootstrap-switcher.tsx b/services/web/.storybook/utils/with-bootstrap-switcher.tsx
deleted file mode 100644
index 640338dbb0..0000000000
--- a/services/web/.storybook/utils/with-bootstrap-switcher.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Meta } from '@storybook/react'
-
-export const bootstrapVersionArg = 'bootstrapVersion'
-
-export const bsVersionDecorator: Meta = {
- argTypes: {
- [bootstrapVersionArg]: {
- name: 'Bootstrap Version',
- description: 'Bootstrap version for components',
- control: { type: 'inline-radio' },
- options: ['3', '5'],
- table: {
- defaultValue: { summary: '5' },
- },
- },
- },
- args: {
- [bootstrapVersionArg]: '5',
- },
-}
diff --git a/services/web/app/src/Features/Authentication/AuthenticationManager.js b/services/web/app/src/Features/Authentication/AuthenticationManager.js
index 33827f1673..6ac510986c 100644
--- a/services/web/app/src/Features/Authentication/AuthenticationManager.js
+++ b/services/web/app/src/Features/Authentication/AuthenticationManager.js
@@ -409,10 +409,6 @@ const AuthenticationManager = {
if (!_exceedsMaximumLengthRatio(password, MAX_SIMILARITY, emailPart)) {
const similarity = DiffHelper.stringSimilarity(password, emailPart)
if (similarity > MAX_SIMILARITY) {
- logger.warn(
- { email, emailPart, similarity, maxSimilarity: MAX_SIMILARITY },
- 'Password too similar to email'
- )
return new Error('password is too similar to email')
}
}
diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js
index 05137a97f8..96b4cd6e37 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js
@@ -122,9 +122,26 @@ async function removeUserFromAllProjects(userId) {
.concat(readOnly)
.concat(tokenReadAndWrite)
.concat(tokenReadOnly)
+ logger.info(
+ {
+ userId,
+ readAndWriteCount: readAndWrite.length,
+ readOnlyCount: readOnly.length,
+ tokenReadAndWriteCount: tokenReadAndWrite.length,
+ tokenReadOnlyCount: tokenReadOnly.length,
+ },
+ 'removing user from projects'
+ )
for (const project of allProjects) {
await removeUserFromProject(project._id, userId)
}
+ logger.info(
+ {
+ userId,
+ allProjectsCount: allProjects.length,
+ },
+ 'removed user from all projects'
+ )
}
async function addUserIdToProject(
diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
index c6ffba1ea5..4c2d911709 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
@@ -16,6 +16,7 @@ import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
import Errors from '../Errors/Errors.js'
import AuthenticationController from '../Authentication/AuthenticationController.js'
import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
// This rate limiter allows a different number of requests depending on the
// number of callaborators a user is allowed. This is implemented by providing
@@ -244,6 +245,10 @@ async function generateNewInvite(req, res) {
async function viewInvite(req, res) {
const projectId = req.params.Project_id
const { token } = req.params
+
+ // Read split test assignment so that it's available for Pug to read
+ await SplitTestHandler.promises.getAssignment(req, res, 'core-pug-bs5')
+
const _renderInvalidPage = function () {
res.status(404)
logger.debug({ projectId }, 'invite not valid, rendering not-valid page')
diff --git a/services/web/app/src/Features/Compile/ClsiCacheController.js b/services/web/app/src/Features/Compile/ClsiCacheController.js
new file mode 100644
index 0000000000..9795fd3ef2
--- /dev/null
+++ b/services/web/app/src/Features/Compile/ClsiCacheController.js
@@ -0,0 +1,193 @@
+const { NotFoundError } = require('../Errors/Errors')
+const {
+ fetchStreamWithResponse,
+ RequestFailedError,
+ fetchJson,
+} = require('@overleaf/fetch-utils')
+const Path = require('path')
+const { pipeline } = require('stream/promises')
+const logger = require('@overleaf/logger')
+const ClsiCacheManager = require('./ClsiCacheManager')
+const CompileController = require('./CompileController')
+const { expressify } = require('@overleaf/promise-utils')
+const ClsiCacheHandler = require('./ClsiCacheHandler')
+const ProjectGetter = require('../Project/ProjectGetter')
+
+/**
+ * Download a file from a specific build on the clsi-cache.
+ *
+ * @param req
+ * @param res
+ * @return {Promise<*>}
+ */
+async function downloadFromCache(req, res) {
+ const { Project_id: projectId, buildId, filename } = req.params
+ const userId = CompileController._getUserIdForCompile(req)
+ const signal = AbortSignal.timeout(60 * 1000)
+ let location, projectName
+ try {
+ ;[{ location }, { name: projectName }] = await Promise.all([
+ ClsiCacheHandler.getOutputFile(
+ projectId,
+ userId,
+ buildId,
+ filename,
+ signal
+ ),
+ ProjectGetter.promises.getProject(projectId, { name: 1 }),
+ ])
+ } catch (err) {
+ if (err instanceof NotFoundError) {
+ // res.sendStatus() sends a description of the status as body.
+ // Using res.status().end() avoids sending that fake body.
+ return res.status(404).end()
+ } else {
+ throw err
+ }
+ }
+
+ const { stream, response } = await fetchStreamWithResponse(location, {
+ signal,
+ })
+ if (req.destroyed) {
+ // The client has disconnected already, avoid trying to write into the broken connection.
+ return
+ }
+
+ for (const key of ['Content-Length', 'Content-Type']) {
+ if (response.headers.has(key)) res.setHeader(key, response.headers.get(key))
+ }
+ const ext = Path.extname(filename)
+ res.attachment(
+ ext === '.pdf'
+ ? `${CompileController._getSafeProjectName({ name: projectName })}.pdf`
+ : filename
+ )
+ try {
+ res.writeHead(response.status)
+ await pipeline(stream, res)
+ } catch (err) {
+ const reqAborted = Boolean(req.destroyed)
+ const streamingStarted = Boolean(res.headersSent)
+ if (!streamingStarted) {
+ if (err instanceof RequestFailedError) {
+ res.sendStatus(err.response.status)
+ } else {
+ res.sendStatus(500)
+ }
+ }
+ if (
+ streamingStarted &&
+ reqAborted &&
+ err.code === 'ERR_STREAM_PREMATURE_CLOSE'
+ ) {
+ // Ignore noisy spurious error
+ return
+ }
+ logger.warn(
+ {
+ err,
+ projectId,
+ location,
+ filename,
+ reqAborted,
+ streamingStarted,
+ },
+ 'CLSI-cache proxy error'
+ )
+ }
+}
+
+/**
+ * Prepare a compile response from the clsi-cache.
+ *
+ * @param req
+ * @param res
+ * @return {Promise}
+ */
+async function getLatestBuildFromCache(req, res) {
+ const { Project_id: projectId } = req.params
+ const userId = CompileController._getUserIdForCompile(req)
+ try {
+ const {
+ internal: { location: metaLocation, zone },
+ external: { isUpToDate, allFiles },
+ } = await ClsiCacheManager.getLatestBuildFromCache(
+ projectId,
+ userId,
+ 'output.overleaf.json'
+ )
+
+ if (!isUpToDate) return res.sendStatus(410)
+
+ const meta = await fetchJson(metaLocation, {
+ signal: AbortSignal.timeout(5 * 1000),
+ })
+
+ const [, editorId, buildId] = metaLocation.match(
+ /\/build\/([a-f0-9-]+?)-([a-f0-9]+-[a-f0-9]+)\//
+ )
+
+ let baseURL = `/project/${projectId}`
+ if (userId) {
+ baseURL += `/user/${userId}`
+ }
+
+ const { ranges, contentId, clsiServerId, compileGroup, size, options } =
+ meta
+
+ const outputFiles = allFiles
+ .filter(
+ path => path !== 'output.overleaf.json' && path !== 'output.tar.gz'
+ )
+ .map(path => {
+ const f = {
+ url: `${baseURL}/build/${editorId}-${buildId}/output/${path}`,
+ downloadURL: `/download/project/${projectId}/build/${editorId}-${buildId}/output/cached/${path}`,
+ build: buildId,
+ path,
+ type: path.split('.').pop(),
+ }
+ if (path === 'output.pdf') {
+ Object.assign(f, {
+ size,
+ editorId,
+ })
+ if (clsiServerId !== 'cache') {
+ // Enable PDF caching and attempt to download from VM first.
+ // (clsi VMs do not have the editorId in the path on disk, omit it).
+ Object.assign(f, {
+ url: `${baseURL}/build/${buildId}/output/output.pdf`,
+ ranges,
+ contentId,
+ })
+ }
+ }
+ return f
+ })
+ let { pdfCachingMinChunkSize, pdfDownloadDomain } =
+ await CompileController._getSplitTestOptions(req, res)
+ pdfDownloadDomain += `/zone/${zone}`
+ res.json({
+ fromCache: true,
+ status: 'success',
+ outputFiles,
+ compileGroup,
+ clsiServerId,
+ pdfDownloadDomain,
+ pdfCachingMinChunkSize,
+ options,
+ })
+ } catch (err) {
+ if (err instanceof NotFoundError) {
+ res.sendStatus(404)
+ } else {
+ throw err
+ }
+ }
+}
+
+module.exports = {
+ downloadFromCache: expressify(downloadFromCache),
+ getLatestBuildFromCache: expressify(getLatestBuildFromCache),
+}
diff --git a/services/web/app/src/Features/Compile/ClsiCacheHandler.js b/services/web/app/src/Features/Compile/ClsiCacheHandler.js
new file mode 100644
index 0000000000..54ebd9e259
--- /dev/null
+++ b/services/web/app/src/Features/Compile/ClsiCacheHandler.js
@@ -0,0 +1,218 @@
+const _ = require('lodash')
+const {
+ fetchNothing,
+ fetchRedirectWithResponse,
+ RequestFailedError,
+} = require('@overleaf/fetch-utils')
+const logger = require('@overleaf/logger')
+const Settings = require('@overleaf/settings')
+const OError = require('@overleaf/o-error')
+const { NotFoundError, InvalidNameError } = require('../Errors/Errors')
+
+function validateFilename(filename) {
+ if (
+ !(
+ [
+ 'output.blg',
+ 'output.log',
+ 'output.pdf',
+ 'output.synctex.gz',
+ 'output.overleaf.json',
+ 'output.tar.gz',
+ ].includes(filename) || filename.endsWith('.blg')
+ )
+ ) {
+ throw new InvalidNameError('bad filename')
+ }
+}
+
+/**
+ * Clear the cache on all clsi-cache instances.
+ *
+ * @param projectId
+ * @param userId
+ * @return {Promise}
+ */
+async function clearCache(projectId, userId) {
+ let path = `/project/${projectId}`
+ if (userId) {
+ path += `/user/${userId}`
+ }
+ path += '/output'
+
+ await Promise.all(
+ Settings.apis.clsiCache.instances.map(async ({ url, zone }) => {
+ const u = new URL(url)
+ u.pathname = path
+ try {
+ await fetchNothing(u, {
+ method: 'DELETE',
+ signal: AbortSignal.timeout(15_000),
+ })
+ } catch (err) {
+ throw OError.tag(err, 'clear clsi-cache', { url, zone })
+ }
+ })
+ )
+}
+
+/**
+ * Get an output file from a specific build.
+ *
+ * @param projectId
+ * @param userId
+ * @param buildId
+ * @param filename
+ * @param signal
+ * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>}
+ */
+async function getOutputFile(
+ projectId,
+ userId,
+ buildId,
+ filename,
+ signal = AbortSignal.timeout(15_000)
+) {
+ validateFilename(filename)
+ if (!/^[a-f0-9-]+$/.test(buildId)) {
+ throw new InvalidNameError('bad buildId')
+ }
+
+ let path = `/project/${projectId}`
+ if (userId) {
+ path += `/user/${userId}`
+ }
+ path += `/build/${buildId}/search/output/${filename}`
+ return getRedirectWithFallback(projectId, userId, path, signal)
+}
+
+/**
+ * Get an output file from the most recent build.
+ *
+ * @param projectId
+ * @param userId
+ * @param filename
+ * @param signal
+ * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>}
+ */
+async function getLatestOutputFile(
+ projectId,
+ userId,
+ filename,
+ signal = AbortSignal.timeout(15_000)
+) {
+ validateFilename(filename)
+
+ let path = `/project/${projectId}`
+ if (userId) {
+ path += `/user/${userId}`
+ }
+ path += `/latest/output/${filename}`
+ return getRedirectWithFallback(projectId, userId, path, signal)
+}
+
+/**
+ * Request the given path from any of the clsi-cache instances.
+ *
+ * Some of them might be down temporarily. Try the next one until we receive a redirect or 404.
+ *
+ * This function is similar to the Coordinator in the clsi-cache, notable differences:
+ * - all the logic for sorting builds is in clsi-cache (re-used by clsi and web)
+ * - fan-out (1 client performs lookup on many clsi-cache instances) is "central" in clsi-cache, resulting in better connection re-use
+ * - we only cross the k8s cluster boundary via an internal GCLB once ($$$)
+ *
+ * @param projectId
+ * @param userId
+ * @param path
+ * @param signal
+ * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>}
+ */
+async function getRedirectWithFallback(
+ projectId,
+ userId,
+ path,
+ signal = AbortSignal.timeout(15_000)
+) {
+ // Avoid hitting the same instance first all the time.
+ const instances = _.shuffle(Settings.apis.clsiCache.instances)
+ for (const { url, zone } of instances) {
+ const u = new URL(url)
+ u.pathname = path
+ try {
+ const {
+ location,
+ response: { headers },
+ } = await fetchRedirectWithResponse(u, {
+ signal,
+ })
+ // Success, return the cache entry.
+ return {
+ location,
+ zone: headers.get('X-Zone'),
+ lastModified: new Date(headers.get('X-Last-Modified')),
+ size: parseInt(headers.get('X-Content-Length'), 10),
+ allFiles: JSON.parse(headers.get('X-All-Files')),
+ }
+ } catch (err) {
+ if (err instanceof RequestFailedError && err.response.status === 404) {
+ break // No clsi-cache instance has cached something for this project/user.
+ }
+ logger.warn(
+ { err, projectId, userId, url, zone },
+ 'getLatestOutputFile from clsi-cache failed'
+ )
+ // This clsi-cache instance is down, try the next backend.
+ }
+ }
+ throw new NotFoundError('nothing cached yet')
+}
+
+/**
+ * Populate the clsi-cache for the given project/user with the provided source
+ *
+ * This is either another project, or a template (id+version).
+ *
+ * @param projectId
+ * @param userId
+ * @param sourceProjectId
+ * @param templateId
+ * @param templateVersionId
+ * @param lastUpdated
+ * @param zone
+ * @param signal
+ * @return {Promise}
+ */
+async function prepareCacheSource(
+ projectId,
+ userId,
+ { sourceProjectId, templateId, templateVersionId, lastUpdated, zone, signal }
+) {
+ const url = new URL(
+ `/project/${projectId}/user/${userId}/import-from`,
+ Settings.apis.clsiCache.instances.find(i => i.zone === zone).url
+ )
+ try {
+ await fetchNothing(url, {
+ method: 'POST',
+ json: {
+ sourceProjectId,
+ lastUpdated,
+ templateId,
+ templateVersionId,
+ },
+ signal,
+ })
+ } catch (err) {
+ if (err instanceof RequestFailedError && err.response.status === 404) {
+ throw new NotFoundError()
+ }
+ throw err
+ }
+}
+
+module.exports = {
+ clearCache,
+ getOutputFile,
+ getLatestOutputFile,
+ prepareCacheSource,
+}
diff --git a/services/web/app/src/Features/Compile/ClsiCacheManager.js b/services/web/app/src/Features/Compile/ClsiCacheManager.js
new file mode 100644
index 0000000000..3fe4b987c5
--- /dev/null
+++ b/services/web/app/src/Features/Compile/ClsiCacheManager.js
@@ -0,0 +1,111 @@
+const { NotFoundError } = require('../Errors/Errors')
+const ClsiCacheHandler = require('./ClsiCacheHandler')
+const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
+const ProjectGetter = require('../Project/ProjectGetter')
+const SplitTestHandler = require('../SplitTests/SplitTestHandler')
+const UserGetter = require('../User/UserGetter')
+
+/**
+ * Get the most recent build and metadata
+ *
+ * Internal: internal metadata; External: fine to send to user as-is.
+ *
+ * @param projectId
+ * @param userId
+ * @param filename
+ * @param signal
+ * @return {Promise<{internal: {zone: string, location: string}, external: {isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
+ */
+async function getLatestBuildFromCache(projectId, userId, filename, signal) {
+ const [
+ { location, lastModified: lastCompiled, zone, size, allFiles },
+ lastUpdatedInRedis,
+ { lastUpdated: lastUpdatedInMongo },
+ ] = await Promise.all([
+ ClsiCacheHandler.getLatestOutputFile(projectId, userId, filename, signal),
+ DocumentUpdaterHandler.promises.getProjectLastUpdatedAt(projectId),
+ ProjectGetter.promises.getProject(projectId, { lastUpdated: 1 }),
+ ])
+
+ const lastUpdated =
+ lastUpdatedInRedis > lastUpdatedInMongo
+ ? lastUpdatedInRedis
+ : lastUpdatedInMongo
+ const isUpToDate = lastCompiled >= lastUpdated
+
+ return {
+ internal: {
+ location,
+ zone,
+ },
+ external: {
+ isUpToDate,
+ lastUpdated,
+ size,
+ allFiles,
+ },
+ }
+}
+
+/**
+ * Collect metadata and prepare the clsi-cache for the given project.
+ *
+ * @param projectId
+ * @param userId
+ * @param sourceProjectId
+ * @param templateId
+ * @param templateVersionId
+ * @return {Promise}
+ */
+async function prepareClsiCache(
+ projectId,
+ userId,
+ { sourceProjectId, templateId, templateVersionId }
+) {
+ const { variant } = await SplitTestHandler.promises.getAssignmentForUser(
+ userId,
+ 'copy-clsi-cache'
+ )
+ if (variant !== 'enabled') return
+
+ const features = await UserGetter.promises.getUserFeatures(userId)
+ if (features.compileGroup !== 'priority') return
+
+ const signal = AbortSignal.timeout(5_000)
+ let lastUpdated
+ let zone = 'b' // populate template data on zone b
+ if (sourceProjectId) {
+ try {
+ ;({
+ internal: { zone },
+ external: { lastUpdated },
+ } = await getLatestBuildFromCache(
+ sourceProjectId,
+ userId,
+ 'output.tar.gz',
+ signal
+ ))
+ } catch (err) {
+ if (err instanceof NotFoundError) return // nothing cached yet
+ throw err
+ }
+ }
+ try {
+ await ClsiCacheHandler.prepareCacheSource(projectId, userId, {
+ sourceProjectId,
+ templateId,
+ templateVersionId,
+ zone,
+ lastUpdated,
+ signal,
+ })
+ } catch (err) {
+ if (err instanceof NotFoundError) return // nothing cached yet/expired.
+ throw err
+ }
+}
+
+module.exports = {
+ getLatestBuildFromCache,
+ prepareClsiCache,
+}
diff --git a/services/web/app/src/Features/Compile/ClsiManager.js b/services/web/app/src/Features/Compile/ClsiManager.js
index 68c8a9c0de..2e32aa9622 100644
--- a/services/web/app/src/Features/Compile/ClsiManager.js
+++ b/services/web/app/src/Features/Compile/ClsiManager.js
@@ -25,6 +25,7 @@ const ClsiFormatChecker = require('./ClsiFormatChecker')
const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
const Metrics = require('@overleaf/metrics')
const Errors = require('../Errors/Errors')
+const ClsiCacheHandler = require('./ClsiCacheHandler')
const { getBlobLocation } = require('../History/HistoryManager')
const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
@@ -148,6 +149,13 @@ async function deleteAuxFiles(projectId, userId, options, clsiserverid) {
clsiserverid
)
} finally {
+ // always clear the clsi-cache
+ try {
+ await ClsiCacheHandler.clearCache(projectId, userId)
+ } catch (err) {
+ logger.warn({ err, projectId, userId }, 'purge clsi-cache failed')
+ }
+
// always clear the project state from the docupdater, even if there
// was a problem with the request to the clsi
try {
@@ -766,6 +774,7 @@ function _finaliseRequest(projectId, options, project, docs, files) {
compile: {
options: {
buildId: options.buildId,
+ editorId: options.editorId,
compiler: project.compiler,
timeout: options.timeout,
imageName: project.imageName,
@@ -775,6 +784,13 @@ function _finaliseRequest(projectId, options, project, docs, files) {
syncType: options.syncType,
syncState: options.syncState,
compileGroup: options.compileGroup,
+ // Overleaf alpha/staff users get compileGroup=alpha (via getProjectCompileLimits in CompileManager), enroll them into the premium rollout of clsi-cache.
+ compileFromClsiCache:
+ ['alpha', 'priority'].includes(options.compileGroup) &&
+ options.compileFromClsiCache,
+ populateClsiCache:
+ ['alpha', 'priority'].includes(options.compileGroup) &&
+ options.populateClsiCache,
enablePdfCaching:
(Settings.enablePdfCaching && options.enablePdfCaching) || false,
pdfCachingMinChunkSize: options.pdfCachingMinChunkSize,
diff --git a/services/web/app/src/Features/Compile/CompileController.js b/services/web/app/src/Features/Compile/CompileController.js
index 327c7cc9a3..5d2bbcda3e 100644
--- a/services/web/app/src/Features/Compile/CompileController.js
+++ b/services/web/app/src/Features/Compile/CompileController.js
@@ -57,7 +57,7 @@ async function getPdfCachingMinChunkSize(req, res) {
return parseInt(variant, 10)
}
-const getSplitTestOptions = callbackify(async function (req, res) {
+async function _getSplitTestOptions(req, res) {
// Use the query flags from the editor request for overriding the split test.
let query = {}
try {
@@ -66,11 +66,31 @@ const getSplitTestOptions = callbackify(async function (req, res) {
} catch (e) {}
const editorReq = { ...req, query }
+ // Lookup the clsi-cache flag in the backend.
+ // We may need to turn off the feature on a short notice, without requiring
+ // all users to reload their editor page to disable the feature.
+ const { variant: compileFromClsiCacheVariant } =
+ await SplitTestHandler.promises.getAssignment(
+ editorReq,
+ res,
+ 'compile-from-clsi-cache'
+ )
+ const compileFromClsiCache = compileFromClsiCacheVariant === 'enabled'
+ const { variant: populateClsiCacheVariant } =
+ await SplitTestHandler.promises.getAssignment(
+ editorReq,
+ res,
+ 'populate-clsi-cache'
+ )
+ const populateClsiCache = populateClsiCacheVariant === 'enabled'
+
const pdfDownloadDomain = Settings.pdfDownloadDomain
if (!req.query.enable_pdf_caching) {
// The frontend does not want to do pdf caching.
return {
+ compileFromClsiCache,
+ populateClsiCache,
pdfDownloadDomain,
enablePdfCaching: false,
}
@@ -88,17 +108,22 @@ const getSplitTestOptions = callbackify(async function (req, res) {
if (!enablePdfCaching) {
// Skip the lookup of the chunk size when caching is not enabled.
return {
+ compileFromClsiCache,
+ populateClsiCache,
pdfDownloadDomain,
enablePdfCaching: false,
}
}
const pdfCachingMinChunkSize = await getPdfCachingMinChunkSize(editorReq, res)
return {
+ compileFromClsiCache,
+ populateClsiCache,
pdfDownloadDomain,
enablePdfCaching,
pdfCachingMinChunkSize,
}
-})
+}
+const getSplitTestOptionsCb = callbackify(_getSplitTestOptions)
module.exports = CompileController = {
compile(req, res, next) {
@@ -112,6 +137,7 @@ module.exports = CompileController = {
isAutoCompile,
fileLineErrors,
stopOnFirstError,
+ editorId: req.body.editorId,
}
if (req.body.rootDoc_id) {
@@ -136,10 +162,17 @@ module.exports = CompileController = {
options.incrementalCompilesEnabled = true
}
- getSplitTestOptions(req, res, (err, splitTestOptions) => {
+ getSplitTestOptionsCb(req, res, (err, splitTestOptions) => {
if (err) return next(err)
- let { enablePdfCaching, pdfCachingMinChunkSize, pdfDownloadDomain } =
- splitTestOptions
+ let {
+ compileFromClsiCache,
+ populateClsiCache,
+ enablePdfCaching,
+ pdfCachingMinChunkSize,
+ pdfDownloadDomain,
+ } = splitTestOptions
+ options.compileFromClsiCache = compileFromClsiCache
+ options.populateClsiCache = populateClsiCache
options.enablePdfCaching = enablePdfCaching
if (enablePdfCaching) {
options.pdfCachingMinChunkSize = pdfCachingMinChunkSize
@@ -170,14 +203,7 @@ module.exports = CompileController = {
pdfDownloadDomain += outputUrlPrefix
}
- if (
- limits &&
- SplitTestHandler.getPercentile(
- AnalyticsManager.getIdsFromSession(req.session).analyticsId,
- 'compile-result-backend',
- 'release'
- ) === 1
- ) {
+ if (limits) {
// For a compile request to be sent to clsi we need limits.
// If we get here without having the limits object populated, it is
// a reasonable assumption to make that nothing was compiled.
@@ -193,6 +219,8 @@ module.exports = CompileController = {
timeout: limits.timeout === 60 ? 'short' : 'long',
server: clsiServerId?.includes('-c2d-') ? 'faster' : 'normal',
isAutoCompile,
+ isInitialCompile: stats?.isInitialCompile === 1,
+ restoredClsiCache: stats?.restoredClsiCache === 1,
stopOnFirstError,
}
)
@@ -271,25 +299,20 @@ module.exports = CompileController = {
)
},
- _compileAsUser(req, callback) {
- // callback with userId if per-user, undefined otherwise
- if (!Settings.disablePerUserCompiles) {
- const userId = SessionManager.getLoggedInUserId(req.session)
- callback(null, userId)
- } else {
- callback()
- }
- }, // do a per-project compile, not per-user
+ _getSplitTestOptions,
- _downloadAsUser(req, callback) {
- // callback with userId if per-user, undefined otherwise
+ _getUserIdForCompile(req) {
if (!Settings.disablePerUserCompiles) {
- const userId = SessionManager.getLoggedInUserId(req.session)
- callback(null, userId)
- } else {
- callback()
+ return SessionManager.getLoggedInUserId(req.session)
}
- }, // do a per-project compile, not per-user
+ return null
+ },
+ _compileAsUser(req, callback) {
+ callback(null, CompileController._getUserIdForCompile(req))
+ },
+ _downloadAsUser(req, callback) {
+ callback(null, CompileController._getUserIdForCompile(req))
+ },
downloadPdf(req, res, next) {
Metrics.inc('pdf-downloads')
@@ -497,7 +520,7 @@ module.exports = CompileController = {
proxySyncPdf(req, res, next) {
const projectId = req.params.Project_id
- const { page, h, v } = req.query
+ const { page, h, v, editorId, buildId } = req.query
if (!page?.match(/^\d+$/)) {
return next(new Error('invalid page parameter'))
}
@@ -515,23 +538,29 @@ module.exports = CompileController = {
getImageNameForProject(projectId, (error, imageName) => {
if (error) return next(error)
- const url = CompileController._getUrl(projectId, userId, 'sync/pdf')
- CompileController.proxyToClsi(
- projectId,
- 'sync-to-pdf',
- url,
- { page, h, v, imageName },
- req,
- res,
- next
- )
+ getSplitTestOptionsCb(req, res, (error, splitTestOptions) => {
+ if (error) return next(error)
+ const { compileFromClsiCache } = splitTestOptions
+
+ const url = CompileController._getUrl(projectId, userId, 'sync/pdf')
+
+ CompileController.proxyToClsi(
+ projectId,
+ 'sync-to-pdf',
+ url,
+ { page, h, v, imageName, editorId, buildId, compileFromClsiCache },
+ req,
+ res,
+ next
+ )
+ })
})
})
},
proxySyncCode(req, res, next) {
const projectId = req.params.Project_id
- const { file, line, column } = req.query
+ const { file, line, column, editorId, buildId } = req.query
if (file == null) {
return next(new Error('missing file parameter'))
}
@@ -557,16 +586,29 @@ module.exports = CompileController = {
getImageNameForProject(projectId, (error, imageName) => {
if (error) return next(error)
- const url = CompileController._getUrl(projectId, userId, 'sync/code')
- CompileController.proxyToClsi(
- projectId,
- 'sync-to-code',
- url,
- { file, line, column, imageName },
- req,
- res,
- next
- )
+ getSplitTestOptionsCb(req, res, (error, splitTestOptions) => {
+ if (error) return next(error)
+ const { compileFromClsiCache } = splitTestOptions
+
+ const url = CompileController._getUrl(projectId, userId, 'sync/code')
+ CompileController.proxyToClsi(
+ projectId,
+ 'sync-to-code',
+ url,
+ {
+ file,
+ line,
+ column,
+ imageName,
+ editorId,
+ buildId,
+ compileFromClsiCache,
+ },
+ req,
+ res,
+ next
+ )
+ })
})
})
},
diff --git a/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js b/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js
index 493b812dab..2ea06601c7 100644
--- a/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js
+++ b/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js
@@ -64,6 +64,11 @@ function flushProjectToMongoAndDelete(projectId, callback) {
)
}
+/**
+ * @param {string} projectId
+ * @param {string} docId
+ * @param {Callback} callback
+ */
function flushDocToMongo(projectId, docId, callback) {
_makeRequest(
{
diff --git a/services/web/app/src/Features/Editor/EditorController.js b/services/web/app/src/Features/Editor/EditorController.js
index ea0153fe37..4d3a5e9eb0 100644
--- a/services/web/app/src/Features/Editor/EditorController.js
+++ b/services/web/app/src/Features/Editor/EditorController.js
@@ -307,6 +307,7 @@ const EditorController = {
projectId,
folderId,
folderName,
+ userId,
(err, folder, folderId) => {
if (err) {
OError.tag(err, 'could not add folder', {
@@ -333,11 +334,12 @@ const EditorController = {
)
},
- mkdirp(projectId, path, callback) {
+ mkdirp(projectId, path, userId, callback) {
logger.debug({ projectId, path }, "making directories if they don't exist")
ProjectEntityUpdateHandler.mkdirp(
projectId,
path,
+ userId,
(err, newFolders, lastFolder) => {
if (err) {
OError.tag(err, 'could not mkdirp', {
diff --git a/services/web/app/src/Features/History/HistoryController.js b/services/web/app/src/Features/History/HistoryController.js
index f6ca483dce..a0f0183f44 100644
--- a/services/web/app/src/Features/History/HistoryController.js
+++ b/services/web/app/src/Features/History/HistoryController.js
@@ -1,8 +1,17 @@
-let HistoryController
+// @ts-check
+
+const { setTimeout } = require('timers/promises')
+const { pipeline } = require('stream/promises')
const OError = require('@overleaf/o-error')
-const async = require('async')
const logger = require('@overleaf/logger')
-const request = require('request')
+const { expressify } = require('@overleaf/promise-utils')
+const {
+ fetchStream,
+ fetchStreamWithResponse,
+ fetchJson,
+ fetchNothing,
+ RequestFailedError,
+} = require('@overleaf/fetch-utils')
const settings = require('@overleaf/settings')
const SessionManager = require('../Authentication/SessionManager')
const UserGetter = require('../User/UserGetter')
@@ -12,11 +21,8 @@ const HistoryManager = require('./HistoryManager')
const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
const ProjectEntityUpdateHandler = require('../Project/ProjectEntityUpdateHandler')
const RestoreManager = require('./RestoreManager')
-const { pipeline } = require('stream')
-const Stream = require('stream')
const { prepareZipAttachment } = require('../../infrastructure/Response')
const Features = require('../../infrastructure/Features')
-const { expressify } = require('@overleaf/promise-utils')
// Number of seconds after which the browser should send a request to revalidate
// blobs
@@ -26,6 +32,8 @@ const REVALIDATE_BLOB_AFTER_SECONDS = 86400 // 1 day
// revalidating
const STALE_WHILE_REVALIDATE_SECONDS = 365 * 86400 // 1 year
+const MAX_HISTORY_ZIP_ATTEMPTS = 40
+
async function getBlob(req, res) {
await requestBlob('GET', req, res)
}
@@ -44,9 +52,9 @@ async function requestBlob(method, req, res) {
}
const range = req.get('Range')
- let url, stream, source, contentLength
+ let stream, source, contentLength
try {
- ;({ url, stream, source, contentLength } =
+ ;({ stream, source, contentLength } =
await HistoryManager.promises.requestBlobWithFallback(
projectId,
hash,
@@ -65,14 +73,13 @@ async function requestBlob(method, req, res) {
setBlobCacheHeaders(res, hash)
try {
- await Stream.promises.pipeline(stream, res)
+ await pipeline(stream, res)
} catch (err) {
// If the downstream request is cancelled, we get an
// ERR_STREAM_PREMATURE_CLOSE, ignore these "errors".
- if (err?.code === 'ERR_STREAM_PREMATURE_CLOSE') return
-
- logger.warn({ err, url, method, range }, 'streaming blob error')
- throw err
+ if (!isPrematureClose(err)) {
+ throw err
+ }
}
}
@@ -88,492 +95,408 @@ function setBlobCacheHeaders(res, etag) {
res.set('ETag', etag)
}
-module.exports = HistoryController = {
- getBlob: expressify(getBlob),
- headBlob: expressify(headBlob),
+async function proxyToHistoryApi(req, res, next) {
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ const url = settings.apis.project_history.url + req.url
- proxyToHistoryApi(req, res, next) {
- const userId = SessionManager.getLoggedInUserId(req.session)
- const url = settings.apis.project_history.url + req.url
+ const { stream, response } = await fetchStreamWithResponse(url, {
+ method: req.method,
+ headers: { 'X-User-Id': userId },
+ })
- const getReq = request({
- url,
- method: req.method,
- headers: {
- 'X-User-Id': userId,
- },
- })
- pipeline(getReq, res, function (err) {
+ const contentType = response.headers.get('Content-Type')
+ const contentLength = response.headers.get('Content-Length')
+ if (contentType != null) {
+ res.set('Content-Type', contentType)
+ }
+ if (contentLength != null) {
+ res.set('Content-Length', contentLength)
+ }
+
+ try {
+ await pipeline(stream, res)
+ } catch (err) {
+ // If the downstream request is cancelled, we get an
+ // ERR_STREAM_PREMATURE_CLOSE.
+ if (!isPrematureClose(err)) {
+ throw err
+ }
+ }
+}
+
+async function proxyToHistoryApiAndInjectUserDetails(req, res, next) {
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ const url = settings.apis.project_history.url + req.url
+ const body = await fetchJson(url, {
+ method: req.method,
+ headers: { 'X-User-Id': userId },
+ })
+ const data = await HistoryManager.promises.injectUserDetails(body)
+ res.json(data)
+}
+
+async function resyncProjectHistory(req, res, next) {
+ // increase timeout to 6 minutes
+ res.setTimeout(6 * 60 * 1000)
+ const projectId = req.params.Project_id
+ const opts = {}
+ const historyRangesMigration = req.body.historyRangesMigration
+ if (historyRangesMigration) {
+ opts.historyRangesMigration = historyRangesMigration
+ }
+ if (req.body.resyncProjectStructureOnly) {
+ opts.resyncProjectStructureOnly = req.body.resyncProjectStructureOnly
+ }
+
+ try {
+ await ProjectEntityUpdateHandler.promises.resyncProjectHistory(
+ projectId,
+ opts
+ )
+ } catch (err) {
+ if (err instanceof Errors.ProjectHistoryDisabledError) {
+ return res.sendStatus(404)
+ } else {
+ throw err
+ }
+ }
+
+ res.sendStatus(204)
+}
+
+async function restoreFileFromV2(req, res, next) {
+ const { project_id: projectId } = req.params
+ const { version, pathname } = req.body
+ const userId = SessionManager.getLoggedInUserId(req.session)
+
+ const entity = await RestoreManager.promises.restoreFileFromV2(
+ userId,
+ projectId,
+ version,
+ pathname
+ )
+
+ res.json({
+ type: entity.type,
+ id: entity._id,
+ })
+}
+
+async function revertFile(req, res, next) {
+ const { project_id: projectId } = req.params
+ const { version, pathname } = req.body
+ const userId = SessionManager.getLoggedInUserId(req.session)
+
+ const entity = await RestoreManager.promises.revertFile(
+ userId,
+ projectId,
+ version,
+ pathname,
+ {}
+ )
+
+ res.json({
+ type: entity.type,
+ id: entity._id,
+ })
+}
+
+async function revertProject(req, res, next) {
+ const { project_id: projectId } = req.params
+ const { version } = req.body
+ const userId = SessionManager.getLoggedInUserId(req.session)
+
+ await RestoreManager.promises.revertProject(userId, projectId, version)
+
+ res.sendStatus(200)
+}
+
+async function getLabels(req, res, next) {
+ const projectId = req.params.Project_id
+
+ let labels = await fetchJson(
+ `${settings.apis.project_history.url}/project/${projectId}/labels`
+ )
+ labels = await _enrichLabels(labels)
+
+ res.json(labels)
+}
+
+async function createLabel(req, res, next) {
+ const projectId = req.params.Project_id
+ const { comment, version } = req.body
+ const userId = SessionManager.getLoggedInUserId(req.session)
+
+ let label = await fetchJson(
+ `${settings.apis.project_history.url}/project/${projectId}/labels`,
+ {
+ method: 'POST',
+ json: { comment, version, user_id: userId },
+ }
+ )
+ label = await _enrichLabel(label)
+
+ res.json(label)
+}
+
+async function _enrichLabel(label) {
+ const newLabel = Object.assign({}, label)
+ if (!label.user_id) {
+ newLabel.user_display_name = _displayNameForUser(null)
+ return newLabel
+ }
+
+ const user = await UserGetter.promises.getUser(label.user_id, {
+ first_name: 1,
+ last_name: 1,
+ email: 1,
+ })
+ newLabel.user_display_name = _displayNameForUser(user)
+ return newLabel
+}
+
+async function _enrichLabels(labels) {
+ if (!labels || !labels.length) {
+ return []
+ }
+ const uniqueUsers = new Set(labels.map(label => label.user_id))
+
+ // For backwards compatibility, and for anonymously created labels in SP
+ // expect missing user_id fields
+ uniqueUsers.delete(undefined)
+
+ if (!uniqueUsers.size) {
+ return labels
+ }
+
+ const rawUsers = await UserGetter.promises.getUsers(Array.from(uniqueUsers), {
+ first_name: 1,
+ last_name: 1,
+ email: 1,
+ })
+ const users = new Map(rawUsers.map(user => [String(user._id), user]))
+
+ labels.forEach(label => {
+ const user = users.get(label.user_id)
+ label.user_display_name = _displayNameForUser(user)
+ })
+ return labels
+}
+
+function _displayNameForUser(user) {
+ if (user == null) {
+ return 'Anonymous'
+ }
+ if (user.name) {
+ return user.name
+ }
+ let name = [user.first_name, user.last_name]
+ .filter(n => n != null)
+ .join(' ')
+ .trim()
+ if (name === '') {
+ name = user.email.split('@')[0]
+ }
+ if (!name) {
+ return '?'
+ }
+ return name
+}
+
+async function deleteLabel(req, res, next) {
+ const { Project_id: projectId, label_id: labelId } = req.params
+ const userId = SessionManager.getLoggedInUserId(req.session)
+
+ const project = await ProjectGetter.promises.getProject(projectId, {
+ owner_ref: true,
+ })
+
+ // If the current user is the project owner, we can use the non-user-specific
+ // delete label endpoint. Otherwise, we have to use the user-specific version
+ // (which only deletes the label if it is owned by the user)
+ const deleteEndpointUrl = project.owner_ref.equals(userId)
+ ? `${settings.apis.project_history.url}/project/${projectId}/labels/${labelId}`
+ : `${settings.apis.project_history.url}/project/${projectId}/user/${userId}/labels/${labelId}`
+
+ await fetchNothing(deleteEndpointUrl, {
+ method: 'DELETE',
+ })
+ res.sendStatus(204)
+}
+
+async function downloadZipOfVersion(req, res, next) {
+ const { project_id: projectId, version } = req.params
+
+ const project = await ProjectDetailsHandler.promises.getDetails(projectId)
+ const v1Id =
+ project.overleaf && project.overleaf.history && project.overleaf.history.id
+
+ if (v1Id == null) {
+ logger.error(
+ { projectId, version },
+ 'got request for zip version of non-v1 history project'
+ )
+ return res.sendStatus(402)
+ }
+
+ await _pipeHistoryZipToResponse(
+ v1Id,
+ version,
+ `${project.name} (Version ${version})`,
+ req,
+ res
+ )
+}
+
+async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) {
+ if (req.destroyed) {
+ // client has disconnected -- skip project history api call and download
+ return
+ }
+ // increase timeout to 6 minutes
+ res.setTimeout(6 * 60 * 1000)
+ const url = `${settings.apis.v1_history.url}/projects/${v1ProjectId}/version/${version}/zip`
+ const basicAuth = {
+ user: settings.apis.v1_history.user,
+ password: settings.apis.v1_history.pass,
+ }
+
+ if (!Features.hasFeature('saas')) {
+ let stream
+ try {
+ stream = await fetchStream(url, { basicAuth })
+ } catch (err) {
+ if (err instanceof RequestFailedError && err.response.status === 404) {
+ return res.sendStatus(404)
+ } else {
+ throw err
+ }
+ }
+
+ prepareZipAttachment(res, `${name}.zip`)
+
+ try {
+ await pipeline(stream, res)
+ } catch (err) {
// If the downstream request is cancelled, we get an
// ERR_STREAM_PREMATURE_CLOSE.
- if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
- logger.warn({ url, err }, 'history API error')
- next(err)
+ if (!isPrematureClose(err)) {
+ throw err
}
+ }
+ return
+ }
+
+ let body
+ try {
+ body = await fetchJson(url, { method: 'POST', basicAuth })
+ } catch (err) {
+ if (err instanceof RequestFailedError && err.response.status === 404) {
+ throw new Errors.NotFoundError('zip not found')
+ } else {
+ throw err
+ }
+ }
+
+ if (req.destroyed) {
+ // client has disconnected -- skip delayed s3 download
+ return
+ }
+
+ if (!body.zipUrl) {
+ throw new OError('Missing zipUrl, cannot fetch zip file', {
+ v1ProjectId,
+ body,
})
- },
+ }
- proxyToHistoryApiAndInjectUserDetails(req, res, next) {
- const userId = SessionManager.getLoggedInUserId(req.session)
- const url = settings.apis.project_history.url + req.url
- HistoryController._makeRequest(
- {
- url,
- method: req.method,
- json: true,
- headers: {
- 'X-User-Id': userId,
- },
- },
- function (err, body) {
- if (err) {
- return next(err)
- }
- HistoryManager.injectUserDetails(body, function (err, data) {
- if (err) {
- return next(err)
- }
- res.json(data)
- })
- }
- )
- },
+ // retry for about 6 minutes starting with short delay
+ let retryDelay = 2000
+ let attempt = 0
+ while (true) {
+ attempt += 1
+ await setTimeout(retryDelay)
- resyncProjectHistory(req, res, next) {
- // increase timeout to 6 minutes
- res.setTimeout(6 * 60 * 1000)
- const projectId = req.params.Project_id
- const opts = {}
- const historyRangesMigration = req.body.historyRangesMigration
- if (historyRangesMigration) {
- opts.historyRangesMigration = historyRangesMigration
- }
- if (req.body.resyncProjectStructureOnly) {
- opts.resyncProjectStructureOnly = req.body.resyncProjectStructureOnly
- }
- ProjectEntityUpdateHandler.resyncProjectHistory(
- projectId,
- opts,
- function (err) {
- if (err instanceof Errors.ProjectHistoryDisabledError) {
- return res.sendStatus(404)
- }
- if (err) {
- return next(err)
- }
- res.sendStatus(204)
- }
- )
- },
-
- restoreFileFromV2(req, res, next) {
- const { project_id: projectId } = req.params
- const { version, pathname } = req.body
- const userId = SessionManager.getLoggedInUserId(req.session)
- RestoreManager.restoreFileFromV2(
- userId,
- projectId,
- version,
- pathname,
- function (err, entity) {
- if (err) {
- return next(err)
- }
- res.json({
- type: entity.type,
- id: entity._id,
- })
- }
- )
- },
-
- revertFile(req, res, next) {
- const { project_id: projectId } = req.params
- const { version, pathname } = req.body
- const userId = SessionManager.getLoggedInUserId(req.session)
- RestoreManager.revertFile(
- userId,
- projectId,
- version,
- pathname,
- {},
- function (err, entity) {
- if (err) {
- return next(err)
- }
- res.json({
- type: entity.type,
- id: entity._id,
- })
- }
- )
- },
-
- revertProject(req, res, next) {
- const { project_id: projectId } = req.params
- const { version } = req.body
- const userId = SessionManager.getLoggedInUserId(req.session)
- RestoreManager.revertProject(userId, projectId, version, function (err) {
- if (err) {
- return next(err)
- }
- res.sendStatus(200)
- })
- },
-
- getLabels(req, res, next) {
- const projectId = req.params.Project_id
- HistoryController._makeRequest(
- {
- method: 'GET',
- url: `${settings.apis.project_history.url}/project/${projectId}/labels`,
- json: true,
- },
- function (err, labels) {
- if (err) {
- return next(err)
- }
- HistoryController._enrichLabels(labels, (err, labels) => {
- if (err) {
- return next(err)
- }
- res.json(labels)
- })
- }
- )
- },
-
- createLabel(req, res, next) {
- const projectId = req.params.Project_id
- const { comment, version } = req.body
- const userId = SessionManager.getLoggedInUserId(req.session)
- HistoryController._makeRequest(
- {
- method: 'POST',
- url: `${settings.apis.project_history.url}/project/${projectId}/labels`,
- json: { comment, version, user_id: userId },
- },
- function (err, label) {
- if (err) {
- return next(err)
- }
- HistoryController._enrichLabel(label, (err, label) => {
- if (err) {
- return next(err)
- }
- res.json(label)
- })
- }
- )
- },
-
- _enrichLabel(label, callback) {
- if (!label.user_id) {
- const newLabel = Object.assign({}, label)
- newLabel.user_display_name = HistoryController._displayNameForUser(null)
- return callback(null, newLabel)
- }
- UserGetter.getUser(
- label.user_id,
- { first_name: 1, last_name: 1, email: 1 },
- (err, user) => {
- if (err) {
- return callback(err)
- }
- const newLabel = Object.assign({}, label)
- newLabel.user_display_name = HistoryController._displayNameForUser(user)
- callback(null, newLabel)
- }
- )
- },
-
- _enrichLabels(labels, callback) {
- if (!labels || !labels.length) {
- return callback(null, [])
- }
- const uniqueUsers = new Set(labels.map(label => label.user_id))
-
- // For backwards compatibility, and for anonymously created labels in SP
- // expect missing user_id fields
- uniqueUsers.delete(undefined)
-
- if (!uniqueUsers.size) {
- return callback(null, labels)
- }
-
- UserGetter.getUsers(
- Array.from(uniqueUsers),
- { first_name: 1, last_name: 1, email: 1 },
- function (err, rawUsers) {
- if (err) {
- return callback(err)
- }
- const users = new Map(rawUsers.map(user => [String(user._id), user]))
-
- labels.forEach(label => {
- const user = users.get(label.user_id)
- label.user_display_name = HistoryController._displayNameForUser(user)
- })
- callback(null, labels)
- }
- )
- },
-
- _displayNameForUser(user) {
- if (user == null) {
- return 'Anonymous'
- }
- if (user.name) {
- return user.name
- }
- let name = [user.first_name, user.last_name]
- .filter(n => n != null)
- .join(' ')
- .trim()
- if (name === '') {
- name = user.email.split('@')[0]
- }
- if (!name) {
- return '?'
- }
- return name
- },
-
- deleteLabel(req, res, next) {
- const { Project_id: projectId, label_id: labelId } = req.params
- const userId = SessionManager.getLoggedInUserId(req.session)
-
- ProjectGetter.getProject(
- projectId,
- {
- owner_ref: true,
- },
- (err, project) => {
- if (err) {
- return next(err)
- }
-
- // If the current user is the project owner, we can use the non-user-specific delete label endpoint.
- // Otherwise, we have to use the user-specific version (which only deletes the label if it is owned by the user)
- const deleteEndpointUrl = project.owner_ref.equals(userId)
- ? `${settings.apis.project_history.url}/project/${projectId}/labels/${labelId}`
- : `${settings.apis.project_history.url}/project/${projectId}/user/${userId}/labels/${labelId}`
-
- HistoryController._makeRequest(
- {
- method: 'DELETE',
- url: deleteEndpointUrl,
- },
- function (err) {
- if (err) {
- return next(err)
- }
- res.sendStatus(204)
- }
- )
- }
- )
- },
-
- _makeRequest(options, callback) {
- return request(options, function (err, response, body) {
- if (err) {
- return callback(err)
- }
- if (response.statusCode >= 200 && response.statusCode < 300) {
- callback(null, body)
- } else {
- err = new Error(
- `history api responded with non-success code: ${response.statusCode}`
- )
- callback(err)
- }
- })
- },
-
- downloadZipOfVersion(req, res, next) {
- const { project_id: projectId, version } = req.params
- ProjectDetailsHandler.getDetails(projectId, function (err, project) {
- if (err) {
- return next(err)
- }
- const v1Id =
- project.overleaf &&
- project.overleaf.history &&
- project.overleaf.history.id
- if (v1Id == null) {
- logger.error(
- { projectId, version },
- 'got request for zip version of non-v1 history project'
- )
- return res.sendStatus(402)
- }
- HistoryController._pipeHistoryZipToResponse(
- v1Id,
- version,
- `${project.name} (Version ${version})`,
- req,
- res,
- next
- )
- })
- },
-
- _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res, next) {
if (req.destroyed) {
- // client has disconnected -- skip project history api call and download
- return
- }
- // increase timeout to 6 minutes
- res.setTimeout(6 * 60 * 1000)
- const url = `${settings.apis.v1_history.url}/projects/${v1ProjectId}/version/${version}/zip`
- const options = {
- auth: {
- user: settings.apis.v1_history.user,
- pass: settings.apis.v1_history.pass,
- },
- json: true,
- url,
- }
-
- if (!Features.hasFeature('saas')) {
- const getReq = request({ ...options, method: 'get' })
-
- getReq.on('error', function (err) {
- logger.warn({ err, v1ProjectId, version }, 'history zip download error')
- res.sendStatus(500)
- })
- getReq.on('response', function (response) {
- const statusCode = response.statusCode
- if (statusCode !== 200) {
- logger.warn(
- { v1ProjectId, version, statusCode },
- 'history zip download failed'
- )
- if (statusCode === 404) {
- res.sendStatus(404)
- } else {
- res.sendStatus(500)
- }
- return
- }
-
- prepareZipAttachment(res, `${name}.zip`)
- pipeline(response, res, function (err) {
- // If the downstream request is cancelled, we get an
- // ERR_STREAM_PREMATURE_CLOSE.
- if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
- logger.error({ err, v1ProjectId, version }, 'history API error')
- next(err)
- }
- })
- })
+ // client has disconnected -- skip s3 download
return
}
- request({ ...options, method: 'post' }, function (err, response, body) {
- if (err) {
- OError.tag(err, 'history API error', {
- v1ProjectId,
- version,
- })
- return next(err)
+ // increase delay by 1 second up to 10
+ if (retryDelay < 10000) {
+ retryDelay += 1000
+ }
+
+ try {
+ const stream = await fetchStream(body.zipUrl)
+ prepareZipAttachment(res, `${name}.zip`)
+ await pipeline(stream, res)
+ } catch (err) {
+ if (attempt > MAX_HISTORY_ZIP_ATTEMPTS) {
+ throw err
}
- if (response.statusCode !== 200) {
- if (response.statusCode === 404) {
- return next(new Errors.NotFoundError('zip not found'))
- } else {
- return next(
- new OError('Error while getting zip for download', {
- v1ProjectId,
- statusCode: response.statusCode,
- })
- )
- }
- }
- if (req.destroyed) {
- // client has disconnected -- skip delayed s3 download
- return
- }
- if (!body.zipUrl) {
- return next(
- new OError('Missing zipUrl, cannot fetch zip file', {
- v1ProjectId,
- body,
- statusCode: response.statusCode,
- })
+
+ if (err instanceof RequestFailedError && err.response.status === 404) {
+ // File not ready yet. Retry.
+ continue
+ } else if (isPrematureClose(err)) {
+ // Downstream request cancelled. Retry.
+ continue
+ } else {
+ // Unknown error. Log and retry.
+ logger.warn(
+ { err, v1ProjectId, version, retryAttempt: attempt },
+ 'history s3 proxying error'
)
+ continue
}
- let retryAttempt = 0
- let retryDelay = 2000
- // retry for about 6 minutes starting with short delay
- async.retry(
- 40,
- callback =>
- setTimeout(function () {
- if (req.destroyed) {
- // client has disconnected -- skip s3 download
- return callback() // stop async.retry loop
- }
+ }
- // increase delay by 1 second up to 10
- if (retryDelay < 10000) {
- retryDelay += 1000
- }
- retryAttempt++
- const getReq = request({
- url: body.zipUrl,
- sendImmediately: true,
- })
- const abortS3Request = () => getReq.abort()
- req.on('close', abortS3Request)
- res.on('timeout', abortS3Request)
- function cleanupAbortTrigger() {
- req.off('close', abortS3Request)
- res.off('timeout', abortS3Request)
- }
- getReq.on('response', function (response) {
- if (response.statusCode !== 200) {
- cleanupAbortTrigger()
- return callback(new Error('invalid response'))
- }
- // pipe also proxies the headers, but we want to customize these ones
- delete response.headers['content-disposition']
- delete response.headers['content-type']
- res.status(response.statusCode)
- prepareZipAttachment(res, `${name}.zip`)
- pipeline(response, res, err => {
- // If the downstream request is cancelled, we get an
- // ERR_STREAM_PREMATURE_CLOSE.
- if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
- logger.warn(
- { err, v1ProjectId, version, retryAttempt },
- 'history s3 proxying error'
- )
- }
- })
- callback()
- })
- getReq.on('error', function (err) {
- logger.warn(
- { err, v1ProjectId, version, retryAttempt },
- 'history s3 download error'
- )
- cleanupAbortTrigger()
- callback(err)
- })
- }, retryDelay),
- function (err) {
- if (err) {
- OError.tag(err, 'history s3 download failed', {
- v1ProjectId,
- version,
- retryAttempt,
- })
- next(err)
- }
- }
- )
- })
+ // We made it through. No need to retry anymore. Exit loop
+ break
+ }
+}
+
+async function getLatestHistory(req, res, next) {
+ const projectId = req.params.project_id
+ const history = await HistoryManager.promises.getLatestHistory(projectId)
+ res.json(history)
+}
+
+async function getChanges(req, res, next) {
+ const projectId = req.params.project_id
+ const since = req.query.since
+ const changes = await HistoryManager.promises.getChanges(projectId, { since })
+ res.json(changes)
+}
+
+function isPrematureClose(err) {
+ return (
+ err instanceof Error &&
+ 'code' in err &&
+ err.code === 'ERR_STREAM_PREMATURE_CLOSE'
+ )
+}
+
+module.exports = {
+ getBlob: expressify(getBlob),
+ headBlob: expressify(headBlob),
+ proxyToHistoryApi: expressify(proxyToHistoryApi),
+ proxyToHistoryApiAndInjectUserDetails: expressify(
+ proxyToHistoryApiAndInjectUserDetails
+ ),
+ resyncProjectHistory: expressify(resyncProjectHistory),
+ restoreFileFromV2: expressify(restoreFileFromV2),
+ revertFile: expressify(revertFile),
+ revertProject: expressify(revertProject),
+ getLabels: expressify(getLabels),
+ createLabel: expressify(createLabel),
+ deleteLabel: expressify(deleteLabel),
+ downloadZipOfVersion: expressify(downloadZipOfVersion),
+ getLatestHistory: expressify(getLatestHistory),
+ getChanges: expressify(getChanges),
+ _displayNameForUser,
+ promises: {
+ _pipeHistoryZipToResponse,
},
}
diff --git a/services/web/app/src/Features/History/HistoryManager.js b/services/web/app/src/Features/History/HistoryManager.js
index 78730c4782..6e40907d1b 100644
--- a/services/web/app/src/Features/History/HistoryManager.js
+++ b/services/web/app/src/Features/History/HistoryManager.js
@@ -21,6 +21,12 @@ const projectKey = require('./project_key')
const GLOBAL_BLOBS = new Set() // CHANGE FROM SOURCE: only store hashes.
+const HISTORY_V1_URL = settings.apis.v1_history.url
+const HISTORY_V1_BASIC_AUTH = {
+ user: settings.apis.v1_history.user,
+ password: settings.apis.v1_history.pass,
+}
+
function makeGlobalKey(hash) {
return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}`
}
@@ -144,16 +150,10 @@ async function _deleteProjectInProjectHistory(projectId) {
async function _deleteProjectInFullProjectHistory(historyId) {
try {
- await fetchNothing(
- `${settings.apis.v1_history.url}/projects/${historyId}`,
- {
- method: 'DELETE',
- basicAuth: {
- user: settings.apis.v1_history.user,
- password: settings.apis.v1_history.pass,
- },
- }
- )
+ await fetchNothing(`${HISTORY_V1_URL}/projects/${historyId}`, {
+ method: 'DELETE',
+ basicAuth: HISTORY_V1_BASIC_AUTH,
+ })
} catch (err) {
throw OError.tag(err, 'failed to clear project history', { historyId })
}
@@ -162,29 +162,23 @@ async function _deleteProjectInFullProjectHistory(historyId) {
async function uploadBlobFromDisk(historyId, hash, byteLength, fsPath) {
const outStream = fs.createReadStream(fsPath)
- const url = `${settings.apis.v1_history.url}/projects/${historyId}/blobs/${hash}`
+ const url = `${HISTORY_V1_URL}/projects/${historyId}/blobs/${hash}`
await fetchNothing(url, {
method: 'PUT',
body: outStream,
headers: { 'Content-Length': byteLength }, // add the content length to work around problems with chunked encoding in node 18
signal: AbortSignal.timeout(60 * 1000),
- basicAuth: {
- user: settings.apis.v1_history.user,
- password: settings.apis.v1_history.pass,
- },
+ basicAuth: HISTORY_V1_BASIC_AUTH,
})
}
async function copyBlob(sourceHistoryId, targetHistoryId, hash) {
- const url = `${settings.apis.v1_history.url}/projects/${targetHistoryId}/blobs/${hash}`
+ const url = `${HISTORY_V1_URL}/projects/${targetHistoryId}/blobs/${hash}`
await fetchNothing(
`${url}?${new URLSearchParams({ copyFrom: sourceHistoryId })}`,
{
method: 'POST',
- basicAuth: {
- user: settings.apis.v1_history.user,
- password: settings.apis.v1_history.pass,
- },
+ basicAuth: HISTORY_V1_BASIC_AUTH,
}
)
}
@@ -200,7 +194,7 @@ async function requestBlobWithFallback(
'overleaf.history.id': true,
})
// Talk to history-v1 directly to avoid streaming via project-history.
- let url = new URL(settings.apis.v1_history.url)
+ let url = new URL(HISTORY_V1_URL)
url.pathname += `/projects/${project.overleaf.history.id}/blobs/${hash}`
const opts = { method, headers: { Range: range } }
@@ -255,22 +249,14 @@ async function requestBlobWithFallback(
* @returns Promise
*/
async function getCurrentContent(projectId) {
- const project = await ProjectGetter.promises.getProject(projectId, {
- overleaf: true,
- })
- const historyId = project?.overleaf?.history?.id
- if (!historyId) {
- throw new OError('project does not have a history id', { projectId })
- }
+ const historyId = await getHistoryId(projectId)
+
try {
return await fetchJson(
- `${settings.apis.v1_history.url}/projects/${historyId}/latest/content`,
+ `${HISTORY_V1_URL}/projects/${historyId}/latest/content`,
{
method: 'GET',
- basicAuth: {
- user: settings.apis.v1_history.user,
- password: settings.apis.v1_history.pass,
- },
+ basicAuth: HISTORY_V1_BASIC_AUTH,
}
)
} catch (err) {
@@ -287,22 +273,14 @@ async function getCurrentContent(projectId) {
* @returns Promise
*/
async function getContentAtVersion(projectId, version) {
- const project = await ProjectGetter.promises.getProject(projectId, {
- overleaf: true,
- })
- const historyId = project?.overleaf?.history?.id
- if (!historyId) {
- throw new OError('project does not have a history id', { projectId })
- }
+ const historyId = await getHistoryId(projectId)
+
try {
return await fetchJson(
- `${settings.apis.v1_history.url}/projects/${historyId}/versions/${version}/content`,
+ `${HISTORY_V1_URL}/projects/${historyId}/versions/${version}/content`,
{
method: 'GET',
- basicAuth: {
- user: settings.apis.v1_history.user,
- password: settings.apis.v1_history.pass,
- },
+ basicAuth: HISTORY_V1_BASIC_AUTH,
}
)
} catch (err) {
@@ -314,6 +292,53 @@ async function getContentAtVersion(projectId, version) {
}
}
+/**
+ * Get the latest chunk from history
+ *
+ * @param {string} projectId
+ */
+async function getLatestHistory(projectId) {
+ const historyId = await getHistoryId(projectId)
+
+ return await fetchJson(
+ `${HISTORY_V1_URL}/projects/${historyId}/latest/history`,
+ {
+ basicAuth: HISTORY_V1_BASIC_AUTH,
+ }
+ )
+}
+
+/**
+ * Get history changes since a given version
+ *
+ * @param {string} projectId
+ * @param {object} opts
+ * @param {number} opts.since - The start version of changes to get
+ */
+async function getChanges(projectId, opts = {}) {
+ const historyId = await getHistoryId(projectId)
+
+ const url = new URL(`${HISTORY_V1_URL}/projects/${historyId}/changes`)
+ if (opts.since) {
+ url.searchParams.set('since', opts.since)
+ }
+
+ return await fetchJson(url, {
+ basicAuth: HISTORY_V1_BASIC_AUTH,
+ })
+}
+
+async function getHistoryId(projectId) {
+ const project = await ProjectGetter.promises.getProject(projectId, {
+ overleaf: true,
+ })
+ const historyId = project?.overleaf?.history?.id
+ if (!historyId) {
+ throw new OError('project does not have a history id', { projectId })
+ }
+ return historyId
+}
+
async function injectUserDetails(data) {
// data can be either:
// {
@@ -404,6 +429,8 @@ module.exports = {
uploadBlobFromDisk: callbackify(uploadBlobFromDisk),
copyBlob: callbackify(copyBlob),
requestBlobWithFallback: callbackify(requestBlobWithFallback),
+ getLatestHistory: callbackify(getLatestHistory),
+ getChanges: callbackify(getChanges),
promises: {
loadGlobalBlobs,
initializeProject,
@@ -417,5 +444,7 @@ module.exports = {
uploadBlobFromDisk,
copyBlob,
requestBlobWithFallback,
+ getLatestHistory,
+ getChanges,
},
}
diff --git a/services/web/app/src/Features/History/HistoryRouter.mjs b/services/web/app/src/Features/History/HistoryRouter.mjs
index 5b37236166..d5c7b46804 100644
--- a/services/web/app/src/Features/History/HistoryRouter.mjs
+++ b/services/web/app/src/Features/History/HistoryRouter.mjs
@@ -151,15 +151,28 @@ function apply(webRouter, privateApiRouter) {
webRouter.get(
'/project/:project_id/latest/history',
+ validate({
+ params: Joi.object({
+ project_id: Joi.objectId().required(),
+ }),
+ }),
AuthorizationMiddleware.blockRestrictedUserFromProject,
AuthorizationMiddleware.ensureUserCanReadProject,
- HistoryController.proxyToHistoryApi
+ HistoryController.getLatestHistory
)
webRouter.get(
'/project/:project_id/changes',
+ validate({
+ params: Joi.object({
+ project_id: Joi.objectId().required(),
+ }),
+ query: Joi.object({
+ since: Joi.number().integer().min(0).optional(),
+ }),
+ }),
AuthorizationMiddleware.blockRestrictedUserFromProject,
AuthorizationMiddleware.ensureUserCanReadProject,
- HistoryController.proxyToHistoryApi
+ HistoryController.getChanges
)
}
diff --git a/services/web/app/src/Features/History/RestoreManager.js b/services/web/app/src/Features/History/RestoreManager.js
index f36f1466af..8c73695eed 100644
--- a/services/web/app/src/Features/History/RestoreManager.js
+++ b/services/web/app/src/Features/History/RestoreManager.js
@@ -33,7 +33,8 @@ const RestoreManager = {
}
const parentFolderId = await RestoreManager._findOrCreateFolder(
projectId,
- dirname
+ dirname,
+ userId
)
const addEntityWithName = async name =>
await FileSystemImportManager.promises.addEntity(
@@ -71,7 +72,8 @@ const RestoreManager = {
}
const parentFolderId = await RestoreManager._findOrCreateFolder(
projectId,
- dirname
+ dirname,
+ userId
)
const file = await ProjectLocator.promises
.findElementByPath({
@@ -264,10 +266,11 @@ const RestoreManager = {
}
},
- async _findOrCreateFolder(projectId, dirname) {
+ async _findOrCreateFolder(projectId, dirname, userId) {
const { lastFolder } = await EditorController.promises.mkdirp(
projectId,
- dirname
+ dirname,
+ userId
)
return lastFolder?._id
},
diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index 8fd4668468..f033436fdd 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -49,7 +49,7 @@ const Modules = require('../../infrastructure/Modules')
const UserGetter = require('../User/UserGetter')
const {
isStandaloneAiAddOnPlanCode,
-} = require('../Subscription/RecurlyEntities')
+} = require('../Subscription/PaymentProviderEntities')
const SubscriptionController = require('../Subscription/SubscriptionController.js')
const { formatCurrency } = require('../../util/currency')
@@ -338,6 +338,8 @@ const _ProjectController = {
'external-socket-heartbeat',
'full-project-search',
'null-test-share-modal',
+ 'fall-back-to-clsi-cache',
+ 'initial-compile-from-clsi-cache',
'pdf-caching-cached-url-lookup',
'pdf-caching-mode',
'pdf-caching-prefetch-large',
@@ -347,15 +349,14 @@ const _ProjectController = {
!anonymous && 'ro-mirror-on-client',
'track-pdf-download',
!anonymous && 'writefull-oauth-promotion',
- 'write-and-cite',
- 'write-and-cite-ars',
- 'default-visual-for-beginners',
'hotjar',
'reviewer-role',
- 'papers-integration',
'editor-redesign',
'paywall-change-compile-timeout',
'overleaf-assist-bundle',
+ 'wf-feature-rebrand',
+ 'word-count-client',
+ 'editor-popup-ux-survey',
].filter(Boolean)
const getUserValues = async userId =>
@@ -364,7 +365,7 @@ const _ProjectController = {
user: (async () => {
const user = await User.findById(
userId,
- 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram completedTutorials writefull aiErrorAssistant'
+ 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram labsExperiments completedTutorials writefull aiErrorAssistant'
).exec()
// Handle case of deleted user
if (!user) {
@@ -404,13 +405,6 @@ const _ProjectController = {
userId,
projectId
),
- usedLatex: OnboardingDataCollectionManager.getOnboardingDataValue(
- userId,
- 'usedLatex'
- ).catch(err => {
- logger.error({ err, userId })
- return null
- }),
odcRole: OnboardingDataCollectionManager.getOnboardingDataValue(
userId,
'role'
@@ -470,7 +464,6 @@ const _ProjectController = {
subscription,
isTokenMember,
isInvitedMember,
- usedLatex,
odcRole,
} = userValues
@@ -567,10 +560,11 @@ const _ProjectController = {
.catch(err =>
logger.error({ err }, 'failed to update split test info in session')
)
+
+ const ownerFeatures = await UserGetter.promises.getUserFeatures(
+ project.owner_ref
+ )
if (userId) {
- const ownerFeatures = await UserGetter.promises.getUserFeatures(
- project.owner_ref
- )
const planLimit = ownerFeatures?.collaborators || 0
const namedEditors = project.collaberator_refs?.length || 0
const pendingEditors = project.pendingEditor_refs?.length || 0
@@ -796,15 +790,19 @@ const _ProjectController = {
let planCode = subscription?.planCode
if (!planCode && !userInNonIndividualSub) {
- planCode = 'free'
+ planCode = 'personal'
}
+ const planDetails = Settings.plans.find(p => p.planCode === planCode)
+
res.render(template, {
title: project.name,
priority_title: true,
bodyClasses: ['editor'],
project_id: project._id,
projectName: project.name,
+ projectOwnerHasPremiumOnPageLoad:
+ ownerFeatures?.compileGroup === 'priority',
user: {
id: userId,
email: user.email,
@@ -829,6 +827,8 @@ const _ProjectController = {
inactiveTutorials: TutorialHandler.getInactiveTutorials(user),
isAdmin: hasAdminAccess(user),
planCode,
+ planName: planDetails?.name,
+ isAnnualPlan: planCode && planDetails?.annual,
isMemberOfGroupSubscription: userIsMemberOfGroupSubscription,
hasInstitutionLicence: userHasInstitutionLicence,
},
@@ -847,6 +847,7 @@ const _ProjectController = {
referencesSearchMode: user.ace.referencesSearchMode,
enableNewEditor: user.ace.enableNewEditor ?? true,
},
+ labsExperiments: user.labsExperiments ?? [],
privilegeLevel,
anonymous,
isTokenMember,
@@ -887,12 +888,6 @@ const _ProjectController = {
fixedSizeDocument: true,
hasTrackChangesFeature: Features.hasFeature('track-changes'),
projectTags,
- usedLatex:
- // only use the usedLatex value if the split test is enabled
- splitTestAssignments['default-visual-for-beginners']?.variant ===
- 'enabled'
- ? usedLatex
- : null,
odcRole:
// only use the ODC role value if the split test is enabled
splitTestAssignments['paywall-change-compile-timeout']?.variant ===
@@ -943,7 +938,7 @@ const _ProjectController = {
return plansData
},
- async _getAddonPrices(req, res, addonPlans = ['assistBundle']) {
+ async _getAddonPrices(req, res, addonPlans = ['assistant']) {
const plansData = {}
const locale = req.i18n.language
diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js
index 62d893dd0f..c5dcafd335 100644
--- a/services/web/app/src/Features/Project/ProjectDeleter.js
+++ b/services/web/app/src/Features/Project/ProjectDeleter.js
@@ -80,7 +80,12 @@ async function unmarkAsDeletedByExternalSource(projectId) {
async function deleteUsersProjects(userId) {
const projects = await Project.find({ owner_ref: userId }).exec()
+ logger.info(
+ { userId, projectCount: projects.length },
+ 'found user projects to delete'
+ )
await promiseMapWithLimit(5, projects, project => deleteProject(project._id))
+ logger.info({ userId }, 'deleted all user projects')
await CollaboratorsHandler.promises.removeUserFromAllProjects(userId)
}
diff --git a/services/web/app/src/Features/Project/ProjectDuplicator.js b/services/web/app/src/Features/Project/ProjectDuplicator.js
index da18c7e9b8..47c00ed3df 100644
--- a/services/web/app/src/Features/Project/ProjectDuplicator.js
+++ b/services/web/app/src/Features/Project/ProjectDuplicator.js
@@ -21,6 +21,7 @@ const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
const _ = require('lodash')
const TagsHandler = require('../Tags/TagsHandler')
const Features = require('../../infrastructure/Features')
+const ClsiCacheManager = require('../Compile/ClsiCacheManager')
module.exports = {
duplicate: callbackify(duplicate),
@@ -35,6 +36,7 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
originalProjectId,
{
compiler: true,
+ imageName: true,
rootFolder: true,
rootDoc_id: true,
fromV1TemplateId: true,
@@ -73,6 +75,21 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
{ segmentation }
)
+ let prepareClsiCacheInBackground = Promise.resolve()
+ if (originalProject.imageName === newProject.imageName) {
+ // Populate the clsi-cache unless the TeXLive release has changed.
+ prepareClsiCacheInBackground = ClsiCacheManager.prepareClsiCache(
+ newProject._id,
+ owner._id,
+ { sourceProjectId: originalProjectId }
+ ).catch(err => {
+ logger.warn(
+ { err, originalProjectId, projectId: newProject._id },
+ 'failed to prepare clsi-cache for cloned project'
+ )
+ })
+ }
+
try {
await ProjectOptionsHandler.promises.setCompiler(
newProject._id,
@@ -120,6 +137,10 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
})
}
+ try {
+ await prepareClsiCacheInBackground
+ } catch {}
+
return newProject
}
diff --git a/services/web/app/src/Features/Project/ProjectEntityHandler.js b/services/web/app/src/Features/Project/ProjectEntityHandler.js
index b834f58d2a..7d0498ffd0 100644
--- a/services/web/app/src/Features/Project/ProjectEntityHandler.js
+++ b/services/web/app/src/Features/Project/ProjectEntityHandler.js
@@ -99,10 +99,18 @@ function getAllDocPathsFromProject(project) {
return docPath
}
-async function getDoc(projectId, docId) {
+/**
+ *
+ * @param {string} projectId
+ * @param {string} docId
+ * @param {{peek?: boolean, include_deleted?: boolean}} options
+ * @return {Promise<{lines: *, rev: *, version: *, ranges: *}>}
+ */
+async function getDoc(projectId, docId, options = {}) {
const { lines, rev, version, ranges } = await DocstoreManager.promises.getDoc(
projectId,
- docId
+ docId,
+ options
)
return { lines, rev, version, ranges }
}
diff --git a/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js b/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js
index 31b3efaec8..84002f1a38 100644
--- a/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js
+++ b/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js
@@ -105,7 +105,7 @@ function wrapWithLock(methodWithoutLock) {
return methodWithLock
}
-async function addDoc(projectId, folderId, doc) {
+async function addDoc(projectId, folderId, doc, userId) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{
@@ -119,12 +119,13 @@ async function addDoc(projectId, folderId, doc) {
project,
folderId,
doc,
- 'doc'
+ 'doc',
+ userId
)
return { result, project: newProject }
}
-async function addFile(projectId, folderId, fileRef) {
+async function addFile(projectId, folderId, fileRef, userId) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{ rootFolder: true, name: true, overleaf: true }
@@ -134,23 +135,24 @@ async function addFile(projectId, folderId, fileRef) {
project,
folderId,
fileRef,
- 'file'
+ 'file',
+ userId
)
return { result, project: newProject }
}
-async function addFolder(projectId, parentFolderId, folderName) {
+async function addFolder(projectId, parentFolderId, folderName, userId) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{ rootFolder: true, name: true, overleaf: true }
)
parentFolderId = _confirmFolder(project, parentFolderId)
const folder = new Folder({ name: folderName })
- await _putElement(project, parentFolderId, folder, 'folder')
+ await _putElement(project, parentFolderId, folder, 'folder', userId)
return { folder, parentFolderId }
}
-async function replaceFileWithNew(projectId, fileId, newFileRef) {
+async function replaceFileWithNew(projectId, fileId, newFileRef, userId) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{ rootFolder: true, name: true, overleaf: true }
@@ -169,6 +171,8 @@ async function replaceFileWithNew(projectId, fileId, newFileRef) {
[`${path.mongo}.created`]: new Date(),
[`${path.mongo}.linkedFileData`]: newFileRef.linkedFileData,
[`${path.mongo}.hash`]: newFileRef.hash,
+ lastUpdated: new Date(),
+ lastUpdatedBy: userId,
},
$inc: {
version: 1,
@@ -194,7 +198,7 @@ async function replaceFileWithNew(projectId, fileId, newFileRef) {
return { oldFileRef: fileRef, project, path, newProject, newFileRef }
}
-async function replaceDocWithFile(projectId, docId, fileRef) {
+async function replaceDocWithFile(projectId, docId, fileRef, userId) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{ rootFolder: true, name: true, overleaf: true }
@@ -215,6 +219,7 @@ async function replaceDocWithFile(projectId, docId, fileRef) {
[`${folderMongoPath}.fileRefs`]: fileRef,
},
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
},
{ new: true }
).exec()
@@ -227,7 +232,7 @@ async function replaceDocWithFile(projectId, docId, fileRef) {
return newProject
}
-async function replaceFileWithDoc(projectId, fileId, newDoc) {
+async function replaceFileWithDoc(projectId, fileId, newDoc, userId) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{ rootFolder: true, name: true, overleaf: true }
@@ -248,6 +253,7 @@ async function replaceFileWithDoc(projectId, fileId, newDoc) {
[`${folderMongoPath}.docs`]: newDoc,
},
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
},
{ new: true }
).exec()
@@ -260,7 +266,7 @@ async function replaceFileWithDoc(projectId, fileId, newDoc) {
return newProject
}
-async function mkdirp(projectId, path, options = {}) {
+async function mkdirp(projectId, path, userId, options = {}) {
// defaults to case insensitive paths, use options {exactCaseMatch:true}
// to make matching case-sensitive
const folders = path.split('/').filter(folder => folder.length !== 0)
@@ -289,7 +295,7 @@ async function mkdirp(projectId, path, options = {}) {
// Folder couldn't be found. Create it.
const parentFolderId = lastFolder && lastFolder._id
const { folder: newFolder, parentFolderId: newParentFolderId } =
- await addFolder(projectId, parentFolderId, folderName)
+ await addFolder(projectId, parentFolderId, folderName, userId)
newFolder.parentFolder_id = newParentFolderId
lastFolder = newFolder
newFolders.push(newFolder)
@@ -298,7 +304,13 @@ async function mkdirp(projectId, path, options = {}) {
return { folder: lastFolder, newFolders }
}
-async function moveEntity(projectId, entityId, destFolderId, entityType) {
+async function moveEntity(
+ projectId,
+ entityId,
+ destFolderId,
+ entityType,
+ userId
+) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{ rootFolder: true, name: true, overleaf: true }
@@ -326,7 +338,8 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) {
project,
destFolderId,
entity,
- entityType
+ entityType,
+ userId
)
// Note: putElement always pushes onto the end of an
// array so it will never change an existing mongo
@@ -337,10 +350,10 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) {
// is done by _checkValidMove above) because that
// would lead to it being deleted.
const newProject = await _removeElementFromMongoArray(
- Project,
projectId,
entityPath.mongo,
- entityId
+ entityId,
+ userId
)
const { docs: newDocs, files: newFiles } =
ProjectEntityHandler.getAllEntitiesFromProject(newProject)
@@ -375,7 +388,7 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) {
return { project, startPath, endPath, rev: entity.rev, changes }
}
-async function deleteEntity(projectId, entityId, entityType) {
+async function deleteEntity(projectId, entityId, entityType, userId) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{ name: true, rootFolder: true, overleaf: true, rootDoc_id: true }
@@ -399,22 +412,16 @@ async function deleteEntity(projectId, entityId, entityType) {
type: entityType,
})
const newProject = await _removeElementFromMongoArray(
- Project,
projectId,
path.mongo,
entityId,
+ userId,
deleteRootDoc
)
return { entity, path, projectBeforeDeletion: project, newProject }
}
-async function renameEntity(
- projectId,
- entityId,
- entityType,
- newName,
- callback
-) {
+async function renameEntity(projectId, entityId, entityType, newName, userId) {
const project = await ProjectGetter.promises.getProjectWithoutLock(
projectId,
{ rootFolder: true, name: true, overleaf: true }
@@ -445,7 +452,14 @@ async function renameEntity(
// we need to increment the project version number for any structure change
const newProject = await Project.findOneAndUpdate(
{ _id: projectId, [entPath.mongo]: { $exists: true } },
- { $set: { [`${entPath.mongo}.name`]: newName }, $inc: { version: 1 } },
+ {
+ $set: {
+ [`${entPath.mongo}.name`]: newName,
+ lastUpdated: new Date(),
+ lastUpdatedBy: userId,
+ },
+ $inc: { version: 1 },
+ },
{ new: true }
).exec()
if (newProject == null) {
@@ -478,10 +492,10 @@ async function _insertDeletedFileReference(projectId, fileRef) {
}
async function _removeElementFromMongoArray(
- model,
modelId,
path,
elementId,
+ userId,
deleteRootDoc = false
) {
const nonArrayPath = path.slice(0, path.lastIndexOf('.'))
@@ -490,11 +504,12 @@ async function _removeElementFromMongoArray(
const update = {
$pull: { [nonArrayPath]: { _id: elementId } },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
if (deleteRootDoc) {
update.$unset = { rootDoc_id: 1 }
}
- return model.findOneAndUpdate(query, update, options).exec()
+ return Project.findOneAndUpdate(query, update, options).exec()
}
function _countElements(project) {
@@ -522,7 +537,7 @@ function _countElements(project) {
return countFolder(project.rootFolder[0])
}
-async function _putElement(project, folderId, element, type) {
+async function _putElement(project, folderId, element, type, userId) {
if (element == null || element._id == null) {
logger.warn(
{ projectId: project._id, folderId, element, type },
@@ -578,7 +593,11 @@ async function _putElement(project, folderId, element, type) {
const mongoPath = `${path.mongo}.${pathSegment}`
const newProject = await Project.findOneAndUpdate(
{ _id: project._id, [path.mongo]: { $exists: true } },
- { $push: { [mongoPath]: element }, $inc: { version: 1 } },
+ {
+ $push: { [mongoPath]: element },
+ $inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
+ },
{ new: true }
).exec()
if (newProject == null) {
@@ -709,7 +728,11 @@ async function createNewFolderStructure(projectId, docEntries, fileEntries) {
'rootFolder.0.files.0': { $exists: false },
},
{
- $set: { rootFolder: [rootFolder] },
+ $set: {
+ rootFolder: [rootFolder],
+ // NOTE: Do not set lastUpdated/lastUpdatedBy here. They are both set when creating the initial record.
+ // The newly created clsi-cache record uses the lastUpdated timestamp of the initial record. Updating the lastUpdated timestamp here invalidates the cache record.
+ },
$inc: { version: 1 },
},
{
diff --git a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js
index dd86dc4724..585c2d2698 100644
--- a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js
+++ b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js
@@ -296,7 +296,8 @@ const addDocWithRanges = wrapWithLock({
await ProjectEntityUpdateHandler._addDocAndSendToTpds(
projectId,
folderId,
- doc
+ doc,
+ userId
)
const docPath = result?.path?.fileSystem
const projectHistoryId = project?.overleaf?.history?.id
@@ -364,7 +365,8 @@ const addFile = wrapWithLock({
await ProjectEntityUpdateHandler._addFileAndSendToTpds(
projectId,
folderId,
- fileRef
+ fileRef,
+ userId
)
const projectHistoryId = project.overleaf?.history?.id
const newFiles = [
@@ -382,12 +384,6 @@ const addFile = wrapWithLock({
{ newFiles, newProject: project },
source
)
-
- ProjectUpdateHandler.promises
- .markAsUpdated(projectId, new Date(), userId)
- .catch(error => {
- logger.error({ error }, 'failed to mark project as updated')
- })
return { fileRef, folderId, createdBlob }
},
})
@@ -434,7 +430,8 @@ const upsertDoc = wrapWithLock(
await ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc(
projectId,
existingFile._id,
- doc
+ doc,
+ userId
)
await TpdsUpdateSender.promises.addDoc({
@@ -616,7 +613,8 @@ const upsertFile = wrapWithLock({
await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
projectId,
existingDoc._id,
- fileRef
+ fileRef,
+ userId
)
const projectHistoryId = project.overleaf?.history?.id
await TpdsUpdateSender.promises.addFile({
@@ -699,7 +697,8 @@ const upsertDocWithPath = wrapWithLock(
const { newFolders, folder } =
await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
projectId,
- folderPath
+ folderPath,
+ userId
)
const { isNew, doc } =
await ProjectEntityUpdateHandler.promises.upsertDoc.withoutLock(
@@ -772,7 +771,8 @@ const upsertFileWithPath = wrapWithLock({
const { newFolders, folder } =
await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
projectId,
- folderPath
+ folderPath,
+ userId
)
// this calls directly into the upsertFile main task (without the beforeLock part)
const {
@@ -818,7 +818,8 @@ const deleteEntity = wrapWithLock(
await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
projectId,
entityId,
- entityType
+ entityType,
+ userId
)
const subtreeListing = await ProjectEntityUpdateHandler._cleanUpEntity(
projectBeforeDeletion,
@@ -866,7 +867,7 @@ const deleteEntityWithPath = wrapWithLock(
}
)
-const mkdirp = wrapWithLock(async function (projectId, path) {
+const mkdirp = wrapWithLock(async function (projectId, path, userId) {
for (const folder of path.split('/')) {
if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
throw new Errors.InvalidNameError('invalid element name')
@@ -875,32 +876,37 @@ const mkdirp = wrapWithLock(async function (projectId, path) {
return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
projectId,
path,
+ userId,
{ exactCaseMatch: false }
)
})
-const mkdirpWithExactCase = wrapWithLock(async function (projectId, path) {
- for (const folder of path.split('/')) {
- if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
- throw new Errors.InvalidNameError('invalid element name')
+const mkdirpWithExactCase = wrapWithLock(
+ async function (projectId, path, userId) {
+ for (const folder of path.split('/')) {
+ if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
+ throw new Errors.InvalidNameError('invalid element name')
+ }
}
+ return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
+ projectId,
+ path,
+ userId,
+ { exactCaseMatch: true }
+ )
}
- return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
- projectId,
- path,
- { exactCaseMatch: true }
- )
-})
+)
const addFolder = wrapWithLock(
- async function (projectId, parentFolderId, folderName) {
+ async function (projectId, parentFolderId, folderName, userId) {
if (!SafePath.isCleanFilename(folderName)) {
throw new Errors.InvalidNameError('invalid element name')
}
return await ProjectEntityMongoUpdateHandler.promises.addFolder(
projectId,
parentFolderId,
- folderName
+ folderName,
+ userId
)
}
)
@@ -929,7 +935,8 @@ const moveEntity = wrapWithLock(
projectId,
entityId,
destFolderId,
- entityType
+ entityType,
+ userId
)
const projectHistoryId = project.overleaf?.history?.id
@@ -988,7 +995,8 @@ const renameEntity = wrapWithLock(
projectId,
entityId,
entityType,
- newName
+ newName,
+ userId
)
const projectHistoryId = project.overleaf?.history?.id
@@ -1115,7 +1123,8 @@ const convertDocToFile = wrapWithLock({
await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
projectId,
doc._id,
- fileRef
+ fileRef,
+ userId
)
const projectHistoryId = project.overleaf?.history?.id
await DocumentUpdaterHandler.promises.updateProjectStructure(
@@ -1274,16 +1283,18 @@ const ProjectEntityUpdateHandler = {
upsertFile,
upsertFileWithPath,
appendToDocWithPath: appendToDoc,
+ setMainBibliographyDoc,
},
- async _addDocAndSendToTpds(projectId, folderId, doc) {
+ async _addDocAndSendToTpds(projectId, folderId, doc, userId) {
let result, project
try {
;({ result, project } =
await ProjectEntityMongoUpdateHandler.promises.addDoc(
projectId,
folderId,
- doc
+ doc,
+ userId
))
} catch (err) {
throw OError.tag(err, 'error adding file with project', {
@@ -1328,14 +1339,15 @@ const ProjectEntityUpdateHandler = {
}
},
- async _addFileAndSendToTpds(projectId, folderId, fileRef) {
+ async _addFileAndSendToTpds(projectId, folderId, fileRef, userId) {
let result, project
try {
;({ result, project } =
await ProjectEntityMongoUpdateHandler.promises.addFile(
projectId,
folderId,
- fileRef
+ fileRef,
+ userId
))
} catch (err) {
throw OError.tag(err, 'error adding file with project', {
@@ -1382,7 +1394,8 @@ const ProjectEntityUpdateHandler = {
} = await ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew(
projectId,
fileId,
- newFileRef
+ newFileRef,
+ userId
)
const oldFiles = [
@@ -1410,11 +1423,6 @@ const ProjectEntityUpdateHandler = {
projectName: project.name,
folderId,
})
- ProjectUpdateHandler.promises
- .markAsUpdated(projectId, new Date(), userId)
- .catch(error => {
- logger.error({ error }, 'failed to mark project as updated')
- })
await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
@@ -1538,7 +1546,8 @@ const ProjectEntityUpdateHandler = {
projectId,
entityId,
entityType,
- rename.newName
+ rename.newName,
+ null // unset lastUpdatedBy
)
// update the renamed entity for the resync
diff --git a/services/web/app/src/Features/Project/ProjectGetter.js b/services/web/app/src/Features/Project/ProjectGetter.js
index 7edf08d7a2..5b93cdc861 100644
--- a/services/web/app/src/Features/Project/ProjectGetter.js
+++ b/services/web/app/src/Features/Project/ProjectGetter.js
@@ -83,6 +83,9 @@ const ProjectGetter = {
return project._id
},
+ /**
+ * @return {Promise}
+ */
async findAllUsersProjects(userId, fields) {
const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter')
const ownedProjects = await Project.find(
diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs
index 6ae7080961..61131ec617 100644
--- a/services/web/app/src/Features/Project/ProjectListController.mjs
+++ b/services/web/app/src/Features/Project/ProjectListController.mjs
@@ -115,7 +115,7 @@ async function projectListPage(req, res, next) {
})
const user = await User.findById(
userId,
- `email emails features alphaProgram betaProgram lastPrimaryEmailCheck signUpDate${
+ `email emails features alphaProgram betaProgram lastPrimaryEmailCheck signUpDate refProviders${
isSaas ? ' enrollment writefull completedTutorials' : ''
}`
)
@@ -126,6 +126,8 @@ async function projectListPage(req, res, next) {
return
}
+ user.refProviders = _.mapValues(user.refProviders, Boolean)
+
if (isSaas) {
await SplitTestSessionHandler.promises.sessionMaintenance(req, user)
@@ -409,13 +411,13 @@ async function projectListPage(req, res, next) {
logger.error({ err: error }, 'Failed to get individual subscription')
}
- // Get the user's assignment for the DS unified nav split test, which
- // populates splitTestVariants with a value for the split test name and allows
- // Pug to send it to the browser
+ // Get the user's assignment for the papers notification banner split test,
+ // which populates splitTestVariants with a value for the split test name and
+ // allows Pug to send it to the browser
await SplitTestHandler.promises.getAssignment(
req,
res,
- 'sidebar-navigation-ui-update'
+ 'papers-notification-banner'
)
res.render('project/list-react', {
diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.js b/services/web/app/src/Features/Subscription/FeaturesUpdater.js
index 1e7a868fc0..eff88d45fb 100644
--- a/services/web/app/src/Features/Subscription/FeaturesUpdater.js
+++ b/services/web/app/src/Features/Subscription/FeaturesUpdater.js
@@ -14,7 +14,7 @@ const UserGetter = require('../User/UserGetter')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const Queues = require('../../infrastructure/Queues')
const Modules = require('../../infrastructure/Modules')
-const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
+const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities')
/**
* Enqueue a job for refreshing features for the given user
@@ -197,6 +197,14 @@ async function doSyncFromV1(v1UserId) {
return refreshFeatures(user._id, 'sync-v1')
}
+async function hasFeaturesViaWritefull(userId) {
+ const user = await UserGetter.promises.getUser(userId, {
+ _id: 1,
+ writefull: 1,
+ })
+ return Boolean(user?.writefull?.isPremium)
+}
+
module.exports = {
featuresEpochIsCurrent,
computeFeatures: callbackify(computeFeatures),
@@ -209,10 +217,12 @@ module.exports = {
'featuresChanged',
]),
scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures),
+ hasFeaturesViaWritefull: callbackify(hasFeaturesViaWritefull),
promises: {
computeFeatures,
refreshFeatures,
scheduleRefreshFeatures,
doSyncFromV1,
+ hasFeaturesViaWritefull,
},
}
diff --git a/services/web/app/src/Features/Subscription/LimitationsManager.js b/services/web/app/src/Features/Subscription/LimitationsManager.js
index d0c3d29b7b..464a8bc273 100644
--- a/services/web/app/src/Features/Subscription/LimitationsManager.js
+++ b/services/web/app/src/Features/Subscription/LimitationsManager.js
@@ -128,7 +128,11 @@ async function userHasSubscription(user) {
)
let hasValidSubscription = false
if (subscription) {
- if (subscription.recurlySubscription_id || subscription.customAccount) {
+ if (
+ subscription.recurlySubscription_id ||
+ subscription.paymentProvider?.subscriptionId ||
+ subscription.customAccount
+ ) {
hasValidSubscription = true
}
}
diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js
similarity index 63%
rename from services/web/app/src/Features/Subscription/RecurlyEntities.js
rename to services/web/app/src/Features/Subscription/PaymentProviderEntities.js
index 5c6f700b5f..8cc15f6f2e 100644
--- a/services/web/app/src/Features/Subscription/RecurlyEntities.js
+++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js
@@ -1,5 +1,9 @@
// @ts-check
+/**
+ * @import { PaymentProvider } from '../../../../types/subscription/dashboard/subscription'
+ */
+
const OError = require('@overleaf/o-error')
const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
const PlansLocator = require('./PlansLocator')
@@ -9,7 +13,7 @@ const AI_ADD_ON_CODE = 'assistant'
const MEMBERS_LIMIT_ADD_ON_CODE = 'additional-license'
const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
-class RecurlySubscription {
+class PaymentProviderSubscription {
/**
* @param {object} props
* @param {string} props.id
@@ -17,7 +21,7 @@ class RecurlySubscription {
* @param {string} props.planCode
* @param {string} props.planName
* @param {number} props.planPrice
- * @param {RecurlySubscriptionAddOn[]} [props.addOns]
+ * @param {PaymentProviderSubscriptionAddOn[]} [props.addOns]
* @param {number} props.subtotal
* @param {number} [props.taxRate]
* @param {number} [props.taxAmount]
@@ -26,7 +30,15 @@ class RecurlySubscription {
* @param {Date} props.periodStart
* @param {Date} props.periodEnd
* @param {string} props.collectionMethod
- * @param {RecurlySubscriptionChange} [props.pendingChange]
+ * @param {string} [props.poNumber]
+ * @param {string} [props.termsAndConditions]
+ * @param {PaymentProviderSubscriptionChange} [props.pendingChange]
+ * @param {PaymentProvider['service']} [props.service]
+ * @param {string} [props.state]
+ * @param {Date|null} [props.trialPeriodStart]
+ * @param {Date|null} [props.trialPeriodEnd]
+ * @param {Date|null} [props.pausePeriodStart]
+ * @param {number|null} [props.remainingPauseCycles]
*/
constructor(props) {
this.id = props.id
@@ -43,7 +55,15 @@ class RecurlySubscription {
this.periodStart = props.periodStart
this.periodEnd = props.periodEnd
this.collectionMethod = props.collectionMethod
+ this.poNumber = props.poNumber ?? ''
+ this.termsAndConditions = props.termsAndConditions ?? ''
this.pendingChange = props.pendingChange ?? null
+ this.service = props.service ?? 'recurly'
+ this.state = props.state ?? 'active'
+ this.trialPeriodStart = props.trialPeriodStart ?? null
+ this.trialPeriodEnd = props.trialPeriodEnd ?? null
+ this.pausePeriodStart = props.pausePeriodStart ?? null
+ this.remainingPauseCycles = props.remainingPauseCycles ?? null
}
/**
@@ -87,7 +107,7 @@ class RecurlySubscription {
/**
* Change this subscription's plan
*
- * @return {RecurlySubscriptionChangeRequest}
+ * @return {PaymentProviderSubscriptionChangeRequest}
*/
getRequestForPlanChange(planCode) {
const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode)
@@ -105,7 +125,7 @@ class RecurlySubscription {
newPlan
)
- const changeRequest = new RecurlySubscriptionChangeRequest({
+ const changeRequest = new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
planCode,
@@ -117,7 +137,7 @@ class RecurlySubscription {
(!shouldChangeAtTermEnd && this.hasAddOn(AI_ADD_ON_CODE)) ||
(shouldChangeAtTermEnd && this.hasAddOnNextPeriod(AI_ADD_ON_CODE))
) {
- const addOnUpdate = new RecurlySubscriptionAddOnUpdate({
+ const addOnUpdate = new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
})
@@ -133,7 +153,7 @@ class RecurlySubscription {
* @param {string} code
* @param {number} [quantity]
* @param {number} [unitPrice]
- * @return {RecurlySubscriptionChangeRequest} - the change request to send to
+ * @return {PaymentProviderSubscriptionChangeRequest} - the change request to send to
* Recurly
*
* @throws {DuplicateAddOnError} if the add-on is already present on the subscription
@@ -148,9 +168,9 @@ class RecurlySubscription {
const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
addOnUpdates.push(
- new RecurlySubscriptionAddOnUpdate({ code, quantity, unitPrice })
+ new PaymentProviderSubscriptionAddOnUpdate({ code, quantity, unitPrice })
)
- return new RecurlySubscriptionChangeRequest({
+ return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
addOnUpdates,
@@ -162,7 +182,7 @@ class RecurlySubscription {
*
* @param {string} code
* @param {number} quantity
- * @return {RecurlySubscriptionChangeRequest} - the change request to send to
+ * @return {PaymentProviderSubscriptionChangeRequest} - the change request to send to
* Recurly
*
* @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
@@ -188,7 +208,7 @@ class RecurlySubscription {
return update
})
- return new RecurlySubscriptionChangeRequest({
+ return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
addOnUpdates,
@@ -199,7 +219,7 @@ class RecurlySubscription {
* Remove an add-on from this subscription
*
* @param {string} code
- * @return {RecurlySubscriptionChangeRequest}
+ * @return {PaymentProviderSubscriptionChangeRequest}
*
* @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
*/
@@ -216,7 +236,7 @@ class RecurlySubscription {
const addOnUpdates = this.addOns
.filter(addOn => addOn.code !== code)
.map(addOn => addOn.toAddOnUpdate())
- return new RecurlySubscriptionChangeRequest({
+ return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'term_end',
addOnUpdates,
@@ -227,19 +247,19 @@ class RecurlySubscription {
* Upgrade group plan with the plan code provided
*
* @param {string} newPlanCode
- * @return {RecurlySubscriptionChangeRequest}
+ * @return {PaymentProviderSubscriptionChangeRequest}
*/
getRequestForGroupPlanUpgrade(newPlanCode) {
// Ensure all the existing add-ons are added to the new plan
const addOns = this.addOns.map(
addOn =>
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: addOn.code,
quantity: addOn.quantity,
})
)
- return new RecurlySubscriptionChangeRequest({
+ return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
addOnUpdates: addOns,
@@ -247,6 +267,39 @@ class RecurlySubscription {
})
}
+ /**
+ * Update the "PO number" and "Terms and conditions" in a subscription
+ *
+ * @param {string} poNumber
+ * @param {string} termsAndConditions
+ * @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to
+ * Recurly
+ */
+ getRequestForPoNumberAndTermsAndConditionsUpdate(
+ poNumber,
+ termsAndConditions
+ ) {
+ return new PaymentProviderSubscriptionUpdateRequest({
+ subscription: this,
+ poNumber,
+ termsAndConditions,
+ })
+ }
+
+ /**
+ * Update the "Terms and conditions" in a subscription
+ *
+ * @param {string} termsAndConditions
+ * @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to
+ * Recurly
+ */
+ getRequestForTermsAndConditionsUpdate(termsAndConditions) {
+ return new PaymentProviderSubscriptionUpdateRequest({
+ subscription: this,
+ termsAndConditions,
+ })
+ }
+
/**
* Returns whether this subscription is manually collected
*
@@ -260,7 +313,7 @@ class RecurlySubscription {
/**
* An add-on attached to a subscription
*/
-class RecurlySubscriptionAddOn {
+class PaymentProviderSubscriptionAddOn {
/**
* @param {object} props
* @param {string} props.code
@@ -280,7 +333,7 @@ class RecurlySubscriptionAddOn {
* Return an add-on update that doesn't modify the add-on
*/
toAddOnUpdate() {
- return new RecurlySubscriptionAddOnUpdate({
+ return new PaymentProviderSubscriptionAddOnUpdate({
code: this.code,
quantity: this.quantity,
unitPrice: this.unitPrice,
@@ -288,17 +341,33 @@ class RecurlySubscriptionAddOn {
}
}
-class RecurlySubscriptionChangeRequest {
+class PaymentProviderSubscriptionUpdateRequest {
/**
* @param {object} props
- * @param {RecurlySubscription} props.subscription
+ * @param {PaymentProviderSubscription} props.subscription
+ * @param {string} [props.poNumber]
+ * @param {string} [props.termsAndConditions]
+ */
+ constructor(props) {
+ this.subscription = props.subscription
+ this.poNumber = props.poNumber ?? ''
+ this.termsAndConditions = props.termsAndConditions ?? ''
+ }
+}
+
+class PaymentProviderSubscriptionChangeRequest {
+ /**
+ * @param {object} props
+ * @param {PaymentProviderSubscription} props.subscription
* @param {"now" | "term_end"} props.timeframe
* @param {string} [props.planCode]
- * @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
+ * @param {PaymentProviderSubscriptionAddOnUpdate[]} [props.addOnUpdates]
*/
constructor(props) {
if (props.planCode == null && props.addOnUpdates == null) {
- throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
+ throw new OError('Invalid PaymentProviderSubscriptionChangeRequest', {
+ props,
+ })
}
this.subscription = props.subscription
this.timeframe = props.timeframe
@@ -307,7 +376,7 @@ class RecurlySubscriptionChangeRequest {
}
}
-class RecurlySubscriptionAddOnUpdate {
+class PaymentProviderSubscriptionAddOnUpdate {
/**
* @param {object} props
* @param {string} props.code
@@ -321,15 +390,15 @@ class RecurlySubscriptionAddOnUpdate {
}
}
-class RecurlySubscriptionChange {
+class PaymentProviderSubscriptionChange {
/**
* @param {object} props
- * @param {RecurlySubscription} props.subscription
+ * @param {PaymentProviderSubscription} props.subscription
* @param {string} props.nextPlanCode
* @param {string} props.nextPlanName
* @param {number} props.nextPlanPrice
- * @param {RecurlySubscriptionAddOn[]} props.nextAddOns
- * @param {RecurlyImmediateCharge} [props.immediateCharge]
+ * @param {PaymentProviderSubscriptionAddOn[]} props.nextAddOns
+ * @param {PaymentProviderImmediateCharge} [props.immediateCharge]
*/
constructor(props) {
this.subscription = props.subscription
@@ -339,7 +408,12 @@ class RecurlySubscriptionChange {
this.nextAddOns = props.nextAddOns
this.immediateCharge =
props.immediateCharge ??
- new RecurlyImmediateCharge({ subtotal: 0, tax: 0, total: 0, discount: 0 })
+ new PaymentProviderImmediateCharge({
+ subtotal: 0,
+ tax: 0,
+ total: 0,
+ discount: 0,
+ })
this.subtotal = this.nextPlanPrice
for (const addOn of this.nextAddOns) {
@@ -378,7 +452,7 @@ class CreditCardPaymentMethod {
}
}
-class RecurlyImmediateCharge {
+class PaymentProviderImmediateCharge {
/**
* @param {object} props
* @param {number} props.subtotal
@@ -397,7 +471,7 @@ class RecurlyImmediateCharge {
/**
* An add-on configuration, independent of any subscription
*/
-class RecurlyAddOn {
+class PaymentProviderAddOn {
/**
* @param {object} props
* @param {string} props.code
@@ -412,7 +486,7 @@ class RecurlyAddOn {
/**
* A plan configuration
*/
-class RecurlyPlan {
+class PaymentProviderPlan {
/**
* @param {object} props
* @param {string} props.code
@@ -424,6 +498,40 @@ class RecurlyPlan {
}
}
+/**
+ * A coupon in the payment provider
+ */
+class PaymentProviderCoupon {
+ /**
+ * @param {object} props
+ * @param {string} props.code
+ * @param {string} props.name
+ * @param {string} props.description
+ */
+ constructor(props) {
+ this.code = props.code
+ this.name = props.name
+ this.description = props.description
+ }
+}
+
+/**
+ * An account in the payment provider
+ */
+class PaymentProviderAccount {
+ /**
+ * @param {object} props
+ * @param {string} props.code
+ * @param {string} props.email
+ * @param {boolean} props.hasPastDueInvoice
+ */
+ constructor(props) {
+ this.code = props.code
+ this.email = props.email
+ this.hasPastDueInvoice = props.hasPastDueInvoice ?? false
+ }
+}
+
/**
* Returns whether the given plan code is a standalone AI plan
*
@@ -433,19 +541,39 @@ function isStandaloneAiAddOnPlanCode(planCode) {
return STANDALONE_AI_ADD_ON_CODES.includes(planCode)
}
+/**
+ * Returns whether subscription change will have have the ai bundle once the change is processed
+ *
+ * @param {PaymentProviderSubscriptionChange} subscriptionChange The subscription change object coming from payment provider
+ *
+ * @return {boolean}
+ */
+function subscriptionChangeIsAiAssistUpgrade(subscriptionChange) {
+ return Boolean(
+ isStandaloneAiAddOnPlanCode(subscriptionChange.nextPlanCode) ||
+ subscriptionChange.nextAddOns?.some(
+ addOn => addOn.code === AI_ADD_ON_CODE
+ )
+ )
+}
+
module.exports = {
AI_ADD_ON_CODE,
MEMBERS_LIMIT_ADD_ON_CODE,
STANDALONE_AI_ADD_ON_CODES,
- RecurlySubscription,
- RecurlySubscriptionAddOn,
- RecurlySubscriptionChange,
- RecurlySubscriptionChangeRequest,
- RecurlySubscriptionAddOnUpdate,
+ PaymentProviderSubscription,
+ PaymentProviderSubscriptionAddOn,
+ PaymentProviderSubscriptionChange,
+ PaymentProviderSubscriptionChangeRequest,
+ PaymentProviderSubscriptionUpdateRequest,
+ PaymentProviderSubscriptionAddOnUpdate,
PaypalPaymentMethod,
CreditCardPaymentMethod,
- RecurlyAddOn,
- RecurlyPlan,
+ PaymentProviderAddOn,
+ PaymentProviderPlan,
+ PaymentProviderCoupon,
+ PaymentProviderAccount,
isStandaloneAiAddOnPlanCode,
- RecurlyImmediateCharge,
+ subscriptionChangeIsAiAssistUpgrade,
+ PaymentProviderImmediateCharge,
}
diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js
index 7bd7d1f3b2..f5f2e5f31f 100644
--- a/services/web/app/src/Features/Subscription/RecurlyClient.js
+++ b/services/web/app/src/Features/Subscription/RecurlyClient.js
@@ -7,22 +7,25 @@ const OError = require('@overleaf/o-error')
const { callbackify } = require('util')
const UserGetter = require('../User/UserGetter')
const {
- RecurlySubscription,
- RecurlySubscriptionAddOn,
- RecurlySubscriptionChange,
+ PaymentProviderSubscription,
+ PaymentProviderSubscriptionAddOn,
+ PaymentProviderSubscriptionChange,
PaypalPaymentMethod,
CreditCardPaymentMethod,
- RecurlyAddOn,
- RecurlyPlan,
- RecurlyImmediateCharge,
-} = require('./RecurlyEntities')
+ PaymentProviderAddOn,
+ PaymentProviderPlan,
+ PaymentProviderCoupon,
+ PaymentProviderAccount,
+ PaymentProviderImmediateCharge,
+} = require('./PaymentProviderEntities')
const {
MissingBillingInfoError,
SubtotalLimitExceededError,
} = require('./Errors')
/**
- * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
+ * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities'
+ * @import { PaymentProviderSubscriptionUpdateRequest } from './PaymentProviderEntities'
* @import { PaymentMethod } from './types'
*/
@@ -31,13 +34,21 @@ const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
const client = new recurly.Client(recurlyApiKey)
+/**
+ * Get account for a given user
+ *
+ * @param {string} userId
+ * @return {Promise}
+ */
async function getAccountForUserId(userId) {
try {
- return await client.getAccount(`code-${userId}`)
+ const account = await client.getAccount(`code-${userId}`)
+ return accountFromApi(account)
} catch (err) {
if (err instanceof recurly.errors.NotFoundError) {
// An expected error, we don't need to handle it, just return nothing
logger.debug({ userId }, 'no recurly account found for user')
+ return null
} else {
throw err
}
@@ -62,11 +73,76 @@ async function createAccountForUserId(userId) {
return account
}
+/**
+ * Get active coupons for a given user
+ *
+ * @param {string} userId
+ * @return {Promise}
+ */
+async function getActiveCouponsForUserId(userId) {
+ try {
+ const redemptions = await client.listActiveCouponRedemptions(
+ `code-${userId}`
+ )
+
+ const coupons = []
+ for await (const redemption of redemptions.each()) {
+ coupons.push(couponFromApi(redemption))
+ }
+
+ return coupons
+ } catch (err) {
+ // An expected error if no coupons have been redeemed
+ if (err instanceof recurly.errors.NotFoundError) {
+ return []
+ } else {
+ throw err
+ }
+ }
+}
+
+/**
+ * Get hosted customer management link
+ *
+ * @param {string} userId
+ * @param {string} pageType
+ * @return {Promise}
+ */
+async function getCustomerManagementLink(userId, pageType) {
+ try {
+ const account = await client.getAccount(`code-${userId}`)
+ const recurlySubdomain = Settings.apis.recurly.subdomain
+ const hostedLoginToken = account.hostedLoginToken
+ if (!hostedLoginToken) {
+ throw new OError('recurly account does not have hosted login token')
+ }
+ let path = ''
+ if (pageType === 'billing-details') {
+ path = 'billing_info/edit?ht='
+ }
+ return [
+ 'https://',
+ recurlySubdomain,
+ '.recurly.com/account/',
+ path,
+ hostedLoginToken,
+ ].join('')
+ } catch (err) {
+ if (err instanceof recurly.errors.NotFoundError) {
+ // An expected error, we don't need to handle it, just return nothing
+ logger.debug({ userId }, 'no recurly account found for user')
+ return null
+ } else {
+ throw err
+ }
+ }
+}
+
/**
* Get a subscription from Recurly
*
* @param {string} subscriptionId
- * @return {Promise}
+ * @return {Promise}
*/
async function getSubscription(subscriptionId) {
const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
@@ -80,7 +156,7 @@ async function getSubscription(subscriptionId) {
* error if the user has more than one subscription.
*
* @param {string} userId
- * @return {Promise}
+ * @return {Promise}
*/
async function getSubscriptionForUser(userId) {
try {
@@ -113,9 +189,31 @@ async function getSubscriptionForUser(userId) {
}
/**
- * Request a susbcription change from Recurly
+ * Request a subscription update from Recurly
*
- * @param {RecurlySubscriptionChangeRequest} changeRequest
+ * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
+ */
+async function updateSubscriptionDetails(updateRequest) {
+ const body = subscriptionUpdateRequestToApi(updateRequest)
+
+ const updatedSubscription = await client.updateSubscription(
+ `uuid-${updateRequest.subscription.id}`,
+ body
+ )
+
+ logger.debug(
+ {
+ subscriptionId: updateRequest.subscription.id,
+ updateId: updatedSubscription.id,
+ },
+ 'updated subscription'
+ )
+}
+
+/**
+ * Request a subscription change from Recurly
+ *
+ * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
*/
async function applySubscriptionChangeRequest(changeRequest) {
const body = subscriptionChangeRequestToApi(changeRequest)
@@ -155,8 +253,8 @@ async function applySubscriptionChangeRequest(changeRequest) {
/**
* Preview a subscription change
*
- * @param {RecurlySubscriptionChangeRequest} changeRequest
- * @return {Promise}
+ * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
+ * @return {Promise}
*/
async function previewSubscriptionChange(changeRequest) {
const body = subscriptionChangeRequestToApi(changeRequest)
@@ -265,7 +363,7 @@ async function getPaymentMethod(userId) {
*
* @param {string} planCode
* @param {string} addOnCode
- * @return {Promise}
+ * @return {Promise}
*/
async function getAddOn(planCode, addOnCode) {
const addOn = await client.getPlanAddOn(
@@ -279,23 +377,80 @@ async function getAddOn(planCode, addOnCode) {
* Get the configuration for a given plan
*
* @param {string} planCode
- * @return {Promise}
+ * @return {Promise}
*/
async function getPlan(planCode) {
const plan = await client.getPlan(`code-${planCode}`)
return planFromApi(plan)
}
+/**
+ * Get the country code for given user
+ *
+ * @param {string} userId
+ * @return {Promise}
+ */
+async function getCountryCode(userId) {
+ const account = await client.getAccount(`code-${userId}`)
+ const countryCode = account.address?.country
+
+ if (!countryCode) {
+ throw new OError('Country code not found', {
+ userId,
+ })
+ }
+
+ return countryCode
+}
+
function subscriptionIsCanceledOrExpired(subscription) {
const state = subscription?.recurlyStatus?.state
return state === 'canceled' || state === 'expired'
}
/**
- * Build a RecurlySubscription from Recurly API data
+ * Build a PaymentProviderAccount from Recurly API data
+ *
+ * @param {recurly.Account} apiAccount
+ * @return {PaymentProviderAccount}
+ */
+function accountFromApi(apiAccount) {
+ if (apiAccount.code == null || apiAccount.email == null) {
+ throw new OError('Invalid Recurly account', {
+ account: apiAccount,
+ })
+ }
+ return new PaymentProviderAccount({
+ code: apiAccount.code,
+ email: apiAccount.email,
+ hasPastDueInvoice: apiAccount.hasPastDueInvoice ?? false,
+ })
+}
+
+/**
+ * Build a PaymentProviderCoupon from Recurly API data
+ *
+ * @param {recurly.CouponRedemption} apiRedemption
+ * @return {PaymentProviderCoupon}
+ */
+function couponFromApi(apiRedemption) {
+ if (apiRedemption.coupon == null || apiRedemption.coupon.code == null) {
+ throw new OError('Invalid Recurly coupon', {
+ coupon: apiRedemption,
+ })
+ }
+ return new PaymentProviderCoupon({
+ code: apiRedemption.coupon.code,
+ name: apiRedemption.coupon.name ?? '',
+ description: apiRedemption.coupon.hostedPageDescription ?? '',
+ })
+}
+
+/**
+ * Build a PaymentProviderSubscription from Recurly API data
*
* @param {recurly.Subscription} apiSubscription
- * @return {RecurlySubscription}
+ * @return {PaymentProviderSubscription}
*/
function subscriptionFromApi(apiSubscription) {
if (
@@ -311,14 +466,17 @@ function subscriptionFromApi(apiSubscription) {
apiSubscription.currency == null ||
apiSubscription.currentPeriodStartedAt == null ||
apiSubscription.currentPeriodEndsAt == null ||
- apiSubscription.collectionMethod == null
+ apiSubscription.collectionMethod == null ||
+ // The values below could be null initially if the subscription has never updated
+ !('poNumber' in apiSubscription) ||
+ !('termsAndConditions' in apiSubscription)
) {
throw new OError('Invalid Recurly subscription', {
subscription: apiSubscription,
})
}
- const subscription = new RecurlySubscription({
+ const subscription = new PaymentProviderSubscription({
id: apiSubscription.uuid,
userId: apiSubscription.account.code,
planCode: apiSubscription.plan.code,
@@ -333,6 +491,14 @@ function subscriptionFromApi(apiSubscription) {
periodStart: apiSubscription.currentPeriodStartedAt,
periodEnd: apiSubscription.currentPeriodEndsAt,
collectionMethod: apiSubscription.collectionMethod,
+ poNumber: apiSubscription.poNumber ?? '',
+ termsAndConditions: apiSubscription.termsAndConditions ?? '',
+ service: 'recurly',
+ state: apiSubscription.state ?? 'active',
+ trialPeriodStart: apiSubscription.trialStartedAt,
+ trialPeriodEnd: apiSubscription.trialEndsAt,
+ pausePeriodStart: apiSubscription.pausedAt,
+ remainingPauseCycles: apiSubscription.remainingPauseCycles,
})
if (apiSubscription.pendingChange != null) {
@@ -346,10 +512,10 @@ function subscriptionFromApi(apiSubscription) {
}
/**
- * Build a RecurlySubscriptionAddOn from Recurly API data
+ * Build a PaymentProviderSubscriptionAddOn from Recurly API data
*
* @param {recurly.SubscriptionAddOn} addOn
- * @return {RecurlySubscriptionAddOn}
+ * @return {PaymentProviderSubscriptionAddOn}
*/
function subscriptionAddOnFromApi(addOn) {
if (
@@ -361,7 +527,7 @@ function subscriptionAddOnFromApi(addOn) {
throw new OError('Invalid Recurly add-on', { addOn })
}
- return new RecurlySubscriptionAddOn({
+ return new PaymentProviderSubscriptionAddOn({
code: addOn.addOn.code,
name: addOn.addOn.name,
quantity: addOn.quantity ?? 1,
@@ -370,11 +536,11 @@ function subscriptionAddOnFromApi(addOn) {
}
/**
- * Build a RecurlySubscriptionChange from Recurly API data
+ * Build a PaymentProviderSubscriptionChange from Recurly API data
*
- * @param {RecurlySubscription} subscription - the current subscription
+ * @param {PaymentProviderSubscription} subscription - the current subscription
* @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
- * @return {RecurlySubscriptionChange}
+ * @return {PaymentProviderSubscriptionChange}
*/
function subscriptionChangeFromApi(subscription, subscriptionChange) {
if (
@@ -391,7 +557,7 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) {
subscriptionAddOnFromApi
)
- return new RecurlySubscriptionChange({
+ return new PaymentProviderSubscriptionChange({
subscription,
nextPlanCode: subscriptionChange.plan.code,
nextPlanName: subscriptionChange.plan.name,
@@ -405,7 +571,7 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) {
* Compute immediate charge based on invoice collection
*
* @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
- * @return {RecurlyImmediateCharge}
+ * @return {PaymentProviderImmediateCharge}
*/
function computeImmediateCharge(subscriptionChange) {
const roundToTwoDecimal = (/** @type {number} */ num) =>
@@ -425,7 +591,7 @@ function computeImmediateCharge(subscriptionChange) {
tax = roundToTwoDecimal(tax + (creditInvoice.tax ?? 0))
discount = roundToTwoDecimal(discount + (creditInvoice.discount ?? 0))
}
- return new RecurlyImmediateCharge({
+ return new PaymentProviderImmediateCharge({
subtotal,
total,
tax,
@@ -459,41 +625,41 @@ function paymentMethodFromApi(billingInfo) {
}
/**
- * Build a RecurlyAddOn from Recurly API data
+ * Build a PaymentProviderAddOn from Recurly API data
*
* @param {recurly.AddOn} addOn
- * @return {RecurlyAddOn}
+ * @return {PaymentProviderAddOn}
*/
function addOnFromApi(addOn) {
if (addOn.code == null || addOn.name == null) {
throw new OError('Invalid Recurly add-on', { addOn })
}
- return new RecurlyAddOn({
+ return new PaymentProviderAddOn({
code: addOn.code,
name: addOn.name,
})
}
/**
- * Build a RecurlyPlan from Recurly API data
+ * Build a PaymentProviderPlan from Recurly API data
*
* @param {recurly.Plan} plan
- * @return {RecurlyPlan}
+ * @return {PaymentProviderPlan}
*/
function planFromApi(plan) {
if (plan.code == null || plan.name == null) {
throw new OError('Invalid Recurly add-on', { plan })
}
- return new RecurlyPlan({
+ return new PaymentProviderPlan({
code: plan.code,
name: plan.name,
})
}
/**
- * Build an API request from a RecurlySubscriptionChangeRequest
+ * Build an API request from a PaymentProviderSubscriptionChangeRequest
*
- * @param {RecurlySubscriptionChangeRequest} changeRequest
+ * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
* @return {recurly.SubscriptionChangeCreate}
*/
function subscriptionChangeRequestToApi(changeRequest) {
@@ -520,14 +686,32 @@ function subscriptionChangeRequestToApi(changeRequest) {
return requestBody
}
+/**
+ * Build an API request from a PaymentProviderSubscriptionUpdateRequest
+ *
+ * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
+ */
+function subscriptionUpdateRequestToApi(updateRequest) {
+ const requestBody = {}
+ if (updateRequest.poNumber) {
+ requestBody.poNumber = updateRequest.poNumber
+ }
+ if (updateRequest.termsAndConditions) {
+ requestBody.termsAndConditions = updateRequest.termsAndConditions
+ }
+ return requestBody
+}
+
module.exports = {
errors: recurly.errors,
getAccountForUserId: callbackify(getAccountForUserId),
createAccountForUserId: callbackify(createAccountForUserId),
+ getActiveCouponsForUserId: callbackify(getActiveCouponsForUserId),
getSubscription: callbackify(getSubscription),
getSubscriptionForUser: callbackify(getSubscriptionForUser),
previewSubscriptionChange: callbackify(previewSubscriptionChange),
+ updateSubscriptionDetails: callbackify(updateSubscriptionDetails),
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
removeSubscriptionChange: callbackify(removeSubscriptionChange),
removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
@@ -536,6 +720,7 @@ module.exports = {
getPaymentMethod: callbackify(getPaymentMethod),
getAddOn: callbackify(getAddOn),
getPlan: callbackify(getPlan),
+ getCountryCode: callbackify(getCountryCode),
subscriptionIsCanceledOrExpired,
pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
@@ -545,7 +730,10 @@ module.exports = {
getSubscriptionForUser,
getAccountForUserId,
createAccountForUserId,
+ getActiveCouponsForUserId,
+ getCustomerManagementLink,
previewSubscriptionChange,
+ updateSubscriptionDetails,
applySubscriptionChangeRequest,
removeSubscriptionChange,
removeSubscriptionChangeByUuid,
@@ -556,5 +744,6 @@ module.exports = {
getPaymentMethod,
getAddOn,
getPlan,
+ getCountryCode,
},
}
diff --git a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js b/services/web/app/src/Features/Subscription/RecurlyEventHandler.js
index 906e1258ea..d97d57ecba 100644
--- a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js
+++ b/services/web/app/src/Features/Subscription/RecurlyEventHandler.js
@@ -1,7 +1,7 @@
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const SubscriptionEmailHandler = require('./SubscriptionEmailHandler')
-const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
+const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities')
const { ObjectId } = require('mongodb-legacy')
const INVOICE_SUBSCRIPTION_LIMIT = 10
diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js
index 053c983502..2227597737 100644
--- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js
+++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js
@@ -547,27 +547,6 @@ const promises = {
updateAccountEmailAddress,
- async getAccountActiveCoupons(accountId) {
- const { body } = await RecurlyWrapper.promises.apiRequest({
- url: `accounts/${accountId}/redemptions`,
- })
-
- const redemptions = await RecurlyWrapper.promises._parseRedemptionsXml(body)
-
- const activeRedemptions = redemptions.filter(
- redemption => redemption.state === 'active'
- )
- const couponCodes = activeRedemptions.map(
- redemption => redemption.coupon_code
- )
-
- return await Promise.all(
- couponCodes.map(couponCode =>
- RecurlyWrapper.promises.getCoupon(couponCode)
- )
- )
- },
-
async getCoupon(couponCode) {
const opts = { url: `coupons/${couponCode}` }
const { body } = await RecurlyWrapper.promises.apiRequest(opts)
@@ -904,7 +883,6 @@ const RecurlyWrapper = {
_buildXml,
_parseXml: callbackify(promises._parseXml),
createFixedAmountCoupon: callbackify(promises.createFixedAmountCoupon),
- getAccountActiveCoupons: callbackify(promises.getAccountActiveCoupons),
getBillingInfo: callbackify(promises.getBillingInfo),
getPaginatedEndpoint: callbackify(promises.getPaginatedEndpoint),
getSubscription: callbackify(promises.getSubscription),
diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js
index cb8a293fb4..885784d10d 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionController.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionController.js
@@ -22,13 +22,14 @@ const Modules = require('../../infrastructure/Modules')
const async = require('async')
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const RecurlyClient = require('./RecurlyClient')
-const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
+const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities')
const PlansLocator = require('./PlansLocator')
+const PaymentProviderEntities = require('./PaymentProviderEntities')
/**
* @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
* @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
- * @import { RecurlySubscriptionChange } from './RecurlyEntities'
+ * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities'
* @import { PaymentMethod } from './types'
*/
@@ -45,7 +46,6 @@ function formatGroupPlansDataForDash() {
async function userSubscriptionPage(req, res) {
const user = SessionManager.getSessionUser(req.session)
-
await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription')
const groupPricingDiscount = await SplitTestHandler.promises.getAssignment(
@@ -154,6 +154,9 @@ async function userSubscriptionPage(req, res) {
)
}
+ const hasAiAssistViaWritefull =
+ await FeaturesUpdater.promises.hasFeaturesViaWritefull(user._id)
+
const data = {
title: 'your_subscription',
plans: plansData?.plans,
@@ -176,6 +179,7 @@ async function userSubscriptionPage(req, res) {
groupSettingsEnabledFor,
isManagedAccount: !!req.managedBy,
userRestrictions: Array.from(req.userRestrictions || []),
+ hasAiAssistViaWritefull,
}
res.render('subscriptions/dashboard-react', data)
}
@@ -310,6 +314,7 @@ function cancelV1Subscription(req, res, next) {
async function previewAddonPurchase(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const addOnCode = req.params.addOnCode
+ const purchaseReferrer = req.query.purchaseReferrer
if (addOnCode !== AI_ADD_ON_CODE) {
return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
@@ -321,13 +326,21 @@ async function previewAddonPurchase(req, res) {
try {
subscriptionChange =
await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode)
+
+ const hasAiAssistViaWritefull =
+ await FeaturesUpdater.promises.hasFeaturesViaWritefull(userId)
+ const isAiUpgrade =
+ PaymentProviderEntities.subscriptionChangeIsAiAssistUpgrade(
+ subscriptionChange
+ )
+ if (hasAiAssistViaWritefull && isAiUpgrade) {
+ return res.redirect(
+ '/user/subscription?redirect-reason=writefull-entitled'
+ )
+ }
} catch (err) {
if (err instanceof DuplicateAddOnError) {
- return HttpErrorHandler.badRequest(
- req,
- res,
- `Subscription already has add-on "${addOnCode}"`
- )
+ return res.redirect('/user/subscription?redirect-reason=double-buy')
}
throw err
}
@@ -351,7 +364,10 @@ async function previewAddonPurchase(req, res) {
paymentMethod
)
- res.render('subscriptions/preview-change', { changePreview })
+ res.render('subscriptions/preview-change', {
+ changePreview,
+ purchaseReferrer,
+ })
}
async function purchaseAddon(req, res, next) {
@@ -599,18 +615,6 @@ async function refreshUserFeatures(req, res) {
res.sendStatus(200)
}
-async function redirectToHostedPage(req, res) {
- const userId = SessionManager.getLoggedInUserId(req.session)
- const { pageType } = req.params
- const url =
- await SubscriptionViewModelBuilder.promises.getRedirectToHostedPage(
- userId,
- pageType
- )
- logger.warn({ userId, pageType }, 'redirecting to recurly hosted page')
- res.redirect(url)
-}
-
async function getRecommendedCurrency(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
let ip = req.ip
@@ -718,8 +722,8 @@ function getPlanNameForDisplay(planName, planCode) {
* Build a subscription change preview for display purposes
*
* @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
- * @param {RecurlySubscriptionChange} subscriptionChange The subscription change object coming from Recurly
- * @param {PaymentMethod} paymentMethod The payment method associated to the user
+ * @param {PaymentProviderSubscriptionChange} subscriptionChange The subscription change object coming from Recurly
+ * @param {PaymentMethod} [paymentMethod] The payment method associated to the user
* @return {SubscriptionChangePreview}
*/
function makeChangePreview(
@@ -735,7 +739,7 @@ function makeChangePreview(
change: subscriptionChangeDescription,
currency: subscription.currency,
immediateCharge: { ...subscriptionChange.immediateCharge },
- paymentMethod: paymentMethod.toString(),
+ paymentMethod: paymentMethod?.toString(),
nextPlan: {
annual: nextPlan.annual ?? false,
},
@@ -781,7 +785,6 @@ module.exports = {
extendTrial: expressify(extendTrial),
recurlyNotificationParser,
refreshUserFeatures: expressify(refreshUserFeatures),
- redirectToHostedPage: expressify(redirectToHostedPage),
previewAddonPurchase: expressify(previewAddonPurchase),
purchaseAddon,
removeAddon,
diff --git a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js
index 8bdfc2a060..1953f337fa 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js
@@ -1,21 +1,4 @@
const dateformat = require('dateformat')
-const { formatCurrency } = require('../../util/currency')
-
-/**
- * @param {number} priceInCents - price in the smallest currency unit (e.g. dollar cents, CLP units, ...)
- * @param {CurrencyCode?} currency - currency code (default to USD)
- * @param {string} [locale] - locale string
- * @returns {string} - formatted price
- */
-function formatPriceLocalized(priceInCents, currency = 'USD', locale) {
- const isNoCentsCurrency = ['CLP', 'JPY', 'KRW', 'VND'].includes(currency)
-
- const priceInCurrencyUnit = isNoCentsCurrency
- ? priceInCents
- : priceInCents / 100
-
- return formatCurrency(priceInCurrencyUnit, currency, locale)
-}
function formatDateTime(date) {
if (!date) {
@@ -32,7 +15,6 @@ function formatDate(date) {
}
module.exports = {
- formatPriceLocalized,
formatDateTime,
formatDate,
}
diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
index 14d73f91de..6ce552ec75 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
@@ -8,6 +8,7 @@ import SessionManager from '../Authentication/SessionManager.js'
import UserAuditLogHandler from '../User/UserAuditLogHandler.js'
import { expressify } from '@overleaf/promise-utils'
import Modules from '../../infrastructure/Modules.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import UserGetter from '../User/UserGetter.js'
import { Subscription } from '../../models/Subscription.js'
import { isProfessionalGroupPlan } from './PlansHelper.mjs'
@@ -135,23 +136,39 @@ async function addSeatsToGroupSubscription(req, res) {
userId
)
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
- await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
- recurlySubscription
- )
await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
recurlySubscription
)
- // Check if the user has missing billing details
- await RecurlyClient.promises.getPaymentMethod(userId)
await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
subscription
)
+ const { variant: flexibleLicensingForManuallyBilledSubscriptionsVariant } =
+ await SplitTestHandler.promises.getAssignment(
+ req,
+ res,
+ 'flexible-group-licensing-for-manually-billed-subscriptions'
+ )
+
+ if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') {
+ await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
+ recurlySubscription,
+ userId
+ )
+ } else {
+ await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
+ recurlySubscription
+ )
+ // Check if the user has missing billing details
+ await RecurlyClient.promises.getPaymentMethod(userId)
+ }
+
res.render('subscriptions/add-seats', {
subscriptionId: subscription._id,
groupName: subscription.teamName,
totalLicenses: subscription.membersLimit,
isProfessional: isProfessionalGroupPlan(subscription),
+ isCollectionMethodManual: recurlySubscription.isCollectionMethodManual,
})
} catch (error) {
if (error instanceof MissingBillingInfoError) {
@@ -231,7 +248,8 @@ async function createAddSeatsSubscriptionChange(req, res) {
const create =
await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
userId,
- req.body.adding
+ req.body.adding,
+ req.body.poNumber
)
res.json(create)
@@ -264,11 +282,27 @@ async function createAddSeatsSubscriptionChange(req, res) {
async function submitForm(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const userEmail = await UserGetter.promises.getUserEmail(userId)
- const { adding } = req.body
+ const { adding, poNumber } = req.body
+
+ const { recurlySubscription } =
+ await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
+ userId
+ )
+
+ if (recurlySubscription.isCollectionMethodManual) {
+ await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
+ userId,
+ recurlySubscription,
+ poNumber
+ )
+ }
const messageLines = [`\n**Overleaf Sales Contact Form:**`]
messageLines.push('**Subject:** Self-Serve Group User Increase Request')
messageLines.push(`**Estimated Number of Users:** ${adding}`)
+ if (poNumber) {
+ messageLines.push(`**PO Number:** ${poNumber}`)
+ }
messageLines.push(
`**Message:** This email has been generated on behalf of user with email **${userEmail}** ` +
'to request an increase in the total number of users for their subscription.'
diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
index 05befd6ca5..b92ce807f6 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
@@ -1,18 +1,25 @@
const { callbackify } = require('util')
+const _ = require('lodash')
+const OError = require('@overleaf/o-error')
const SubscriptionUpdater = require('./SubscriptionUpdater')
const SubscriptionLocator = require('./SubscriptionLocator')
const SubscriptionController = require('./SubscriptionController')
const { Subscription } = require('../../models/Subscription')
+const { User } = require('../../models/User')
const RecurlyClient = require('./RecurlyClient')
const PlansLocator = require('./PlansLocator')
const SubscriptionHandler = require('./SubscriptionHandler')
+const TeamInvitesHandler = require('./TeamInvitesHandler')
const GroupPlansData = require('./GroupPlansData')
-const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./RecurlyEntities')
+const Modules = require('../../infrastructure/Modules')
+const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities')
const {
ManuallyCollectedError,
PendingChangeError,
InactiveError,
} = require('./Errors')
+const EmailHelper = require('../Helpers/EmailHelper')
+const { InvalidEmailError } = require('../Errors/Errors')
async function removeUserFromGroup(subscriptionId, userIdToRemove) {
await SubscriptionUpdater.promises.removeUserFromGroup(
@@ -122,13 +129,21 @@ async function getUsersGroupSubscriptionDetails(userId) {
}
}
+async function checkBillingInfoExistence(recurlySubscription, userId) {
+ // Verify the billing info only if the collection method is not manual (e.g. automatic)
+ if (!recurlySubscription.isCollectionMethodManual) {
+ // Check if the user has missing billing details
+ await RecurlyClient.promises.getPaymentMethod(userId)
+ }
+}
+
async function _addSeatsSubscriptionChange(userId, adding) {
const { subscription, recurlySubscription, plan } =
await getUsersGroupSubscriptionDetails(userId)
await ensureFlexibleLicensingEnabled(plan)
await ensureSubscriptionIsActive(subscription)
- await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription)
await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
+ await checkBillingInfoExistence(recurlySubscription, userId)
const currentAddonQuantity =
recurlySubscription.addOns.find(
@@ -202,7 +217,6 @@ function _shouldUseLegacyPricing(
async function previewAddSeatsSubscriptionChange(userId, adding) {
const { changeRequest, currentAddonQuantity } =
await _addSeatsSubscriptionChange(userId, adding)
- const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
const subscriptionChange =
await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
const subscriptionChangePreview =
@@ -217,16 +231,20 @@ async function previewAddSeatsSubscriptionChange(userId, adding) {
prevQuantity: currentAddonQuantity,
},
},
- subscriptionChange,
- paymentMethod
+ subscriptionChange
)
return subscriptionChangePreview
}
-async function createAddSeatsSubscriptionChange(userId, adding) {
+async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
const { changeRequest, recurlySubscription } =
await _addSeatsSubscriptionChange(userId, adding)
+
+ if (recurlySubscription.isCollectionMethodManual) {
+ await updateSubscriptionPaymentTerms(userId, recurlySubscription, poNumber)
+ }
+
await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
await SubscriptionHandler.promises.syncSubscription(
{ uuid: recurlySubscription.id },
@@ -236,6 +254,29 @@ async function createAddSeatsSubscriptionChange(userId, adding) {
return { adding }
}
+async function updateSubscriptionPaymentTerms(
+ userId,
+ recurlySubscription,
+ poNumber
+) {
+ const countryCode = await RecurlyClient.promises.getCountryCode(userId)
+ const [termsAndConditions] = await Modules.promises.hooks.fire(
+ 'generateTermsAndConditions',
+ { countryCode, poNumber }
+ )
+
+ const updateRequest = poNumber
+ ? recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
+ poNumber,
+ termsAndConditions
+ )
+ : recurlySubscription.getRequestForTermsAndConditionsUpdate(
+ termsAndConditions
+ )
+
+ await RecurlyClient.promises.updateSubscriptionDetails(updateRequest)
+}
+
async function _getUpgradeTargetPlanCodeMaybeThrow(subscription) {
if (
subscription.planCode.includes('professional') ||
@@ -293,6 +334,125 @@ async function upgradeGroupPlan(ownerId) {
)
}
+async function updateGroupMembersBulk(
+ inviterId,
+ subscriptionId,
+ emailList,
+ options = {}
+) {
+ const { removeMembersNotIncluded, commit } = options
+
+ // remove duplications and empty values
+ emailList = _.uniq(_.compact(emailList))
+
+ const invalidEmails = emailList.filter(
+ email => !EmailHelper.parseEmail(email)
+ )
+
+ if (invalidEmails.length > 0) {
+ throw new InvalidEmailError('email not valid', {
+ invalidEmails,
+ })
+ }
+
+ const subscription = await Subscription.findOne({
+ _id: subscriptionId,
+ }).exec()
+
+ const existingUserData = await User.find(
+ {
+ _id: { $in: subscription.member_ids },
+ },
+ { _id: 1, email: 1, 'emails.email': 1 }
+ ).exec()
+
+ const existingUsers = existingUserData.map(user => ({
+ _id: user._id,
+ emails: user.emails?.map(user => user.email),
+ }))
+
+ const currentMemberEmails = _.flatten(
+ existingUsers
+ .filter(userData => userData.emails?.length > 0)
+ .map(user => user.emails)
+ )
+
+ const currentInvites =
+ subscription.teamInvites?.map(invite => invite.email) || []
+ if (subscription.invited_emails?.length > 0) {
+ currentInvites.push(...subscription.invited_emails)
+ }
+
+ const invitesToSend = _.difference(
+ emailList,
+ currentMemberEmails.concat(currentInvites)
+ )
+
+ let membersToRemove
+ let invitesToRevoke
+ let newTotalCount
+
+ if (!removeMembersNotIncluded) {
+ membersToRemove = []
+ invitesToRevoke = []
+ newTotalCount =
+ existingUsers.length + currentInvites.length + invitesToSend.length
+ } else {
+ membersToRemove = []
+ for (const existingUser of existingUsers) {
+ if (_.intersection(existingUser.emails, emailList).length === 0) {
+ membersToRemove.push(existingUser._id)
+ }
+ }
+ const invitesToMaintain = _.intersection(emailList, currentInvites)
+ invitesToRevoke = _.difference(currentInvites, invitesToMaintain)
+ newTotalCount =
+ existingUsers.length -
+ membersToRemove.length +
+ invitesToMaintain.length +
+ invitesToSend.length
+ }
+
+ const result = {
+ emailsToSendInvite: invitesToSend,
+ emailsToRevokeInvite: invitesToRevoke,
+ membersToRemove,
+ currentMemberCount: existingUsers.length,
+ newTotalCount,
+ membersLimit: subscription.membersLimit,
+ }
+
+ if (commit) {
+ if (newTotalCount > subscription.membersLimit) {
+ const { currentMemberCount, newTotalCount, membersLimit } = result
+ throw new OError('limit reached', {
+ currentMemberCount,
+ newTotalCount,
+ membersLimit,
+ })
+ }
+ for (const email of invitesToSend) {
+ await TeamInvitesHandler.promises.createInvite(
+ inviterId,
+ subscription,
+ email
+ )
+ }
+ for (const email of invitesToRevoke) {
+ await TeamInvitesHandler.promises.revokeInvite(
+ inviterId,
+ subscription,
+ email
+ )
+ }
+ for (const user of membersToRemove) {
+ await removeUserFromGroup(subscription._id, user._id)
+ }
+ }
+
+ return result
+}
+
module.exports = {
removeUserFromGroup: callbackify(removeUserFromGroup),
replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
@@ -308,6 +468,8 @@ module.exports = {
isUserPartOfGroup: callbackify(isUserPartOfGroup),
getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
upgradeGroupPlan: callbackify(upgradeGroupPlan),
+ checkBillingInfoExistence: callbackify(checkBillingInfoExistence),
+ updateGroupMembersBulk: callbackify(updateGroupMembersBulk),
promises: {
removeUserFromGroup,
replaceUserReferencesInGroups,
@@ -320,7 +482,10 @@ module.exports = {
getUsersGroupSubscriptionDetails,
previewAddSeatsSubscriptionChange,
createAddSeatsSubscriptionChange,
+ updateSubscriptionPaymentTerms,
getGroupPlanUpgradePreview,
upgradeGroupPlan,
+ checkBillingInfoExistence,
+ updateGroupMembersBulk,
},
}
diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js
index 4988bb5983..9cff487aec 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js
@@ -12,9 +12,10 @@ const EmailHandler = require('../Email/EmailHandler')
const { callbackify } = require('@overleaf/promise-utils')
const UserUpdater = require('../User/UserUpdater')
const { NotFoundError } = require('../Errors/Errors')
+const Modules = require('../../infrastructure/Modules')
/**
- * @import { RecurlySubscription, RecurlySubscriptionChange } from './RecurlyEntities'
+ * @import { PaymentProviderSubscription, PaymentProviderSubscriptionChange } from './PaymentProviderEntities'
*/
async function validateNoSubscriptionInRecurly(userId) {
@@ -69,7 +70,7 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
*
* @param {string} userId
* @param {string} planCode
- * @return {Promise}
+ * @return {Promise}
*/
async function previewSubscriptionChange(userId, planCode) {
const subscription = await getSubscriptionForUser(userId)
@@ -149,10 +150,7 @@ async function cancelSubscription(user) {
const { hasSubscription, subscription } =
await LimitationsManager.promises.userHasSubscription(user)
if (hasSubscription && subscription != null) {
- await RecurlyClient.promises.cancelSubscriptionByUuid(
- subscription.recurlySubscription_id
- )
- await _updateSubscriptionFromRecurly(subscription)
+ await Modules.promises.hooks.fire('cancelPaidSubscription', subscription)
const emailOpts = {
to: user.email,
first_name: user.first_name,
@@ -180,10 +178,10 @@ async function reactivateSubscription(user) {
const { hasSubscription, subscription } =
await LimitationsManager.promises.userHasSubscription(user)
if (hasSubscription && subscription != null) {
- await RecurlyClient.promises.reactivateSubscriptionByUuid(
- subscription.recurlySubscription_id
+ await Modules.promises.hooks.fire(
+ 'reactivatePaidSubscription',
+ subscription
)
- await _updateSubscriptionFromRecurly(subscription)
EmailHandler.sendEmail(
'reactivatedSubscription',
{ to: user.email },
@@ -267,23 +265,12 @@ async function extendTrial(subscription, daysToExend) {
)
}
-async function _updateSubscriptionFromRecurly(subscription) {
- const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
- subscription.recurlySubscription_id,
- {}
- )
- await SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
- recurlySubscription,
- subscription
- )
-}
-
/**
* Preview the effect of purchasing an add-on
*
* @param {string} userId
* @param {string} addOnCode
- * @return {Promise}
+ * @return {Promise}
*/
async function previewAddonPurchase(userId, addOnCode) {
const subscription = await getSubscriptionForUser(userId)
@@ -353,7 +340,7 @@ async function removeAddon(userId, addOnCode) {
* Throws a NotFoundError if the subscription can't be found
*
* @param {string} userId
- * @return {Promise}
+ * @return {Promise}
*/
async function getSubscriptionForUser(userId) {
const subscription =
diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js
index a980399c29..ac0fa5918a 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionLocator.js
@@ -5,7 +5,7 @@ const logger = require('@overleaf/logger')
const {
AI_ADD_ON_CODE,
isStandaloneAiAddOnPlanCode,
-} = require('./RecurlyEntities')
+} = require('./PaymentProviderEntities')
require('./GroupPlansData') // make sure dynamic group plans are loaded
const SubscriptionLocator = {
diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
index 0bb30b578e..54523b0004 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
@@ -23,6 +23,7 @@ const MAX_NUMBER_OF_USERS = 20
const addSeatsValidateSchema = {
body: Joi.object({
adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
+ poNumber: Joi.string(),
}),
}
@@ -54,13 +55,6 @@ export default {
SubscriptionController.canceledSubscription
)
- webRouter.get(
- '/user/subscription/recurly/:pageType',
- AuthenticationController.requireLogin(),
- RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
- SubscriptionController.redirectToHostedPage
- )
-
webRouter.delete(
'/subscription/group/user',
AuthenticationController.requireLogin(),
@@ -97,6 +91,7 @@ export default {
validate({
body: Joi.object({
adding: Joi.number().integer().min(MAX_NUMBER_OF_USERS).required(),
+ poNumber: Joi.string(),
}),
}),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js
index 2e43454da9..482d81ff41 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js
@@ -13,6 +13,11 @@ const UserAuditLogHandler = require('../User/UserAuditLogHandler')
const AccountMappingHelper = require('../Analytics/AccountMappingHelper')
const { SSOConfig } = require('../../models/SSOConfig')
+/**
+ * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription
+ * @typedef {import('../../../../types/subscription/dashboard/subscription').PaymentProvider} PaymentProvider
+ */
+
/**
* Change the admin of the given subscription.
*
@@ -51,7 +56,7 @@ async function syncSubscription(
let subscription =
await SubscriptionLocator.promises.getUsersSubscription(adminUserId)
if (subscription == null) {
- subscription = await _createNewSubscription(adminUserId)
+ subscription = await createNewSubscription(adminUserId)
}
await updateSubscriptionFromRecurly(
recurlySubscription,
@@ -160,7 +165,7 @@ async function deleteSubscription(subscription, deleterData) {
await Subscription.deleteOne({ _id: subscription._id }).exec()
// 4. refresh users features
- await _scheduleRefreshFeatures(subscription)
+ await scheduleRefreshFeatures(subscription)
}
async function restoreSubscription(subscriptionId) {
@@ -201,7 +206,11 @@ async function refreshUsersFeatures(subscription) {
}
}
-async function _scheduleRefreshFeatures(subscription) {
+/**
+ *
+ * @param {Subscription} subscription
+ */
+async function scheduleRefreshFeatures(subscription) {
const userIds = [subscription.admin_id].concat(subscription.member_ids || [])
for (const userId of userIds) {
await FeaturesUpdater.promises.scheduleRefreshFeatures(
@@ -226,7 +235,13 @@ async function createDeletedSubscription(subscription, deleterData) {
await DeletedSubscription.findOneAndUpdate(filter, data, options).exec()
}
-async function _createNewSubscription(adminUserId) {
+/**
+ * Creates a new subscription for the given admin user.
+ *
+ * @param {string} adminUserId
+ * @returns {Promise}
+ */
+async function createNewSubscription(adminUserId) {
const subscription = new Subscription({
admin_id: adminUserId,
manager_ids: [adminUserId],
@@ -242,7 +257,7 @@ async function _deleteAndReplaceSubscriptionFromRecurly(
) {
const adminUserId = subscription.admin_id
await deleteSubscription(subscription, requesterData)
- const newSubscription = await _createNewSubscription(adminUserId)
+ const newSubscription = await createNewSubscription(adminUserId)
await updateSubscriptionFromRecurly(
recurlySubscription,
newSubscription,
@@ -356,7 +371,7 @@ async function updateSubscriptionFromRecurly(
AnalyticsManager.registerAccountMapping(accountMapping)
}
- await _scheduleRefreshFeatures(subscription)
+ await scheduleRefreshFeatures(subscription)
}
async function _sendUserGroupPlanCodeUserProperty(userId) {
@@ -428,6 +443,7 @@ async function _sendSubscriptionEventForAllMembers(subscriptionId, event) {
module.exports = {
updateAdmin: callbackify(updateAdmin),
syncSubscription: callbackify(syncSubscription),
+ createNewSubscription: callbackify(createNewSubscription),
deleteSubscription: callbackify(deleteSubscription),
createDeletedSubscription: callbackify(createDeletedSubscription),
addUserToGroup: callbackify(addUserToGroup),
@@ -437,9 +453,11 @@ module.exports = {
deleteWithV1Id: callbackify(deleteWithV1Id),
restoreSubscription: callbackify(restoreSubscription),
updateSubscriptionFromRecurly: callbackify(updateSubscriptionFromRecurly),
+ scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures),
promises: {
updateAdmin,
syncSubscription,
+ createNewSubscription,
addUserToGroup,
refreshUsersFeatures,
removeUserFromGroup,
@@ -449,5 +467,6 @@ module.exports = {
deleteWithV1Id,
restoreSubscription,
updateSubscriptionFromRecurly,
+ scheduleRefreshFeatures,
},
}
diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
index aa3d577991..129463dcf0 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
@@ -5,7 +5,7 @@ const PlansLocator = require('./PlansLocator')
const {
isStandaloneAiAddOnPlanCode,
MEMBERS_LIMIT_ADD_ON_CODE,
-} = require('./RecurlyEntities')
+} = require('./PaymentProviderEntities')
const SubscriptionFormatters = require('./SubscriptionFormatters')
const SubscriptionLocator = require('./SubscriptionLocator')
const SubscriptionUpdater = require('./SubscriptionUpdater')
@@ -17,19 +17,17 @@ const _ = require('lodash')
const async = require('async')
const SubscriptionHelper = require('./SubscriptionHelper')
const { callbackify } = require('@overleaf/promise-utils')
-const {
- InvalidError,
- NotFoundError,
- V1ConnectionError,
-} = require('../Errors/Errors')
+const { V1ConnectionError } = require('../Errors/Errors')
const FeaturesHelper = require('./FeaturesHelper')
+const { formatCurrency } = require('../../util/currency')
+const Modules = require('../../infrastructure/Modules')
/**
* @import { Subscription } from "../../../../types/project/dashboard/subscription"
*/
function buildHostedLink(type) {
- return `/user/subscription/recurly/${type}`
+ return `/user/subscription/payment/${type}`
}
// Downgrade from Mongoose object, so we can add custom attributes to object
@@ -39,39 +37,6 @@ function serializeMongooseObject(object) {
: object
}
-async function getRedirectToHostedPage(userId, pageType) {
- if (!['billing-details', 'account-management'].includes(pageType)) {
- throw new InvalidError('unexpected page type')
- }
- const personalSubscription =
- await SubscriptionLocator.promises.getUsersSubscription(userId)
- const recurlySubscriptionId = personalSubscription?.recurlySubscription_id
- if (!recurlySubscriptionId) {
- throw new NotFoundError('not a recurly subscription')
- }
- const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
- recurlySubscriptionId,
- { includeAccount: true }
- )
-
- const recurlySubdomain = Settings.apis.recurly.subdomain
- const hostedLoginToken = recurlySubscription.account.hosted_login_token
- if (!hostedLoginToken) {
- throw new Error('recurly account does not have hosted login token')
- }
- let path = ''
- if (pageType === 'billing-details') {
- path = 'billing_info/edit?ht='
- }
- return [
- 'https://',
- recurlySubdomain,
- '.recurly.com/account/',
- path,
- hostedLoginToken,
- ].join('')
-}
-
async function buildUsersSubscriptionViewModel(user, locale = 'en') {
let {
personalSubscription,
@@ -80,38 +45,16 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
currentInstitutionsWithLicence,
managedInstitutions,
managedPublishers,
- recurlySubscription,
- recurlyCoupons,
+ fetchedPaymentRecord,
plan,
} = await async.auto({
personalSubscription(cb) {
SubscriptionLocator.getUsersSubscription(user, cb)
},
- recurlySubscription: [
+ fetchedPaymentRecord: [
'personalSubscription',
({ personalSubscription }, cb) => {
- if (
- personalSubscription == null ||
- personalSubscription.recurlySubscription_id == null ||
- personalSubscription.recurlySubscription_id === ''
- ) {
- return cb(null, null)
- }
- RecurlyWrapper.getSubscription(
- personalSubscription.recurlySubscription_id,
- { includeAccount: true },
- cb
- )
- },
- ],
- recurlyCoupons: [
- 'recurlySubscription',
- ({ recurlySubscription }, cb) => {
- if (!recurlySubscription) {
- return cb(null, null)
- }
- const accountId = recurlySubscription.account.account_code
- RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
+ Modules.hooks.fire('getPaymentFromRecord', personalSubscription, cb)
},
],
plan: [
@@ -158,6 +101,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
},
})
+ const paymentRecord = fetchedPaymentRecord && fetchedPaymentRecord[0]
+
if (memberGroupSubscriptions == null) {
memberGroupSubscriptions = []
} else {
@@ -216,9 +161,6 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
if (managedInstitutions == null) {
managedInstitutions = []
}
- if (recurlyCoupons == null) {
- recurlyCoupons = []
- }
personalSubscription = serializeMongooseObject(personalSubscription)
@@ -235,14 +177,6 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
personalSubscription.plan = plan
}
- // Subscription DB object contains a recurly property, used to cache trial info
- // on the project-list. However, this can cause the wrong template to render,
- // if we do not have any subscription data from Recurly (recurlySubscription)
- // TODO: Delete this workaround once recurly cache property name migration rolled out.
- if (personalSubscription) {
- delete personalSubscription.recurly
- }
-
function getPlanOnlyDisplayPrice(
totalPlanPriceInCents,
taxRate,
@@ -251,8 +185,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
// The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model
const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce(
(prev, curr) => {
- return curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE
- ? curr.quantity * curr.unit_amount_in_cents + prev
+ return curr.code !== MEMBERS_LIMIT_ADD_ON_CODE
+ ? curr.quantity * curr.unitPrice + prev
: prev
},
0
@@ -261,24 +195,24 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
allAddOnsPriceInCentsExceptAdditionalLicensePrice +
allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate
- return SubscriptionFormatters.formatPriceLocalized(
+ return formatCurrency(
totalPlanPriceInCents -
allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice,
- recurlySubscription.currency,
+ paymentRecord.subscription.currency,
locale
)
}
function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) {
return addOns.reduce((prev, curr) => {
- if (curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE) {
- const priceInCents = curr.quantity * curr.unit_amount_in_cents
+ if (curr.code !== MEMBERS_LIMIT_ADD_ON_CODE) {
+ const priceInCents = curr.quantity * curr.unitPrice
const totalPriceInCents = priceInCents + priceInCents * taxRate
if (totalPriceInCents > 0) {
- prev[curr.add_on_code] = SubscriptionFormatters.formatPriceLocalized(
+ prev[curr.code] = formatCurrency(
totalPriceInCents,
- recurlySubscription.currency,
+ paymentRecord.subscription.currency,
locale
)
}
@@ -288,124 +222,131 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
}, {})
}
- if (personalSubscription && recurlySubscription) {
- const tax = recurlySubscription.tax_in_cents || 0
+ if (personalSubscription && paymentRecord && paymentRecord.subscription) {
+ // don't return subscription payment information
+ delete personalSubscription.paymentProvider
+ delete personalSubscription.recurly
+
+ const tax = paymentRecord.subscription.taxAmount || 0
// Some plans allow adding more seats than the base plan provides.
// This is recorded as a subscription add on.
- // Note: tax_in_cents already includes the tax for any addon.
+ // Note: taxAmount already includes the tax for any addon.
let addOnPrice = 0
let additionalLicenses = 0
- const addOns = recurlySubscription.subscription_add_ons || []
- const taxRate = recurlySubscription.tax_rate
- ? parseFloat(recurlySubscription.tax_rate._)
- : 0
+ const addOns = paymentRecord.subscription.addOns || []
+ const taxRate = paymentRecord.subscription.taxRate
addOns.forEach(addOn => {
- addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
- if (addOn.add_on_code === plan.membersLimitAddOn) {
+ addOnPrice += addOn.quantity * addOn.unitPrice
+ if (addOn.code === plan.membersLimitAddOn) {
additionalLicenses += addOn.quantity
}
})
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
- personalSubscription.recurly = {
- tax,
+ const isInTrial =
+ paymentRecord.subscription.trialPeriodEnd &&
+ paymentRecord.subscription.trialPeriodEnd.getTime() > Date.now()
+ personalSubscription.payment = {
taxRate,
- billingDetailsLink: buildHostedLink('billing-details'),
+ billingDetailsLink:
+ paymentRecord.subscription.service === 'recurly'
+ ? buildHostedLink('billing-details')
+ : null,
accountManagementLink: buildHostedLink('account-management'),
additionalLicenses,
addOns,
totalLicenses,
nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
- recurlySubscription.current_period_ends_at
+ paymentRecord.subscription.periodEnd
),
nextPaymentDueDate: SubscriptionFormatters.formatDate(
- recurlySubscription.current_period_ends_at
+ paymentRecord.subscription.periodEnd
),
- currency: recurlySubscription.currency,
- state: recurlySubscription.state,
+ currency: paymentRecord.subscription.currency,
+ state: paymentRecord.subscription.state,
trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
- recurlySubscription.trial_ends_at
+ paymentRecord.subscription.trialPeriodEnd
),
- trial_ends_at: recurlySubscription.trial_ends_at,
- activeCoupons: recurlyCoupons,
- account: recurlySubscription.account,
- pausedAt: recurlySubscription.paused_at,
- remainingPauseCycles: recurlySubscription.remaining_pause_cycles,
+ trialEndsAt: paymentRecord.subscription.trialPeriodEnd,
+ activeCoupons: paymentRecord.coupons,
+ accountEmail: paymentRecord.account.email,
+ hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice,
+ pausedAt: paymentRecord.subscription.pausePeriodStart,
+ remainingPauseCycles: paymentRecord.subscription.remainingPauseCycles,
+ isEligibleForPause:
+ paymentRecord.subscription.service === 'recurly' &&
+ !personalSubscription.pendingPlan &&
+ !personalSubscription.groupPlan &&
+ !isInTrial &&
+ !paymentRecord.subscription.planCode.includes('ann') &&
+ !paymentRecord.subscription.addOns?.length > 0,
+ isEligibleForGroupPlan:
+ paymentRecord.subscription.service === 'recurly' && !isInTrial,
}
- if (recurlySubscription.pending_subscription) {
- const pendingPlan = PlansLocator.findLocalPlanInSettings(
- recurlySubscription.pending_subscription.plan.plan_code
- )
+ if (paymentRecord.subscription.pendingChange) {
+ const pendingPlanCode =
+ paymentRecord.subscription.pendingChange.nextPlanCode
+ const pendingPlan = PlansLocator.findLocalPlanInSettings(pendingPlanCode)
if (pendingPlan == null) {
- throw new Error(
- `No plan found for planCode '${personalSubscription.planCode}'`
- )
+ throw new Error(`No plan found for planCode '${pendingPlanCode}'`)
}
let pendingAdditionalLicenses = 0
let pendingAddOnTax = 0
let pendingAddOnPrice = 0
- if (recurlySubscription.pending_subscription.subscription_add_ons) {
- const pendingRecurlyAddons =
- recurlySubscription.pending_subscription.subscription_add_ons
- pendingRecurlyAddons.forEach(addOn => {
- pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
- if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
+ if (paymentRecord.subscription.pendingChange.nextAddOns) {
+ const pendingAddOns =
+ paymentRecord.subscription.pendingChange.nextAddOns
+ pendingAddOns.forEach(addOn => {
+ pendingAddOnPrice += addOn.quantity * addOn.unitPrice
+ if (addOn.code === pendingPlan.membersLimitAddOn) {
pendingAdditionalLicenses += addOn.quantity
}
})
// Need to calculate tax ourselves as we don't get tax amounts for pending subs
pendingAddOnTax =
- personalSubscription.recurly.taxRate * pendingAddOnPrice
- pendingPlan.addOns = pendingRecurlyAddons
+ personalSubscription.payment.taxRate * pendingAddOnPrice
+ pendingPlan.addOns = pendingAddOns
}
const pendingSubscriptionTax =
- personalSubscription.recurly.taxRate *
- recurlySubscription.pending_subscription.unit_amount_in_cents
- const totalPriceInCents =
- recurlySubscription.pending_subscription.unit_amount_in_cents +
+ personalSubscription.payment.taxRate *
+ paymentRecord.subscription.pendingChange.nextPlanPrice
+ const totalPrice =
+ paymentRecord.subscription.pendingChange.nextPlanPrice +
pendingAddOnPrice +
pendingAddOnTax +
pendingSubscriptionTax
- personalSubscription.recurly.displayPrice =
- SubscriptionFormatters.formatPriceLocalized(
- totalPriceInCents,
- recurlySubscription.currency,
- locale
- )
- personalSubscription.recurly.currentPlanDisplayPrice =
- SubscriptionFormatters.formatPriceLocalized(
- recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
- recurlySubscription.currency,
- locale
- )
- personalSubscription.recurly.planOnlyDisplayPrice =
+
+ personalSubscription.payment.displayPrice = formatCurrency(
+ totalPrice,
+ paymentRecord.subscription.currency,
+ locale
+ )
+ personalSubscription.payment.planOnlyDisplayPrice =
getPlanOnlyDisplayPrice(
- totalPriceInCents,
+ totalPrice,
taxRate,
- recurlySubscription.pending_subscription.subscription_add_ons
+ paymentRecord.subscription.pendingChange.nextAddOns
)
- personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
+ personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense =
getAddOnDisplayPricesWithoutAdditionalLicense(
taxRate,
- recurlySubscription.pending_subscription.subscription_add_ons
+ paymentRecord.subscription.pendingChange.nextAddOns
)
const pendingTotalLicenses =
(pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
- personalSubscription.recurly.pendingAdditionalLicenses =
+ personalSubscription.payment.pendingAdditionalLicenses =
pendingAdditionalLicenses
- personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
+ personalSubscription.payment.pendingTotalLicenses = pendingTotalLicenses
personalSubscription.pendingPlan = pendingPlan
} else {
- const totalPriceInCents =
- recurlySubscription.unit_amount_in_cents + addOnPrice + tax
- personalSubscription.recurly.displayPrice =
- SubscriptionFormatters.formatPriceLocalized(
- totalPriceInCents,
- recurlySubscription.currency,
- locale
- )
- personalSubscription.recurly.planOnlyDisplayPrice =
- getPlanOnlyDisplayPrice(totalPriceInCents, taxRate, addOns)
- personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
+ const totalPrice = paymentRecord.subscription.planPrice + addOnPrice + tax
+ personalSubscription.payment.displayPrice = formatCurrency(
+ totalPrice,
+ paymentRecord.subscription.currency,
+ locale
+ )
+ personalSubscription.payment.planOnlyDisplayPrice =
+ getPlanOnlyDisplayPrice(totalPrice, taxRate, addOns)
+ personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense =
getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns)
}
}
@@ -645,7 +586,6 @@ module.exports = {
getBestSubscription: callbackify(getBestSubscription),
promises: {
buildUsersSubscriptionViewModel,
- getRedirectToHostedPage,
getBestSubscription,
},
}
diff --git a/services/web/app/src/Features/Subscription/types.ts b/services/web/app/src/Features/Subscription/types.ts
index b4989c49ea..59453ac47c 100644
--- a/services/web/app/src/Features/Subscription/types.ts
+++ b/services/web/app/src/Features/Subscription/types.ts
@@ -1,3 +1,6 @@
-import { PaypalPaymentMethod, CreditCardPaymentMethod } from './RecurlyEntities'
+import {
+ PaypalPaymentMethod,
+ CreditCardPaymentMethod,
+} from './PaymentProviderEntities'
export type PaymentMethod = PaypalPaymentMethod | CreditCardPaymentMethod
diff --git a/services/web/app/src/Features/Survey/SurveyHandler.mjs b/services/web/app/src/Features/Survey/SurveyHandler.mjs
index 009bc12a95..7ce89594c6 100644
--- a/services/web/app/src/Features/Survey/SurveyHandler.mjs
+++ b/services/web/app/src/Features/Survey/SurveyHandler.mjs
@@ -4,6 +4,7 @@ import crypto from 'node:crypto'
import SurveyCache from './SurveyCache.mjs'
import SubscriptionLocator from '../Subscription/SubscriptionLocator.js'
import { callbackify } from '@overleaf/promise-utils'
+import UserGetter from '../User/UserGetter.js'
/**
* @import { Survey } from '../../../../types/project/dashboard/survey'
@@ -33,6 +34,25 @@ async function getSurvey(userId) {
return
}
+ const { earliestSignupDate, latestSignupDate } = survey.options || {}
+ if (earliestSignupDate || latestSignupDate) {
+ const user = await UserGetter.promises.getUser(userId, { signUpDate: 1 })
+ if (!user) {
+ return
+ }
+ const { signUpDate } = user
+ if (latestSignupDate) {
+ // Make the check inclusive
+ latestSignupDate.setHours(23, 59, 59, 999)
+ if (signUpDate > latestSignupDate) {
+ return
+ }
+ }
+ if (earliestSignupDate && signUpDate < earliestSignupDate) {
+ return
+ }
+ }
+
return { name, preText, linkText, url }
}
}
diff --git a/services/web/app/src/Features/Survey/SurveyManager.js b/services/web/app/src/Features/Survey/SurveyManager.js
index abaee90f13..3d6f225e10 100644
--- a/services/web/app/src/Features/Survey/SurveyManager.js
+++ b/services/web/app/src/Features/Survey/SurveyManager.js
@@ -10,6 +10,7 @@ async function getSurvey() {
}
async function updateSurvey({ name, preText, linkText, url, options }) {
+ validateOptions(options)
let survey = await getSurvey()
if (!survey) {
survey = new Survey()
@@ -23,6 +24,41 @@ async function updateSurvey({ name, preText, linkText, url, options }) {
return survey
}
+function validateOptions(options) {
+ if (!options) {
+ return
+ }
+ if (typeof options !== 'object') {
+ throw new Error('options must be an object')
+ }
+ const { earliestSignupDate, latestSignupDate } = options
+
+ const earliestDate = parseDate(earliestSignupDate)
+ const latestDate = parseDate(latestSignupDate)
+ if (earliestDate && latestDate) {
+ if (earliestDate > latestDate) {
+ throw new Error('earliestSignupDate must be before latestSignupDate')
+ }
+ }
+}
+
+function parseDate(date) {
+ if (date) {
+ if (typeof date !== 'string') {
+ throw new Error('Date must be a string')
+ }
+ if (date.match(/^\d{4}-\d{2}-\d{2}$/) === null) {
+ throw new Error('Date must be in YYYY-MM-DD format')
+ }
+ const asDate = new Date(date)
+ if (isNaN(asDate.getTime())) {
+ throw new Error('Date must be a valid date')
+ }
+ return asDate
+ }
+ return null
+}
+
async function deleteSurvey() {
const survey = await getSurvey()
if (survey) {
diff --git a/services/web/app/src/Features/Templates/TemplatesController.js b/services/web/app/src/Features/Templates/TemplatesController.js
index ac82d0c5d3..a8730a61be 100644
--- a/services/web/app/src/Features/Templates/TemplatesController.js
+++ b/services/web/app/src/Features/Templates/TemplatesController.js
@@ -4,9 +4,13 @@ const TemplatesManager = require('./TemplatesManager')
const ProjectHelper = require('../Project/ProjectHelper')
const logger = require('@overleaf/logger')
const { expressify } = require('@overleaf/promise-utils')
+const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const TemplatesController = {
- getV1Template(req, res) {
+ async getV1Template(req, res) {
+ // Read split test assignment so that it's available for Pug to read
+ await SplitTestHandler.promises.getAssignment(req, res, 'core-pug-bs5')
+
const templateVersionId = req.params.Template_version_id
const templateId = req.query.id
if (!/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)) {
@@ -25,7 +29,7 @@ const TemplatesController = {
mainFile: req.query.mainFile,
brandVariationId: req.query.brandVariationId,
}
- return res.render(
+ res.render(
path.resolve(
__dirname,
'../../../views/project/editor/new_from_template'
@@ -55,7 +59,7 @@ const TemplatesController = {
}
module.exports = {
- getV1Template: TemplatesController.getV1Template,
+ getV1Template: expressify(TemplatesController.getV1Template),
createProjectFromV1Template: expressify(
TemplatesController.createProjectFromV1Template
),
diff --git a/services/web/app/src/Features/Templates/TemplatesManager.js b/services/web/app/src/Features/Templates/TemplatesManager.js
index f105b6ae85..6a2b6207c1 100644
--- a/services/web/app/src/Features/Templates/TemplatesManager.js
+++ b/services/web/app/src/Features/Templates/TemplatesManager.js
@@ -17,6 +17,7 @@ const settings = require('@overleaf/settings')
const crypto = require('crypto')
const Errors = require('../Errors/Errors')
const { pipeline } = require('stream/promises')
+const ClsiCacheManager = require('../Compile/ClsiCacheManager')
const TemplatesManager = {
async createProjectFromV1Template(
@@ -63,6 +64,17 @@ const TemplatesManager = {
attributes
)
+ const prepareClsiCacheInBackground = ClsiCacheManager.prepareClsiCache(
+ project._id,
+ userId,
+ { templateId, templateVersionId }
+ ).catch(err => {
+ logger.warn(
+ { err, templateId, templateVersionId, projectId: project._id },
+ 'failed to prepare clsi-cache from template'
+ )
+ })
+
await TemplatesManager._setCompiler(project._id, compiler)
await TemplatesManager._setImage(project._id, imageName)
await TemplatesManager._setMainFile(project._id, mainFile)
@@ -74,6 +86,8 @@ const TemplatesManager = {
}
await Project.updateOne({ _id: project._id }, update, {})
+ await prepareClsiCacheInBackground
+
return project
} finally {
await fs.promises.unlink(dumpPath)
diff --git a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs
index c17b54ff1e..219db88b12 100644
--- a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs
+++ b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs
@@ -180,7 +180,11 @@ async function createFolder(userId, projectId, projectName, path) {
return null
}
- const folder = await UpdateMerger.promises.createFolder(project._id, path)
+ const folder = await UpdateMerger.promises.createFolder(
+ project._id,
+ path,
+ userId
+ )
return {
folderId: folder._id,
parentFolderId: folder.parentFolder_id,
diff --git a/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js b/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js
index e67d5fc30d..f68d366d8e 100644
--- a/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js
+++ b/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js
@@ -176,10 +176,11 @@ async function _readFileIntoTextArray(path) {
return lines
}
-async function createFolder(projectId, path) {
+async function createFolder(projectId, path, userId) {
const { lastFolder: folder } = await EditorController.promises.mkdirp(
projectId,
- path
+ path,
+ userId
)
return folder
}
diff --git a/services/web/app/src/Features/Tutorial/TutorialController.mjs b/services/web/app/src/Features/Tutorial/TutorialController.mjs
index 3672f8db0d..a5cdf8d478 100644
--- a/services/web/app/src/Features/Tutorial/TutorialController.mjs
+++ b/services/web/app/src/Features/Tutorial/TutorialController.mjs
@@ -8,10 +8,11 @@ const VALID_KEYS = [
'writefull-oauth-promotion',
'bib-file-tpr-prompt',
'ai-error-assistant-consent',
- 'code-editor-mode-prompt',
'history-restore-promo',
'us-gov-banner',
'us-gov-banner-fedramp',
+ 'full-project-search-promo',
+ 'editor-popup-ux-survey',
]
async function completeTutorial(req, res, next) {
diff --git a/services/web/app/src/Features/Uploads/ProjectUploadController.mjs b/services/web/app/src/Features/Uploads/ProjectUploadController.mjs
index da2847d108..a3bc434ed7 100644
--- a/services/web/app/src/Features/Uploads/ProjectUploadController.mjs
+++ b/services/web/app/src/Features/Uploads/ProjectUploadController.mjs
@@ -68,6 +68,7 @@ async function uploadFile(req, res, next) {
const name = req.body.name
const path = req.file?.path
const projectId = req.params.Project_id
+ const userId = SessionManager.getLoggedInUserId(req.session)
let { folder_id: folderId } = req.query
if (name == null || name.length === 0 || name.length > 150) {
return res.status(422).json({
@@ -87,13 +88,12 @@ async function uploadFile(req, res, next) {
})
const { lastFolder } = await EditorController.promises.mkdirp(
projectId,
- Path.dirname(Path.join('/', path.fileSystem, relativePath))
+ Path.dirname(Path.join('/', path.fileSystem, relativePath)),
+ userId
)
folderId = lastFolder._id
}
- const userId = SessionManager.getLoggedInUserId(req.session)
-
return FileSystemImportManager.addEntity(
userId,
projectId,
diff --git a/services/web/app/src/Features/User/UserDeleter.js b/services/web/app/src/Features/User/UserDeleter.js
index 4009419ffe..721943b163 100644
--- a/services/web/app/src/Features/User/UserDeleter.js
+++ b/services/web/app/src/Features/User/UserDeleter.js
@@ -43,21 +43,28 @@ async function deleteUser(userId, options) {
try {
const user = await User.findById(userId).exec()
- logger.debug({ user }, 'deleting user')
-
+ logger.info({ userId }, 'deleting user')
await ensureCanDeleteUser(user)
+ logger.info({ userId }, 'cleaning up user')
await _cleanupUser(user)
+ logger.info({ userId }, 'firing deleteUser hook')
await Modules.promises.hooks.fire('deleteUser', userId)
+ logger.info({ userId }, 'adding delete-account audit log entry')
await UserAuditLogHandler.promises.addEntry(
userId,
'delete-account',
options.deleterUser ? options.deleterUser._id : userId,
options.ipAddress
)
+ logger.info({ userId }, 'creating deleted user record')
await _createDeletedUser(user, options)
+ logger.info({ userId }, 'deleting user projects')
await ProjectDeleter.promises.deleteUsersProjects(user._id)
+ logger.info({ userId }, 'sending deletion email to user')
await _sendDeleteEmail(user, options.force)
+ logger.info({ userId }, 'deleting user record')
await deleteMongoUser(user._id)
+ logger.info({ userId }, 'user deletion complete')
} catch (error) {
logger.warn({ error, userId }, 'something went wrong deleting the user')
throw error
@@ -161,13 +168,22 @@ async function _createDeletedUser(user, options) {
}
async function _cleanupUser(user) {
+ const userId = user._id
+
+ logger.info({ userId }, '[cleanupUser] removing user sessions from Redis')
await UserSessionsManager.promises.removeSessionsFromRedis(user)
+ logger.info({ userId }, '[cleanupUser] unsubscribing from newsletters')
await NewsletterManager.promises.unsubscribe(user, { delete: true })
+ logger.info({ userId }, '[cleanupUser] cancelling subscription')
await SubscriptionHandler.promises.cancelSubscription(user)
- await InstitutionsAPI.promises.deleteAffiliations(user._id)
- await SubscriptionUpdater.promises.removeUserFromAllGroups(user._id)
- await UserMembershipsHandler.promises.removeUserFromAllEntities(user._id)
- await Modules.promises.hooks.fire('cleanupPersonalAccessTokens', user._id, [
+ logger.info({ userId }, '[cleanupUser] deleting affiliations')
+ await InstitutionsAPI.promises.deleteAffiliations(userId)
+ logger.info({ userId }, '[cleanupUser] removing user from groups')
+ await SubscriptionUpdater.promises.removeUserFromAllGroups(userId)
+ logger.info({ userId }, '[cleanupUser] removing user from memberships')
+ await UserMembershipsHandler.promises.removeUserFromAllEntities(userId)
+ logger.info({ userId }, '[cleanupUser] removing personal access tokens')
+ await Modules.promises.hooks.fire('cleanupPersonalAccessTokens', userId, [
'collabratec',
'git_bridge',
])
diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs
index cd456a4377..6f7bb7802d 100644
--- a/services/web/app/src/Features/User/UserPagesController.mjs
+++ b/services/web/app/src/Features/User/UserPagesController.mjs
@@ -117,11 +117,6 @@ async function settingsPage(req, res) {
)
}
- // Get the users write-and-cite assignment to switch between translation strings
- await SplitTestHandler.promises.getAssignment(req, res, 'write-and-cite')
- // Get the users papers-integration assignment to show the linking widget
- await SplitTestHandler.promises.getAssignment(req, res, 'papers-integration')
-
res.render('user/settings', {
title: 'account_settings',
user: {
@@ -154,6 +149,7 @@ async function settingsPage(req, res) {
enabled: Boolean(user.aiErrorAssistant?.enabled),
},
},
+ labsExperiments: user.labsExperiments ?? [],
hasPassword: !!user.hashedPassword,
shouldAllowEditingDetails,
oauthProviders: UserPagesController._translateProviderDescriptions(
diff --git a/services/web/app/src/infrastructure/mongodb.js b/services/web/app/src/infrastructure/mongodb.js
index 70753dd38d..aa7aa4ac44 100644
--- a/services/web/app/src/infrastructure/mongodb.js
+++ b/services/web/app/src/infrastructure/mongodb.js
@@ -81,6 +81,7 @@ const db = {
userAuditLogEntries: internalDb.collection('userAuditLogEntries'),
users: internalDb.collection('users'),
onboardingDataCollection: internalDb.collection('onboardingDataCollection'),
+ scriptLogs: internalDb.collection('scriptLogs'),
}
const connectionPromise = mongoClient.connect()
diff --git a/services/web/app/src/models/ScriptLog.mjs b/services/web/app/src/models/ScriptLog.mjs
new file mode 100644
index 0000000000..9cc6b8655f
--- /dev/null
+++ b/services/web/app/src/models/ScriptLog.mjs
@@ -0,0 +1,27 @@
+import Mongoose from '../infrastructure/Mongoose.js'
+
+export const ScriptLogSchema = new Mongoose.Schema(
+ {
+ canonicalName: { type: String, required: true },
+ filePathAtVersion: { type: String, required: true },
+ imageVersion: { type: String, required: true },
+ podName: { type: String, required: true },
+ startTime: { type: Date, default: Date.now },
+ endTime: { type: Date, default: null },
+ username: { type: String, required: true },
+ status: {
+ type: String,
+ enum: ['pending', 'success', 'error'],
+ default: 'pending',
+ required: true,
+ },
+ vars: { type: Object, required: true },
+ progressLogs: {
+ type: [{ timestamp: Date, message: String }],
+ required: true,
+ },
+ },
+ { minimize: false, collection: 'scriptLogs' }
+)
+
+export const ScriptLog = Mongoose.model('ScriptLog', ScriptLogSchema)
diff --git a/services/web/app/src/models/Subscription.js b/services/web/app/src/models/Subscription.js
index aae114846c..92a7739515 100644
--- a/services/web/app/src/models/Subscription.js
+++ b/services/web/app/src/models/Subscription.js
@@ -57,6 +57,23 @@ const SubscriptionSchema = new Schema(
type: Date,
},
},
+ paymentProvider: {
+ service: {
+ type: String,
+ },
+ subscriptionId: {
+ type: String,
+ },
+ state: {
+ type: String,
+ },
+ trialStartedAt: {
+ type: Date,
+ },
+ trialEndsAt: {
+ type: Date,
+ },
+ },
collectionMethod: {
type: String,
enum: ['automatic', 'manual'],
diff --git a/services/web/app/src/models/Survey.js b/services/web/app/src/models/Survey.js
index 5d56a20b54..deb5d60454 100644
--- a/services/web/app/src/models/Survey.js
+++ b/services/web/app/src/models/Survey.js
@@ -36,6 +36,12 @@ const SurveySchema = new Schema(
type: Boolean,
default: false,
},
+ earliestSignupDate: {
+ type: Date,
+ },
+ latestSignupDate: {
+ type: Date,
+ },
rolloutPercentage: {
type: Number,
default: 100,
diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js
index 73506f161c..c63647e914 100644
--- a/services/web/app/src/models/User.js
+++ b/services/web/app/src/models/User.js
@@ -203,6 +203,7 @@ const UserSchema = new Schema(
alphaProgram: { type: Boolean, default: false }, // experimental features
betaProgram: { type: Boolean, default: false },
labsProgram: { type: Boolean, default: false },
+ labsExperiments: { type: Array, default: [] },
overleaf: {
id: { type: Number },
accessToken: { type: String },
diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs
index 830620506a..5e1a21c063 100644
--- a/services/web/app/src/router.mjs
+++ b/services/web/app/src/router.mjs
@@ -66,6 +66,7 @@ import _ from 'lodash'
import { plainTextResponse } from './infrastructure/Response.js'
import PublicAccessLevels from './Features/Authorization/PublicAccessLevels.js'
import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs'
+import ClsiCacheController from './Features/Compile/ClsiCacheController.js'
const ClsiCookieManager = ClsiCookieManagerFactory(
Settings.apis.clsi != null ? Settings.apis.clsi.backendGroupName : undefined
)
@@ -606,36 +607,21 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CompileController.stopCompile
)
- // LEGACY: Used by the web download buttons, adds filename header, TODO: remove at some future date
webRouter.get(
- '/project/:Project_id/output/output.pdf',
+ '/project/:Project_id/output/cached/output.overleaf.json',
AuthorizationMiddleware.ensureUserCanReadProject,
- CompileController.downloadPdf
+ ClsiCacheController.getLatestBuildFromCache
)
- // PDF Download button
webRouter.get(
- /^\/download\/project\/([^/]*)\/output\/output\.pdf$/,
- function (req, res, next) {
- const params = { Project_id: req.params[0] }
- req.params = params
- next()
- },
+ '/download/project/:Project_id/build/:buildId/output/cached/:filename',
AuthorizationMiddleware.ensureUserCanReadProject,
- CompileController.downloadPdf
+ ClsiCacheController.downloadFromCache
)
// PDF Download button for specific build
webRouter.get(
- /^\/download\/project\/([^/]*)\/build\/([0-9a-f-]+)\/output\/output\.pdf$/,
- function (req, res, next) {
- const params = {
- Project_id: req.params[0],
- build_id: req.params[1],
- }
- req.params = params
- next()
- },
+ '/download/project/:Project_id/build/:build_id/output/output.pdf',
AuthorizationMiddleware.ensureUserCanReadProject,
CompileController.downloadPdf
)
@@ -646,22 +632,7 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
{ params: ['Project_id'] }
)
- // Used by the pdf viewers
- webRouter.get(
- /^\/project\/([^/]*)\/output\/(.*)$/,
- function (req, res, next) {
- const params = {
- Project_id: req.params[0],
- file: req.params[1],
- }
- req.params = params
- next()
- },
- rateLimiterMiddlewareOutputFiles,
- AuthorizationMiddleware.ensureUserCanReadProject,
- CompileController.getFileFromClsi
- )
- // direct url access to output files for a specific build (query string not required)
+ // direct url access to output files for a specific build
webRouter.get(
/^\/project\/([^/]*)\/build\/([0-9a-f-]+)\/output\/(.*)$/,
function (req, res, next) {
@@ -678,24 +649,7 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CompileController.getFileFromClsi
)
- // direct url access to output files for user but no build, to retrieve files when build fails
- webRouter.get(
- /^\/project\/([^/]*)\/user\/([0-9a-f-]+)\/output\/(.*)$/,
- function (req, res, next) {
- const params = {
- Project_id: req.params[0],
- user_id: req.params[1],
- file: req.params[2],
- }
- req.params = params
- next()
- },
- rateLimiterMiddlewareOutputFiles,
- AuthorizationMiddleware.ensureUserCanReadProject,
- CompileController.getFileFromClsi
- )
-
- // direct url access to output files for a specific user and build (query string not required)
+ // direct url access to output files for a specific user and build
webRouter.get(
/^\/project\/([^/]*)\/user\/([0-9a-f]+)\/build\/([0-9a-f-]+)\/output\/(.*)$/,
function (req, res, next) {
@@ -991,20 +945,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
InactiveProjectController.deactivateProject
)
- privateApiRouter.get(
- /^\/internal\/project\/([^/]*)\/output\/(.*)$/,
- function (req, res, next) {
- const params = {
- Project_id: req.params[0],
- file: req.params[1],
- }
- req.params = params
- next()
- },
- AuthenticationController.requirePrivateApiAuth(),
- CompileController.getFileFromClsi
- )
-
privateApiRouter.get(
'/project/:Project_id/doc/:doc_id',
AuthenticationController.requirePrivateApiAuth(),
diff --git a/services/web/app/views/_mixins/quote.pug b/services/web/app/views/_mixins/quote.pug
index 1590833e62..b8065dbdab 100644
--- a/services/web/app/views/_mixins/quote.pug
+++ b/services/web/app/views/_mixins/quote.pug
@@ -32,7 +32,8 @@ mixin collinsQuote1
-var quotePerson = 'Christopher Collins'
-var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University'
-var quotePersonImg = buildImgPath("advocates/collins.jpg")
- +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson)
+ .card-body
+ +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson)
mixin collinsQuote2
.card.card-dark-green-bg
@@ -40,7 +41,8 @@ mixin collinsQuote2
-var quotePerson = 'Christopher Collins'
-var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University'
-var quotePersonImg = buildImgPath("advocates/collins.jpg")
- +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson)
+ .card-body
+ +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson)
mixin bennettQuote1
.card.card-dark-green-bg
@@ -48,4 +50,5 @@ mixin bennettQuote1
-var quotePerson = 'Andrew Bennett'
-var quotePersonPosition = 'Software Architect, Symplectic'
-var quotePersonImg = buildImgPath("advocates/bennett.jpg")
- +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson)
\ No newline at end of file
+ .card-body
+ +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson)
\ No newline at end of file
diff --git a/services/web/app/views/layout-react.pug b/services/web/app/views/layout-react.pug
index 4fba24c226..f3dc8e6a06 100644
--- a/services/web/app/views/layout-react.pug
+++ b/services/web/app/views/layout-react.pug
@@ -19,6 +19,7 @@ block append meta
- const staffAccess = sessionUser?.staffAccess
- const canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || staffAccess?.splitTestMetrics || staffAccess?.splitTestManagement)
- const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
+ - const canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu
- const enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on')
- const showSignUpLink = hasFeature('registration-page')
@@ -29,6 +30,7 @@ block append meta
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
+ canDisplayScriptLogMenu,
enableUpgradeButton,
suppressNavbarRight: !!suppressNavbarRight,
suppressNavContentLinks: !!suppressNavContentLinks,
diff --git a/services/web/app/views/layout/fat-footer-website-redesign.pug b/services/web/app/views/layout/fat-footer-website-redesign.pug
index bde9113c75..cd68e1daf6 100644
--- a/services/web/app/views/layout/fat-footer-website-redesign.pug
+++ b/services/web/app/views/layout/fat-footer-website-redesign.pug
@@ -13,7 +13,7 @@ footer.fat-footer.hidden-print.website-redesign-fat-footer
li
a(href="/about/values") #{translate('our_values')}
li
- a(href="/about/careers") #{translate('careers')}
+ a(href="https://digitalscience.pinpointhq.com/") #{translate('careers')}
li
a(href="/for/press") !{translate('press_and_awards')}
li
diff --git a/services/web/app/views/layout/fat-footer.pug b/services/web/app/views/layout/fat-footer.pug
index 95de4a3567..d319a217cb 100644
--- a/services/web/app/views/layout/fat-footer.pug
+++ b/services/web/app/views/layout/fat-footer.pug
@@ -13,7 +13,7 @@ footer.fat-footer.hidden-print
li
a(href="/about/values") #{translate('our_values')}
li
- a(href="/about/careers") #{translate('careers')}
+ a(href="https://digitalscience.pinpointhq.com/") #{translate('careers')}
li
a(href="/for/press") !{translate('press_and_awards')}
li
diff --git a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug
index 1c527c93d2..ee94394bc4 100644
--- a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug
+++ b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug
@@ -27,6 +27,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
+ - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu
if (typeof suppressNavbarRight === "undefined")
button.navbar-toggler.collapsed(
@@ -66,6 +67,8 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={
+dropdown-menu-link-item()(href="/admin/split-test") Manage Feature Flags
if canDisplaySurveyMenu
+dropdown-menu-link-item()(href="/admin/survey") Manage Surveys
+ if canDisplayScriptLogMenu
+ +dropdown-menu-link-item()(href="/admin/script-logs") View Script Logs
// loop over header_extras
each item in nav.header_extras
diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug
index ebf698f726..e0f36004b8 100644
--- a/services/web/app/views/layout/navbar-marketing.pug
+++ b/services/web/app/views/layout/navbar-marketing.pug
@@ -1,4 +1,6 @@
-nav.navbar.navbar-default.navbar-main
+nav.navbar.navbar-default.navbar-main(class={
+ 'website-redesign-navbar': isWebsiteRedesign
+})
.container-fluid
.navbar-header
if (typeof(suppressNavbarRight) == "undefined")
@@ -30,6 +32,7 @@ nav.navbar.navbar-default.navbar-main
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
+ - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu
if (typeof(suppressNavbarRight) == "undefined")
.navbar-collapse.collapse#navbar-main-collapse
@@ -66,6 +69,9 @@ nav.navbar.navbar-default.navbar-main
if canDisplaySurveyMenu
li
a(href="/admin/survey") Manage Surveys
+ if canDisplayScriptLogMenu
+ li
+ a(href="/admin/script-logs") View Script Logs
// loop over header_extras
each item in nav.header_extras
diff --git a/services/web/app/views/layout/navbar-website-redesign.pug b/services/web/app/views/layout/navbar-website-redesign.pug
index 3e18ca94bc..c4b712e955 100644
--- a/services/web/app/views/layout/navbar-website-redesign.pug
+++ b/services/web/app/views/layout/navbar-website-redesign.pug
@@ -30,6 +30,7 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
+ - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu
if (typeof(suppressNavbarRight) == "undefined")
.navbar-collapse.collapse#navbar-main-collapse
@@ -66,6 +67,9 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar
if canDisplaySurveyMenu
li
a(href="/admin/survey") Manage Surveys
+ if canDisplayScriptLogMenu
+ li
+ a(href="/admin/script-logs") View Script Logs
// loop over header_extras
each item in nav.header_extras
diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug
index 720802aba8..ab31cef89e 100644
--- a/services/web/app/views/project/editor/_meta.pug
+++ b/services/web/app/views/project/editor/_meta.pug
@@ -1,7 +1,9 @@
meta(name="ol-project_id" content=project_id)
meta(name="ol-projectName" content=projectName)
+meta(name="ol-projectOwnerHasPremiumOnPageLoad" data-type="boolean" content=projectOwnerHasPremiumOnPageLoad)
meta(name="ol-userSettings" data-type="json" content=userSettings)
meta(name="ol-user" data-type="json" content=user)
+meta(name="ol-labsExperiments" data-type="json" content=labsExperiments)
meta(name="ol-learnedWords" data-type="json" content=learnedWords)
meta(name="ol-anonymous" data-type="boolean" content=anonymous)
meta(name="ol-brandVariation" data-type="json" content=brandVariation)
@@ -35,7 +37,6 @@ meta(name="ol-showTemplatesServerPro", data-type="boolean" content=showTemplates
meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
meta(name="ol-projectTags" data-type="json" content=projectTags)
-meta(name="ol-usedLatex" data-type="string" content=usedLatex)
meta(name="ol-ro-mirror-on-client-no-local-storage" data-type="boolean" content=roMirrorOnClientNoLocalStorage)
meta(name="ol-isSaas" data-type="boolean" content=isSaas)
meta(name="ol-shouldLoadHotjar" data-type="boolean" content=shouldLoadHotjar)
diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug
index c3b40cecf5..f2945b20f1 100644
--- a/services/web/app/views/project/editor/new_from_template.pug
+++ b/services/web/app/views/project/editor/new_from_template.pug
@@ -4,7 +4,8 @@ block vars
- var suppressFooter = true
- var suppressCookieBanner = true
- var suppressSkipToContent = true
- - bootstrap5PageStatus = 'disabled'
+ - bootstrap5PageStatus = 'enabled'
+ - bootstrap5PageSplitTest = 'core-pug-bs5'
block content
.editor.full-size
diff --git a/services/web/app/views/project/invite/not-valid.pug b/services/web/app/views/project/invite/not-valid.pug
index ac3eaed80a..693c162205 100644
--- a/services/web/app/views/project/invite/not-valid.pug
+++ b/services/web/app/views/project/invite/not-valid.pug
@@ -1,21 +1,22 @@
extends ../../layout-marketing
block vars
- - bootstrap5PageStatus = 'disabled'
+ - bootstrap5PageStatus = 'enabled'
+ - bootstrap5PageSplitTest = 'core-pug-bs5'
block content
main.content.content-alt#main-content
.container
.row
- .col-md-8.col-md-offset-2
+ .col-md-8.col-md-offset-2.offset-md-2
.card.project-invite-invalid
- .page-header.text-centered
- h1 #{translate("invite_not_valid")}
- .row.text-center
- .col-md-12
- p
- | #{translate("invite_not_valid_description")}.
- .row.text-center.actions
- .col-md-12
- a.btn.btn-secondary-info.btn-secondary(href="/project") #{translate("back_to_your_projects")}
-
+ .card-body
+ .page-header.text-center
+ h1 #{translate("invite_not_valid")}
+ .row.text-center
+ .col-12.col-md-12
+ p
+ | #{translate("invite_not_valid_description")}.
+ .row.text-center.actions
+ .col-12.col-md-12
+ a.btn.btn-secondary-info.btn-secondary(href="/project") #{translate("back_to_your_projects")}
diff --git a/services/web/app/views/project/invite/show.pug b/services/web/app/views/project/invite/show.pug
index 8d19a1edba..35926977e2 100644
--- a/services/web/app/views/project/invite/show.pug
+++ b/services/web/app/views/project/invite/show.pug
@@ -1,37 +1,39 @@
extends ../../layout-marketing
block vars
- - bootstrap5PageStatus = 'disabled'
+ - bootstrap5PageStatus = 'enabled'
+ - bootstrap5PageSplitTest = 'core-pug-bs5'
block content
main.content.content-alt#main-content
.container
.row
- .col-md-8.col-md-offset-2
+ .col-12.col-md-8.col-md-offset-2.offset-md-2
.card.project-invite-accept
- .page-header.text-centered
- h1 #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})}
- br
- em #{project.name}
- .row.text-center
- .col-md-12
- p
- | #{translate("accepting_invite_as")}
- em #{user.email}
- .row
- .col-md-12
- form.form(
- data-ol-regular-form
- method="POST",
- action="/project/"+invite.projectId+"/invite/token/"+token+"/accept"
- )
- input(name='_csrf', type='hidden', value=csrfToken)
- input(name='token', type='hidden', value=token)
- .form-group.text-center
- button.btn.btn-lg.btn-primary(
- type="submit"
- data-ol-disabled-inflight
- )
- span(data-ol-inflight="idle") #{translate("join_project")}
- span(hidden data-ol-inflight="pending") #{translate("joining")}…
- .form-group.text-center
+ .card-body
+ .page-header.text-center
+ h1 #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})}
+ br
+ em #{project.name}
+ .row.text-center
+ .col-12.col-md-12
+ p
+ | #{translate("accepting_invite_as")}
+ em #{user.email}
+ .row
+ .col-12.col-md-12
+ form.form(
+ data-ol-regular-form
+ method="POST",
+ action="/project/"+invite.projectId+"/invite/token/"+token+"/accept"
+ )
+ input(name='_csrf', type='hidden', value=csrfToken)
+ input(name='token', type='hidden', value=token)
+ .form-group.text-center
+ button.btn.btn-lg.btn-primary(
+ type="submit"
+ data-ol-disabled-inflight
+ )
+ span(data-ol-inflight="idle") #{translate("join_project")}
+ span(hidden data-ol-inflight="pending") #{translate("joining")}…
+ .form-group.text-center
diff --git a/services/web/app/views/subscriptions/add-seats.pug b/services/web/app/views/subscriptions/add-seats.pug
index fb04ef91fb..697a554c97 100644
--- a/services/web/app/views/subscriptions/add-seats.pug
+++ b/services/web/app/views/subscriptions/add-seats.pug
@@ -4,11 +4,11 @@ block entrypointVar
- entrypoint = 'pages/user/subscription/group-management/add-seats'
block append meta
- meta(name="ol-subscriptionData" data-type="json" content=subscriptionData)
meta(name="ol-groupName", data-type="string", content=groupName)
meta(name="ol-subscriptionId", data-type="string", content=subscriptionId)
meta(name="ol-totalLicenses", data-type="number", content=totalLicenses)
meta(name="ol-isProfessional", data-type="boolean", content=isProfessional)
+ meta(name="ol-isCollectionMethodManual", data-type="boolean", content=isCollectionMethodManual)
block content
main.content.content-alt#main-content
diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug
index aa3e597dc5..dab505e4e5 100644
--- a/services/web/app/views/subscriptions/dashboard-react.pug
+++ b/services/web/app/views/subscriptions/dashboard-react.pug
@@ -1,33 +1,34 @@
extends ../layout-react
block entrypointVar
- - entrypoint = 'pages/user/subscription/dashboard'
+ - entrypoint = 'pages/user/subscription/dashboard'
block head-scripts
- script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js")
+ script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js")
block append meta
- meta(name="ol-subscription" data-type="json" content=personalSubscription)
- meta(name="ol-userCanExtendTrial" data-type="boolean" content=userCanExtendTrial)
- meta(name="ol-managedGroupSubscriptions" data-type="json" content=managedGroupSubscriptions)
- meta(name="ol-memberGroupSubscriptions" data-type="json" content=memberGroupSubscriptions)
- meta(name="ol-managedInstitutions" data-type="json" content=managedInstitutions)
- meta(name="ol-managedPublishers" data-type="json" content=managedPublishers)
- meta(name="ol-planCodesChangingAtTermEnd" data-type="json", content=planCodesChangingAtTermEnd)
- meta(name="ol-currentInstitutionsWithLicence" data-type="json" content=currentInstitutionsWithLicence)
- meta(name="ol-hasSubscription" data-type="boolean" content=hasSubscription)
- meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage)
- meta(name="ol-plans", data-type="json" content=plans)
- meta(name="ol-groupSettingsAdvertisedFor", data-type="json" content=groupSettingsAdvertisedFor)
- meta(name="ol-canUseFlexibleLicensing", data-type="boolean", content=canUseFlexibleLicensing)
- meta(name="ol-showGroupDiscount", data-type="boolean", content=showGroupDiscount)
- meta(name="ol-groupSettingsEnabledFor", data-type="json" content=groupSettingsEnabledFor)
- meta(name="ol-user" data-type="json" content=user)
- if (personalSubscription && personalSubscription.recurly)
- meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
- meta(name="ol-recommendedCurrency" content=personalSubscription.recurly.currency)
- meta(name="ol-groupPlans" data-type="json" content=groupPlans)
+ meta(name="ol-subscription" data-type="json" content=personalSubscription)
+ meta(name="ol-userCanExtendTrial" data-type="boolean" content=userCanExtendTrial)
+ meta(name="ol-managedGroupSubscriptions" data-type="json" content=managedGroupSubscriptions)
+ meta(name="ol-memberGroupSubscriptions" data-type="json" content=memberGroupSubscriptions)
+ meta(name="ol-managedInstitutions" data-type="json" content=managedInstitutions)
+ meta(name="ol-managedPublishers" data-type="json" content=managedPublishers)
+ meta(name="ol-planCodesChangingAtTermEnd" data-type="json", content=planCodesChangingAtTermEnd)
+ meta(name="ol-currentInstitutionsWithLicence" data-type="json" content=currentInstitutionsWithLicence)
+ meta(name="ol-hasSubscription" data-type="boolean" content=hasSubscription)
+ meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage)
+ meta(name="ol-plans" data-type="json" content=plans)
+ meta(name="ol-groupSettingsAdvertisedFor" data-type="json" content=groupSettingsAdvertisedFor)
+ meta(name="ol-canUseFlexibleLicensing" data-type="boolean", content=canUseFlexibleLicensing)
+ meta(name="ol-showGroupDiscount" data-type="boolean", content=showGroupDiscount)
+ meta(name="ol-groupSettingsEnabledFor" data-type="json" content=groupSettingsEnabledFor)
+ meta(name="ol-hasAiAssistViaWritefull" data-type="boolean", content=hasAiAssistViaWritefull)
+ meta(name="ol-user" data-type="json" content=user)
+ if (personalSubscription && personalSubscription.payment)
+ meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
+ meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency)
+ meta(name="ol-groupPlans" data-type="json" content=groupPlans)
block content
- main.content.content-alt#main-content
- #subscription-dashboard-root
+ main.content.content-alt#main-content
+ #subscription-dashboard-root
diff --git a/services/web/app/views/subscriptions/plans/_faq_new.pug b/services/web/app/views/subscriptions/plans/_faq_new.pug
index 823931111f..baefb6ed3f 100644
--- a/services/web/app/views/subscriptions/plans/_faq_new.pug
+++ b/services/web/app/views/subscriptions/plans/_faq_new.pug
@@ -13,21 +13,40 @@ include ../../_mixins/eyebrow
.row
.col-xs-12
- .ol-tabs-scrollable
+ div(
+ class={
+ 'plans-faq-tabs': bootstrapVersion === 5,
+ 'ol-tabs': bootstrapVersion === 5,
+ 'ol-tabs-scrollable': bootstrapVersion === 3
+ }
+ )
.nav-tabs-container
ul.nav.nav-tabs(role="tablist")
- li.active(role="presentation")
+ //- In the bs5 version of plans page, the `active` class need to be added to the `a` tag instead of the parent `li` tag
+ //- If the `plans-page-bs5` split test has been completed, remove the `active` class from the `li` tag since we're not using it anymore
+ //- If the `plans-page-bs5` split test has been completed, remove the `data-toggle` because it is not needed anymore (bs5 uses `data-bs-toggle`)
+ li(
+ role="presentation"
+ class={
+ active: bootstrapVersion === 3
+ }
+ )
a(
role="tab"
data-toggle="tab"
+ data-bs-toggle="tab"
href='#' + managingYourSubscription
aria-controls=managingYourSubscription
+ class={
+ active: bootstrapVersion === 5
+ }
)
| #{translate('managing_your_subscription')}
li(role="presentation")
a(
role="tab"
data-toggle="tab"
+ data-bs-toggle="tab"
href='#' + overleafIndividualPlans
aria-controls=overleafIndividualPlans
)
@@ -36,6 +55,7 @@ include ../../_mixins/eyebrow
a(
role="tab"
data-toggle="tab"
+ data-bs-toggle="tab"
href='#' + overleafGroupPlans
aria-controls=overleafGroupPlans
)
@@ -58,8 +78,9 @@ include ../../_mixins/eyebrow
)
+overleafGroupPlans()
- .row.plans-faq-support
- span #{translate('still_have_questions')}
- button(data-ol-open-contact-form-modal="general")
- span(style="margin-right: 4px") #{translate('contact_support')}
- i.icon-md.material-symbols.material-symbols-rounded.material-symbols-arrow-right(aria-hidden="true") arrow_right_alt
+ .row
+ .col-xs-12.plans-faq-support
+ span #{translate('still_have_questions')}
+ button(data-ol-open-contact-form-modal="general")
+ span(style="margin-right: 4px") #{translate('contact_support')}
+ i.icon-md.material-symbols.material-symbols-rounded.material-symbols-arrow-right(aria-hidden="true") arrow_right_alt
diff --git a/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug b/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug
index 7f41c4d83e..f312ebeb46 100644
--- a/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug
+++ b/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug
@@ -1,7 +1,17 @@
+//- If the `plans-page-bs5` split test has been completed, remove the `data-toggle` and `data-target` because it is not needed anymore (bs5 uses `data-bs-toggle` and `data-bs-target`)
+
mixin managingYourSubscription()
.ol-accordions-container
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#managingYourSubscriptionQ1" aria-expanded="false" aria-controls="managingYourSubscriptionQ1")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#managingYourSubscriptionQ1"
+ data-bs-toggle="collapse"
+ data-bs-target="#managingYourSubscriptionQ1"
+ aria-expanded="false"
+ aria-controls="managingYourSubscriptionQ1"
+ )
| Can I change plans or cancel later?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -11,7 +21,15 @@ mixin managingYourSubscription()
strong Account > Subscription
span when logged in to Overleaf. You can change plans, switch between monthly and annual billing options, or cancel to downgrade to the free plan. When canceling, your subscription will continue until the end of the billing period.
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#managingYourSubscriptionQ2" aria-expanded="false" aria-controls="managingYourSubscriptionQ2")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#managingYourSubscriptionQ2"
+ data-bs-toggle="collapse"
+ data-bs-target="#managingYourSubscriptionQ2"
+ aria-expanded="false"
+ aria-controls="managingYourSubscriptionQ2"
+ )
| If I change or cancel my Overleaf plan, will I lose my projects?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -19,7 +37,15 @@ mixin managingYourSubscription()
.custom-accordion-body
| No. Changing or canceling your plan won’t affect your projects, the only change will be to the features available to you. You can see which features are available only on paid plans in the comparison table.
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#managingYourSubscriptionQ3" aria-expanded="false" aria-controls="managingYourSubscriptionQ3")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#managingYourSubscriptionQ3"
+ data-bs-toggle="collapse"
+ data-bs-target="#managingYourSubscriptionQ3"
+ aria-expanded="false"
+ aria-controls="managingYourSubscriptionQ3"
+ )
| Can I pay by invoice or purchase order?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -27,7 +53,15 @@ mixin managingYourSubscription()
.custom-accordion-body
| This is possible when you’re purchasing a group subscription for five or more people, or a site license. For individual subscriptions, we can only accept payment online via credit card, debit card, or PayPal.
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#managingYourSubscriptionQ4" aria-expanded="false" aria-controls="managingYourSubscriptionQ4")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#managingYourSubscriptionQ4"
+ data-bs-toggle="collapse"
+ data-bs-target="#managingYourSubscriptionQ4"
+ aria-expanded="false"
+ aria-controls="managingYourSubscriptionQ4"
+ )
| How do I view/update the credit card being charged for my subscription?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -51,7 +85,15 @@ mixin managingYourSubscription()
mixin overleafIndividualPlans()
.ol-accordions-container
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ1" aria-expanded="false" aria-controls="overleafIndividualPlansQ1")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafIndividualPlansQ1"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafIndividualPlansQ1"
+ aria-expanded="false"
+ aria-controls="overleafIndividualPlansQ1"
+ )
| How does the free trial work?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -71,7 +113,15 @@ mixin overleafIndividualPlans()
span when logged in to Overleaf (the trial will continue for the full 7 days).
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ2" aria-expanded="false" aria-controls="overleafIndividualPlansQ2")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafIndividualPlansQ2"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafIndividualPlansQ2"
+ aria-expanded="false"
+ aria-controls="overleafIndividualPlansQ2"
+ )
| What’s a collaborator on an Overleaf individual subscription?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -80,7 +130,15 @@ mixin overleafIndividualPlans()
| A collaborator is someone you invite to work with you on a project. So, for example, on our Standard plan you can have up to 10 people collaborating with you on any given project.
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ3" aria-expanded="false" aria-controls="overleafIndividualPlansQ3")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafIndividualPlansQ3"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafIndividualPlansQ3"
+ aria-expanded="false"
+ aria-controls="overleafIndividualPlansQ3"
+ )
| The individual Standard plan has 10 project collaborators, does it mean that 10 people will be upgraded?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -90,7 +148,15 @@ mixin overleafIndividualPlans()
strong only
span for the project(s) they’re working on with you. If your collaborators want access to those features on their own projects, they will need to purchase their own subscription. (If you work with the same people regularly, you might find a group subscription more cost effective.)
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ4" aria-expanded="false" aria-controls="overleafIndividualPlansQ4")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafIndividualPlansQ4"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafIndividualPlansQ4"
+ aria-expanded="false"
+ aria-controls="overleafIndividualPlansQ4"
+ )
| Do collaborators also have access to the editing and collaboration features I’ve paid for?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -100,7 +166,15 @@ mixin overleafIndividualPlans()
strong only
span for the project(s) they’re working on with you. If your collaborators want access to those features on their own projects, they will need to purchase their own subscription. (If you work with the same people regularly, you might find a group subscription more cost effective.)
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ5" aria-expanded="false" aria-controls="overleafIndividualPlansQ5")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafIndividualPlansQ5"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafIndividualPlansQ5"
+ aria-expanded="false"
+ aria-controls="overleafIndividualPlansQ5"
+ )
| Can I purchase an individual plan on behalf of someone else?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -108,7 +182,15 @@ mixin overleafIndividualPlans()
.custom-accordion-body
| Individual subscriptions must be purchased by the account that will be the end user. If you want to purchase a plan for someone else, you’ll need to provide them with relevant payment details to enable them to make the purchase.
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ6" aria-expanded="false" aria-controls="overleafIndividualPlansQ6")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafIndividualPlansQ6"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafIndividualPlansQ6"
+ aria-expanded="false"
+ aria-controls="overleafIndividualPlansQ6"
+ )
| Who is eligible for the Student plan?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -116,7 +198,15 @@ mixin overleafIndividualPlans()
.custom-accordion-body
| As the name suggests, the Student plan is only for students at educational institutions. This includes graduate students.
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ7" aria-expanded="false" aria-controls="overleafIndividualPlansQ7")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafIndividualPlansQ7"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafIndividualPlansQ7"
+ aria-expanded="false"
+ aria-controls="overleafIndividualPlansQ7"
+ )
| Can I transfer an individual subscription to someone else?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -131,7 +221,15 @@ mixin overleafIndividualPlans()
mixin overleafGroupPlans()
.ol-accordions-container
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ1" aria-expanded="false" aria-controls="overleafGroupPlansQ1")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafGroupPlansQ1"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafGroupPlansQ1"
+ aria-expanded="false"
+ aria-controls="overleafGroupPlansQ1"
+ )
| What’s the difference between users and collaborators on an Overleaf group subscription?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -140,7 +238,15 @@ mixin overleafGroupPlans()
div On any of our group plans, the number of users refers to the number of people you can invite to join your group. All of these people will have access to the plan’s paid-for features across all their projects, such as real-time track changes and document history.
div.mt-2 Collaborators are people that your group users may invite to work with them on their projects. So, for example, if you have the Group Standard plan, the users in your group can invite up to 10 people to work with them on a project. And if you have the Group Professional plan, your users can invite as many people to work with them as they want.
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ2" aria-expanded="false" aria-controls="overleafGroupPlansQ2")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafGroupPlansQ2"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafGroupPlansQ2"
+ aria-expanded="false"
+ aria-controls="overleafGroupPlansQ2"
+ )
| What is the benefit of purchasing an Overleaf Group plan?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -158,7 +264,15 @@ mixin overleafGroupPlans()
span Sales team
| .
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ3" aria-expanded="false" aria-controls="overleafGroupPlansQ3")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafGroupPlansQ3"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafGroupPlansQ3"
+ aria-expanded="false"
+ aria-controls="overleafGroupPlansQ3"
+ )
| Who is eligible for the educational discount?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -166,7 +280,15 @@ mixin overleafGroupPlans()
.custom-accordion-body
| The educational discount for group subscriptions is for students or faculty who are using Overleaf primarily for teaching.
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ4" aria-expanded="false" aria-controls="overleafGroupPlansQ4")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafGroupPlansQ4"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafGroupPlansQ4"
+ aria-expanded="false"
+ aria-controls="overleafGroupPlansQ4"
+ )
| How do I add more licenses to my group subscription, and what will it cost?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
@@ -207,7 +329,15 @@ mixin overleafGroupPlans()
span contact the Sales team
| .
.custom-accordion-item
- button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ5" aria-expanded="false" aria-controls="overleafGroupPlansQ5")
+ button.custom-accordion-header.collapsed(
+ type="button"
+ data-toggle="collapse"
+ data-target="#overleafGroupPlansQ5"
+ data-bs-toggle="collapse"
+ data-bs-target="#overleafGroupPlansQ5"
+ aria-expanded="false"
+ aria-controls="overleafGroupPlansQ5"
+ )
| How do I upgrade my plan from Group Standard to Group Professional?
span.custom-accordion-icon
i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down
diff --git a/services/web/app/views/subscriptions/preview-change.pug b/services/web/app/views/subscriptions/preview-change.pug
index ab70d2d6b6..663bbe30d2 100644
--- a/services/web/app/views/subscriptions/preview-change.pug
+++ b/services/web/app/views/subscriptions/preview-change.pug
@@ -5,6 +5,7 @@ block entrypointVar
block append meta
meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview)
+ meta(name="ol-purchaseReferrer" data-type="string" content=purchaseReferrer)
block content
main.content.content-alt#main-content
diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug
index 57eef44c27..4f939a41ca 100644
--- a/services/web/app/views/user/settings.pug
+++ b/services/web/app/views/user/settings.pug
@@ -22,6 +22,7 @@ block append meta
meta(name="ol-passwordStrengthOptions", data-type="json", content=settings.passwordStrengthOptions || {})
meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed())
meta(name="ol-user" data-type="json" content=user)
+ meta(name="ol-labsExperiments" data-type="json" content=labsExperiments)
meta(name="ol-dropbox" data-type="json" content=dropbox)
meta(name="ol-github" data-type="json" content=github)
meta(name="ol-projectSyncSuccessMessage", content=projectSyncSuccessMessage)
diff --git a/services/web/babel.config.json b/services/web/babel.config.json
index 9bb4c73829..0eee4a9880 100644
--- a/services/web/babel.config.json
+++ b/services/web/babel.config.json
@@ -6,7 +6,7 @@
{
"useBuiltIns": "usage",
// This version must be aligned with the `core-js` version in `package.json`
- "corejs": { "version": "3.38" },
+ "corejs": { "version": "3.41" },
"exclude": [
// Exclude Array.prototype.push polyfill, as it's not needed and affects performance in Chrome
"es.array.push",
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index 1f86da66a0..be567bf13e 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -242,6 +242,9 @@ module.exports = {
submissionBackendClass:
process.env.CLSI_SUBMISSION_BACKEND_CLASS || 'n2d',
},
+ clsiCache: {
+ instances: JSON.parse(process.env.CLSI_CACHE_INSTANCES || '[]'),
+ },
project_history: {
sendProjectStructureOps: true,
url: `http://${process.env.PROJECT_HISTORY_HOST || '127.0.0.1'}:3054`,
diff --git a/services/web/cypress/support/ct/commands/index.ts b/services/web/cypress/support/ct/commands/index.ts
index 4ad9c7940c..f5528bc1bb 100644
--- a/services/web/cypress/support/ct/commands/index.ts
+++ b/services/web/cypress/support/ct/commands/index.ts
@@ -1,4 +1,4 @@
-import { mount } from 'cypress/react'
+import { mount } from 'cypress/react18'
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-namespace
declare global {
diff --git a/services/web/cypress/support/ct/window.ts b/services/web/cypress/support/ct/window.ts
index 3f415cf3c5..ae2a194bc1 100644
--- a/services/web/cypress/support/ct/window.ts
+++ b/services/web/cypress/support/ct/window.ts
@@ -10,7 +10,6 @@ export function resetMeta() {
hasLinkedProjectOutputFileFeature: true,
hasLinkUrlFeature: true,
})
- window.metaAttributesCache.set('ol-bootstrapVersion', 5)
}
// Populate meta for top-level access in modules on import
diff --git a/services/web/cypress/support/shared/commands/compile.ts b/services/web/cypress/support/shared/commands/compile.ts
index 90567d8e74..9f7273c403 100644
--- a/services/web/cypress/support/shared/commands/compile.ts
+++ b/services/web/cypress/support/shared/commands/compile.ts
@@ -43,8 +43,42 @@ const outputFiles = () => {
]
}
-export const interceptCompile = (prefix = 'compile', times = 1) => {
- cy.intercept(
+const compileFromCacheResponse = () => {
+ return {
+ fromCache: true,
+ status: 'success',
+ clsiServerId: 'foo',
+ compileGroup: 'priority',
+ pdfDownloadDomain: 'https://clsi.test-overleaf.com',
+ outputFiles: outputFiles(),
+ options: {
+ rootResourcePath: 'main.tex',
+ imageName: 'texlive-full:2024.1',
+ compiler: 'pdflatex',
+ stopOnFirstError: false,
+ draft: false,
+ },
+ }
+}
+
+export const interceptCompileFromCacheRequest = ({
+ times,
+ promise,
+}: {
+ times: number
+ promise: Promise
+}) => {
+ return cy.intercept(
+ { path: '/project/*/output/cached/output.overleaf.json', times },
+ async req => {
+ await promise
+ req.reply({ body: compileFromCacheResponse() })
+ }
+ )
+}
+
+export const interceptCompileRequest = ({ times = 1 } = {}) => {
+ return cy.intercept(
{ method: 'POST', pathname: '/project/*/compile', times },
{
body: {
@@ -55,11 +89,48 @@ export const interceptCompile = (prefix = 'compile', times = 1) => {
outputFiles: outputFiles(),
},
}
- ).as(`${prefix}`)
+ )
+}
+
+export const interceptCompile = ({
+ prefix = 'compile',
+ times = 1,
+ cached = false,
+ regular = true,
+ outputPDFFixture = 'output.pdf',
+} = {}) => {
+ if (cached) {
+ cy.intercept(
+ { path: '/project/*/output/cached/output.overleaf.json', times },
+ { body: compileFromCacheResponse() }
+ ).as(`${prefix}-cached`)
+ } else {
+ cy.intercept(
+ { pathname: '/project/*/output/cached/output.overleaf.json', times },
+ { statusCode: 404 }
+ ).as(`${prefix}-cached`)
+ }
+
+ if (regular) {
+ interceptCompileRequest({ times }).as(`${prefix}`)
+ } else {
+ cy.intercept(
+ { method: 'POST', pathname: '/project/*/compile', times },
+ {
+ body: {
+ status: 'unavailable',
+ clsiServerId: 'foo',
+ compileGroup: 'priority',
+ pdfDownloadDomain: 'https://clsi.test-overleaf.com',
+ outputFiles: [],
+ },
+ }
+ ).as(`${prefix}`)
+ }
cy.intercept(
{ pathname: '/build/*/output.pdf', times },
- { fixture: 'build/output.pdf,null' }
+ { fixture: `build/${outputPDFFixture},null` }
).as(`${prefix}-pdf`)
cy.intercept(
@@ -73,12 +144,36 @@ export const interceptCompile = (prefix = 'compile', times = 1) => {
).as(`${prefix}-blg`)
}
-export const waitForCompile = ({ prefix = 'compile', pdf = false } = {}) => {
- cy.wait(`@${prefix}`)
+export const waitForCompile = ({
+ prefix = 'compile',
+ pdf = false,
+ cached = false,
+ regular = true,
+} = {}) => {
+ if (cached) {
+ cy.wait(`@${prefix}-cached`)
+ }
+ if (regular) {
+ cy.wait(`@${prefix}`)
+ }
+ return waitForCompileOutput({ prefix, pdf, cached })
+}
+
+export const waitForCompileOutput = ({
+ prefix = 'compile',
+ pdf = false,
+ cached = false,
+} = {}) => {
cy.wait(`@${prefix}-log`)
+ .its('request.query.clsiserverid')
+ .should('eq', cached ? 'cache' : 'foo') // straight from cache if cached
cy.wait(`@${prefix}-blg`)
+ .its('request.query.clsiserverid')
+ .should('eq', cached ? 'cache' : 'foo') // straight from cache if cached
if (pdf) {
cy.wait(`@${prefix}-pdf`)
+ .its('request.query.clsiserverid')
+ .should('eq', 'foo') // always from VM first
}
return cy.wrap(null)
}
diff --git a/services/web/cypress/support/shared/commands/index.ts b/services/web/cypress/support/shared/commands/index.ts
index cd39f8f5d1..bb55fdddac 100644
--- a/services/web/cypress/support/shared/commands/index.ts
+++ b/services/web/cypress/support/shared/commands/index.ts
@@ -1,8 +1,10 @@
import '@testing-library/cypress/add-commands'
import {
interceptCompile,
+ interceptCompileFromCacheRequest,
waitForCompile,
interceptDeferredCompile,
+ interceptCompileRequest,
} from './compile'
import { interceptEvents } from './events'
import { interceptAsync } from './intercept-async'
@@ -21,6 +23,8 @@ declare global {
interface Chainable {
interceptAsync: typeof interceptAsync
interceptCompile: typeof interceptCompile
+ interceptCompileRequest: typeof interceptCompileRequest
+ interceptCompileFromCacheRequest: typeof interceptCompileFromCacheRequest
interceptEvents: typeof interceptEvents
interceptMetadata: typeof interceptMetadata
waitForCompile: typeof waitForCompile
@@ -36,6 +40,11 @@ declare global {
Cypress.Commands.add('interceptAsync', interceptAsync)
Cypress.Commands.add('interceptCompile', interceptCompile)
+Cypress.Commands.add('interceptCompileRequest', interceptCompileRequest)
+Cypress.Commands.add(
+ 'interceptCompileFromCacheRequest',
+ interceptCompileFromCacheRequest
+)
Cypress.Commands.add('interceptEvents', interceptEvents)
Cypress.Commands.add('interceptMetadata', interceptMetadata)
Cypress.Commands.add('waitForCompile', waitForCompile)
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index f0c492800c..7df8553f55 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -46,6 +46,7 @@
"access_denied": "",
"access_edit_your_projects": "",
"access_levels_changed": "",
+ "access_your_favourite_features_faster_with_our_new_streamlined_editor": "",
"account_billed_manually": "",
"account_has_been_link_to_institution_account": "",
"account_has_past_due_invoice_change_plan_warning": "",
@@ -60,6 +61,7 @@
"add_add_on_to_your_plan": "",
"add_additional_certificate": "",
"add_affiliation": "",
+ "add_ai_assist": "",
"add_another_address_line": "",
"add_another_email": "",
"add_another_token": "",
@@ -73,21 +75,20 @@
"add_error_assist_annual_to_your_projects": "",
"add_error_assist_to_your_projects": "",
"add_files": "",
- "add_more_editors": "",
+ "add_more_collaborators": "",
"add_more_licenses_to_my_plan": "",
"add_more_managers": "",
"add_new_email": "",
"add_on": "",
"add_ons": "",
"add_or_remove_project_from_tag": "",
- "add_overleaf_assist": "",
"add_overleaf_assist_to_your_group_subscription": "",
"add_overleaf_assist_to_your_institution": "",
- "add_overleaf_assist_to_your_plan": "",
"add_people": "",
"add_role_and_department": "",
"add_to_dictionary": "",
"add_to_tag": "",
+ "add_unlimited_ai_to_your_overleaf_plan": "",
"add_your_comment_here": "",
"add_your_first_group_member_now": "",
"added_by_on": "",
@@ -101,7 +102,9 @@
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
"aggregate_changed": "",
"aggregate_to": "",
+ "agree": "",
"agree_with_the_terms": "",
+ "ai_assist_in_overleaf_is_included_via_writefull": "",
"ai_assistance_to_help_you": "",
"ai_based_language_tools": "",
"ai_can_make_mistakes": "",
@@ -122,6 +125,7 @@
"all_projects_will_be_transferred_immediately": "",
"all_these_experiments_are_available_exclusively": "",
"allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "",
+ "already_have_a_papers_account": "",
"already_subscribed_try_refreshing_the_page": "",
"an_email_has_already_been_sent_to": "",
"an_error_occured_while_restoring_project": "",
@@ -180,7 +184,9 @@
"blank_project": "",
"blocked_filename": "",
"blog": "",
+ "bold": "",
"browser": "",
+ "bullet_list": "",
"buy_licenses": "",
"buy_more_licenses": "",
"by_subscribing_you_agree_to_our_terms_of_service": "",
@@ -241,6 +247,7 @@
"choose_from_group_members": "",
"choose_how_you_search_your_references": "",
"choose_which_experiments": "",
+ "citation": "",
"clear_cached_files": "",
"clear_search": "",
"click_here_to_view_sl_in_lng": "",
@@ -254,8 +261,6 @@
"code_check_failed": "",
"code_check_failed_explanation": "",
"code_editor": "",
- "code_editor_tooltip_message": "",
- "code_editor_tooltip_title": "",
"collaborate_online_and_offline": "",
"collaborator_chat": "",
"collabs_per_proj": "",
@@ -334,6 +339,7 @@
"create_project_in_github": "",
"created": "",
"created_at": "",
+ "cross_reference": "",
"current_file": "",
"current_password": "",
"currently_seeing_only_24_hrs_history": "",
@@ -344,9 +350,9 @@
"customize_your_group_subscription": "",
"customizing_figures": "",
"customizing_tables": "",
- "dark_mode": "",
"date_and_owner": "",
"dealing_with_errors": "",
+ "decrease_indent": "",
"delete": "",
"delete_account": "",
"delete_account_confirmation_label": "",
@@ -377,6 +383,7 @@
"deleted_by_id": "",
"deleted_by_ip": "",
"deleted_by_on": "",
+ "deleted_user": "",
"deleting": "",
"demonstrating_git_integration": "",
"demonstrating_track_changes_feature": "",
@@ -395,12 +402,15 @@
"disable_stop_on_first_error": "",
"disabled": "",
"disabling": "",
+ "disagree": "",
"disconnected": "",
"discount": "",
"discount_of": "",
"discover_the_fastest_way_to_search_and_cite": "",
"dismiss_error_popup": "",
+ "display": "",
"display_deleted_user": "",
+ "display_math": "",
"do_you_need_edit_access": "",
"do_you_want_to_change_your_primary_email_address_to": "",
"do_you_want_to_overwrite_it": "",
@@ -464,7 +474,7 @@
"edit_your_custom_dictionary": "",
"editing": "",
"editing_captions": "",
- "editing_tools_to_paraphrase_change_style_and_more": "",
+ "editing_tools": "",
"editor": "",
"editor_and_pdf": "",
"editor_disconected_click_to_reconnect": "",
@@ -514,7 +524,7 @@
"equation_preview": "",
"error": "",
"error_assist": "",
- "error_assist_to_help_fixing_latex_errors": "",
+ "error_log": "",
"error_opening_document": "",
"error_opening_document_detail": "",
"error_performing_request": "",
@@ -537,6 +547,7 @@
"fast": "",
"fast_draft": "",
"features_like_track_changes": "",
+ "figure": "",
"file": "",
"file_action_created": "",
"file_action_deleted": "",
@@ -557,6 +568,7 @@
"files_cannot_include_invalid_characters": "",
"files_selected": "",
"filter_projects": "",
+ "find": "",
"find_out_more": "",
"find_out_more_about_institution_login": "",
"find_out_more_about_the_file_outline": "",
@@ -599,6 +611,7 @@
"full_doc_history": "",
"full_width": "",
"future_payments": "",
+ "generate_from_text_or_image": "",
"generate_token": "",
"generic_if_problem_continues_contact_us": "",
"generic_linked_file_compile_error": "",
@@ -661,6 +674,9 @@
"go_to_pdf_location_in_code": "",
"go_to_settings": "",
"go_to_subscriptions": "",
+ "go_to_writefull": "",
+ "good_news_you_already_purchased_this_add_on": "",
+ "good_news_you_are_already_receiving_this_add_on_via_writefull": "",
"group_admin": "",
"group_invitations": "",
"group_invite_has_been_sent_to_email": "",
@@ -744,6 +760,7 @@
"how_we_use_your_data": "",
"how_we_use_your_data_explanation": "",
"i_confirm_am_student": "",
+ "i_want_to_add_a_po_number": "",
"i_want_to_stay": "",
"id": "",
"identify_errors_with_your_compile": "",
@@ -769,6 +786,7 @@
"imported_from_zotero_at_date": "",
"importing": "",
"importing_and_merging_changes_in_github": "",
+ "improved_dark_mode": "",
"in_order_to_match_institutional_metadata_2": "",
"in_order_to_match_institutional_metadata_associated": "",
"include_caption": "",
@@ -776,7 +794,11 @@
"include_results_from_your_reference_manager": "",
"include_results_from_your_x_account": "",
"include_the_error_message_and_ai_response": "",
+ "included_as_part_of_your_writefull_subscription": "",
+ "increase_indent": "",
"increased_compile_timeout": "",
+ "inline": "",
+ "inline_math": "",
"inr_discount_modal_info": "",
"inr_discount_modal_title": "",
"insert": "",
@@ -827,6 +849,7 @@
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
"it_looks_like_your_account_is_billed_manually": "",
"it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "",
+ "italics": "",
"join_beta_program": "",
"join_now": "",
"join_overleaf_labs": "",
@@ -846,7 +869,7 @@
"labels_help_you_to_reference_your_tables": "",
"labs": "",
"language": "",
- "language_suggestions_for_texts_in_any_language": "",
+ "language_suggestions": "",
"large_or_high-resolution_images_taking_too_long": "",
"large_or_high_resolution_images_taking_too_long_to_process": "",
"last_active": "",
@@ -889,11 +912,9 @@
"licenses": "",
"limited_document_history": "",
"limited_offer": "",
+ "limited_to_n_collaborators_per_project": "",
+ "limited_to_n_collaborators_per_project_plural": "",
"limited_to_n_editors": "",
- "limited_to_n_editors_or_reviewers": "",
- "limited_to_n_editors_or_reviewers_per_project": "",
- "limited_to_n_editors_or_reviewers_per_project_plural": "",
- "limited_to_n_editors_or_reviewers_plural": "",
"limited_to_n_editors_per_project": "",
"limited_to_n_editors_per_project_plural": "",
"limited_to_n_editors_plural": "",
@@ -964,6 +985,7 @@
"manage_publisher_managers": "",
"manage_sessions": "",
"manage_subscription": "",
+ "manage_your_ai_assist_add_on": "",
"managed": "",
"managed_user_accounts": "",
"managed_user_invite_has_been_sent_to_email": "",
@@ -974,6 +996,7 @@
"managers_management": "",
"managing_your_subscription": "",
"marked_as_resolved": "",
+ "math": "",
"math_display": "",
"math_inline": "",
"maximum_files_uploaded_together": "",
@@ -1001,9 +1024,11 @@
"month_plural": "",
"more": "",
"more_actions": "",
+ "more_changes_based_on_your_feedback": "",
"more_collabs_per_project": "",
"more_comments": "",
"more_compile_time": "",
+ "more_editor_toolbar_item": "",
"more_info": "",
"more_options": "",
"more_options_for_border_settings_coming_soon": "",
@@ -1024,11 +1049,16 @@
"need_more_than_x_licenses": "",
"need_to_add_new_primary_before_remove": "",
"need_to_leave": "",
+ "neither_agree_nor_disagree": "",
"new_compile_domain_notice": "",
"new_file": "",
"new_folder": "",
"new_font_open_dyslexic": "",
+ "new_look_and_feel": "",
+ "new_look_and_placement_of_the_settings": "",
"new_name": "",
+ "new_navigation_introducing_left_hand_side_rail_and_top_menus": "",
+ "new_overleaf_editor": "",
"new_password": "",
"new_project": "",
"new_subscription_will_be_billed_immediately": "",
@@ -1074,7 +1104,9 @@
"notification_personal_and_group_subscriptions": "",
"notification_project_invite_accepted_message": "",
"notification_project_invite_message": "",
+ "now_you_can_search_your_whole_project_not_just_this_file": "",
"number_of_users": "",
+ "numbered_list": "",
"oauth_orcid_description": "",
"of": "",
"off": "",
@@ -1102,7 +1134,6 @@
"optional": "",
"or": "",
"organization_name": "",
- "organize_projects": "",
"organize_tags": "",
"other": "",
"other_causes_of_compile_timeouts": "",
@@ -1115,10 +1146,11 @@
"output_file": "",
"overall_theme": "",
"overleaf": "",
- "overleaf_assist_streamline_your_workflow": "",
"overleaf_history_system": "",
+ "overleaf_is_easy_to_use": "",
"overleaf_labs": "",
"overleaf_logo": "",
+ "overleafs_functionality_meets_my_needs": "",
"overview": "",
"overwrite": "",
"overwriting_the_original_folder": "",
@@ -1137,6 +1169,7 @@
"papers_sync_description": "",
"papers_upgrade_prompt_content": "",
"papers_upgrade_prompt_title": "",
+ "paragraph_styles": "",
"partial_outline_warning": "",
"password": "",
"password_managed_externally": "",
@@ -1156,6 +1189,7 @@
"pdf_in_separate_tab": "",
"pdf_only": "",
"pdf_only_hide_editor": "",
+ "pdf_preview": "",
"pdf_preview_error": "",
"pdf_rendering_error": "",
"pdf_unavailable_for_download": "",
@@ -1166,13 +1200,14 @@
"pending_invite": "",
"per_license": "",
"per_month": "",
+ "per_month_billed_annually": "",
"percent_is_the_percentage_of_the_line_width": "",
"permanently_disables_the_preview": "",
"personal_library": "",
"pick_up_where_you_left_off": "",
"plan": "",
"plan_tooltip": "",
- "please_ask_the_project_owner_to_upgrade_more_editors": "",
+ "please_ask_the_project_owner_to_upgrade_more_collaborators": "",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
"please_change_primary_to_remove": "",
"please_check_your_inbox_to_confirm": "",
@@ -1187,6 +1222,7 @@
"please_link_before_making_primary": "",
"please_provide_a_message": "",
"please_provide_a_subject": "",
+ "please_provide_a_valid_email_address": "",
"please_reconfirm_institutional_email": "",
"please_reconfirm_your_affiliation_before_making_this_primary": "",
"please_refresh": "",
@@ -1199,6 +1235,7 @@
"plus_additional_collaborators_document_history_track_changes_and_more": "",
"plus_more": "",
"plus_x_additional_licenses_for_a_total_of_y_licenses": "",
+ "po_number": "",
"postal_code": "",
"premium": "",
"premium_feature": "",
@@ -1300,6 +1337,7 @@
"recurly_email_updated": "",
"redirect_to_editor": "",
"redirect_url": "",
+ "redo": "",
"reduce_costs_group_licenses": "",
"reference_error_relink_hint": "",
"reference_manager_searched_groups": "",
@@ -1378,7 +1416,8 @@
"reverse_x_sort_order": "",
"revert_pending_plan_change": "",
"review": "",
- "review_panel_comments_and_track_changes": "",
+ "review_panel": "",
+ "review_panel_and_error_logs_moved_to_the_left": "",
"review_your_peers_work": "",
"reviewer": "",
"reviewer_dropbox_sync_message": "",
@@ -1408,7 +1447,6 @@
"search": "",
"search_all_project_files": "",
"search_bib_files": "",
- "search_by_author_journal_title_and_more_link_to_zotero_mendeley": "",
"search_by_author_journal_title_and_more_link_to_zotero_mendeley_papers": "",
"search_by_citekey_author_year_title": "",
"search_command_find": "",
@@ -1466,6 +1504,7 @@
"select_image_from_project_files": "",
"select_project": "",
"select_projects": "",
+ "select_size": "",
"select_tag": "",
"select_user": "",
"selected": "",
@@ -1488,7 +1527,6 @@
"set_up_single_sign_on": "",
"set_up_sso": "",
"settings": "",
- "settings_for_git_github_and_dropbox_integrations": "",
"setup_another_account_under_a_personal_email_address": "",
"share": "",
"share_project": "",
@@ -1497,6 +1535,7 @@
"shortcut_to_open_advanced_reference_search": "",
"show_all_projects": "",
"show_document_preamble": "",
+ "show_equation_preview": "",
"show_file_tree": "",
"show_hotkeys": "",
"show_in_code": "",
@@ -1531,6 +1570,7 @@
"sorry_there_was_an_issue_adding_x_users_to_your_subscription": "",
"sorry_there_was_an_issue_upgrading_your_subscription": "",
"sorry_you_can_only_change_to_group_from_trial_via_support": "",
+ "sorry_you_can_only_change_to_group_via_support": "",
"sorry_your_table_cant_be_displayed_at_the_moment": "",
"sort_by": "",
"sort_by_x": "",
@@ -1602,6 +1642,8 @@
"stop_on_validation_error": "",
"store_your_work": "",
"stretch_width_to_text": "",
+ "strongly_agree": "",
+ "strongly_disagree": "",
"student": "",
"student_disclaimer": "",
"subject": "",
@@ -1637,6 +1679,7 @@
"switch_to_old_editor": "",
"switch_to_pdf": "",
"switch_to_standard_plan": "",
+ "symbol": "",
"symbol_palette": "",
"sync": "",
"sync_dropbox_github": "",
@@ -1647,6 +1690,7 @@
"syntax_validation": "",
"tab_connecting": "",
"tab_no_longer_connected": "",
+ "table": "",
"table_generator": "",
"tag_color": "",
"tag_name_cannot_exceed_characters": "",
@@ -1667,7 +1711,8 @@
"test_configuration": "",
"test_configuration_successful": "",
"tex_live_version": "",
- "texgpt_for_help_writing_latex": "",
+ "texgpt": "",
+ "thank_you": "",
"thank_you_exclamation": "",
"thank_you_for_your_feedback": "",
"thanks_for_being_part_of_this_labs_experiment_your_feedback_will_help_us_make_the_new_editor_the_best_yet": "",
@@ -1711,10 +1756,10 @@
"this_is_a_labs_experiment_for_the_new_overleaf_editor_some_features_are_still_in_progress": "",
"this_is_a_new_feature": "",
"this_is_the_file_that_references_pulled_from_your_reference_manager_will_be_added_to": "",
- "this_project_already_has_maximum_editors": "",
+ "this_project_already_has_maximum_collaborators": "",
"this_project_contains_a_file_called_output": "",
+ "this_project_exceeded_collaborator_limit": "",
"this_project_exceeded_compile_timeout_limit_on_free_plan": "",
- "this_project_exceeded_editor_limit": "",
"this_project_has_more_than_max_collabs": "",
"this_project_is_public": "",
"this_project_is_public_read_only": "",
@@ -1769,6 +1814,8 @@
"toolbar_editor": "",
"toolbar_format_bold": "",
"toolbar_format_italic": "",
+ "toolbar_generate_math": "",
+ "toolbar_generate_table": "",
"toolbar_increase_indent": "",
"toolbar_insert_citation": "",
"toolbar_insert_cross_reference": "",
@@ -1778,6 +1825,7 @@
"toolbar_insert_link": "",
"toolbar_insert_math": "",
"toolbar_insert_math_and_symbols": "",
+ "toolbar_insert_math_lowercase": "",
"toolbar_insert_misc": "",
"toolbar_insert_table": "",
"toolbar_list_indentation": "",
@@ -1836,6 +1884,7 @@
"try_for_free": "",
"try_it_for_free": "",
"try_now": "",
+ "try_papers_for_free": "",
"try_premium_for_free": "",
"try_recompile_project_or_troubleshoot": "",
"try_relinking_provider": "",
@@ -1852,6 +1901,7 @@
"unconfirmed": "",
"undelete": "",
"understanding_labels": "",
+ "undo": "",
"unfold_line": "",
"unique_identifier_attribute": "",
"university": "",
@@ -1900,7 +1950,7 @@
"upgrade_now": "",
"upgrade_plan": "",
"upgrade_summary": "",
- "upgrade_to_add_more_editors_and_access_collaboration_features": "",
+ "upgrade_to_add_more_collaborators_and_access_collaboration_features": "",
"upgrade_to_get_feature": "",
"upgrade_to_review": "",
"upgrade_to_track_changes": "",
@@ -1964,6 +2014,7 @@
"view_only_downgraded": "",
"view_only_reviewer_downgraded": "",
"view_options": "",
+ "view_payment_portal": "",
"view_pdf": "",
"view_your_invoices": "",
"viewer": "",
@@ -2013,6 +2064,7 @@
"work_offline_pull_to_overleaf": "",
"work_with_non_overleaf_users": "",
"work_with_other_github_users": "",
+ "write_faster_smarter_with_overleaf_and_writefull_ai_tools": "",
"writefull": "",
"writefull_loading_error_body": "",
"writefull_loading_error_title": "",
@@ -2045,9 +2097,10 @@
"you_can_manage_your_reference_manager_integrations_from_your_account_settings_page": "",
"you_can_now_enable_sso": "",
"you_can_now_log_in_sso": "",
+ "you_can_now_sync_your_papers_library_directly_with_your_overleaf_projects": "",
"you_can_request_a_maximum_of_limit_fixes_per_day": "",
- "you_can_select_or_invite": "",
- "you_can_select_or_invite_plural": "",
+ "you_can_select_or_invite_collaborator": "",
+ "you_can_select_or_invite_collaborator_plural": "",
"you_can_still_use_your_premium_features": "",
"you_cant_add_or_change_password_due_to_sso": "",
"you_cant_join_this_group_subscription": "",
@@ -2079,6 +2132,7 @@
"your_current_plan_gives_you": "",
"your_current_plan_supports_up_to_x_licenses": "",
"your_current_project_will_revert_to_the_version_from_time": "",
+ "your_feedback_matters_answer_two_quick_questions": "",
"your_git_access_info": "",
"your_git_access_info_bullet_1": "",
"your_git_access_info_bullet_2": "",
@@ -2095,8 +2149,8 @@
"your_plan_is_limited_to_n_editors": "",
"your_plan_is_limited_to_n_editors_plural": "",
"your_premium_plan_is_paused": "",
+ "your_project_exceeded_collaborator_limit": "",
"your_project_exceeded_compile_timeout_limit_on_free_plan": "",
- "your_project_exceeded_editor_limit": "",
"your_project_near_compile_timeout_limit": "",
"your_project_need_more_time_to_compile": "",
"your_projects": "",
@@ -2117,7 +2171,7 @@
"youre_signed_in_as_logout": "",
"youve_added_more_licenses": "",
"youve_added_x_more_licenses_to_your_subscription_invite_people": "",
- "youve_lost_edit_access": "",
+ "youve_lost_collaboration_access": "",
"youve_paused_your_subscription": "",
"youve_unlinked_all_users": "",
"youve_upgraded_your_plan": "",
diff --git a/services/web/frontend/js/features/chat/components/chat-pane.tsx b/services/web/frontend/js/features/chat/components/chat-pane.tsx
index d51037762e..1ef4d46a31 100644
--- a/services/web/frontend/js/features/chat/components/chat-pane.tsx
+++ b/services/web/frontend/js/features/chat/components/chat-pane.tsx
@@ -114,4 +114,4 @@ function Placeholder() {
)
}
-export default withErrorBoundary(ChatPane, ChatFallbackError)
+export default withErrorBoundary(ChatPane, () => )
diff --git a/services/web/frontend/js/features/chat/context/chat-context.tsx b/services/web/frontend/js/features/chat/context/chat-context.tsx
index d2b078138d..d86171a451 100644
--- a/services/web/frontend/js/features/chat/context/chat-context.tsx
+++ b/services/web/frontend/js/features/chat/context/chat-context.tsx
@@ -190,7 +190,7 @@ export const ChatContext = createContext<
| undefined
>(undefined)
-export const ChatProvider: FC = ({ children }) => {
+export const ChatProvider: FC = ({ children }) => {
const chatEnabled = getMeta('ol-chatEnabled')
const clientId = useRef()
@@ -283,7 +283,7 @@ export const ChatProvider: FC = ({ children }) => {
])
const sendMessage = useCallback(
- content => {
+ (content: string) => {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't send message`)
return
diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx
index e38bbc18ae..38795e145c 100644
--- a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx
+++ b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx
@@ -33,7 +33,7 @@ export default function DictionaryModalContent({
const { isError, runAsync } = useAsync()
const handleRemove = useCallback(
- word => {
+ (word: string) => {
runAsync(postJSON('/spelling/unlearn', { body: { word } }))
.then(() => {
setLearnedWords(prevLearnedWords => {
diff --git a/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx b/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx
index 3e05c907d6..7832939541 100644
--- a/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx
+++ b/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx
@@ -1,15 +1,10 @@
import { useState, useCallback } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import WordCountModal from '../../word-count-modal/components/word-count-modal'
-import LeftMenuButton from './left-menu-button'
import * as eventTracking from '../../../infrastructure/event-tracking'
-import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import { WordCountButton } from '@/features/word-count-modal/components/word-count-button'
export default function ActionsWordCount() {
const [showModal, setShowModal] = useState(false)
- const { pdfUrl } = useCompileContext()
- const { t } = useTranslation()
const handleShowModal = useCallback(() => {
eventTracking.sendMB('left-menu-count')
@@ -18,32 +13,7 @@ export default function ActionsWordCount() {
return (
<>
- {pdfUrl ? (
-
- {t('word_count')}
-
- ) : (
-
- {/* OverlayTrigger won't fire unless the child is a non-react html element (e.g div, span) */}
-
-
- {t('word_count')}
-
-
-
- )}
+
setShowModal(false)} />
>
)
diff --git a/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu-context.tsx b/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu-context.tsx
index 8682b056ec..ed2b4e9736 100644
--- a/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu-context.tsx
+++ b/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu-context.tsx
@@ -9,17 +9,19 @@ export const EditorLeftMenuContext = createContext<
EditorLeftMenuState | undefined
>(undefined)
-export const EditorLeftMenuProvider: FC = ({ children }) => {
+export const EditorLeftMenuProvider: FC = ({
+ children,
+}) => {
const [value, setValue] = useState(() => ({
settingToFocus: undefined,
}))
useEventListener(
'ui.focus-setting',
- useCallback(event => {
+ useCallback((event: CustomEvent) => {
setValue(value => ({
...value,
- settingToFocus: (event as CustomEvent).detail,
+ settingToFocus: event.detail,
}))
}, [])
)
diff --git a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx
index 20aa62b2d6..e40c4c6872 100644
--- a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx
+++ b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx
@@ -37,7 +37,9 @@ export const ProjectSettingsContext = createContext<
ProjectSettingsContextValue | undefined
>(undefined)
-export const ProjectSettingsProvider: FC = ({ children }) => {
+export const ProjectSettingsProvider: FC = ({
+ children,
+}) => {
const {
compiler,
setCompiler,
diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx
index 4b3795f70c..293f66d5b2 100644
--- a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx
+++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx
@@ -56,7 +56,7 @@ const EditorNavigationToolbarRoot = React.memo(
}, [chatIsOpen, setChatIsOpen, markMessagesAsRead])
const toggleReviewPanelOpen = useCallback(
- event => {
+ (event: any) => {
event.preventDefault()
eventTracking.sendMB('navigation-clicked-review', {
action: isOpentoString(!reviewPanelOpen),
@@ -93,7 +93,7 @@ const EditorNavigationToolbarRoot = React.memo(
}, [setLeftMenuShown])
const goToUser = useCallback(
- user => {
+ (user: any) => {
if (user.doc && typeof user.row === 'number') {
openDoc(user.doc, { gotoLine: user.row + 1 })
}
diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx b/services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx
index 91cc150ff6..6fc3bc80a3 100644
--- a/services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx
+++ b/services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx
@@ -17,7 +17,7 @@ const TryNewEditorButton = () => {
onClick={onClick}
size="sm"
leadingIcon={ }
- variant="info"
+ variant="secondary"
>
{t('try_the_new_editor')}
diff --git a/services/web/frontend/js/features/event-tracking/search-events.ts b/services/web/frontend/js/features/event-tracking/search-events.ts
index 9829a2024e..cd9ff4b8ba 100644
--- a/services/web/frontend/js/features/event-tracking/search-events.ts
+++ b/services/web/frontend/js/features/event-tracking/search-events.ts
@@ -4,7 +4,10 @@ type SearchEventSegmentation = {
'search-open':
| ({
searchType: 'full-project'
- } & ({ method: 'keyboard' } | { method: 'button'; location: 'toolbar' }))
+ } & (
+ | { method: 'keyboard' }
+ | { method: 'button'; location: 'toolbar' | 'search-form' }
+ ))
| ({
searchType: 'document'
mode: 'visual' | 'source'
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx
index 5e66614dcd..18b8ee117d 100644
--- a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef } from 'react'
+import React, { useCallback, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import {
Dropdown,
@@ -13,30 +13,34 @@ function FileTreeContextMenu() {
const { fileTreeReadOnly } = useFileTreeData()
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
const toggleButtonRef = useRef(null)
+ const keyboardInputRef = useRef(false)
useEffect(() => {
if (contextMenuCoords) {
toggleButtonRef.current = document.querySelector(
'.entity-menu-toggle'
) as HTMLButtonElement | null
- focusContextMenu()
}
}, [contextMenuCoords])
- if (!contextMenuCoords || fileTreeReadOnly) return null
+ useEffect(() => {
+ if (contextMenuCoords && keyboardInputRef.current) {
+ const firstDropdownMenuItem = document.querySelector(
+ '#dropdown-file-tree-context-menu .dropdown-item:not([disabled])'
+ ) as HTMLButtonElement | null
- // A11y - Move the focus to the context menu when it opens
- function focusContextMenu() {
- const BS3contextMenu = document.querySelector(
- '[aria-labelledby="dropdown-file-tree-context-menu"]'
- ) as HTMLElement | null
- BS3contextMenu?.focus()
- }
+ if (firstDropdownMenuItem) {
+ firstDropdownMenuItem.focus()
+ }
+ }
+ }, [contextMenuCoords])
function close() {
+ if (!contextMenuCoords) return
setContextMenuCoords(null)
+
if (toggleButtonRef.current) {
- // A11y - Move the focus back to the toggle button when the context menu closes by pressing the Esc key
+ // A11y - Focus moves back to the trigger button when the context menu is dismissed
toggleButtonRef.current.focus()
}
}
@@ -45,14 +49,33 @@ function FileTreeContextMenu() {
if (!wantOpen) close()
}
- // A11y - Close the context menu when the user presses the Tab key
- // Focus should move to the next element in the filetree
- function handleKeyDown(event: React.KeyboardEvent) {
- if (event.key === 'Tab') {
+ function handleClose(event: React.KeyboardEvent) {
+ if (event.key === 'Tab' || event.key === 'Escape') {
+ event.preventDefault()
close()
}
}
+ const handleKeyDown = useCallback(() => {
+ keyboardInputRef.current = true
+ }, [])
+
+ const handleMouseDown = useCallback(() => {
+ keyboardInputRef.current = false
+ }, [])
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleKeyDown)
+ document.addEventListener('mousedown', handleMouseDown)
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ document.removeEventListener('mousedown', handleMouseDown)
+ }
+ }, [handleKeyDown, handleMouseDown])
+
+ if (!contextMenuCoords || fileTreeReadOnly) return null
+
return ReactDOM.createPortal(
(
- ({ bsRole }, ref) => {
- return null
- }
-)
-
-FakeDropDownToggle.displayName = 'FakeDropDownToggle'
-
export default FileTreeContextMenu
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx
index 2913c6f9e0..6beecd7e0f 100644
--- a/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx
@@ -9,13 +9,15 @@ import { FC } from 'react'
// FileTreeActionable: global UI state for actions (rename, delete, etc.)
// FileTreeMutable: provides entities mutation operations
// FileTreeSelectable: handles selection and multi-selection
-const FileTreeContext: FC<{
- refProviders: Record
- setRefProviderEnabled: (provider: string, value: boolean) => void
- setStartedFreeTrial: (value: boolean) => void
- onSelect: () => void
- fileTreeContainer?: HTMLDivElement
-}> = ({
+const FileTreeContext: FC<
+ React.PropsWithChildren<{
+ refProviders: Record
+ setRefProviderEnabled: (provider: string, value: boolean) => void
+ setStartedFreeTrial: (value: boolean) => void
+ onSelect: () => void
+ fileTreeContainer?: HTMLDivElement
+ }>
+> = ({
refProviders,
setRefProviderEnabled,
setStartedFreeTrial,
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx
index f8be632986..7afdbbdd55 100644
--- a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx
@@ -50,7 +50,7 @@ export default function FileTreeImportFromProject() {
// use the basename of a path as the file name
const setNameFromPath = useCallback(
- path => {
+ (path: string) => {
const filename = path.split('/').pop()
if (filename) {
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx
index de211c6c07..816aa46fb8 100644
--- a/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx
@@ -1,7 +1,7 @@
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
import { FC, useCallback } from 'react'
-const FileTreeInner: FC = ({ children }) => {
+const FileTreeInner: FC = ({ children }) => {
const { setIsRootFolderSelected, selectedEntityIds, select } =
useFileTreeSelectable()
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx
index 6223c49054..6b26de3298 100644
--- a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx
@@ -218,7 +218,9 @@ function fileTreeActionableReducer(state: State, action: Action) {
}
}
-export const FileTreeActionableProvider: FC = ({ children }) => {
+export const FileTreeActionableProvider: FC = ({
+ children,
+}) => {
const { _id: projectId } = useProjectContext()
const { fileTreeReadOnly } = useFileTreeData()
const { indexAllReferences } = useReferencesContext()
@@ -400,7 +402,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => {
}, [fileTreeData, selectedEntityIds])
const finishCreatingEntity = useCallback(
- entity => {
+ (entity: any) => {
const error = validateCreate(fileTreeData, parentFolderId, entity)
if (error) {
return Promise.reject(error)
@@ -412,7 +414,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => {
)
const finishCreatingFolder = useCallback(
- name => {
+ (name: any) => {
dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
return finishCreatingEntity({ endpoint: 'folder', name })
.then(() => {
@@ -425,7 +427,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => {
[finishCreatingEntity]
)
- const startCreatingFile = useCallback(newFileCreateMode => {
+ const startCreatingFile = useCallback((newFileCreateMode: any) => {
dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
}, [])
@@ -438,7 +440,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => {
}, [startCreatingFile])
const finishCreatingDocOrFile = useCallback(
- entity => {
+ (entity: any) => {
dispatch({ type: ACTION_TYPES.CREATING_FILE })
return finishCreatingEntity(entity)
@@ -453,7 +455,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => {
)
const finishCreatingDoc = useCallback(
- entity => {
+ (entity: any) => {
entity.endpoint = 'doc'
return finishCreatingDocOrFile(entity)
},
@@ -461,7 +463,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => {
)
const finishCreatingLinkedFile = useCallback(
- entity => {
+ (entity: any) => {
entity.endpoint = 'linked_file'
return finishCreatingDocOrFile(entity)
},
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx
index 93315ec836..8d9e0b596e 100644
--- a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx
@@ -16,7 +16,9 @@ export const useFileTreeCreateForm = () => {
return context
}
-const FileTreeCreateFormProvider: FC = ({ children }) => {
+const FileTreeCreateFormProvider: FC = ({
+ children,
+}) => {
// is the form valid
const [valid, setValid] = useState(false)
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx
index ed9a8f4a38..59b2dc6fcc 100644
--- a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx
@@ -28,10 +28,9 @@ type State = {
touchedName: boolean
}
-const FileTreeCreateNameProvider: FC<{ initialName?: string }> = ({
- children,
- initialName = '',
-}) => {
+const FileTreeCreateNameProvider: FC<
+ React.PropsWithChildren<{ initialName?: string }>
+> = ({ children, initialName = '' }) => {
const [state, setName] = useReducer(
(state: State, name: string) => ({
name, // the file name
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx
index c255c25231..e6f8cac3f8 100644
--- a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx
@@ -18,9 +18,11 @@ import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file'
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
const DRAGGABLE_TYPE = 'ENTITY'
-export const FileTreeDraggableProvider: FC<{
- fileTreeContainer?: HTMLDivElement
-}> = ({ fileTreeContainer, children }) => {
+export const FileTreeDraggableProvider: FC<
+ React.PropsWithChildren<{
+ fileTreeContainer?: HTMLDivElement
+ }>
+> = ({ fileTreeContainer, children }) => {
const options = useMemo(
() => ({ rootElement: fileTreeContainer }),
[fileTreeContainer]
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx
index 85872fe129..d8cceb31c3 100644
--- a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx
@@ -25,11 +25,13 @@ export function useFileTreeMainContext() {
return context
}
-export const FileTreeMainProvider: FC<{
- refProviders: object
- setRefProviderEnabled: (provider: string, value: boolean) => void
- setStartedFreeTrial: (value: boolean) => void
-}> = ({
+export const FileTreeMainProvider: FC<
+ React.PropsWithChildren<{
+ refProviders: object
+ setRefProviderEnabled: (provider: string, value: boolean) => void
+ setStartedFreeTrial: (value: boolean) => void
+ }>
+> = ({
refProviders,
setRefProviderEnabled,
setStartedFreeTrial,
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx
index 593d51c6e1..e0b860be87 100644
--- a/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx
@@ -22,7 +22,9 @@ export const FileTreePathContext = createContext<
FileTreePathContextValue | undefined
>(undefined)
-export const FileTreePathProvider: FC = ({ children }) => {
+export const FileTreePathProvider: FC = ({
+ children,
+}) => {
const { fileTreeData }: { fileTreeData: Folder } = useFileTreeData()
const projectId = getMeta('ol-project_id')
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx
index f52fe385e5..edd1334169 100644
--- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx
@@ -1,3 +1,6 @@
+// TODO: The types in this file have mismatches between string and string[] and
+// it's not immediately clear how to resolve it. I've therefore left in a bunch
+// of `any` types. We should fix this.
import {
createContext,
useCallback,
@@ -120,9 +123,11 @@ function fileTreeSelectableReadOnlyReducer(
}
}
-export const FileTreeSelectableProvider: FC<{
- onSelect: (value: FindResult[]) => void
-}> = ({ onSelect, children }) => {
+export const FileTreeSelectableProvider: FC<
+ React.PropsWithChildren<{
+ onSelect: (value: FindResult[]) => void
+ }>
+> = ({ onSelect, children }) => {
const { _id: projectId, rootDocId } = useProjectContext()
const [initialSelectedEntityId] = usePersistedState(
@@ -206,21 +211,24 @@ export const FileTreeSelectableProvider: FC<{
)
)
- const select = useCallback(id => {
+ const select = useCallback((id: any) => {
dispatch({ type: ACTION_TYPES.SELECT, id })
}, [])
- const unselect = useCallback(id => {
+ const unselect = useCallback((id: any) => {
dispatch({ type: ACTION_TYPES.UNSELECT, id })
}, [])
- const selectOrMultiSelectEntity = useCallback((id, isMultiSelect) => {
- const actionType = isMultiSelect
- ? ACTION_TYPES.MULTI_SELECT
- : ACTION_TYPES.SELECT
+ const selectOrMultiSelectEntity = useCallback(
+ (id: any, isMultiSelect: any) => {
+ const actionType = isMultiSelect
+ ? ACTION_TYPES.MULTI_SELECT
+ : ACTION_TYPES.SELECT
- dispatch({ type: actionType, id })
- }, [])
+ dispatch({ type: actionType, id })
+ },
+ []
+ )
// TODO: wrap in useMemo
const value = {
@@ -254,7 +262,7 @@ export function useSelectableEntity(id: string, type: string) {
const isSelected = selectedEntityIds.has(id)
const buildSelectedRange = useCallback(
- id => {
+ (id: string) => {
const selected = []
let started = false
@@ -306,7 +314,7 @@ export function useSelectableEntity(id: string, type: string) {
}, [fileTreeData, selectedEntityIds, view])
const handleEvent = useCallback(
- ev => {
+ (ev: any) => {
ev.stopPropagation()
// use Command (macOS) or Ctrl (other OS) to select multiple items,
// as long as the root folder wasn't selected
@@ -342,7 +350,7 @@ export function useSelectableEntity(id: string, type: string) {
)
const handleClick = useCallback(
- ev => {
+ (ev: any) => {
handleEvent(ev)
if (!ev.ctrlKey && !ev.metaKey) {
setContextMenuCoords(null)
@@ -352,7 +360,7 @@ export function useSelectableEntity(id: string, type: string) {
)
const handleKeyPress = useCallback(
- ev => {
+ (ev: any) => {
if (ev.key === 'Enter' || ev.key === ' ') {
handleEvent(ev)
}
@@ -361,7 +369,7 @@ export function useSelectableEntity(id: string, type: string) {
)
const handleContextMenu = useCallback(
- ev => {
+ (ev: any) => {
// make sure the right-clicked entity gets selected
if (!selectedEntityIds.has(id)) {
handleEvent(ev)
diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts
index b6197ce5a9..a9f15721fc 100644
--- a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts
+++ b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts
@@ -25,7 +25,7 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) {
const selectEntityIfCreatedByUser = useCallback(
// hack to automatically re-open refreshed linked files
- (entityId, entityName, userId) => {
+ (entityId: any, entityName: any, userId: string) => {
// If the created entity's user exists and is the current user
if (userId && user?.id === userId) {
// And we're expecting a refreshed socket for this entity
diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx
index 7163fd2cee..988876a4a0 100644
--- a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx
+++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx
@@ -16,6 +16,7 @@ import {
} from 'react-bootstrap-5'
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
import Button from '@/features/ui/components/bootstrap-5/button'
+import PoNumber from '@/features/group-management/components/add-seats/po-number'
import CostSummary from '@/features/group-management/components/add-seats/cost-summary'
import RequestStatus from '@/features/group-management/components/request-status'
import useAsync from '@/shared/hooks/use-async'
@@ -29,6 +30,7 @@ import {
} from '../../../../../../types/subscription/subscription-change-preview'
import { MergeAndOverride, Nullable } from '../../../../../../types/utils'
import { sendMB } from '../../../../infrastructure/event-tracking'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
export const MAX_NUMBER_OF_USERS = 20
@@ -43,8 +45,12 @@ function AddSeats() {
const subscriptionId = getMeta('ol-subscriptionId')
const totalLicenses = Number(getMeta('ol-totalLicenses'))
const isProfessional = getMeta('ol-isProfessional')
+ const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual')
const [addSeatsInputError, setAddSeatsInputError] = useState()
const [shouldContactSales, setShouldContactSales] = useState(false)
+ const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag(
+ 'flexible-group-licensing-for-manually-billed-subscriptions'
+ )
const controller = useAbortController()
const { signal: addSeatsSignal } = useAbortController()
const { signal: contactSalesSignal } = useAbortController()
@@ -151,6 +157,9 @@ function AddSeats() {
formData.get('seats') === ''
? undefined
: (formData.get('seats') as string)
+ const poNumber = !formData.get('po_number')
+ ? undefined
+ : (formData.get('po_number') as string)
if (!(await validateSeats(rawSeats))) {
return
@@ -166,6 +175,7 @@ function AddSeats() {
signal: contactSalesSignal,
body: {
adding: rawSeats,
+ poNumber,
},
}
)
@@ -176,7 +186,10 @@ function AddSeats() {
})
const post = postJSON('/user/subscription/group/add-users/create', {
signal: addSeatsSignal,
- body: { adding: Number(rawSeats) },
+ body: {
+ adding: Number(rawSeats),
+ poNumber,
+ },
})
runAsyncAddSeats(post)
.then(() => {
@@ -323,6 +336,8 @@ function AddSeats() {
{addSeatsInputError}
)}
+ {isFlexibleGroupLicensingForManuallyBilledSubscriptions &&
+ isCollectionMethodManual && }
+
+ setIsPoNumberChecked(e.target.checked)}
+ />
+
+ {isPoNumberChecked && (
+
+ {t('po_number')}
+
+
+ )}
+ >
+ )
+}
+
+export default PoNumber
diff --git a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx
index e6d9ae4700..af6d74928d 100644
--- a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx
+++ b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx
@@ -1,5 +1,6 @@
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import AddSeats from '@/features/group-management/components/add-seats/add-seats'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
function Root() {
const { isReady } = useWaitForI18n()
@@ -8,7 +9,11 @@ function Root() {
return null
}
- return
+ return (
+
+
+
+ )
}
export default Root
diff --git a/services/web/frontend/js/features/group-management/components/group-members.tsx b/services/web/frontend/js/features/group-management/components/group-members.tsx
index be4f84375c..2e87fe5051 100644
--- a/services/web/frontend/js/features/group-management/components/group-members.tsx
+++ b/services/web/frontend/js/features/group-management/components/group-members.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from 'react'
+import React, { ChangeEvent, useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import getMeta from '../../../utils/meta'
@@ -37,7 +37,7 @@ export default function GroupMembers() {
const canUseAddSeatsFeature = getMeta('ol-canUseAddSeatsFeature')
const handleEmailsChange = useCallback(
- e => {
+ (e: ChangeEvent) => {
setEmailString(e.target.value)
},
[setEmailString]
diff --git a/services/web/frontend/js/features/group-management/components/managers-table.tsx b/services/web/frontend/js/features/group-management/components/managers-table.tsx
index 5efe2ffbb9..3f701b698d 100644
--- a/services/web/frontend/js/features/group-management/components/managers-table.tsx
+++ b/services/web/frontend/js/features/group-management/components/managers-table.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState } from 'react'
+import { ChangeEvent, FormEvent, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { deleteJSON, FetchError, postJSON } from '@/infrastructure/fetch-json'
import getMeta from '../../../utils/meta'
@@ -58,7 +58,7 @@ export function ManagersTable({
const [removeMemberError, setRemoveMemberError] = useState()
const addManagers = useCallback(
- e => {
+ (e: FormEvent | React.MouseEvent) => {
e.preventDefault()
setInviteError(undefined)
const emails = parseEmails(emailString)
@@ -92,7 +92,7 @@ export function ManagersTable({
)
const removeManagers = useCallback(
- e => {
+ (e: React.MouseEvent) => {
e.preventDefault()
setRemoveMemberError(undefined)
;(async () => {
@@ -131,7 +131,7 @@ export function ManagersTable({
)
const handleEmailsChange = useCallback(
- e => {
+ (e: ChangeEvent) => {
setEmailString(e.target.value)
},
[setEmailString]
diff --git a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx
index 691a4b3636..a78f08d40c 100644
--- a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx
+++ b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx
@@ -64,7 +64,7 @@ export default function DropdownButton({
const isUserManaged = !userPending && user.enrollment?.managedBy === groupId
const handleResendManagedUserInvite = useCallback(
- async user => {
+ async (user: User) => {
try {
const result = await runResendManagedUserInviteAsync(
postJSON(
@@ -96,7 +96,7 @@ export default function DropdownButton({
)
const handleResendLinkSSOInviteAsync = useCallback(
- async user => {
+ async (user: User) => {
try {
const result = await runResendLinkSSOInviteAsync(
postJSON(`/manage/groups/${groupId}/resendSSOLinkInvite/${user._id}`)
@@ -126,7 +126,7 @@ export default function DropdownButton({
)
const handleResendGroupInvite = useCallback(
- async user => {
+ async (user: User) => {
try {
await runResendGroupInviteAsync(
postJSON(`/manage/groups/${groupId}/resendInvite/`, {
diff --git a/services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx b/services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx
index 5bc234a789..4528aab13f 100644
--- a/services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx
+++ b/services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx
@@ -101,7 +101,6 @@ export default function OffboardManagedUserModal({
setSelectedRecipientId(e.target.value)}
>
diff --git a/services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx b/services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx
index a2d32810bc..19aec5d6c7 100644
--- a/services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx
+++ b/services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx
@@ -16,7 +16,7 @@ export default function SelectUserCheckbox({
useGroupMembersContext()
const handleSelectUser = useCallback(
- (event, user) => {
+ (event: React.ChangeEvent, user: User) => {
if (event.target.checked) {
selectUser(user)
} else {
diff --git a/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx b/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx
index f63bda765e..ce583a09e3 100644
--- a/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx
+++ b/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx
@@ -47,7 +47,7 @@ export default function UnlinkUserModal({
}, [groupId, updateMemberView, user])
const handleUnlink = useCallback(
- event => {
+ (event: React.MouseEvent) => {
event.preventDefault()
setHasError(undefined)
if (!user) {
@@ -108,7 +108,11 @@ export default function UnlinkUserModal({
-
+
{t('cancel')}
,
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
diff --git a/services/web/frontend/js/features/group-management/components/user-row.tsx b/services/web/frontend/js/features/group-management/components/user-row.tsx
index 895cc69f04..505dd83e76 100644
--- a/services/web/frontend/js/features/group-management/components/user-row.tsx
+++ b/services/web/frontend/js/features/group-management/components/user-row.tsx
@@ -21,7 +21,7 @@ export default function UserRow({
const { t } = useTranslation()
const handleSelectUser = useCallback(
- (event, user) => {
+ (event: React.ChangeEvent, user: User) => {
if (event.target.checked) {
selectUser(user)
} else {
diff --git a/services/web/frontend/js/features/group-management/context/group-members-context.tsx b/services/web/frontend/js/features/group-management/context/group-members-context.tsx
index 748d845e9d..63619dc361 100644
--- a/services/web/frontend/js/features/group-management/context/group-members-context.tsx
+++ b/services/web/frontend/js/features/group-management/context/group-members-context.tsx
@@ -72,7 +72,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
)
const addMembers = useCallback(
- emailString => {
+ (emailString: string) => {
setInviteError(undefined)
const emails = parseEmails(emailString)
mapSeries(emails, async email => {
@@ -102,7 +102,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
)
const removeMember = useCallback(
- async user => {
+ async (user: User) => {
let url
if (paths.removeInvite && user.invite && user._id == null) {
url = `${paths.removeInvite}/${encodeURIComponent(user.email)}`
@@ -126,7 +126,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
)
const removeMembers = useCallback(
- e => {
+ (e: any) => {
e.preventDefault()
setRemoveMemberError(undefined)
;(async () => {
@@ -142,7 +142,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
)
const updateMemberView = useCallback(
- (userId, updatedUser) => {
+ (userId: string, updatedUser: User) => {
setUsers(
users.map(u => {
if (u._id === userId) {
diff --git a/services/web/frontend/js/features/header-footer-react/index.tsx b/services/web/frontend/js/features/header-footer-react/index.tsx
index e808b58322..ebbf4ddecf 100644
--- a/services/web/frontend/js/features/header-footer-react/index.tsx
+++ b/services/web/frontend/js/features/header-footer-react/index.tsx
@@ -1,4 +1,4 @@
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import getMeta from '@/utils/meta'
import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
@@ -7,16 +7,17 @@ import { SplitTestProvider } from '@/shared/context/split-test-context'
const navbarElement = document.getElementById('navbar-container')
if (navbarElement) {
const navbarProps = getMeta('ol-navbar')
- ReactDOM.render(
+ const root = createRoot(navbarElement)
+ root.render(
- ,
- navbarElement
+
)
}
const footerElement = document.getElementById('footer-container')
if (footerElement) {
const footerProps = getMeta('ol-footer')
- ReactDOM.render(, footerElement)
+ const root = createRoot(footerElement)
+ root.render()
}
diff --git a/services/web/frontend/js/features/history/components/change-list/change-list.tsx b/services/web/frontend/js/features/history/components/change-list/change-list.tsx
index cb0e93fae4..f072d24a57 100644
--- a/services/web/frontend/js/features/history/components/change-list/change-list.tsx
+++ b/services/web/frontend/js/features/history/components/change-list/change-list.tsx
@@ -11,10 +11,7 @@ function ChangeList() {
-
+
diff --git a/services/web/frontend/js/features/history/components/change-list/dropdown/actions-dropdown.tsx b/services/web/frontend/js/features/history/components/change-list/dropdown/actions-dropdown.tsx
index 90f23cf746..9029260057 100644
--- a/services/web/frontend/js/features/history/components/change-list/dropdown/actions-dropdown.tsx
+++ b/services/web/frontend/js/features/history/components/change-list/dropdown/actions-dropdown.tsx
@@ -3,26 +3,20 @@ import {
Dropdown,
DropdownMenu,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
-import BS5DropdownToggleWithTooltip from '@/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip'
+import DropdownToggleWithTooltip from '@/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip'
type ActionDropdownProps = {
id: string
children: React.ReactNode
- parentSelector?: string
isOpened: boolean
iconTag: ReactNode
toolTipDescription: string
setIsOpened: (isOpened: boolean) => void
}
-function BS5ActionsDropdown({
- id,
- children,
- isOpened,
- iconTag,
- setIsOpened,
- toolTipDescription,
-}: Omit
) {
+function ActionsDropdown(props: ActionDropdownProps) {
+ const { id, children, isOpened, iconTag, setIsOpened, toolTipDescription } =
+ props
return (
setIsOpened(open)}
>
-
{iconTag}
-
+
{children}
@@ -47,8 +41,4 @@ function BS5ActionsDropdown({
)
}
-function ActionsDropdown(props: ActionDropdownProps) {
- return
-}
-
export default ActionsDropdown
diff --git a/services/web/frontend/js/features/history/components/change-list/dropdown/compare-version-dropdown.tsx b/services/web/frontend/js/features/history/components/change-list/dropdown/compare-version-dropdown.tsx
index 6cf2d54f1f..91f0bf991a 100644
--- a/services/web/frontend/js/features/history/components/change-list/dropdown/compare-version-dropdown.tsx
+++ b/services/web/frontend/js/features/history/components/change-list/dropdown/compare-version-dropdown.tsx
@@ -21,7 +21,6 @@ function CompareVersionDropdown({
id={id}
isOpened={isOpened}
setIsOpened={setIsOpened}
- parentSelector="[data-history-version-list-container]"
toolTipDescription={t('compare')}
iconTag={
}
- parentSelector="[data-history-version-list-container]"
>
{children}
diff --git a/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx b/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx
index 82695a8189..7f2a834ef6 100644
--- a/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx
+++ b/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx
@@ -5,7 +5,7 @@ import { Diff, DocDiffResponse } from '../../services/types/doc'
import { useHistoryContext } from '../../context/history-context'
import { diffDoc } from '../../services/api'
import { highlightsFromDiffResponse } from '../../utils/highlights-from-diff-response'
-import { useErrorHandler } from 'react-error-boundary'
+import { useErrorBoundary } from 'react-error-boundary'
import useAsync from '../../../../shared/hooks/use-async'
import { useTranslation } from 'react-i18next'
@@ -14,7 +14,7 @@ function DiffView() {
const { isLoading, data, runAsync } = useAsync()
const { t } = useTranslation()
const { updateRange, selectedFile } = selection
- const handleError = useErrorHandler()
+ const { showBoundary } = useErrorBoundary()
useEffect(() => {
if (!updateRange || !selectedFile?.pathname || loadingFileDiffs) {
@@ -33,7 +33,7 @@ function DiffView() {
abortController.signal
)
)
- .catch(handleError)
+ .catch(showBoundary)
.finally(() => {
abortController = null
})
@@ -50,7 +50,7 @@ function DiffView() {
updateRange,
selectedFile,
loadingFileDiffs,
- handleError,
+ showBoundary,
])
const diff = useMemo(() => {
diff --git a/services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx b/services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx
index d99f9521ec..324ad38935 100644
--- a/services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx
+++ b/services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx
@@ -77,7 +77,7 @@ function DocumentDiffViewer({
// Append the editor view DOM to the container node when mounted
const containerRef = useCallback(
- node => {
+ (node: HTMLDivElement) => {
if (node) {
node.appendChild(view.dom)
}
diff --git a/services/web/frontend/js/features/history/context/history-context.tsx b/services/web/frontend/js/features/history/context/history-context.tsx
index 7dc2c996c5..4fa7fabea6 100644
--- a/services/web/frontend/js/features/history/context/history-context.tsx
+++ b/services/web/frontend/js/features/history/context/history-context.tsx
@@ -25,7 +25,7 @@ import {
Update,
} from '../services/types/update'
import { Selection } from '../services/types/selection'
-import { useErrorHandler } from 'react-error-boundary'
+import { useErrorBoundary } from 'react-error-boundary'
import { getUpdateForVersion } from '../utils/history-details'
import { getHueForUserId } from '@/shared/utils/colors'
@@ -99,7 +99,7 @@ function useHistory() {
)
const updatesAbortControllerRef = useRef(null)
- const handleError = useErrorHandler()
+ const { showBoundary } = useErrorBoundary()
const fetchNextBatchOfUpdates = useCallback(() => {
// If there is an in-flight request for updates, just let it complete, by
@@ -199,11 +199,11 @@ function useHistory() {
loadingState: 'ready',
})
})
- .catch(handleError)
+ .catch(showBoundary)
.finally(() => {
updatesAbortControllerRef.current = null
})
- }, [updatesInfo, projectId, labels, handleError, userHasFullFeature])
+ }, [updatesInfo, projectId, labels, showBoundary, userHasFullFeature])
// Abort in-flight updates request on unmount
useEffect(() => {
@@ -284,7 +284,7 @@ function useHistory() {
}
})
})
- .catch(handleError)
+ .catch(showBoundary)
.finally(() => {
setLoadingFileDiffs(false)
abortController = null
@@ -295,7 +295,7 @@ function useHistory() {
abortController.abort()
}
}
- }, [projectId, fromV, toV, updateForToV, handleError])
+ }, [projectId, fromV, toV, updateForToV, showBoundary])
useEffect(() => {
// Set update range if there isn't one and updates have loaded
diff --git a/services/web/frontend/js/features/history/context/hooks/use-restore-deleted-file.ts b/services/web/frontend/js/features/history/context/hooks/use-restore-deleted-file.ts
index 226991e3b1..2521454ebc 100644
--- a/services/web/frontend/js/features/history/context/hooks/use-restore-deleted-file.ts
+++ b/services/web/frontend/js/features/history/context/hooks/use-restore-deleted-file.ts
@@ -4,7 +4,7 @@ import { restoreFile } from '../../services/api'
import { isFileRemoved } from '../../utils/file-diff'
import { useHistoryContext } from '../history-context'
import type { HistoryContextValue } from '../types/history-context-value'
-import { useErrorHandler } from 'react-error-boundary'
+import { useErrorBoundary } from 'react-error-boundary'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findInTree } from '@/features/file-tree/util/find-in-tree'
import { useCallback, useEffect, useState } from 'react'
@@ -23,7 +23,7 @@ export function useRestoreDeletedFile() {
const { projectId } = useHistoryContext()
const { setView } = useLayoutContext()
const { openDocWithId, openFileWithId } = useEditorManagerContext()
- const handleError = useErrorHandler()
+ const { showBoundary } = useErrorBoundary()
const { fileTreeData } = useFileTreeData()
const [state, setState] = useState('idle')
const [restoredFileMetadata, setRestoredFileMetadata] =
@@ -59,14 +59,14 @@ export function useRestoreDeletedFile() {
if (state === 'waitingForFileTree') {
const timer = window.setTimeout(() => {
setState('timedOut')
- handleError(new Error('timed out'))
+ showBoundary(new Error('timed out'))
}, 3000)
return () => {
window.clearTimeout(timer)
}
}
- }, [handleError, state])
+ }, [showBoundary, state])
const restoreDeletedFile = useCallback(
(selection: HistoryContextValue['selection']) => {
@@ -93,13 +93,13 @@ export function useRestoreDeletedFile() {
},
error => {
setState('error')
- handleError(error)
+ showBoundary(error)
}
)
}
}
},
- [handleError, projectId]
+ [showBoundary, projectId]
)
return { restoreDeletedFile, isLoading }
diff --git a/services/web/frontend/js/features/history/context/hooks/use-restore-project.ts b/services/web/frontend/js/features/history/context/hooks/use-restore-project.ts
index ec4e4a4ef8..71b1b6af12 100644
--- a/services/web/frontend/js/features/history/context/hooks/use-restore-project.ts
+++ b/services/web/frontend/js/features/history/context/hooks/use-restore-project.ts
@@ -1,12 +1,12 @@
import { useCallback, useState } from 'react'
-import { useErrorHandler } from 'react-error-boundary'
+import { useErrorBoundary } from 'react-error-boundary'
import { restoreProjectToVersion } from '../../services/api'
import { useLayoutContext } from '@/shared/context/layout-context'
type RestorationState = 'initial' | 'restoring' | 'restored' | 'error'
export const useRestoreProject = () => {
- const handleError = useErrorHandler()
+ const { showBoundary } = useErrorBoundary()
const { setView } = useLayoutContext()
const [restorationState, setRestorationState] =
@@ -22,10 +22,10 @@ export const useRestoreProject = () => {
})
.catch(err => {
setRestorationState('error')
- handleError(err)
+ showBoundary(err)
})
},
- [handleError, setView]
+ [showBoundary, setView]
)
return {
diff --git a/services/web/frontend/js/features/history/context/hooks/use-restore-selected-file.ts b/services/web/frontend/js/features/history/context/hooks/use-restore-selected-file.ts
index 12b72faafd..168495bc92 100644
--- a/services/web/frontend/js/features/history/context/hooks/use-restore-selected-file.ts
+++ b/services/web/frontend/js/features/history/context/hooks/use-restore-selected-file.ts
@@ -3,7 +3,7 @@ import { restoreFileToVersion } from '../../services/api'
import { isFileRemoved } from '../../utils/file-diff'
import { useHistoryContext } from '../history-context'
import type { HistoryContextValue } from '../types/history-context-value'
-import { useErrorHandler } from 'react-error-boundary'
+import { useErrorBoundary } from 'react-error-boundary'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findInTree } from '@/features/file-tree/util/find-in-tree'
import { useCallback, useEffect, useState } from 'react'
@@ -24,7 +24,7 @@ export function useRestoreSelectedFile() {
const { projectId } = useHistoryContext()
const { setView } = useLayoutContext()
const { openDocWithId, openFileWithId } = useEditorManagerContext()
- const handleError = useErrorHandler()
+ const { showBoundary } = useErrorBoundary()
const { fileTreeData } = useFileTreeData()
const [state, setState] = useState('idle')
const [restoredFileMetadata, setRestoredFileMetadata] =
@@ -60,14 +60,14 @@ export function useRestoreSelectedFile() {
if (state === 'waitingForFileTree') {
const timer = window.setTimeout(() => {
setState('timedOut')
- handleError(new Error('timed out'))
+ showBoundary(new Error('timed out'))
}, RESTORE_FILE_TIMEOUT)
return () => {
window.clearTimeout(timer)
}
}
- }, [handleError, state])
+ }, [showBoundary, state])
const restoreSelectedFile = useCallback(
(selection: HistoryContextValue['selection']) => {
@@ -91,13 +91,13 @@ export function useRestoreSelectedFile() {
},
error => {
setState('error')
- handleError(error)
+ showBoundary(error)
}
)
}
}
},
- [handleError, projectId]
+ [showBoundary, projectId]
)
return { restoreSelectedFile, isLoading }
diff --git a/services/web/frontend/js/features/ide-react/components/alerts/alerts.tsx b/services/web/frontend/js/features/ide-react/components/alerts/alerts.tsx
index 98f23d8f2a..4829913ade 100644
--- a/services/web/frontend/js/features/ide-react/components/alerts/alerts.tsx
+++ b/services/web/frontend/js/features/ide-react/components/alerts/alerts.tsx
@@ -2,11 +2,9 @@ import { useTranslation } from 'react-i18next'
import { LostConnectionAlert } from './lost-connection-alert'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { debugging } from '@/utils/debugging'
-import useScopeValue from '@/shared/hooks/use-scope-value'
import { createPortal } from 'react-dom'
import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
import OLNotification from '@/features/ui/components/ol/ol-notification'
-import OLButton from '@/features/ui/components/ol/ol-button'
export function Alerts() {
const { t } = useTranslation()
@@ -19,8 +17,6 @@ export function Alerts() {
} = useConnectionContext()
const globalAlertsContainer = useGlobalAlertsContainer()
- const [synctexError] = useScopeValue('sync_tex_error')
-
if (!globalAlertsContainer) {
return null
}
@@ -50,24 +46,6 @@ export function Alerts() {
/>
) : null}
- {synctexError ? (
- {t('synctex_failed')}}
- action={
-
- {t('more_info')}
-
- }
- />
- ) : null}
-
{connectionState.inactiveDisconnect ||
(connectionState.readyState === WebSocket.CLOSED &&
(connectionState.error === 'rate-limited' ||
diff --git a/services/web/frontend/js/features/ide-react/components/editor-survey.tsx b/services/web/frontend/js/features/ide-react/components/editor-survey.tsx
new file mode 100644
index 0000000000..e28aa7698d
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/editor-survey.tsx
@@ -0,0 +1,211 @@
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLForm from '@/features/ui/components/ol/ol-form'
+import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
+import { OLToast } from '@/features/ui/components/ol/ol-toast'
+import { OLToastContainer } from '@/features/ui/components/ol/ol-toast-container'
+import { useEditorContext } from '@/shared/context/editor-context'
+import useTutorial from '@/shared/hooks/promotions/use-tutorial'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { sendMB } from '@/infrastructure/event-tracking'
+import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
+import { useTranslation } from 'react-i18next'
+
+type EditorSurveyPage = 'ease-of-use' | 'meets-my-needs' | 'thank-you'
+
+export default function EditorSurvey() {
+ return (
+
+
+
+ )
+}
+
+const TUTORIAL_KEY = 'editor-popup-ux-survey'
+
+const EditorSurveyContent = () => {
+ const [easeOfUse, setEaseOfUse] = useState(null)
+ const [meetsMyNeeds, setMeetsMyNeeds] = useState(null)
+ const [page, setPage] = useState('ease-of-use')
+ const { inactiveTutorials } = useEditorContext()
+ const hasCompletedSurvey = inactiveTutorials.includes(TUTORIAL_KEY)
+ const newEditor = useIsNewEditorEnabled()
+
+ const { t } = useTranslation()
+
+ const {
+ tryShowingPopup: tryShowingSurvey,
+ showPopup: showSurvey,
+ dismissTutorial: dismissSurvey,
+ completeTutorial: completeSurvey,
+ } = useTutorial(TUTORIAL_KEY, {
+ name: TUTORIAL_KEY,
+ })
+
+ useEffect(() => {
+ if (!hasCompletedSurvey) {
+ tryShowingSurvey()
+ }
+ }, [hasCompletedSurvey, tryShowingSurvey])
+
+ const onSubmit = useCallback(() => {
+ sendMB('editor-survey-submit', {
+ easeOfUse,
+ meetsMyNeeds,
+ newEditor,
+ })
+ setPage('thank-you')
+ completeSurvey({ event: 'promo-click', action: 'complete' })
+ }, [easeOfUse, meetsMyNeeds, completeSurvey, newEditor])
+
+ if (!showSurvey && page !== 'thank-you') {
+ return null
+ }
+
+ if (page === 'ease-of-use') {
+ return (
+ setPage('meets-my-needs')}
+ value={easeOfUse}
+ onValueChange={setEaseOfUse}
+ onDismiss={dismissSurvey}
+ />
+ }
+ type="info"
+ />
+ )
+ }
+ if (page === 'meets-my-needs') {
+ return (
+
+ }
+ type="info"
+ />
+ )
+ }
+
+ return
+}
+
+const EditorSurveyQuestion = ({
+ onDismiss,
+ name,
+ questionText,
+ buttonText,
+ onButtonClick,
+ value,
+ onValueChange,
+}: {
+ onDismiss: () => void
+ name: string
+ questionText: string
+ buttonText: string
+ onButtonClick: () => void
+ value: number | null
+ onValueChange: (newValue: number) => void
+}) => {
+ const { t } = useTranslation()
+
+ const options = useMemo(
+ () => [
+ {
+ value: 1,
+ description: t('strongly_disagree'),
+ },
+ {
+ value: 2,
+ description: t('disagree'),
+ },
+ {
+ value: 3,
+ description: t('neither_agree_nor_disagree'),
+ },
+ {
+ value: 4,
+ description: t('agree'),
+ },
+ {
+ value: 5,
+ description: t('strongly_agree'),
+ },
+ ],
+ [t]
+ )
+
+ const onChange = useCallback(
+ (event: React.ChangeEvent) => {
+ const newValue = event.target.value
+ if (newValue) {
+ onValueChange(Number(newValue))
+ }
+ },
+ [onValueChange]
+ )
+
+ return (
+
+
+
{t('your_feedback_matters_answer_two_quick_questions')}
+
+
+
+
+ {questionText}
+
+
+
+ {options.map(({ value: optionValue, description }) => (
+
+ ))}
+
+
+
+
{t('strongly_disagree')}
+
{t('strongly_agree')}
+
+
+
+ {buttonText}
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/history-root.tsx b/services/web/frontend/js/features/ide-react/components/history-root.tsx
index 07e6c5f663..c349bd0829 100644
--- a/services/web/frontend/js/features/ide-react/components/history-root.tsx
+++ b/services/web/frontend/js/features/ide-react/components/history-root.tsx
@@ -10,4 +10,6 @@ const HistoryRoot = () => (
)
-export default withErrorBoundary(memo(HistoryRoot), ErrorBoundaryFallback)
+export default withErrorBoundary(memo(HistoryRoot), () => (
+
+))
diff --git a/services/web/frontend/js/features/ide-react/components/ide-root.tsx b/services/web/frontend/js/features/ide-react/components/ide-root.tsx
index 874f534386..795de6c56d 100644
--- a/services/web/frontend/js/features/ide-react/components/ide-root.tsx
+++ b/services/web/frontend/js/features/ide-react/components/ide-root.tsx
@@ -15,4 +15,6 @@ const IdeRoot: FC = () => {
)
}
-export default withErrorBoundary(memo(IdeRoot), GenericErrorBoundaryFallback)
+export default withErrorBoundary(memo(IdeRoot), () => (
+
+))
diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
index fa235560e8..6a03d5b205 100644
--- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
+++ b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
@@ -10,7 +10,12 @@ import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-e
import { Modals } from '@/features/ide-react/components/modals/modals'
import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
import { GlobalToasts } from '../global-toasts'
-import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
+import {
+ canUseNewEditor,
+ useIsNewEditorEnabled,
+} from '@/features/ide-redesign/utils/new-editor-utils'
+import EditorSurvey from '../editor-survey'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
const MainLayoutNew = lazy(
() => import('@/features/ide-redesign/components/main-layout')
@@ -27,6 +32,9 @@ export default function IdePage() {
useHasLintingError() // pass editor:lint hasLintingError to the compiler
const newEditor = useIsNewEditorEnabled()
+ const canAccessNewEditor = canUseNewEditor()
+ const editorSurveyFlag = useFeatureFlag('editor-popup-ux-survey')
+ const showEditorSurvey = editorSurveyFlag && !canAccessNewEditor
return (
@@ -44,6 +52,7 @@ export default function IdePage() {
>
)}
+ {showEditorSurvey && }
)
}
diff --git a/services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx b/services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx
index ef68b216e9..7ba48e30db 100644
--- a/services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx
+++ b/services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx
@@ -10,7 +10,9 @@ type HorizontalResizeHandleOwnProps = {
}
export const HorizontalResizeHandle: FC<
- HorizontalResizeHandleOwnProps & PanelResizeHandleProps
+ React.PropsWithChildren<
+ HorizontalResizeHandleOwnProps & PanelResizeHandleProps
+ >
> = ({ children, resizable = true, onDoubleClick, ...props }) => {
const { t } = useTranslation()
const [isDragging, setIsDragging] = useState(false)
diff --git a/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx
index f70a550060..8224bb6e62 100644
--- a/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx
+++ b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx
@@ -30,7 +30,7 @@ export const UnsavedDocs: FC = () => {
useEventListener(
'beforeunload',
useCallback(
- event => {
+ (event: BeforeUnloadEvent) => {
if (openDocs.hasUnsavedChanges()) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
event.preventDefault()
diff --git a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx
index 340d4e1b77..e8bec19b8b 100644
--- a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx
@@ -23,7 +23,9 @@ type CommandRegistry = {
unregister: (...id: string[]) => void
}
-export const CommandRegistryProvider: React.FC = ({ children }) => {
+export const CommandRegistryProvider: React.FC = ({
+ children,
+}) => {
const [registry, setRegistry] = useState(new Map())
const register = useCallback((...elements: Command[]) => {
setRegistry(
diff --git a/services/web/frontend/js/features/ide-react/context/connection-context.tsx b/services/web/frontend/js/features/ide-react/context/connection-context.tsx
index 8cffc2485e..cb2b24d737 100644
--- a/services/web/frontend/js/features/ide-react/context/connection-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/connection-context.tsx
@@ -36,7 +36,9 @@ export const ConnectionContext = createContext<
ConnectionContextValue | undefined
>(undefined)
-export const ConnectionProvider: FC = ({ children }) => {
+export const ConnectionProvider: FC = ({
+ children,
+}) => {
const location = useLocation()
const [connectionManager] = useState(() => new ConnectionManager())
diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
index cc82723aa1..e1bb49c39c 100644
--- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
@@ -88,7 +88,9 @@ export const EditorManagerContext = createContext(
undefined
)
-export const EditorManagerProvider: FC = ({ children }) => {
+export const EditorManagerProvider: FC = ({
+ children,
+}) => {
const { t } = useTranslation()
const { scopeStore } = useIdeContext()
const { reportError, eventEmitter, projectId } = useIdeReactContext()
@@ -580,7 +582,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
editorContent =
typeof editorContent === 'string'
? editorContent
- : document.doc?._doc.snapshot
+ : document.getSnapshot()
// Tear down the ShareJsDoc.
if (document.doc) document.doc.clearInflightAndPendingOps()
diff --git a/services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx b/services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx
index eec94f5d99..b46ac158c7 100644
--- a/services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx
@@ -36,7 +36,9 @@ const FileTreeOpenContext = createContext<
| undefined
>(undefined)
-export const FileTreeOpenProvider: FC = ({ children }) => {
+export const FileTreeOpenProvider: FC = ({
+ children,
+}) => {
const { rootDocId, owner } = useProjectContext()
const { eventEmitter, projectJoined } = useIdeReactContext()
const { openDocWithId, currentDocumentId, openInitialDoc } =
diff --git a/services/web/frontend/js/features/ide-react/context/global-alerts-context.tsx b/services/web/frontend/js/features/ide-react/context/global-alerts-context.tsx
index d2dfefd8c0..52b67c2e11 100644
--- a/services/web/frontend/js/features/ide-react/context/global-alerts-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/global-alerts-context.tsx
@@ -4,7 +4,9 @@ const GlobalAlertsContext = createContext(
undefined
)
-export const GlobalAlertsProvider: FC = ({ children }) => {
+export const GlobalAlertsProvider: FC = ({
+ children,
+}) => {
const [globalAlertsContainer, setGlobalAlertsContainer] =
useState(null)
diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
index 313989903a..bb3d0c1a3c 100644
--- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
@@ -41,7 +41,6 @@ export const IdeReactContext = createContext(
function populateIdeReactScope(store: ReactScopeValueStore) {
store.set('settings', {})
- store.set('sync_tex_error', false)
}
function populateProjectScope(store: ReactScopeValueStore) {
@@ -78,7 +77,7 @@ export function createReactScopeValueStore(projectId: string) {
return scopeStore
}
-export const IdeReactProvider: FC = ({ children }) => {
+export const IdeReactProvider: FC = ({ children }) => {
const projectId = getMeta('ol-project_id')
const [scopeStore] = useState(() => createReactScopeValueStore(projectId))
const [eventEmitter] = useState(createIdeEventEmitter)
diff --git a/services/web/frontend/js/features/ide-react/context/ide-redesign-switcher-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-redesign-switcher-context.tsx
index 464812d663..6eb8cd6d0b 100644
--- a/services/web/frontend/js/features/ide-react/context/ide-redesign-switcher-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/ide-redesign-switcher-context.tsx
@@ -16,7 +16,9 @@ export const IdeRedesignSwitcherContext = createContext<
IdeRedesignSwitcherContextValue | undefined
>(undefined)
-export const IdeRedesignSwitcherProvider: FC = ({ children }) => {
+export const IdeRedesignSwitcherProvider: FC = ({
+ children,
+}) => {
const [showSwitcherModal, setShowSwitcherModal] = useState(false)
return (
diff --git a/services/web/frontend/js/features/ide-react/context/metadata-context.tsx b/services/web/frontend/js/features/ide-react/context/metadata-context.tsx
index c1e9d14502..4d5ab28050 100644
--- a/services/web/frontend/js/features/ide-react/context/metadata-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/metadata-context.tsx
@@ -47,7 +47,7 @@ export const MetadataContext = createContext<
| undefined
>(undefined)
-export const MetadataProvider: FC = ({ children }) => {
+export const MetadataProvider: FC = ({ children }) => {
const { t } = useTranslation()
const { eventEmitter, projectId } = useIdeReactContext()
const { socket } = useConnectionContext()
diff --git a/services/web/frontend/js/features/ide-react/context/modals-context.tsx b/services/web/frontend/js/features/ide-react/context/modals-context.tsx
index 097ce37120..a00b676262 100644
--- a/services/web/frontend/js/features/ide-react/context/modals-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/modals-context.tsx
@@ -30,7 +30,9 @@ type ModalsContextValue = {
const ModalsContext = createContext(undefined)
-export const ModalsContextProvider: FC = ({ children }) => {
+export const ModalsContextProvider: FC = ({
+ children,
+}) => {
const [showGenericModal, setShowGenericModal] = useState(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [genericMessageModalData, setGenericMessageModalData] =
diff --git a/services/web/frontend/js/features/ide-react/context/online-users-context.tsx b/services/web/frontend/js/features/ide-react/context/online-users-context.tsx
index baae763633..1dba40e6d7 100644
--- a/services/web/frontend/js/features/ide-react/context/online-users-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/online-users-context.tsx
@@ -65,7 +65,9 @@ export const OnlineUsersContext = createContext<
OnlineUsersContextValue | undefined
>(undefined)
-export const OnlineUsersProvider: FC = ({ children }) => {
+export const OnlineUsersProvider: FC = ({
+ children,
+}) => {
const { eventEmitter } = useIdeReactContext()
const { socket } = useConnectionContext()
const { currentDocumentId } = useEditorManagerContext()
diff --git a/services/web/frontend/js/features/ide-react/context/outline-context.tsx b/services/web/frontend/js/features/ide-react/context/outline-context.tsx
index 41c2c7b0f1..8e582e607d 100644
--- a/services/web/frontend/js/features/ide-react/context/outline-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/outline-context.tsx
@@ -42,7 +42,7 @@ const OutlineContext = createContext<
| undefined
>(undefined)
-export const OutlineProvider: FC = ({ children }) => {
+export const OutlineProvider: FC = ({ children }) => {
const [flatOutline, setFlatOutline] = useState(undefined)
const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
useState(-1)
@@ -55,7 +55,7 @@ export const OutlineProvider: FC = ({ children }) => {
useEventListener(
'file-view:file-opened',
- useCallback(_ => {
+ useCallback(() => {
setBinaryFileOpened(true)
}, [])
)
@@ -63,7 +63,7 @@ export const OutlineProvider: FC = ({ children }) => {
useEventListener(
'scroll:editor:update',
useCallback(
- evt => {
+ (evt: CustomEvent) => {
if (ignoreNextScroll) {
setIgnoreNextScroll(false)
return
@@ -77,7 +77,7 @@ export const OutlineProvider: FC = ({ children }) => {
useEventListener(
'cursor:editor:update',
useCallback(
- evt => {
+ (evt: CustomEvent) => {
if (ignoreNextCursorUpdate) {
setIgnoreNextCursorUpdate(false)
return
@@ -90,7 +90,7 @@ export const OutlineProvider: FC = ({ children }) => {
useEventListener(
'doc:after-opened',
- useCallback(evt => {
+ useCallback((evt: CustomEvent) => {
if (evt.detail.isNewDoc) {
setIgnoreNextCursorUpdate(true)
}
diff --git a/services/web/frontend/js/features/ide-react/context/permissions-context.tsx b/services/web/frontend/js/features/ide-react/context/permissions-context.tsx
index 2f8fa65096..1e10a2cd11 100644
--- a/services/web/frontend/js/features/ide-react/context/permissions-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/permissions-context.tsx
@@ -79,7 +79,9 @@ const noTrackChangesPermissionsMap: typeof permissionsMap = {
owner: permissionsMap.owner,
}
-export const PermissionsProvider: React.FC = ({ children }) => {
+export const PermissionsProvider: React.FC = ({
+ children,
+}) => {
const [permissions, setPermissions] =
useScopeValue>('permissions')
const { connectionState } = useConnectionContext()
diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx
index 72c25b88c9..f8b094f821 100644
--- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx
+++ b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx
@@ -27,10 +27,11 @@ import { UserSettingsProvider } from '@/shared/context/user-settings-context'
import { IdeRedesignSwitcherProvider } from './ide-redesign-switcher-context'
import { CommandRegistryProvider } from './command-registry-context'
-export const ReactContextRoot: FC<{ providers?: Record }> = ({
- children,
- providers = {},
-}) => {
+export const ReactContextRoot: FC<
+ React.PropsWithChildren<{
+ providers?: Record
+ }>
+> = ({ children, providers = {} }) => {
const Providers = {
ChatProvider,
ConnectionProvider,
@@ -77,33 +78,33 @@ export const ReactContextRoot: FC<{ providers?: Record }> = ({
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
{children}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/web/frontend/js/features/ide-react/context/references-context.tsx b/services/web/frontend/js/features/ide-react/context/references-context.tsx
index 36f33ba7b1..3b250658f1 100644
--- a/services/web/frontend/js/features/ide-react/context/references-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/references-context.tsx
@@ -26,7 +26,9 @@ export const ReferencesContext = createContext<
| undefined
>(undefined)
-export const ReferencesProvider: FC = ({ children }) => {
+export const ReferencesProvider: FC = ({
+ children,
+}) => {
const { fileTreeData } = useFileTreeData()
const { eventEmitter, projectId } = useIdeReactContext()
const { socket } = useConnectionContext()
@@ -60,7 +62,7 @@ export const ReferencesProvider: FC = ({ children }) => {
// avoid reindexing references if the bib file has not changed since the
// last time they were indexed
const docId = doc.doc_id
- const snapshot = doc._doc.snapshot
+ const snapshot = doc.getSnapshot()
const now = Date.now()
const sha1 = generateSHA1Hash(
'blob ' + snapshot.length + '\x00' + snapshot
diff --git a/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx b/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx
index 686940f612..70f170a8b0 100644
--- a/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx
@@ -50,7 +50,7 @@ export const SnapshotContext = createContext<
| undefined
>(undefined)
-export const SnapshotProvider: FC = ({ children }) => {
+export const SnapshotProvider: FC = ({ children }) => {
const { _id: projectId } = useProjectContext()
const [snapshotLoadingState, setSnapshotLoadingState] =
useState('')
diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts
index b56d6443dd..7b4e3492f8 100644
--- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts
+++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts
@@ -142,11 +142,8 @@ export class ShareJsDoc extends EventEmitter {
private removeCarriageReturnCharFromShareJsDoc() {
const doc = this._doc
- if (doc.snapshot.indexOf('\r') === -1) {
- return
- }
let nextPos
- while ((nextPos = doc.snapshot.indexOf('\r')) !== -1) {
+ while ((nextPos = doc.getText().indexOf('\r')) !== -1) {
debugConsole.log('[ShareJsDoc] remove-carriage-return-char', nextPos)
doc.del(nextPos, 1)
}
@@ -259,7 +256,7 @@ export class ShareJsDoc extends EventEmitter {
}
getSnapshot() {
- return this._doc.snapshot as string | undefined
+ return this._doc.getText() as string
}
getVersion() {
diff --git a/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts b/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts
index 537c501c72..42e03ff8f6 100644
--- a/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts
+++ b/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts
@@ -1,6 +1,5 @@
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import customLocalStorage from '@/infrastructure/local-storage'
-import getMeta from '@/utils/meta'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
export type EditorScopeValue = {
@@ -37,24 +36,13 @@ export function populateEditorScope(
store.persisted(
'editor.showVisual',
- getMeta('ol-usedLatex') === 'never' || showVisualFallbackValue(projectId),
+ showVisualFallbackValue(projectId),
`editor.lastUsedMode`,
{
toPersisted: showVisual => (showVisual ? 'visual' : 'code'),
fromPersisted: mode => mode === 'visual',
}
)
-
- store.persisted(
- 'editor.codeEditorOpened',
- codeEditorOpenedFallbackValue(),
- 'editor.codeEditorOpened'
- )
- store.watch('editor.showVisual', showVisual => {
- if (store.get('editor.codeEditorOpened') !== true && showVisual === false) {
- store.set('editor.codeEditorOpened', true)
- }
- })
}
function showVisualFallbackValue(projectId: string) {
@@ -68,16 +56,3 @@ function showVisualFallbackValue(projectId: string) {
return editorModeVal === 'rich-text'
}
-
-function codeEditorOpenedFallbackValue() {
- const signUpDate = getMeta('ol-user').signUpDate
- if (
- typeof signUpDate === 'string' &&
- new Date(signUpDate) < new Date('2024-08-02')
- ) {
- // if signUpDate is before releasing "codeEditorOpened" value
- // it is assumed that the user has opened the code editor at some point
- return true
- }
- return false
-}
diff --git a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx
index 18b7567f41..455df85d7f 100644
--- a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx
@@ -3,14 +3,10 @@ import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-o
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
import useNestedOutline from '@/features/outline/hooks/use-nested-outline'
import getChildrenLines from '@/features/outline/util/get-children-lines'
-import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
import MaterialIcon from '@/shared/components/material-icon'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
-import { getPanel } from '@codemirror/view'
import { Fragment, useMemo } from 'react'
import { Outline } from '@/features/source-editor/utils/tree-operations/outline'
-import { createPortal } from 'react-dom'
-import { createBreadcrumbsPanel } from '@/features/source-editor/extensions/breadcrumbs-panel'
const constructOutlineHierarchy = (
items: Outline[],
@@ -37,16 +33,6 @@ const constructOutlineHierarchy = (
}
export default function Breadcrumbs() {
- const view = useCodeMirrorViewContext()
- const panel = getPanel(view, createBreadcrumbsPanel)
-
- if (!panel) {
- return null
- }
- return createPortal( , panel.dom)
-}
-
-function BreadcrumbsContent() {
const { openEntity } = useFileTreeOpenContext()
const { fileTreeData } = useFileTreeData()
const outline = useNestedOutline()
diff --git a/services/web/frontend/js/features/ide-redesign/components/chat/message.tsx b/services/web/frontend/js/features/ide-redesign/components/chat/message.tsx
index a3676e2216..057ea69266 100644
--- a/services/web/frontend/js/features/ide-redesign/components/chat/message.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/chat/message.tsx
@@ -3,12 +3,23 @@ import { User } from '../../../../../../types/user'
import { getHueForUserId } from '@/shared/utils/colors'
import MessageContent from '@/features/chat/components/message-content'
import classNames from 'classnames'
+import MaterialIcon from '@/shared/components/material-icon'
+import { t } from 'i18next'
function hue(user?: User) {
return user ? getHueForUserId(user.id) : 0
}
function getAvatarStyle(user?: User) {
+ if (!user?.id) {
+ // Deleted user
+ return {
+ backgroundColor: 'var(--bg-light-disabled)',
+ borderColor: 'var(--bg-light-disabled)',
+ color: 'var(--content-disabled)',
+ }
+ }
+
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
@@ -16,13 +27,19 @@ function getAvatarStyle(user?: User) {
}
function Message({ message, fromSelf }: MessageProps) {
+ const userAvailable = message.user?.id && message.user.email
+
return (
{!fromSelf && (
- {message.user.first_name || message.user.email}
+
+ {userAvailable
+ ? message.user.first_name || message.user.email
+ : t('deleted_user')}
+
)}
@@ -32,8 +49,15 @@ function Message({ message, fromSelf }: MessageProps) {
{!fromSelf && index === message.contents.length - 1 ? (
- {message.user.first_name?.charAt(0) ||
- message.user.email.charAt(0)}
+ {userAvailable ? (
+ message.user.first_name?.charAt(0) ||
+ message.user.email.charAt(0)
+ ) : (
+
+ )}
) : (
diff --git a/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx b/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx
index 957226c414..f1d72941f5 100644
--- a/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx
@@ -9,6 +9,7 @@ import MaterialIcon, {
import React from 'react'
import useCollapsibleFileTree from '../hooks/use-collapsible-file-tree'
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
+import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
function FileTreeToolbar() {
const { t } = useTranslation()
@@ -38,6 +39,7 @@ function FileTreeToolbar() {
function FileTreeActionButtons() {
const { t } = useTranslation()
const { fileTreeReadOnly } = useFileTreeData()
+ const { write } = usePermissionsContext()
const {
canCreate,
@@ -46,7 +48,7 @@ function FileTreeActionButtons() {
startUploadingDocOrFile,
} = useFileTreeActionable()
useCommandProvider(() => {
- if (!canCreate || fileTreeReadOnly) return
+ if (!canCreate || fileTreeReadOnly || !write) return
return [
{
label: t('new_file'),
@@ -77,6 +79,7 @@ function FileTreeActionButtons() {
t,
startCreatingFolder,
startUploadingDocOrFile,
+ write,
])
if (!canCreate || fileTreeReadOnly) return null
diff --git a/services/web/frontend/js/features/ide-redesign/components/labs-widget.tsx b/services/web/frontend/js/features/ide-redesign/components/labs-widget.tsx
new file mode 100644
index 0000000000..6b9cc657b5
--- /dev/null
+++ b/services/web/frontend/js/features/ide-redesign/components/labs-widget.tsx
@@ -0,0 +1,32 @@
+import { useState } from 'react'
+import LabsExperimentWidget from '../../../shared/components/labs/labs-experiments-widget'
+import { isInExperiment } from '@/utils/labs-utils'
+import { useTranslation } from 'react-i18next'
+import labsIcon from '../images/labs-icon.svg'
+
+const EditorRedesignLabsWidget = ({
+ labsProgram,
+ setErrorMessage,
+}: {
+ labsProgram: boolean
+ setErrorMessage: (err: string) => void
+}) => {
+ const { t } = useTranslation()
+ const [optedIn, setOptedIn] = useState(isInExperiment('editor-redesign'))
+ return (
+
}
+ labsEnabled={labsProgram}
+ setErrorMessage={setErrorMessage}
+ optedIn={optedIn}
+ setOptedIn={setOptedIn}
+ title={t('new_overleaf_editor')}
+ />
+ )
+}
+
+export default EditorRedesignLabsWidget
diff --git a/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx b/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx
index cf71ed07cb..ccdf7c8b53 100644
--- a/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx
@@ -12,6 +12,7 @@ import { useState } from 'react'
import EditorPanel from './editor-panel'
import { useRailContext } from '../contexts/rail-context'
import HistoryContainer from '@/features/ide-react/components/history-container'
+import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
export default function MainLayout() {
const [resizing, setResizing] = useState(false)
@@ -80,6 +81,11 @@ export default function MainLayout() {
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
+ {pdfLayout === 'sideBySide' && (
+
+
+
+ )}
+ {pdfLayout === 'flat' && view === 'pdf' && (
+
+
+
+ )}
diff --git a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx
index 2de51dc11e..ef77c0fa5d 100644
--- a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx
@@ -11,7 +11,7 @@ function PdfErrorState() {
// TODO ide-redesign-cleanup: rename showLogs to something else and check usages
const { showLogs } = useCompileContext()
const { t } = useTranslation()
- const { setSelectedTab: setSelectedRailTab } = useRailContext()
+ const { openTab: openRailTab } = useRailContext()
const newEditor = useIsNewEditorEnabled()
if (!newEditor || (!loadingError && !showLogs)) {
@@ -34,7 +34,7 @@ function PdfErrorState() {
variant="secondary"
size="sm"
onClick={() => {
- setSelectedRailTab('errors')
+ openRailTab('errors')
}}
>
{t('check_logs')}
diff --git a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.jsx b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.tsx
similarity index 71%
rename from services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.jsx
rename to services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.tsx
index f2cdca095c..b11dcaa7cf 100644
--- a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.jsx
+++ b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.tsx
@@ -2,6 +2,8 @@ import { memo } from 'react'
import OlButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
import PdfCompileButton from '@/features/pdf-preview/components/pdf-compile-button'
import PdfHybridDownloadButton from '@/features/pdf-preview/components/pdf-hybrid-download-button'
+import { DetachedSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
+import SwitchToEditorButton from '@/features/pdf-preview/components/switch-to-editor-button'
function PdfPreviewHybridToolbar() {
// TODO: add detached pdf logic
@@ -13,7 +15,9 @@ function PdfPreviewHybridToolbar() {
- {/* TODO: should we have switch to editor/code check/synctex buttons? */}
+
+
+ {/* TODO: should we have code check? */}
)
diff --git a/services/web/frontend/js/features/ide-redesign/components/rail.tsx b/services/web/frontend/js/features/ide-redesign/components/rail.tsx
index 8610f1696e..34cb7df3f4 100644
--- a/services/web/frontend/js/features/ide-redesign/components/rail.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/rail.tsx
@@ -1,4 +1,5 @@
import { FC, ReactElement, useCallback, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
import { Nav, NavLink, Tab, TabContainer } from 'react-bootstrap-5'
import MaterialIcon, {
AvailableUnfilledIcon,
@@ -16,10 +17,8 @@ import { ChatIndicator, ChatPane } from './chat/chat'
import getMeta from '@/utils/meta'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
-import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import IntegrationsPanel from './integrations-panel/integrations-panel'
-import OLButton from '@/features/ui/components/ol/ol-button'
import {
Dropdown,
DropdownDivider,
@@ -31,58 +30,32 @@ import { RailHelpShowHotkeysModal } from './help/keyboard-shortcuts'
import { RailHelpContactUsModal } from './help/contact-us'
import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
import DictionarySettingsModal from './settings/editor-settings/dictionary-settings-modal'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type RailElement = {
icon: AvailableUnfilledIcon
key: RailTabKey
- component: ReactElement
+ component: ReactElement | null
indicator?: ReactElement
+ title: string
hide?: boolean
}
type RailActionButton = {
key: string
icon: AvailableUnfilledIcon
+ title: string
action: () => void
}
type RailDropdown = {
key: string
icon: AvailableUnfilledIcon
+ title: string
dropdown: ReactElement
}
type RailAction = RailDropdown | RailActionButton
-const RAIL_TABS: RailElement[] = [
- {
- key: 'file-tree',
- icon: 'description',
- component: ,
- },
- {
- key: 'integrations',
- icon: 'integration_instructions',
- component: ,
- },
- {
- key: 'review-panel',
- icon: 'rate_review',
- component: <>Review panel>,
- },
- {
- key: 'chat',
- icon: 'forum',
- component: ,
- indicator: ,
- hide: !getMeta('ol-chatEnabled'),
- },
- {
- key: 'errors',
- icon: 'report',
- component: ,
- indicator: ,
- },
-]
-
const RAIL_MODALS: {
key: RailModalKey
modalComponentFunction: FC<{ show: boolean }>
@@ -106,7 +79,7 @@ export const RailLayout = () => {
const {
activeModal,
selectedTab,
- setSelectedTab,
+ openTab,
isOpen,
setIsOpen,
panelRef,
@@ -120,20 +93,61 @@ export const RailLayout = () => {
const isHistoryView = view === 'history'
+ const railTabs: RailElement[] = useMemo(
+ () => [
+ {
+ key: 'file-tree',
+ icon: 'description',
+ title: t('file_tree'),
+ component: ,
+ },
+ {
+ key: 'integrations',
+ icon: 'integration_instructions',
+ title: t('integrations'),
+ component: ,
+ },
+ {
+ key: 'review-panel',
+ icon: 'rate_review',
+ title: t('review_panel'),
+ component: null,
+ },
+ {
+ key: 'chat',
+ icon: 'forum',
+ component: ,
+ indicator: ,
+ title: t('chat'),
+ hide: !getMeta('ol-chatEnabled'),
+ },
+ {
+ key: 'errors',
+ icon: 'report',
+ title: t('error_log'),
+ component: ,
+ indicator: ,
+ },
+ ],
+ [t]
+ )
+
const railActions: RailAction[] = useMemo(
() => [
{
key: 'support',
icon: 'help',
+ title: t('help'),
dropdown: ,
},
{
key: 'settings',
icon: 'settings',
+ title: t('settings'),
action: () => setLeftMenuShown(true),
},
],
- [setLeftMenuShown]
+ [setLeftMenuShown, t]
)
const onTabSelect = useCallback(
@@ -143,18 +157,19 @@ export const RailLayout = () => {
} else {
// HACK: Apparently the onSelect event is triggered with href attributes
// from DropdownItems
- if (!RAIL_TABS.some(tab => !tab.hide && tab.key === key)) {
+ if (!railTabs.some(tab => !tab.hide && tab.key === key)) {
// Attempting to open a non-existent tab
return
}
// Change the selected tab and make sure it's open
- setSelectedTab((key ?? 'file-tree') as RailTabKey)
- setIsOpen(true)
+ openTab((key ?? 'file-tree') as RailTabKey)
}
},
- [setSelectedTab, selectedTab, setIsOpen, togglePane]
+ [openTab, togglePane, selectedTab, railTabs]
)
+ const isReviewPanelOpen = selectedTab === 'review-panel'
+
return (
{
>
- {RAIL_TABS.filter(({ hide }) => !hide).map(
- ({ icon, key, indicator }) => (
+ {railTabs
+ .filter(({ hide }) => !hide)
+ .map(({ icon, key, indicator, title }) => (
- )
- )}
+ ))}
{railActions?.map(action => (
@@ -185,6 +201,7 @@ export const RailLayout = () => {
{
return (
-
- {open ? (
-
- ) : (
-
- )}
- {indicator}
-
+
+ {open ? (
+
+ ) : (
+
+ )}
+ {indicator}
+
+
)
}
const RailActionElement = ({ action }: { action: RailAction }) => {
- const icon = (
-
- )
const onActionClick = useCallback(() => {
if ('action' in action) {
action.action()
@@ -274,41 +306,61 @@ const RailActionElement = ({ action }: { action: RailAction }) => {
if ('dropdown' in action) {
return (
-
- {icon}
-
+
+
+
+
+
+
{action.dropdown}
)
} else {
return (
-
- {icon}
-
+
+
)
}
}
export const RailPanelHeader: FC<{ title: string }> = ({ title }) => {
+ const { t } = useTranslation()
const { handlePaneCollapse } = useRailContext()
return (
)
}
diff --git a/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx b/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx
index 11807e1a53..fb5898fcb0 100644
--- a/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx
@@ -149,15 +149,18 @@ const SwitcherWhatsNew = () => {
{t('whats_new')}
- {t('chat')}
- {t('settings_for_git_github_and_dropbox_integrations')}
- {t('dark_mode')}
+ {t('new_look_and_feel')}
+
+ {t('new_navigation_introducing_left_hand_side_rail_and_top_menus')}
+
+ {t('new_look_and_placement_of_the_settings')}
+ {t('improved_dark_mode')}
+ {t('review_panel_and_error_logs_moved_to_the_left')}
{t('whats_next')}
- {t('history')}
- {t('review_panel_comments_and_track_changes')}
+ {t('more_changes_based_on_your_feedback')}
)
diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-button.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-button.tsx
index db26928599..eb51dd51c8 100644
--- a/services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-button.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-button.tsx
@@ -1,41 +1,40 @@
-import OLButton from '@/features/ui/components/ol/ol-button'
-import MaterialIcon from '@/shared/components/material-icon'
+import { useTranslation } from 'react-i18next'
+import classNames from 'classnames'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
-import React, { forwardRef } from 'react'
import ChangeLayoutOptions from './change-layout-options'
-
-const LayoutDropdownToggleButton = forwardRef<
- HTMLButtonElement,
- {
- onClick: (e: React.MouseEvent) => void
- }
->(({ onClick }, ref) => {
- return (
- }
- />
- )
-})
-
-LayoutDropdownToggleButton.displayName = 'LayoutDropdownToggleButton'
+import MaterialIcon from '@/shared/components/material-icon'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export default function ChangeLayoutButton() {
+ const { t } = useTranslation()
+ const toggleButtonClassName = classNames(
+ 'ide-redesign-toolbar-button-subdued',
+ 'ide-redesign-toolbar-dropdown-toggle-subdued',
+ 'ide-redesign-toolbar-button-icon'
+ )
+
return (
-
+
+
+
+
+
+
+
diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx
index 9d0deea0c5..ca23de7fce 100644
--- a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx
@@ -11,7 +11,7 @@ import {
NestedMenuBarDropdown,
} from '@/shared/components/menu-bar/menu-bar-dropdown'
import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option'
-import { Fragment, useCallback } from 'react'
+import { Fragment, useCallback, useMemo } from 'react'
type CommandId = string
type TaggedCommand = Command & { type: 'command' }
@@ -21,7 +21,7 @@ type GroupStructure = {
title: string
children: Array>
}
-type MenuSectionStructure = {
+export type MenuSectionStructure = {
title?: string
id: string
children: Array>
@@ -38,9 +38,18 @@ const CommandDropdown = ({
id: string
}) => {
const { registry } = useCommandRegistry()
- const populatedSections = menu
- .map(section => populateSectionOrGroup(section, registry))
- .filter(x => x.children.length > 0)
+ const populatedSections = useMemo(
+ () =>
+ menu
+ .map(section => populateSectionOrGroup(section, registry))
+ .filter(x => x.children.length > 0),
+ [menu, registry]
+ )
+
+ if (populatedSections.length === 0) {
+ return null
+ }
+
return (
+}) => {
+ const { registry } = useCommandRegistry()
+ const section = populateSectionOrGroup(sectionStructure, registry)
+ if (section.children.length === 0) {
+ return null
+ }
+ return (
+ <>
+ {section.title && {section.title} }
+ {section.children.map(child => (
+
+ ))}
+ >
+ )
+}
+
const CommandDropdownChild = ({ item }: { item: Entry }) => {
const onClickHandler = useCallback(() => {
if (isTaggedCommand(item)) {
@@ -107,7 +136,12 @@ function populateSectionOrGroup<
children: children
.map(child => {
if (typeof child !== 'string') {
- return populateSectionOrGroup(child, registry)
+ const populatedChild = populateSectionOrGroup(child, registry)
+ if (populatedChild.children.length === 0) {
+ // Skip empty groups
+ return undefined
+ }
+ return populatedChild
}
const command = registry.get(child)
if (command) {
diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/labs-actions.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/labs-actions.tsx
index 735c0cfd27..06a3a2ab4a 100644
--- a/services/web/frontend/js/features/ide-redesign/components/toolbar/labs-actions.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/labs-actions.tsx
@@ -23,7 +23,7 @@ export const LabsActions = () => {
>
}
diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
index fc24161c6b..d75dfeb632 100644
--- a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx
@@ -3,10 +3,7 @@ import {
DropdownHeader,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { MenuBar } from '@/shared/components/menu-bar/menu-bar'
-import {
- MenuBarDropdown,
- NestedMenuBarDropdown,
-} from '@/shared/components/menu-bar/menu-bar-dropdown'
+import { MenuBarDropdown } from '@/shared/components/menu-bar/menu-bar-dropdown'
import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option'
import { useTranslation } from 'react-i18next'
import ChangeLayoutOptions from './change-layout-options'
@@ -17,7 +14,13 @@ import MaterialIcon from '@/shared/components/material-icon'
import OLSpinner from '@/features/ui/components/ol/ol-spinner'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
-import CommandDropdown, { MenuStructure } from './command-dropdown'
+import CommandDropdown, {
+ CommandSection,
+ MenuSectionStructure,
+ MenuStructure,
+} from './command-dropdown'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
+import { useRailContext } from '../../contexts/rail-context'
export const ToolbarMenuBar = () => {
const { t } = useTranslation()
@@ -30,6 +33,7 @@ export const ToolbarMenuBar = () => {
useCommandProvider(
() => [
{
+ type: 'command',
label: t('show_version_history'),
handler: () => {
setView(view === 'history' ? 'editor' : 'history')
@@ -54,28 +58,134 @@ export const ToolbarMenuBar = () => {
[]
)
+ const editMenuStructure: MenuStructure = useMemo(
+ () => [
+ {
+ id: 'edit-undo-redo',
+ children: ['undo', 'redo'],
+ },
+ {
+ id: 'edit-search',
+ children: ['find', 'select-all'],
+ },
+ ],
+ []
+ )
+
+ const insertMenuStructure: MenuStructure = useMemo(
+ () => [
+ {
+ id: 'insert-latex',
+ children: [
+ {
+ id: 'insert-math-group',
+ title: t('math'),
+ children: ['insert-inline-math', 'insert-display-math'],
+ },
+ 'insert-symbol',
+ {
+ id: 'insert-figure-group',
+ title: t('figure'),
+ children: [
+ 'insert-figure-from-computer',
+ 'insert-figure-from-project-files',
+ 'insert-figure-from-another-project',
+ 'insert-figure-from-url',
+ ],
+ },
+ 'insert-table',
+ 'insert-citation',
+ 'insert-link',
+ 'insert-cross-reference',
+ ],
+ },
+ {
+ id: 'insert-comment',
+ children: ['comment'],
+ },
+ ],
+ [t]
+ )
+
+ const formatMenuStructure: MenuStructure = useMemo(
+ () => [
+ {
+ id: 'format-text',
+ children: ['format-bold', 'format-italics'],
+ },
+ {
+ id: 'format-list',
+ children: [
+ 'format-bullet-list',
+ 'format-numbered-list',
+ 'format-increase-indentation',
+ 'format-decrease-indentation',
+ ],
+ },
+ {
+ id: 'format-paragraph',
+ title: t('paragraph_styles'),
+ children: [
+ 'format-style-normal',
+ 'format-style-section',
+ 'format-style-subsection',
+ 'format-style-subsubsection',
+ 'format-style-paragraph',
+ 'format-style-subparagraph',
+ ],
+ },
+ ],
+ [t]
+ )
+
+ const pdfControlsMenuSectionStructure: MenuSectionStructure = useMemo(
+ () => ({
+ title: t('pdf_preview'),
+ id: 'pdf-controls',
+ children: [
+ 'view-pdf-presentation-mode',
+ 'view-pdf-zoom-in',
+ 'view-pdf-zoom-out',
+ 'view-pdf-fit-width',
+ 'view-pdf-fit-height',
+ ],
+ }),
+ [t]
+ )
+
+ const {
+ userSettings: { mathPreview },
+ setUserSettings,
+ } = useUserSettingsContext()
+
+ const toggleMathPreview = useCallback(() => {
+ setUserSettings(prev => {
+ return {
+ ...prev,
+ mathPreview: !prev.mathPreview,
+ }
+ })
+ }, [setUserSettings])
+
+ const { setActiveModal } = useRailContext()
+ const openKeyboardShortcutsModal = useCallback(() => {
+ setActiveModal('keyboard-shortcuts')
+ }, [setActiveModal])
+ const openContactUsModal = useCallback(() => {
+ setActiveModal('contact-us')
+ }, [setActiveModal])
return (
diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/toolbar.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/toolbar.tsx
index e9c05cbffc..ed1b2509ff 100644
--- a/services/web/frontend/js/features/ide-redesign/components/toolbar/toolbar.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/toolbar.tsx
@@ -7,8 +7,31 @@ import ShareProjectButton from './share-project-button'
import ChangeLayoutButton from './change-layout-button'
import ShowHistoryButton from './show-history-button'
import { LabsActions } from './labs-actions'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import BackToEditorButton from '@/features/editor-navigation-toolbar/components/back-to-editor-button'
+import { useCallback } from 'react'
+import * as eventTracking from '../../../../infrastructure/event-tracking'
export const Toolbar = () => {
+ const { view, setView } = useLayoutContext()
+
+ const handleBackToEditorClick = useCallback(() => {
+ eventTracking.sendMB('navigation-clicked-history', { action: 'close' })
+ setView('editor')
+ }, [setView])
+
+ if (view === 'history') {
+ return (
+
+
+
+
+
+
{/* Empty div used for spacing */}
+
+ )
+ }
+
return (
diff --git a/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx b/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx
index 1f14b294ea..51c797fa1d 100644
--- a/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx
+++ b/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx
@@ -24,7 +24,6 @@ export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary'
const RailContext = createContext<
| {
selectedTab: RailTabKey
- setSelectedTab: Dispatch
>
isOpen: boolean
setIsOpen: Dispatch>
panelRef: React.RefObject
@@ -35,11 +34,12 @@ const RailContext = createContext<
setResizing: Dispatch>
activeModal: RailModalKey | null
setActiveModal: Dispatch>
+ openTab: (tab: RailTabKey) => void
}
| undefined
>(undefined)
-export const RailProvider: FC = ({ children }) => {
+export const RailProvider: FC = ({ children }) => {
const [isOpen, setIsOpen] = useState(true)
const [resizing, setResizing] = useState(false)
const [activeModal, setActiveModalInternal] = useState(
@@ -69,10 +69,17 @@ export const RailProvider: FC = ({ children }) => {
// since it is responsible for opening the initial document.
const [selectedTab, setSelectedTab] = useState('file-tree')
+ const openTab = useCallback(
+ (tab: RailTabKey) => {
+ setSelectedTab(tab)
+ setIsOpen(true)
+ },
+ [setIsOpen, setSelectedTab]
+ )
+
const value = useMemo(
() => ({
selectedTab,
- setSelectedTab,
isOpen,
setIsOpen,
panelRef,
@@ -83,10 +90,10 @@ export const RailProvider: FC = ({ children }) => {
setResizing,
activeModal,
setActiveModal,
+ openTab,
}),
[
selectedTab,
- setSelectedTab,
isOpen,
setIsOpen,
panelRef,
@@ -97,6 +104,7 @@ export const RailProvider: FC = ({ children }) => {
setResizing,
activeModal,
setActiveModal,
+ openTab,
]
)
diff --git a/services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx b/services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx
new file mode 100644
index 0000000000..e2bbae35ff
--- /dev/null
+++ b/services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx
@@ -0,0 +1,345 @@
+import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
+import {
+ useCodeMirrorStateContext,
+ useCodeMirrorViewContext,
+} from '@/features/source-editor/components/codemirror-context'
+import { FigureModalSource } from '@/features/source-editor/components/figure-modal/figure-modal-context'
+import * as commands from '@/features/source-editor/extensions/toolbar/commands'
+import { setSectionHeadingLevel } from '@/features/source-editor/extensions/toolbar/sections'
+import { useEditorContext } from '@/shared/context/editor-context'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import getMeta from '@/utils/meta'
+import { redo, selectAll, undo } from '@codemirror/commands'
+import { openSearchPanel } from '@codemirror/search'
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useIsNewEditorEnabled } from '../utils/new-editor-utils'
+import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
+import { language } from '@codemirror/language'
+
+export const useToolbarMenuBarEditorCommands = () => {
+ const view = useCodeMirrorViewContext()
+ const state = useCodeMirrorStateContext()
+ const { t } = useTranslation()
+ const { view: layoutView } = useLayoutContext()
+ const editorIsVisible = layoutView === 'editor'
+ const { trackedWrite } = usePermissionsContext()
+ const languageName = state.facet(language)?.name
+ const isTeXFile = languageName === 'latex'
+
+ const openFigureModal = useCallback((source: FigureModalSource) => {
+ window.dispatchEvent(
+ new CustomEvent('figure-modal:open', {
+ detail: { source },
+ })
+ )
+ }, [])
+
+ const newEditor = useIsNewEditorEnabled()
+
+ useCommandProvider(() => {
+ if (!newEditor) {
+ return
+ }
+
+ return [
+ /************************************
+ * Edit menu
+ ************************************/
+ {
+ id: 'undo',
+ label: t('undo'),
+ handler: () => {
+ undo(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible || !trackedWrite,
+ },
+ {
+ id: 'redo',
+ label: t('redo'),
+ handler: () => {
+ redo(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible || !trackedWrite,
+ },
+ {
+ id: 'find',
+ label: t('find'),
+ handler: () => {
+ openSearchPanel(view)
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'select-all',
+ label: t('select_all'),
+ handler: () => {
+ selectAll(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ ]
+ }, [editorIsVisible, t, view, trackedWrite, newEditor])
+
+ // LaTeX commands
+ useCommandProvider(() => {
+ if (!newEditor) {
+ return
+ }
+ if (!isTeXFile || !trackedWrite) {
+ return
+ }
+
+ return [
+ /************************************
+ * Insert menu
+ ************************************/
+ {
+ id: 'insert-inline-math',
+ label: t('inline_math'),
+ handler: () => {
+ commands.wrapInInlineMath(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'insert-display-math',
+ label: t('display_math'),
+ handler: () => {
+ commands.wrapInDisplayMath(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ label: t('upload_from_computer'),
+ id: 'insert-figure-from-computer',
+ handler: () => {
+ openFigureModal(FigureModalSource.FILE_UPLOAD)
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ label: t('from_project_files'),
+ id: 'insert-figure-from-project-files',
+ handler: () => {
+ openFigureModal(FigureModalSource.FILE_TREE)
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ label: t('from_another_project'),
+ id: 'insert-figure-from-another-project',
+ handler: () => {
+ openFigureModal(FigureModalSource.OTHER_PROJECT)
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ label: t('from_url'),
+ id: 'insert-figure-from-url',
+ handler: () => {
+ openFigureModal(FigureModalSource.FROM_URL)
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'insert-table',
+ label: t('table'),
+ handler: () => {
+ commands.insertTable(view, 3, 3)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'insert-citation',
+ label: t('citation'),
+ handler: () => {
+ commands.insertCite(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'insert-link',
+ label: t('link'),
+ handler: () => {
+ commands.wrapInHref(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'insert-cross-reference',
+ label: t('cross_reference'),
+ handler: () => {
+ commands.insertRef(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'comment',
+ label: t('comment'),
+ handler: () => {
+ commands.addComment()
+ },
+ disabled: !editorIsVisible,
+ },
+ /************************************
+ * Format menu
+ ************************************/
+ {
+ id: 'format-bold',
+ label: t('bold'),
+ handler: () => {
+ commands.toggleBold(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-italics',
+ label: t('italics'),
+ handler: () => {
+ commands.toggleItalic(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-bullet-list',
+ label: t('bullet_list'),
+ handler: () => {
+ commands.toggleBulletList(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-numbered-list',
+ label: t('numbered_list'),
+ handler: () => {
+ commands.toggleNumberedList(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-increase-indentation',
+ label: t('increase_indent'),
+ handler: () => {
+ commands.indentIncrease(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-decrease-indentation',
+ label: t('decrease_indent'),
+ handler: () => {
+ commands.indentDecrease(view)
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-style-normal',
+ label: t('normal'),
+ handler: () => {
+ setSectionHeadingLevel(view, 'text')
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-style-section',
+ label: 'Section',
+ handler: () => {
+ setSectionHeadingLevel(view, 'section')
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-style-subsection',
+ label: 'Subsection',
+ handler: () => {
+ setSectionHeadingLevel(view, 'subsection')
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-style-subsubsection',
+ label: 'Subsubsection',
+ handler: () => {
+ setSectionHeadingLevel(view, 'subsubsection')
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-style-paragraph',
+ label: 'Paragraph',
+ handler: () => {
+ setSectionHeadingLevel(view, 'paragraph')
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ {
+ id: 'format-style-subparagraph',
+ label: 'Subparagraph',
+ handler: () => {
+ setSectionHeadingLevel(view, 'subparagraph')
+ view.focus()
+ },
+ disabled: !editorIsVisible,
+ },
+ ]
+ }, [
+ view,
+ t,
+ editorIsVisible,
+ openFigureModal,
+ newEditor,
+ trackedWrite,
+ isTeXFile,
+ ])
+
+ const { toggleSymbolPalette } = useEditorContext()
+ const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
+ useCommandProvider(() => {
+ if (!symbolPaletteAvailable) {
+ return
+ }
+
+ if (!isTeXFile || !trackedWrite) {
+ return
+ }
+
+ return [
+ {
+ id: 'insert-symbol',
+ label: t('symbol'),
+ handler: () => {
+ toggleSymbolPalette?.()
+ },
+ disabled: !editorIsVisible,
+ },
+ ]
+ }, [
+ symbolPaletteAvailable,
+ t,
+ toggleSymbolPalette,
+ editorIsVisible,
+ isTeXFile,
+ trackedWrite,
+ ])
+}
diff --git a/services/web/frontend/js/features/ide-redesign/images/labs-icon.svg b/services/web/frontend/js/features/ide-redesign/images/labs-icon.svg
new file mode 100644
index 0000000000..f26bd28bfb
--- /dev/null
+++ b/services/web/frontend/js/features/ide-redesign/images/labs-icon.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts b/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts
index 5dbffab7d5..ecd492cd5f 100644
--- a/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts
+++ b/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts
@@ -1,9 +1,7 @@
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
-import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import { isInExperiment } from '@/utils/labs-utils'
-// TODO: For now we're using the feature flag, but eventually we'll read this
-// from labs.
-export const canUseNewEditor = () => isSplitTestEnabled('editor-redesign')
+export const canUseNewEditor = () => isInExperiment('editor-redesign')
export const useIsNewEditorEnabled = () => {
const { userSettings } = useUserSettingsContext()
diff --git a/services/web/frontend/js/features/mathjax/load-mathjax.ts b/services/web/frontend/js/features/mathjax/load-mathjax.ts
index 0d9153ba56..c80d8a3d85 100644
--- a/services/web/frontend/js/features/mathjax/load-mathjax.ts
+++ b/services/web/frontend/js/features/mathjax/load-mathjax.ts
@@ -30,6 +30,10 @@ export const loadMathJax = async (options?: {
// Implements support for the \bm command from the bm package. It bolds the argument in math mode.
// https://github.com/mathjax/MathJax/issues/1219#issuecomment-341059843
bm: ['\\boldsymbol{#1}', 1],
+ // MathJax 3 renders \coloneq as :- whereas the mathtools package
+ // renders it as :=. Here we override the \coloneq macro to produce
+ // the := symbol.
+ coloneq: '\\coloneqq',
},
inlineMath,
displayMath: [
diff --git a/services/web/frontend/js/features/pdf-preview/components/compile-time-warning-upgrade-prompt.tsx b/services/web/frontend/js/features/pdf-preview/components/compile-time-warning-upgrade-prompt.tsx
index b6d56df936..6595df854c 100644
--- a/services/web/frontend/js/features/pdf-preview/components/compile-time-warning-upgrade-prompt.tsx
+++ b/services/web/frontend/js/features/pdf-preview/components/compile-time-warning-upgrade-prompt.tsx
@@ -15,7 +15,7 @@ function CompileTimeWarningUpgradePrompt() {
>(`has-dismissed-10s-compile-time-warning-until`)
const handleNewCompile = useCallback(
- compileTime => {
+ (compileTime: number) => {
setShowWarning(false)
if (compileTime > 10000) {
if (isProjectOwner) {
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-file-list.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-file-list.tsx
index 8ed0b86d9b..753aa32805 100644
--- a/services/web/frontend/js/features/pdf-preview/components/pdf-file-list.tsx
+++ b/services/web/frontend/js/features/pdf-preview/components/pdf-file-list.tsx
@@ -24,7 +24,11 @@ function PdfFileList({ fileList }: { fileList: PdfFileDataList }) {
{fileList.top.map(file => (
-
+
{file.path}
@@ -36,7 +40,11 @@ function PdfFileList({ fileList }: { fileList: PdfFileDataList }) {
{fileList.other.map(file => (
-
+
{file.path}
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx
index 04332d2485..9d0cfca638 100644
--- a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx
+++ b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx
@@ -62,10 +62,10 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
// create the viewer when the container is mounted
const handleContainer = useCallback(
- parent => {
+ (parent: HTMLDivElement | null) => {
if (parent) {
try {
- setPdfJsWrapper(new PDFJSWrapper(parent.firstChild))
+ setPdfJsWrapper(new PDFJSWrapper(parent.firstChild as HTMLDivElement))
} catch (error: any) {
setLoadingError(true)
captureException(error)
@@ -144,6 +144,10 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
setRawScale(scale.scale)
}
+ const handlePageChanging = (event: { pageNumber: number }) => {
+ setPage(event.pageNumber)
+ }
+
// `pagesinit` fires when the data for rendering the first page is ready.
pdfJsWrapper.eventBus.on('pagesinit', handlePagesinit)
// `pagerendered` fires when a page was actually rendered.
@@ -151,12 +155,15 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
// Once a page has been rendered we can set the initial current page number.
pdfJsWrapper.eventBus.on('pagerendered', handleRenderedInitialPageNumber)
pdfJsWrapper.eventBus.on('scalechanging', handleScaleChanged)
+ // `pagechanging` fires when the page number changes.
+ pdfJsWrapper.eventBus.on('pagechanging', handlePageChanging)
return () => {
pdfJsWrapper.eventBus.off('pagesinit', handlePagesinit)
pdfJsWrapper.eventBus.off('pagerendered', handleRendered)
pdfJsWrapper.eventBus.off('pagerendered', handleRenderedInitialPageNumber)
pdfJsWrapper.eventBus.off('scalechanging', handleScaleChanged)
+ pdfJsWrapper.eventBus.off('pagechanging', handlePageChanging)
}
}, [pdfJsWrapper, firstRenderDone, startFetch])
@@ -168,10 +175,10 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
setStartFetch(performance.now())
const abortController = new AbortController()
- const handleFetchError = (err: Error) => {
+ const handleFetchError = (err: any) => {
if (abortController.signal.aborted) return
// The error is already logged at the call-site with additional context.
- if (err instanceof PDFJS.MissingPDFException) {
+ if (err instanceof PDFJS.ResponseException && err.missing) {
setError('rendering-error-expected')
} else {
setError('rendering-error')
@@ -385,7 +392,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
// set the scale in response to zoom option changes
const setZoom = useCallback(
- zoom => {
+ (zoom: any) => {
switch (zoom) {
case 'zoom-in':
if (pdfJsWrapper) {
@@ -430,7 +437,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
}, [pdfJsWrapper])
const handleKeyDown = useCallback(
- event => {
+ (event: React.KeyboardEvent) => {
if (!initialised || !pdfJsWrapper) {
return
}
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-log-entry-raw-content.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-log-entry-raw-content.tsx
index ab0957b331..39f46fbed3 100644
--- a/services/web/frontend/js/features/pdf-preview/components/pdf-log-entry-raw-content.tsx
+++ b/services/web/frontend/js/features/pdf-preview/components/pdf-log-entry-raw-content.tsx
@@ -17,7 +17,7 @@ export default function PdfLogEntryRawContent({
const { elementRef } = useResizeObserver(
useCallback(
- element => {
+ (element: Element) => {
if (element.scrollHeight === 0) return // skip update when logs-pane is closed
setNeedsExpander(element.scrollHeight > collapsedSize)
},
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-page-number-control.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-page-number-control.tsx
index 80e2c8e66a..af35d538d8 100644
--- a/services/web/frontend/js/features/pdf-preview/components/pdf-page-number-control.tsx
+++ b/services/web/frontend/js/features/pdf-preview/components/pdf-page-number-control.tsx
@@ -57,6 +57,7 @@ function PdfPageNumberControl({
-
+
diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
index 5de7d9372e..679b645a6f 100644
--- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx
+++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
@@ -16,12 +16,8 @@ import getMeta from '@/utils/meta'
import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
-import ProjectListDefault from '@/features/project-list/components/project-list-default'
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
-import {
- DsNavStyleProvider,
- hasDsNav,
-} from '@/features/project-list/components/use-is-ds-nav'
+import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
function ProjectListRoot() {
const { isReady } = useWaitForI18n()
@@ -87,15 +83,7 @@ function ProjectListPageContent() {
)
- if (hasDsNav()) {
- return loadingComponent
- } else {
- return (
-
- {loadingComponent}
-
- )
- }
+ return loadingComponent
}
if (totalProjectsCount === 0) {
@@ -104,19 +92,14 @@ function ProjectListPageContent() {
)
- } else if (hasDsNav()) {
- return (
-
-
-
- )
- } else {
- return (
-
-
-
- )
}
+ return (
+
+
+
+ )
}
-export default withErrorBoundary(ProjectListRoot, GenericErrorBoundaryFallback)
+export default withErrorBoundary(ProjectListRoot, () => (
+
+))
diff --git a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx
index ad908238ca..efcba056be 100644
--- a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx
+++ b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx
@@ -5,7 +5,6 @@ import {
} from '../../context/project-list-context'
import TagsList from './tags-list'
import ProjectsFilterMenu from '../projects-filter-menu'
-import { hasDsNav } from '@/features/project-list/components/use-is-ds-nav'
type SidebarFilterProps = {
filter: Filter
@@ -38,11 +37,9 @@ export default function SidebarFilters() {
- {hasDsNav() && (
-
-
-
- )}
+
+
+
)
diff --git a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx b/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx
index 6b1c0db706..452b003b2b 100644
--- a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx
+++ b/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx
@@ -1,7 +1,6 @@
import { sortBy } from 'lodash'
import { useTranslation } from 'react-i18next'
import { DotsThreeVertical, Plus, TagSimple } from '@phosphor-icons/react'
-import MaterialIcon from '../../../../shared/components/material-icon'
import {
UNCATEGORIZED_KEY,
useProjectListContext,
@@ -14,7 +13,6 @@ import {
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
-import { hasDsNav } from '@/features/project-list/components/use-is-ds-nav'
export default function TagsList() {
const { t } = useTranslation()
@@ -42,16 +40,11 @@ export default function TagsList() {
aria-hidden="true"
data-testid="organize-projects"
>
- {hasDsNav() ? t('organize_tags') : t('organize_projects')}
+ {t('organize_tags')}
- {hasDsNav() ? (
-
- ) : (
-
- )}
-
+
{t('new_tag')}
@@ -73,11 +66,7 @@ export default function TagsList() {
color: getTagColor(tag),
}}
>
- {hasDsNav() ? (
-
- ) : (
-
- )}
+
{tag.name}{' '}
@@ -93,7 +82,7 @@ export default function TagsList() {
id={`${tag._id}-dropdown-toggle`}
data-testid="tag-dropdown-toggle"
>
- {hasDsNav() && }
+
{
setDismissedSurvey(true)
@@ -24,14 +21,8 @@ export function SurveyWidgetDsNav() {
return null
}
- // Hide the survey for users who have sidebar-navigation-ui-update:
- // They've had it for months. We don't need their feedback anymore
- if (hideDsSurvey && survey?.name === 'ds-nav') {
- return null
- }
-
return (
-
+
diff --git a/services/web/frontend/js/features/project-list/components/survey-widget.tsx b/services/web/frontend/js/features/project-list/components/survey-widget.tsx
index a5604d6b01..ae0847ef92 100644
--- a/services/web/frontend/js/features/project-list/components/survey-widget.tsx
+++ b/services/web/frontend/js/features/project-list/components/survey-widget.tsx
@@ -18,12 +18,6 @@ export default function SurveyWidget() {
return null
}
- // Short-term hard-coded special case: hide the "DS nav" survey for users on
- // the default variant
- if (survey?.name === 'ds-nav') {
- return null
- }
-
return (
diff --git a/services/web/frontend/js/features/project-list/components/table/project-checkbox.tsx b/services/web/frontend/js/features/project-list/components/table/project-checkbox.tsx
index 80a6574e6b..e1aabf8ab0 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-checkbox.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/project-checkbox.tsx
@@ -1,4 +1,4 @@
-import { memo, useCallback } from 'react'
+import { ChangeEvent, memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '@/features/project-list/context/project-list-context'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
@@ -10,7 +10,7 @@ export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>(
useProjectListContext()
const handleCheckboxChange = useCallback(
- event => {
+ (event: ChangeEvent
) => {
toggleSelectedProject(projectId, event.target.checked)
},
[projectId, toggleSelectedProject]
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
index 35f906f1b2..443962cc3c 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
@@ -14,6 +14,7 @@ import {
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import { Tag } from '../../../../../../../../app/src/Features/Tags/types'
function TagsDropdown() {
const {
@@ -26,7 +27,7 @@ function TagsDropdown() {
const { openCreateTagModal, CreateTagModal } = useTag()
const handleOpenCreateTagModal = useCallback(
- e => {
+ (e: React.MouseEvent) => {
e.preventDefault()
openCreateTagModal()
},
@@ -34,7 +35,7 @@ function TagsDropdown() {
)
const handleAddTagToSelectedProjects = useCallback(
- (e, tagId) => {
+ (e: React.MouseEvent, tagId: string) => {
e.preventDefault()
const tag = tags.find(tag => tag._id === tagId)
const projectIds = []
@@ -50,7 +51,7 @@ function TagsDropdown() {
)
const handleRemoveTagFromSelectedProjects = useCallback(
- (e, tagId) => {
+ (e: React.MouseEvent, tagId: string) => {
e.preventDefault()
for (const selectedProject of selectedProjects) {
removeProjectFromTagInView(tagId, selectedProject.id)
@@ -64,7 +65,7 @@ function TagsDropdown() {
)
const containsAllSelectedProjects = useCallback(
- tag => {
+ (tag: Tag) => {
for (const project of selectedProjects) {
if (!(tag.project_ids || []).includes(project.id)) {
return false
diff --git a/services/web/frontend/js/features/project-list/components/use-is-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/use-is-ds-nav.tsx
index 91ba19ab22..9734cefda3 100644
--- a/services/web/frontend/js/features/project-list/components/use-is-ds-nav.tsx
+++ b/services/web/frontend/js/features/project-list/components/use-is-ds-nav.tsx
@@ -1,16 +1,4 @@
import { createContext, type FC, type ReactNode, useContext } from 'react'
-import { useSplitTestContext } from '@/shared/context/split-test-context'
-import getMeta from '@/utils/meta'
-
-export const hasDsNav = () => getMeta('ol-ExposedSettings').isOverleaf
-
-/**
- * This hook returns whether the user has the split-test assignment 'sidebar-navigation-ui-update'
- */
-export const useHideDsSurvey = () => {
- const { splitTestVariants } = useSplitTestContext()
- return splitTestVariants['sidebar-navigation-ui-update'] === 'active'
-}
/**
* This context wraps elements that should be styled according to the sidebar-navigation-ui-update redesign
@@ -18,9 +6,11 @@ export const useHideDsSurvey = () => {
*/
const DsNavStyleContext = createContext(undefined)
-export const DsNavStyleProvider: FC<{
- children: ReactNode
-}> = ({ children }) => (
+export const DsNavStyleProvider: FC<
+ React.PropsWithChildren<{
+ children: ReactNode
+ }>
+> = ({ children }) => (
{children}
)
diff --git a/services/web/frontend/js/features/project-list/context/project-list-context.tsx b/services/web/frontend/js/features/project-list/context/project-list-context.tsx
index 918a54781d..cb0bce5715 100644
--- a/services/web/frontend/js/features/project-list/context/project-list-context.tsx
+++ b/services/web/frontend/js/features/project-list/context/project-list-context.tsx
@@ -293,7 +293,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
}, [selectedProjectIds, visibleProjects])
const selectOrUnselectAllProjects = useCallback(
- checked => {
+ (checked: any) => {
setSelectedProjectIds(prevSelectedProjectIds => {
const selectedProjectIds = new Set(prevSelectedProjectIds)
for (const project of visibleProjects) {
diff --git a/services/web/frontend/js/features/project-list/hooks/use-tag.tsx b/services/web/frontend/js/features/project-list/hooks/use-tag.tsx
index 6a745883c7..e90e9a7984 100644
--- a/services/web/frontend/js/features/project-list/hooks/use-tag.tsx
+++ b/services/web/frontend/js/features/project-list/hooks/use-tag.tsx
@@ -50,7 +50,7 @@ function useTag() {
)
const handleEditTag = useCallback(
- (e, tagId) => {
+ (e: React.MouseEvent, tagId: string) => {
e.preventDefault()
const tag = find(tags, ['_id', tagId])
if (tag) {
@@ -69,7 +69,7 @@ function useTag() {
)
const handleDeleteTag = useCallback(
- (e, tagId) => {
+ (e: React.MouseEvent, tagId: string) => {
e.preventDefault()
const tag = find(tags, ['_id', tagId])
if (tag) {
@@ -80,7 +80,7 @@ function useTag() {
)
const onDelete = useCallback(
- tagId => {
+ (tagId: string) => {
deleteTag(tagId)
setDeletingTag(undefined)
},
@@ -88,7 +88,7 @@ function useTag() {
)
const handleManageTag = useCallback(
- (e, tagId) => {
+ (e: React.MouseEvent, tagId: string) => {
e.preventDefault()
const tag = find(tags, ['_id', tagId])
if (tag) {
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 7213dc46ff..87f9934395 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
@@ -36,7 +36,7 @@ export const ReviewPanelAddComment = memo<{
}, [view, threadId])
const submitForm = useCallback(
- async message => {
+ async (message: string) => {
setSubmitting(true)
const content = view.state.sliceDoc(from, to)
@@ -85,14 +85,15 @@ export const ReviewPanelAddComment = memo<{
// We cannot use the autofocus attribute as we need to wait until the parent element
// has been positioned (with the "top" attribute) to avoid scrolling to the initial
// position of the element
- const observerCallback = useCallback(mutationList => {
+ const observerCallback = useCallback((mutationList: MutationRecord[]) => {
if (hasBeenFocused.current) {
return
}
for (const mutation of mutationList) {
- if (mutation.target.style.top) {
- const textArea = mutation.target.getElementsByTagName('textarea')[0]
+ const target = mutation.target as HTMLElement
+ if (target.style.top) {
+ const textArea = target.getElementsByTagName('textarea')[0]
if (textArea) {
textArea.focus()
hasBeenFocused.current = true
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx
index c85c69d7b7..972dcc10c0 100644
--- a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx
+++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx
@@ -1,4 +1,4 @@
-import { memo, useCallback, useState } from 'react'
+import { Dispatch, memo, SetStateAction, useCallback, useState } from 'react'
import { Change, CommentOperation } from '../../../../../types/change'
import { ReviewPanelMessage } from './review-panel-message'
import { useTranslation } from 'react-i18next'
@@ -41,7 +41,7 @@ export const ReviewPanelCommentContent = memo<{
const [submitting, setSubmitting] = useState(false)
const handleSubmit = useCallback(
- (content, setContent) => {
+ (content: string, setContent: Dispatch>) => {
if (!onReply || submitting) {
return
}
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx
index 9ee8bc4062..269ffde3b6 100644
--- a/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx
+++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx
@@ -2,20 +2,15 @@ import ReactDOM from 'react-dom'
import { useCodeMirrorViewContext } from '../../source-editor/components/codemirror-context'
import { memo } from 'react'
import ReviewPanel from './review-panel'
-import { useLayoutContext } from '@/shared/context/layout-context'
-import { useRangesContext } from '../context/ranges-context'
-import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
-import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
import TrackChangesOnWidget from './track-changes-on-widget'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import ReviewModeSwitcher from './review-mode-switcher'
import getMeta from '@/utils/meta'
+import useReviewPanelLayout from '../hooks/use-review-panel-layout'
function ReviewPanelContainer() {
const view = useCodeMirrorViewContext()
- const ranges = useRangesContext()
- const threads = useThreadsContext()
- const { reviewPanelOpen } = useLayoutContext()
+ const { showPanel, mini } = useReviewPanelLayout()
const { wantTrackChanges } = useEditorManagerContext()
const enableReviewerRole = getMeta('ol-isReviewerRoleEnabled')
@@ -23,16 +18,13 @@ function ReviewPanelContainer() {
return null
}
- const hasCommentOrChange = hasActiveRange(ranges, threads)
- const showPanel = reviewPanelOpen || hasCommentOrChange
- const showTrackChangesWidget =
- !enableReviewerRole && wantTrackChanges && !reviewPanelOpen
+ const showTrackChangesWidget = !enableReviewerRole && wantTrackChanges && mini
return ReactDOM.createPortal(
<>
{showTrackChangesWidget && }
{enableReviewerRole && }
- {showPanel && }
+ {showPanel && }
>,
view.scrollDOM
)
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx
index 03fde3fd67..4192dd518e 100644
--- a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx
+++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx
@@ -11,24 +11,26 @@ import {
highlightRanges,
} from '@/features/source-editor/extensions/ranges'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
-import { useLayoutContext } from '@/shared/context/layout-context'
import { EditorSelection } from '@codemirror/state'
import MaterialIcon from '@/shared/components/material-icon'
import { OFFSET_FOR_ENTRIES_ABOVE } from '../utils/position-items'
+import useReviewPanelLayout from '../hooks/use-review-panel-layout'
-export const ReviewPanelEntry: FC<{
- position: number
- op: AnyOperation
- docId: string
- top?: number
- className?: string
- selectLineOnFocus?: boolean
- hoverRanges?: boolean
- disabled?: boolean
- onEnterEntryIndicator?: () => void
- onLeaveEntryIndicator?: () => void
- entryIndicator?: 'comment' | 'edit'
-}> = ({
+export const ReviewPanelEntry: FC<
+ React.PropsWithChildren<{
+ position: number
+ op: AnyOperation
+ docId: string
+ top?: number
+ className?: string
+ selectLineOnFocus?: boolean
+ hoverRanges?: boolean
+ disabled?: boolean
+ onEnterEntryIndicator?: () => void
+ onLeaveEntryIndicator?: () => void
+ entryIndicator?: 'comment' | 'edit'
+ }>
+> = ({
children,
position,
top,
@@ -48,17 +50,13 @@ export const ReviewPanelEntry: FC<{
const [selected, setSelected] = useState(false)
const [focused, setFocused] = useState(false)
const [textareaFocused, setTextareaFocused] = useState(false)
- const { setReviewPanelOpen } = useLayoutContext()
+ const { openReviewPanel } = useReviewPanelLayout()
const highlighted = isSelectionWithinOp(op, state.selection.main)
const entryRef = useRef(null)
const mousePressedRef = useRef(false)
- const openReviewPanel = useCallback(() => {
- setReviewPanelOpen(true)
- }, [setReviewPanelOpen])
-
const selectEntry = useCallback(
- event => {
+ (event: React.FocusEvent | React.MouseEvent) => {
setFocused(true)
if (event.target instanceof HTMLTextAreaElement) {
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx
index 6dafe4dcf0..258922479c 100644
--- a/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx
+++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx
@@ -2,25 +2,22 @@ import { FC, memo, useState } from 'react'
import { ReviewPanelResolvedThreadsButton } from './review-panel-resolved-threads-button'
import { ReviewPanelTrackChangesMenu } from './review-panel-track-changes-menu'
import ReviewPanelTrackChangesMenuButton from './review-panel-track-changes-menu-button'
-import { useLayoutContext } from '@/shared/context/layout-context'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { PanelHeading } from '@/shared/components/panel-heading'
+import useReviewPanelLayout from '../hooks/use-review-panel-layout'
const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
const ReviewPanelHeader: FC = () => {
const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] =
useState(false)
- const { setReviewPanelOpen } = useLayoutContext()
+ const { closeReviewPanel } = useReviewPanelLayout()
const { t } = useTranslation()
return (
-
setReviewPanelOpen(false)}
- >
+
{isReviewerRoleEnabled && }
{!isReviewerRoleEnabled && (
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx
index 3a20892f1c..160a55b0c7 100644
--- a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx
+++ b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx
@@ -6,9 +6,11 @@ import { ReviewPanelOverview } from './review-panel-overview'
import classnames from 'classnames'
import { useReviewPanelStyles } from '@/features/review-panel-new/hooks/use-review-panel-styles'
import { useReviewPanelViewContext } from '../context/review-panel-view-context'
+import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => {
const choosenSubView = useReviewPanelViewContext()
+ const newEditor = useIsNewEditorEnabled()
const activeSubView = useMemo(
() => (mini ? 'cur_file' : choosenSubView),
@@ -25,7 +27,7 @@ const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => {
return (
- {!mini &&
}
+ {!newEditor && !mini &&
}
{activeSubView === 'cur_file' &&
}
{activeSubView === 'overview' &&
}
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 1b996ebf0b..066cffae56 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
@@ -35,6 +35,7 @@ import { useEditorManagerContext } from '@/features/ide-react/context/editor-man
import classNames from 'classnames'
import useEventListener from '@/shared/hooks/use-event-listener'
import getMeta from '@/utils/meta'
+import useReviewPanelLayout from '../hooks/use-review-panel-layout'
const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
const TRACK_CHANGES_ON_WIDGET_HEIGHT = 25
@@ -48,8 +49,7 @@ const ReviewTooltipMenu: FC = () => {
const isViewer = useViewerPermissions()
const [show, setShow] = useState(true)
const { setView } = useReviewPanelViewActionsContext()
- const { setReviewPanelOpen } = useLayoutContext()
-
+ const { openReviewPanel } = useReviewPanelLayout()
const tooltipState = state.field(reviewTooltipStateField, false)?.tooltip
const previousTooltipState = usePreviousValue(tooltipState)
@@ -65,7 +65,7 @@ const ReviewTooltipMenu: FC = () => {
return
}
- setReviewPanelOpen(true)
+ openReviewPanel()
setView('cur_file')
const effects = isCursorNearViewportEdge(view, main.anchor)
@@ -77,7 +77,7 @@ const ReviewTooltipMenu: FC = () => {
view.dispatch({ effects })
setShow(false)
- }, [setReviewPanelOpen, setView, setShow, view])
+ }, [openReviewPanel, setView, setShow, view])
useEventListener('add-new-review-comment', addComment)
diff --git a/services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx b/services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx
index e875736f52..5911b3fc0e 100644
--- a/services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx
+++ b/services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx
@@ -26,7 +26,9 @@ export const ChangesUsersContext = createContext
(
undefined
)
-export const ChangesUsersProvider: FC = ({ children }) => {
+export const ChangesUsersProvider: FC = ({
+ children,
+}) => {
const { _id: projectId, members, owner } = useProjectContext()
const { isRestrictedTokenMember } = useEditorContext()
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 0ed20347c8..2e816ccdb2 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
@@ -78,7 +78,7 @@ const buildRanges = (currentDocument: DocumentContainer | null) => {
const RangesActionsContext = createContext(undefined)
-export const RangesProvider: FC = ({ children }) => {
+export const RangesProvider: FC = ({ children }) => {
const view = useCodeMirrorViewContext()
const { projectId } = useIdeReactContext()
const { currentDocument } = useEditorManagerContext()
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 34cb175129..20e157dfee 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
@@ -5,7 +5,9 @@ import { TrackChangesStateProvider } from './track-changes-state-context'
import { ThreadsProvider } from './threads-context'
import { ReviewPanelViewProvider } from './review-panel-view-context'
-export const ReviewPanelProviders: FC = ({ children }) => {
+export const ReviewPanelProviders: FC = ({
+ children,
+}) => {
return (
diff --git a/services/web/frontend/js/features/review-panel-new/context/review-panel-view-context.tsx b/services/web/frontend/js/features/review-panel-new/context/review-panel-view-context.tsx
index 7305c68fc0..03d274a8af 100644
--- a/services/web/frontend/js/features/review-panel-new/context/review-panel-view-context.tsx
+++ b/services/web/frontend/js/features/review-panel-new/context/review-panel-view-context.tsx
@@ -20,7 +20,9 @@ const ReviewPanelViewActionsContext = createContext(
undefined
)
-export const ReviewPanelViewProvider: FC = ({ children }) => {
+export const ReviewPanelViewProvider: FC = ({
+ children,
+}) => {
const [view, setView] = useState('cur_file')
const actions = useMemo(
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 fce47e9054..d5cf34ef93 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
@@ -48,7 +48,7 @@ const ThreadsActionsContext = createContext(
undefined
)
-export const ThreadsProvider: FC = ({ children }) => {
+export const ThreadsProvider: FC = ({ children }) => {
const { _id: projectId } = useProjectContext()
const { currentDocument } = useEditorManagerContext()
const { isRestrictedTokenMember } = useEditorContext()
@@ -225,7 +225,7 @@ export const ThreadsProvider: FC = ({ children }) => {
useSocketListener(
socket,
'new-comment-threads',
- useCallback(threads => {
+ useCallback((threads: any) => {
setData(prevState => {
const newThreads = { ...prevState }
for (const threadId of Object.keys(threads)) {
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 b9eb61c7a5..2d77ef9d8d 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
@@ -44,7 +44,9 @@ const TrackChangesStateActionsContext = createContext<
TrackChangesStateActions | undefined
>(undefined)
-export const TrackChangesStateProvider: FC = ({ children }) => {
+export const TrackChangesStateProvider: FC = ({
+ children,
+}) => {
const permissions = usePermissionsContext()
const { socket } = useConnectionContext()
const project = useProjectContext()
diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-more-comments.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-more-comments.ts
index 5fbc94d241..f541839e03 100644
--- a/services/web/frontend/js/features/review-panel-new/hooks/use-more-comments.ts
+++ b/services/web/frontend/js/features/review-panel-new/hooks/use-more-comments.ts
@@ -8,7 +8,7 @@ import {
import { DecorationSet, EditorView } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state'
import _ from 'lodash'
-import { useLayoutContext } from '@/shared/context/layout-context'
+import useReviewPanelLayout from './use-review-panel-layout'
const useMoreCommments = (
changes: Change[],
@@ -20,7 +20,8 @@ const useMoreCommments = (
onMoreCommentsBelowClick: null | (() => void)
} => {
const view = useCodeMirrorViewContext()
- const { reviewPanelOpen } = useLayoutContext()
+ const { showPanel, mini } = useReviewPanelLayout()
+ const reviewPanelOpen = showPanel && !mini
const [positionAbove, setPositionAbove] = useState(null)
const [positionBelow, setPositionBelow] = useState(null)
diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-layout.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-layout.ts
new file mode 100644
index 0000000000..97f3deac16
--- /dev/null
+++ b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-layout.ts
@@ -0,0 +1,55 @@
+import { useLayoutContext } from '@/shared/context/layout-context'
+import { useRangesContext } from '../context/ranges-context'
+import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
+import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
+import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
+import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
+import { useCallback } from 'react'
+
+export default function useReviewPanelLayout(): {
+ showPanel: boolean
+ showHeader: boolean
+ mini: boolean
+ openReviewPanel: () => void
+ closeReviewPanel: () => void
+} {
+ const ranges = useRangesContext()
+ const threads = useThreadsContext()
+ const {
+ selectedTab: selectedRailTab,
+ isOpen: railIsOpen,
+ openTab: openRailTab,
+ setIsOpen: setRailIsOpen,
+ } = useRailContext()
+ const { reviewPanelOpen: reviewPanelOpenOldEditor, setReviewPanelOpen } =
+ useLayoutContext()
+
+ const newEditor = useIsNewEditorEnabled()
+
+ const reviewPanelOpen = newEditor
+ ? selectedRailTab === 'review-panel' && railIsOpen
+ : reviewPanelOpenOldEditor
+
+ const openReviewPanel = useCallback(() => {
+ if (newEditor) {
+ openRailTab('review-panel')
+ } else {
+ setReviewPanelOpen(true)
+ }
+ }, [newEditor, setReviewPanelOpen, openRailTab])
+
+ const closeReviewPanel = useCallback(() => {
+ if (newEditor) {
+ setRailIsOpen(false)
+ } else {
+ setReviewPanelOpen(false)
+ }
+ }, [newEditor, setReviewPanelOpen, setRailIsOpen])
+
+ const hasCommentOrChange = hasActiveRange(ranges, threads)
+ const showPanel = reviewPanelOpen || !!hasCommentOrChange
+ const mini = !reviewPanelOpen
+ const showHeader = showPanel && !mini
+
+ return { showPanel, showHeader, mini, openReviewPanel, closeReviewPanel }
+}
diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts
index f40e42e9a5..59006db354 100644
--- a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts
+++ b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts
@@ -30,13 +30,6 @@ export const useReviewPanelStyles = (mini: boolean) => {
}))
}, [])
- useEffect(() => {
- setStyles(value => ({
- ...value,
- '--review-panel-width': mini ? '22px' : '230px',
- }))
- }, [mini])
-
useEffect(() => {
if ('ResizeObserver' in window) {
const scrollDomObserver = new window.ResizeObserver(entries =>
diff --git a/services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx b/services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx
index cdc1b9d481..66f4da7c99 100644
--- a/services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx
+++ b/services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx
@@ -1,5 +1,4 @@
import { useTranslation, Trans } from 'react-i18next'
-import AccessibleModal from '../../../../../../shared/components/accessible-modal'
import { MergeAndOverride } from '../../../../../../../../types/utils'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
@@ -11,7 +10,7 @@ import OLModal, {
import { type UserEmailData } from '../../../../../../../../types/user-email'
type ConfirmationModalProps = MergeAndOverride<
- React.ComponentProps,
+ React.ComponentProps,
{
email: string
isConfirmDisabled: boolean
diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
index 77f219c070..f55344a2f2 100644
--- a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
+++ b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
@@ -24,10 +24,8 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
getLabelProps,
getMenuProps,
getInputProps,
- getComboboxProps,
getItemProps,
highlightedIndex,
- openMenu,
selectedItem,
} = useCombobox({
inputValue,
@@ -50,7 +48,7 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
return (
-
+
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
{t('country')}
@@ -60,11 +58,6 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
onChange: (event: React.ChangeEvent) => {
setInputValue(event.target.value)
},
- onFocus: () => {
- if (!isOpen) {
- openMenu()
- }
- },
ref: inputRef,
})}
placeholder={t('country')}
diff --git a/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx b/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx
index eb0563d2c2..ba8077b07c 100644
--- a/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx
+++ b/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx
@@ -47,10 +47,8 @@ function Downshift({
getLabelProps,
getMenuProps,
getInputProps,
- getComboboxProps,
getItemProps,
highlightedIndex,
- openMenu,
selectedItem,
} = useCombobox({
inputValue,
@@ -82,8 +80,7 @@ function Downshift({
return (
-
- {/* eslint-disable-next-line jsx-a11y/label-has-for */}
+
) => {
setValue(event.target.value)
},
- onFocus: () => {
- if (!isOpen) {
- openMenu()
- }
- },
ref: inputRef,
})}
placeholder={placeholder}
diff --git a/services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt-text.tsx b/services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt-text.tsx
index 4dace050ef..898649504d 100644
--- a/services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt-text.tsx
+++ b/services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt-text.tsx
@@ -4,19 +4,16 @@ import { Institution } from '../../../../../../../types/institution'
type ReconfirmationInfoPromptTextProps = {
primary: boolean
institutionName: Institution['name']
- icon?: React.ReactElement // BS3 only
}
function ReconfirmationInfoPromptText({
primary,
institutionName,
- icon,
}: ReconfirmationInfoPromptTextProps) {
const { t } = useTranslation()
return (
<>
- {icon}
{t('your_project_exceeded_editor_limit')}
+ {t('your_project_exceeded_collaborator_limit')}
) : (
- {t('this_project_exceeded_editor_limit')}{' '}
- {t('you_can_select_or_invite', {
+ {t('this_project_exceeded_collaborator_limit')}{' '}
+ {t('you_can_select_or_invite_collaborator', {
count: features.collaborators,
})}
diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx
index 904946775b..cad9d03177 100644
--- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx
+++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx
@@ -94,6 +94,18 @@ export default function AddCollaborators({ readOnly }) {
data = await sendInvite(projectId, email, privileges)
}
+ const role = data?.invite?.privileges
+ const membersAndInvites = (members || []).concat(invites || [])
+ const previousEditorsAmount = membersAndInvites.filter(
+ member => member.privileges === 'readAndWrite'
+ ).length
+ const previousReviewersAmount = membersAndInvites.filter(
+ member => member.privileges === 'review'
+ ).length
+ const previousViewersAmount = membersAndInvites.filter(
+ member => member.privileges === 'readOnly'
+ ).length
+
sendMB('collaborator-invited', {
project_id: projectId,
// invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
@@ -101,6 +113,22 @@ export default function AddCollaborators({ readOnly }) {
users_updated: !!(data.users || data.user),
current_collaborators_amount: members.length,
current_invites_amount: invites.length,
+ role,
+ previousEditorsAmount,
+ previousReviewersAmount,
+ previousViewersAmount,
+ newEditorsAmount:
+ role === 'readAndWrite'
+ ? previousEditorsAmount + 1
+ : previousEditorsAmount,
+ newReviewersAmount:
+ role === 'review'
+ ? previousReviewersAmount + 1
+ : previousReviewersAmount,
+ newViewersAmount:
+ role === 'readOnly'
+ ? previousViewersAmount + 1
+ : previousViewersAmount,
})
} catch (error) {
setInFlight(false)
diff --git a/services/web/frontend/js/features/share-project-modal/components/collaborators-limit-upgrade.tsx b/services/web/frontend/js/features/share-project-modal/components/collaborators-limit-upgrade.tsx
index 450e42669d..4b63c63e08 100644
--- a/services/web/frontend/js/features/share-project-modal/components/collaborators-limit-upgrade.tsx
+++ b/services/web/frontend/js/features/share-project-modal/components/collaborators-limit-upgrade.tsx
@@ -15,10 +15,12 @@ export default function CollaboratorsLimitUpgrade() {
}
- title={t('add_more_editors')}
+ title={t('add_more_collaborators')}
content={
- {t('upgrade_to_add_more_editors_and_access_collaboration_features')}
+ {t(
+ 'upgrade_to_add_more_collaborators_and_access_collaboration_features'
+ )}
}
isActionBelowContent
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 5b7ac58fd7..45a04dd99c 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
@@ -286,7 +286,7 @@ function SelectPrivilege({
if (hasBeenDowngraded) {
if (isSplitTestEnabled('reviewer-role')) {
- return t('limited_to_n_editors_or_reviewers', {
+ return t('limited_to_n_collaborators_per_project', {
count: features.collaborators,
})
} else {
@@ -297,7 +297,7 @@ function SelectPrivilege({
!['readAndWrite', 'review'].includes(value)
) {
if (isSplitTestEnabled('reviewer-role')) {
- return t('limited_to_n_editors_or_reviewers_per_project', {
+ return t('limited_to_n_collaborators_per_project', {
count: features.collaborators,
})
} else {
diff --git a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx
index ffaadb6b01..a683201d0b 100644
--- a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx
+++ b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx
@@ -94,7 +94,6 @@ export default function SelectCollaborators({
getLabelProps,
getMenuProps,
getInputProps,
- getComboboxProps,
highlightedIndex,
getItemProps,
reset,
@@ -171,11 +170,7 @@ export default function SelectCollaborators({
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
-
+
{selectedItems.map((selectedItem, index) => (
- {t('this_project_already_has_maximum_editors')}
- {t('please_ask_the_project_owner_to_upgrade_more_editors')}
+ {t('this_project_already_has_maximum_collaborators')}
+
+ {t(
+ 'please_ask_the_project_owner_to_upgrade_more_collaborators'
+ )}
+
}
/>
diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
index 7a4139e2de..2328d1d844 100644
--- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
+++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
@@ -126,7 +126,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
}, [handleHide, inFlight])
// update `error` and `inFlight` while sending a request
- const monitorRequest = useCallback(request => {
+ const monitorRequest = useCallback((request: () => any) => {
setError(undefined)
setInFlight(true)
@@ -149,7 +149,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
// merge the new data with the old project data
const updateProject = useCallback(
- data => Object.assign(project, data),
+ (data: ProjectContextUpdateValue) => Object.assign(project, data),
[project]
)
diff --git a/services/web/frontend/js/features/share-project-modal/components/view-only-access-modal-content.tsx b/services/web/frontend/js/features/share-project-modal/components/view-only-access-modal-content.tsx
index 622e8c859e..7fb3de5e74 100644
--- a/services/web/frontend/js/features/share-project-modal/components/view-only-access-modal-content.tsx
+++ b/services/web/frontend/js/features/share-project-modal/components/view-only-access-modal-content.tsx
@@ -24,8 +24,8 @@ export default function ViewOnlyAccessModalContent({
- {t('this_project_already_has_maximum_editors')}
- {t('please_ask_the_project_owner_to_upgrade_more_editors')}
+ {t('this_project_already_has_maximum_collaborators')}
+ {t('please_ask_the_project_owner_to_upgrade_more_collaborators')}
(null)
if (viewRef.current === null) {
@@ -62,33 +59,40 @@ function CodeMirrorEditor() {
return (
-
-
-
-
-
-
- {sourceEditorToolbarComponents.map(
- ({ import: { default: Component }, path }) => (
-
- )
- )}
- {newEditor && }
-
-
-
-
-
-
- {sourceEditorComponents.map(
- ({ import: { default: Component }, path }) => (
-
- )
- )}
-
+
)
}
+function CodeMirrorEditorComponents() {
+ useToolbarMenuBarEditorCommands()
+
+ return (
+
+
+
+
+
+
+ {sourceEditorToolbarComponents.map(
+ ({ import: { default: Component }, path }) => (
+
+ )
+ )}
+
+
+
+
+
+
+ {sourceEditorComponents.map(
+ ({ import: { default: Component }, path }) => (
+
+ )
+ )}
+
+ )
+}
+
export default memo(CodeMirrorEditor)
diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx
index a51075cb46..c013dcdb42 100644
--- a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx
+++ b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx
@@ -2,7 +2,15 @@ import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from './codemirror-context'
-import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ FC,
+ FormEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
import { runScopeHandlers } from '@codemirror/view'
import {
closeSearchPanel,
@@ -21,6 +29,7 @@ import MaterialIcon from '@/shared/components/material-icon'
import OLButtonGroup from '@/features/ui/components/ol/ol-button-group'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLCloseButton from '@/features/ui/components/ol/ol-close-button'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
@@ -28,6 +37,8 @@ import { getStoredSelection, setStoredSelection } from '../extensions/search'
import { debounce } from 'lodash'
import { EditorSelection, EditorState } from '@codemirror/state'
import { sendSearchEvent } from '@/features/event-tracking/search-events'
+import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
+import { FullProjectSearchButton } from './full-project-search-button'
const MATCH_COUNT_DEBOUNCE_WAIT = 100 // the amount of ms to wait before counting matches
const MAX_MATCH_COUNT = 999 // the maximum number of matches to count
@@ -46,7 +57,7 @@ type MatchPositions = {
interrupted: boolean
}
-const CodeMirrorSearchForm: FC = () => {
+const CodeMirrorSearchForm: FC = () => {
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
@@ -72,7 +83,9 @@ const CodeMirrorSearchForm: FC = () => {
const inputRef = useRef(null)
const replaceRef = useRef(null)
- const handleInputRef = useCallback(node => {
+ const newEditor = useIsNewEditorEnabled()
+
+ const handleInputRef = useCallback((node: HTMLInputElement) => {
inputRef.current = node
// focus the search input when the panel opens
@@ -82,11 +95,11 @@ const CodeMirrorSearchForm: FC = () => {
}
}, [])
- const handleReplaceRef = useCallback(node => {
+ const handleReplaceRef = useCallback((node: HTMLInputElement) => {
replaceRef.current = node
}, [])
- const handleSubmit = useCallback(event => {
+ const handleSubmit = useCallback((event: FormEvent) => {
event.preventDefault()
}, [])
@@ -119,8 +132,8 @@ const CodeMirrorSearchForm: FC = () => {
}, [handleChange, state, view])
const handleFormKeyDown = useCallback(
- event => {
- if (runScopeHandlers(view, event, 'search-panel')) {
+ (event: React.KeyboardEvent) => {
+ if (runScopeHandlers(view, event.nativeEvent, 'search-panel')) {
event.preventDefault()
}
},
@@ -129,7 +142,7 @@ const CodeMirrorSearchForm: FC = () => {
// Returns true if the event was handled, false otherwise
const handleEmacsNavigation = useCallback(
- event => {
+ (event: KeyboardEvent) => {
const emacsCtrlSeq =
emacsKeybindingsActive &&
event.ctrlKey &&
@@ -157,7 +170,14 @@ const CodeMirrorSearchForm: FC = () => {
event.stopPropagation()
event.preventDefault()
closeSearchPanel(view)
- document.dispatchEvent(new CustomEvent('cm:emacs-close-search-panel'))
+ // Wait for the search panel to close before moving the cursor
+ window.setTimeout(
+ () =>
+ document.dispatchEvent(
+ new CustomEvent('cm:emacs-close-search-panel')
+ ),
+ 0
+ )
return true
}
default: {
@@ -169,7 +189,7 @@ const CodeMirrorSearchForm: FC = () => {
)
const handleSearchKeyDown = useCallback(
- event => {
+ (event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
event.preventDefault()
@@ -185,13 +205,13 @@ const CodeMirrorSearchForm: FC = () => {
}
break
}
- handleEmacsNavigation(event)
+ handleEmacsNavigation(event.nativeEvent)
},
[view, handleEmacsNavigation, emacsKeybindingsActive]
)
const handleReplaceKeyDown = useCallback(
- event => {
+ (event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
event.preventDefault()
@@ -210,7 +230,7 @@ const CodeMirrorSearchForm: FC = () => {
}
}
}
- handleEmacsNavigation(event)
+ handleEmacsNavigation(event.nativeEvent)
},
[view, handleEmacsNavigation]
)
@@ -424,6 +444,10 @@ const CodeMirrorSearchForm: FC = () => {
+ {!newEditor && isSplitTestEnabled('full-project-search') && (
+
+ )}
+
{position !== null && (
{position.current === null ? '?' : position.current} {t('of')}{' '}
diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
index 4eb9ed2842..026c1be078 100644
--- a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
+++ b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
@@ -20,6 +20,11 @@ import { minimumListDepthForSelection } from '../utils/tree-operations/ancestors
import { debugConsole } from '@/utils/debugging'
import { useTranslation } from 'react-i18next'
import { ToggleSearchButton } from '@/features/source-editor/components/toolbar/toggle-search-button'
+import ReviewPanelHeader from '@/features/review-panel-new/components/review-panel-header'
+import useReviewPanelLayout from '@/features/review-panel-new/hooks/use-review-panel-layout'
+import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
+import Breadcrumbs from '@/features/ide-redesign/components/breadcrumbs'
+import classNames from 'classnames'
export const CodeMirrorToolbar = () => {
const view = useCodeMirrorViewContext()
@@ -46,6 +51,9 @@ const Toolbar = memo(function Toolbar() {
const listDepth = minimumListDepthForSelection(state)
+ const newEditor = useIsNewEditorEnabled()
+ const { showHeader: showReviewPanelHeader } = useReviewPanelLayout()
+
const {
open: overflowOpen,
onToggle: setOverflowOpen,
@@ -98,7 +106,7 @@ const Toolbar = memo(function Toolbar() {
// calculate overflow when buttons change
const observerRef = useRef
(null)
const handleButtons = useCallback(
- node => {
+ (node: HTMLDivElement) => {
if (!('MutationObserver' in window)) {
return
}
@@ -131,50 +139,61 @@ const Toolbar = memo(function Toolbar() {
const showActions = !state.readOnly && !insideTable
return (
-
-
- {showActions && (
-
- )}
-
-
- {showActions && (
-
+ <>
+ {newEditor && showReviewPanelHeader && }
+
-
+ >
)
})
diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-view.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-view.tsx
index c369aa63a1..06484db1b6 100644
--- a/services/web/frontend/js/features/source-editor/components/codemirror-view.tsx
+++ b/services/web/frontend/js/features/source-editor/components/codemirror-view.tsx
@@ -10,7 +10,7 @@ function CodeMirrorView() {
// append the editor view dom to the container node when mounted
const containerRef = useCallback(
- node => {
+ (node: HTMLDivElement) => {
if (node) {
node.appendChild(view.dom)
}
diff --git a/services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx
index 0e157c3a64..0a9cab34ea 100644
--- a/services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx
+++ b/services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx
@@ -1,4 +1,4 @@
-import { FC, useCallback, useEffect, useRef, useState } from 'react'
+import { FC, FormEvent, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
useCodeMirrorStateContext,
@@ -71,7 +71,7 @@ export const HrefTooltipContent: FC = () => {
}, [view])
const handleSubmit = useCallback(
- event => {
+ (event: FormEvent) => {
event.preventDefault()
view.dispatch(closeCommandTooltip())
view.focus()
diff --git a/services/web/frontend/js/features/source-editor/components/editor-switch-beginner-popover.tsx b/services/web/frontend/js/features/source-editor/components/editor-switch-beginner-popover.tsx
deleted file mode 100644
index 40fea785bf..0000000000
--- a/services/web/frontend/js/features/source-editor/components/editor-switch-beginner-popover.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { ReactElement, useCallback, useEffect, useState } from 'react'
-import Close from '@/shared/components/close'
-import { useEditorContext } from '@/shared/context/editor-context'
-import useTutorial from '@/shared/hooks/promotions/use-tutorial'
-import { useUserContext } from '@/shared/context/user-context'
-import useScopeValue from '@/shared/hooks/use-scope-value'
-import { useTranslation } from 'react-i18next'
-import getMeta from '@/utils/meta'
-import OLPopover from '@/features/ui/components/ol/ol-popover'
-import OLOverlay from '@/features/ui/components/ol/ol-overlay'
-
-const CODE_EDITOR_POPOVER_TIMEOUT = 1000
-export const codeEditorModePrompt = 'code-editor-mode-prompt'
-
-export const EditorSwitchBeginnerPopover = ({
- children,
- targetRef,
-}: {
- children: ReactElement
- targetRef: React.RefObject
-}) => {
- const user = useUserContext()
- const { inactiveTutorials } = useEditorContext()
- const { t } = useTranslation()
- const [codeEditorOpened] = useScopeValue('editor.codeEditorOpened')
- const { completeTutorial } = useTutorial(codeEditorModePrompt, {
- location: 'logs',
- name: codeEditorModePrompt,
- })
- const [popoverShown, setPopoverShown] = useState(false)
-
- const shouldShowCodeEditorPopover = useCallback(() => {
- if (inactiveTutorials.includes(codeEditorModePrompt)) {
- return false
- }
-
- if (getMeta('ol-usedLatex') !== 'never') {
- // only show popover to the users that never used LaTeX (submitted in onboarding data collection)
- return false
- }
-
- if (codeEditorOpened) {
- // dont show popover if code editor was opened at some point
- return false
- }
-
- const msSinceSignedUp =
- user.signUpDate && Date.now() - new Date(user.signUpDate).getTime()
-
- if (msSinceSignedUp && msSinceSignedUp < 24 * 60 * 60 * 1000) {
- // dont show popover if user has signed up is less than 24 hours
- return false
- }
-
- return true
- }, [codeEditorOpened, inactiveTutorials, user.signUpDate])
-
- useEffect(() => {
- if (popoverShown && codeEditorOpened) {
- setPopoverShown(false)
- }
- }, [codeEditorOpened, popoverShown])
-
- useEffect(() => {
- const timeout = setTimeout(() => {
- if (shouldShowCodeEditorPopover()) {
- setPopoverShown(true)
- }
- }, CODE_EDITOR_POPOVER_TIMEOUT)
-
- return () => clearTimeout(timeout)
- }, [shouldShowCodeEditorPopover])
-
- return (
- <>
- {children}
- setPopoverShown(false)}
- target={targetRef.current}
- >
-
-
-
{
- setPopoverShown(false)
- completeTutorial({ event: 'promo-click', action: 'complete' })
- }}
- />
-
- {t('code_editor_tooltip_title')}
-
- {t('code_editor_tooltip_message')}
-
-
-
- >
- )
-}
diff --git a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx
index 525f979e1c..c042c1bc94 100644
--- a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx
+++ b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx
@@ -1,40 +1,25 @@
-import { ChangeEvent, FC, memo, useCallback, useRef } from 'react'
+import { ChangeEvent, FC, memo, useCallback } from 'react'
import useScopeValue from '@/shared/hooks/use-scope-value'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
-import useTutorial from '@/shared/hooks/promotions/use-tutorial'
import { sendMB } from '../../../infrastructure/event-tracking'
import { isValidTeXFile } from '../../../main/is-valid-tex-file'
import { useTranslation } from 'react-i18next'
-import {
- EditorSwitchBeginnerPopover,
- codeEditorModePrompt,
-} from './editor-switch-beginner-popover'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
function EditorSwitch() {
const { t } = useTranslation()
const [visual, setVisual] = useScopeValue('editor.showVisual')
- const [codeEditorOpened] = useScopeValue('editor.codeEditorOpened')
const { openDocName } = useEditorManagerContext()
- const codeEditorRef = useRef(null)
-
const richTextAvailable = openDocName ? isValidTeXFile(openDocName) : false
- const { completeTutorial } = useTutorial(codeEditorModePrompt, {
- location: 'logs',
- name: codeEditorModePrompt,
- })
const handleChange = useCallback(
- event => {
+ (event: ChangeEvent) => {
const editorType = event.target.value
switch (editorType) {
case 'cm6':
setVisual(false)
- if (!codeEditorOpened) {
- completeTutorial({ event: 'promo-click', action: 'complete' })
- }
break
case 'rich-text':
@@ -44,7 +29,7 @@ function EditorSwitch() {
sendMB('editor-switch-change', { editorType })
},
- [codeEditorOpened, completeTutorial, setVisual]
+ [setVisual]
)
return (
@@ -64,15 +49,9 @@ function EditorSwitch() {
checked={!richTextAvailable || !visual}
onChange={handleChange}
/>
-
-
- {t('code_editor')}
-
-
+
+ {t('code_editor')}
+
(undefined)
-export const FigureModalProvider: FC = ({ children }) => {
+export const FigureModalProvider: FC = ({
+ children,
+}) => {
const [state, dispatch] = useReducer(reducer, {
source: FigureModalSource.NONE,
helpShown: false,
diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-help.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-help.tsx
index e65e21b5ea..d5f8f0f1fe 100644
--- a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-help.tsx
+++ b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-help.tsx
@@ -1,7 +1,10 @@
import { FC } from 'react'
import { Trans, useTranslation } from 'react-i18next'
-const LearnWikiLink: FC<{ article: string }> = ({ article, children }) => {
+const LearnWikiLink: FC> = ({
+ article,
+ children,
+}) => {
return {children}
}
diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx
index 08494d1832..b392b7e627 100644
--- a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx
+++ b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx
@@ -104,7 +104,8 @@ const FigureModalContent = () => {
const hide = useCallback(() => {
dispatch({ source: FigureModalSource.NONE })
view.requestMeasure()
- view.focus()
+ // Wait for the modal to close before focusing the editor
+ window.setTimeout(() => view.focus(), 0)
}, [dispatch, view])
useEventListener(
diff --git a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx
new file mode 100644
index 0000000000..e4e77c5859
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx
@@ -0,0 +1,165 @@
+import { sendSearchEvent } from '@/features/event-tracking/search-events'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import { closeSearchPanel, SearchQuery } from '@codemirror/search'
+import { forwardRef, memo, Ref, useCallback, useEffect, useRef } from 'react'
+import { useCodeMirrorViewContext } from './codemirror-context'
+import MaterialIcon from '@/shared/components/material-icon'
+import { useTranslation } from 'react-i18next'
+import { Overlay, Popover } from 'react-bootstrap-5'
+import Close from '@/shared/components/close'
+import useTutorial from '@/shared/hooks/promotions/use-tutorial'
+import { useEditorContext } from '@/shared/context/editor-context'
+import getMeta from '@/utils/meta'
+
+const PROMOTION_SIGNUP_CUT_OFF_DATE = new Date('2025-04-22T00:00:00Z')
+
+export const FullProjectSearchButton = ({ query }: { query: SearchQuery }) => {
+ const view = useCodeMirrorViewContext()
+ const { t } = useTranslation()
+ const { setProjectSearchIsOpen } = useLayoutContext()
+ const ref = useRef(null)
+
+ const { inactiveTutorials } = useEditorContext()
+
+ const hasCompletedTutorial = inactiveTutorials.includes(
+ 'full-project-search-promo'
+ )
+
+ const { showPopup, tryShowingPopup, hideUntilReload, completeTutorial } =
+ useTutorial('full-project-search-promo', {
+ name: 'full-project-search-promotion',
+ })
+
+ let isEligibleForPromotion = true
+ const signUpDateString = getMeta('ol-user')?.signUpDate
+ if (!signUpDateString) {
+ isEligibleForPromotion = false
+ } else {
+ const signupDate = new Date(signUpDateString)
+ if (signupDate > PROMOTION_SIGNUP_CUT_OFF_DATE) {
+ isEligibleForPromotion = false
+ }
+ }
+
+ const openFullProjectSearch = useCallback(() => {
+ setProjectSearchIsOpen(true)
+ closeSearchPanel(view)
+ window.setTimeout(() => {
+ window.dispatchEvent(
+ new CustomEvent('editor:full-project-search', { detail: query })
+ )
+ }, 200)
+ }, [setProjectSearchIsOpen, query, view])
+
+ const onClick = useCallback(() => {
+ sendSearchEvent('search-open', {
+ searchType: 'full-project',
+ method: 'button',
+ location: 'search-form',
+ })
+ openFullProjectSearch()
+ if (!hasCompletedTutorial && isEligibleForPromotion) {
+ completeTutorial({ action: 'complete', event: 'promo-click' })
+ }
+ }, [
+ completeTutorial,
+ openFullProjectSearch,
+ hasCompletedTutorial,
+ isEligibleForPromotion,
+ ])
+
+ return (
+ <>
+
+
+
+
+
+ {!hasCompletedTutorial && isEligibleForPromotion && (
+
+ )}
+ >
+ )
+}
+
+type PromotionOverlayProps = {
+ showPopup: boolean
+ tryShowingPopup: () => void
+ completeTutorial: (event: {
+ action: 'complete'
+ event: 'promo-dismiss'
+ }) => void
+ hideUntilReload: () => void
+}
+
+const PromotionOverlay = forwardRef(
+ function PromotionOverlay(
+ props: PromotionOverlayProps,
+ ref: Ref
+ ) {
+ if (typeof ref === 'function' || !ref?.current) {
+ return null
+ }
+
+ return
+ }
+)
+
+const PromotionContent = memo(function PromotionContent({
+ showPopup,
+ tryShowingPopup,
+ completeTutorial,
+ hideUntilReload,
+ target,
+}: PromotionOverlayProps & {
+ target: HTMLButtonElement
+}) {
+ const { t } = useTranslation()
+
+ useEffect(() => {
+ tryShowingPopup()
+ }, [tryShowingPopup])
+
+ const onHide = useCallback(() => {
+ hideUntilReload()
+ }, [hideUntilReload])
+
+ const onClose = useCallback(() => {
+ completeTutorial({
+ action: 'complete',
+ event: 'promo-dismiss',
+ })
+ }, [completeTutorial])
+
+ return (
+
+
+
+
+ {t('now_you_can_search_your_whole_project_not_just_this_file')}
+
+
+
+ )
+})
diff --git a/services/web/frontend/js/features/source-editor/components/source-editor.tsx b/services/web/frontend/js/features/source-editor/components/source-editor.tsx
index d71f896687..ca3e8123a8 100644
--- a/services/web/frontend/js/features/source-editor/components/source-editor.tsx
+++ b/services/web/frontend/js/features/source-editor/components/source-editor.tsx
@@ -16,4 +16,6 @@ function SourceEditor() {
)
}
-export default withErrorBoundary(memo(SourceEditor), ErrorBoundaryFallback)
+export default withErrorBoundary(memo(SourceEditor), () => (
+
+))
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx
index cf705240bd..3e4484c6ae 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx
@@ -176,8 +176,8 @@ export const Cell: FC<{
}, [cellData.content, editing, view])
const onInput = useCallback(
- e => {
- update(filterInput(e.target.value))
+ (e: React.FormEvent) => {
+ update(filterInput((e.target as HTMLTextAreaElement).value))
},
[update, filterInput]
)
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx
index 24b8688f20..01696a9f3c 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx
@@ -38,7 +38,9 @@ export const useEditingContext = () => {
return context
}
-export const EditingContextProvider: FC = ({ children }) => {
+export const EditingContextProvider: FC = ({
+ children,
+}) => {
const { table } = useTableContext()
const [cellData, setCellData] = useState(null)
const [initialContent, setInitialContent] = useState(
@@ -85,7 +87,7 @@ export const EditingContextProvider: FC = ({ children }) => {
}, [setCellData])
const startEditing = useCallback(
- (rowIndex: number, cellIndex: number, initialContent = undefined) => {
+ (rowIndex: number, cellIndex: number, initialContent?: string) => {
if (cellData?.dirty) {
// We're already editing something else
commitCellData()
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx
index b7854dc2de..7010eef144 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx
@@ -411,7 +411,9 @@ export const useSelectionContext = () => {
return context
}
-export const SelectionContextProvider: FC = ({ children }) => {
+export const SelectionContextProvider: FC = ({
+ children,
+}) => {
const [selection, setSelection] = useState(null)
const [dragging, setDragging] = useState(false)
return (
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx
index 6b0557afb7..43fe869a2f 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx
@@ -34,13 +34,15 @@ const TableContext = createContext<
| undefined
>(undefined)
-export const TableProvider: FC<{
- tableData: ParsedTableData
- tableNode: SyntaxNode | null
- tabularNode: SyntaxNode
- view: EditorView
- directTableChild?: boolean
-}> = ({
+export const TableProvider: FC<
+ React.PropsWithChildren<{
+ tableData: ParsedTableData
+ tableNode: SyntaxNode | null
+ tabularNode: SyntaxNode
+ view: EditorView
+ directTableChild?: boolean
+ }>
+> = ({
tableData,
children,
tableNode,
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx
index be098d0a68..e9328f0504 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx
@@ -21,7 +21,7 @@ const TabularContext = createContext<
| undefined
>(undefined)
-export const TabularProvider: FC = ({ children }) => {
+export const TabularProvider: FC = ({ children }) => {
const ref = useRef(null)
const [helpShown, setHelpShown] = useState(false)
const [columnWidthModalShown, setColumnWidthModalShown] = useState(false)
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx
index c012cda12e..0de999b214 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx
@@ -273,7 +273,7 @@ export const Tabular: FC<{
)
}
-const TabularWrapper: FC = () => {
+const TabularWrapper: FC = () => {
const { setSelection, selection } = useSelectionContext()
const { commitCellData, cellData } = useEditingContext()
const { ref } = useTabularContext()
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx
index f8bd056f58..51c68872f6 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx
@@ -7,13 +7,15 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useTabularContext } from '../contexts/tabular-context'
-export const ToolbarButtonMenu: FC<{
- id: string
- label: string
- icon: string
- disabled?: boolean
- disabledLabel?: string
-}> = memo(function ButtonMenu({
+export const ToolbarButtonMenu: FC<
+ React.PropsWithChildren<{
+ id: string
+ label: string
+ icon: string
+ disabled?: boolean
+ disabledLabel?: string
+ }>
+> = memo(function ButtonMenu({
icon,
id,
label,
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx
index 6368da025b..e6d39f6054 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx
@@ -28,12 +28,12 @@ export const ToolbarButton = memo<{
disabledLabel,
}) {
const view = useCodeMirrorViewContext()
- const handleMouseDown = useCallback(event => {
+ const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault()
}, [])
const handleClick = useCallback(
- event => {
+ (event: React.MouseEvent) => {
if (command) {
emitTableGeneratorEvent(view, id)
event.preventDefault()
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx
index 6fab575472..bbb046a4f8 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx
@@ -9,16 +9,18 @@ import { emitTableGeneratorEvent } from '../analytics'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import classNames from 'classnames'
-export const ToolbarDropdown: FC<{
- id: string
- label?: string
- btnClassName?: string
- icon?: string
- tooltip?: string
- disabled?: boolean
- disabledTooltip?: string
- showCaret?: boolean
-}> = ({
+export const ToolbarDropdown: FC<
+ React.PropsWithChildren<{
+ id: string
+ label?: string
+ btnClassName?: string
+ icon?: string
+ tooltip?: string
+ disabled?: boolean
+ disabledTooltip?: string
+ showCaret?: boolean
+ }>
+> = ({
id,
label,
children,
@@ -112,12 +114,14 @@ export const ToolbarDropdown: FC<{
}
export const ToolbarDropdownItem: FC<
- Omit, 'onClick'> & {
- command: () => void
- id: string
- icon?: string
- active?: boolean
- }
+ React.PropsWithChildren<
+ Omit, 'onClick'> & {
+ command: () => void
+ id: string
+ icon?: string
+ active?: boolean
+ }
+ >
> = ({ children, command, id, icon, active, ...props }) => {
const view = useCodeMirrorViewContext()
const onClick = useCallback(() => {
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx
index 6f5bd52dbc..12fa52032e 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx
@@ -8,12 +8,14 @@ import { EditorView } from '@codemirror/view'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
import { useCodeMirrorViewContext } from '../codemirror-context'
-export const ToolbarButtonMenu: FC<{
- id: string
- label: string
- icon: React.ReactNode
- altCommand?: (view: EditorView) => void
-}> = memo(function ButtonMenu({ icon, id, label, altCommand, children }) {
+export const ToolbarButtonMenu: FC<
+ React.PropsWithChildren<{
+ id: string
+ label: string
+ icon: React.ReactNode
+ altCommand?: (view: EditorView) => void
+ }>
+> = memo(function ButtonMenu({ icon, id, label, altCommand, children }) {
const target = useRef(null)
const { open, onToggle, ref } = useDropdown()
const view = useCodeMirrorViewContext()
@@ -21,7 +23,7 @@ export const ToolbarButtonMenu: FC<{
const button = (
{
event.preventDefault()
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx
index 0cead93358..710f42c07e 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx
@@ -1,25 +1,59 @@
+import { DropdownHeader } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { ToolbarButtonMenu } from './button-menu'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
import MaterialIcon from '../../../../shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import { useCodeMirrorViewContext } from '../codemirror-context'
+import { useEditorContext } from '@/shared/context/editor-context'
import {
wrapInDisplayMath,
wrapInInlineMath,
} from '../../extensions/toolbar/commands'
import { memo } from 'react'
import OLListGroupItem from '@/features/ui/components/ol/ol-list-group-item'
+import sparkleWhite from '@/shared/svgs/sparkle-small-white.svg'
+import sparkle from '@/shared/svgs/ai-sparkle-text.svg'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export const MathDropdown = memo(function MathDropdown() {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
+ const { writefullInstance } = useEditorContext()
+ const wfRebrandEnabled = isSplitTestEnabled('wf-feature-rebrand')
return (
}
>
+ {wfRebrandEnabled && writefullInstance && (
+ <>
+
+ {t('toolbar_insert_math_lowercase')}
+
+ {
+ writefullInstance?.openEquationGenerator()
+ }}
+ >
+
+
+ {t('generate_from_text_or_image')}
+
+ >
+ )}
{
@@ -30,7 +64,7 @@ export const MathDropdown = memo(function MathDropdown() {
}}
>
- {t('toolbar_insert_inline_math')}
+ {t('inline')}
- {t('toolbar_insert_display_math')}
+ {t('display')}
)
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx
index 574780f761..b564505387 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx
@@ -1,17 +1,22 @@
-import { FC, useRef } from 'react'
+import { FC, useCallback, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import { useCodeMirrorViewContext } from '../codemirror-context'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
-export const ToolbarOverflow: FC<{
- overflowed: boolean
- overflowOpen: boolean
- setOverflowOpen: (open: boolean) => void
- overflowRef?: React.Ref
-}> = ({ overflowed, overflowOpen, setOverflowOpen, overflowRef, children }) => {
+export const ToolbarOverflow: FC<
+ React.PropsWithChildren<{
+ overflowed: boolean
+ overflowOpen: boolean
+ setOverflowOpen: (open: boolean) => void
+ overflowRef?: React.Ref
+ }>
+> = ({ overflowed, overflowOpen, setOverflowOpen, overflowRef, children }) => {
+ const { t } = useTranslation()
const buttonRef = useRef(null)
+ const keyboardInputRef = useRef(false)
const view = useCodeMirrorViewContext()
const className = classnames(
@@ -22,6 +27,46 @@ export const ToolbarOverflow: FC<{
}
)
+ // A11y - Move the focus inside the popover to the first toolbar button when it opens
+ const handlePopoverFocus = useCallback(() => {
+ if (keyboardInputRef.current) {
+ const firstToolbarItem = document.querySelector(
+ '#popover-toolbar-overflow .ol-cm-toolbar-overflow button:not([disabled])'
+ ) as HTMLButtonElement | null
+
+ if (firstToolbarItem) {
+ firstToolbarItem.focus()
+ }
+ }
+ }, [])
+
+ const handleKeyDown = useCallback(() => {
+ keyboardInputRef.current = true
+ }, [])
+
+ const handleMouseDown = useCallback(() => {
+ keyboardInputRef.current = false
+ }, [])
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleKeyDown)
+ document.addEventListener('mousedown', handleMouseDown)
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ document.removeEventListener('mousedown', handleMouseDown)
+ }
+ }, [handleKeyDown, handleMouseDown])
+
+ // A11y - Move the focus back to the trigger when the popover is dismissed
+ const handleCloseAndReturnFocus = useCallback(() => {
+ setOverflowOpen(false)
+
+ if (keyboardInputRef.current && buttonRef.current) {
+ buttonRef.current.focus()
+ }
+ }, [setOverflowOpen])
+
return (
<>
{
event.preventDefault()
event.stopPropagation()
@@ -49,9 +96,14 @@ export const ToolbarOverflow: FC<{
// containerPadding={0}
transition
rootClose
- onHide={() => setOverflowOpen(false)}
+ onHide={handleCloseAndReturnFocus}
+ onEntered={handlePopoverFocus}
>
-
+
{children}
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx
new file mode 100644
index 0000000000..a98c78584a
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx
@@ -0,0 +1,101 @@
+import { DropdownHeader } from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import { ToolbarButtonMenu } from './button-menu'
+import MaterialIcon from '../../../../shared/components/material-icon'
+import { useTranslation } from 'react-i18next'
+import { useEditorContext } from '@/shared/context/editor-context'
+import { memo, useRef, useCallback } from 'react'
+import OLListGroupItem from '@/features/ui/components/ol/ol-list-group-item'
+import sparkleWhite from '@/shared/svgs/sparkle-small-white.svg'
+import sparkle from '@/shared/svgs/ai-sparkle-text.svg'
+import { TableInserterDropdown } from './table-inserter-dropdown'
+import OLOverlay from '@/features/ui/components/ol/ol-overlay'
+import OLPopover from '@/features/ui/components/ol/ol-popover'
+import useDropdown from '../../../../shared/hooks/use-dropdown'
+import * as commands from '../../extensions/toolbar/commands'
+import { useCodeMirrorViewContext } from '../codemirror-context'
+import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
+
+export const TableDropdown = memo(function TableDropdown() {
+ const { t } = useTranslation()
+ const { writefullInstance } = useEditorContext()
+ const { open, onToggle, ref } = useDropdown()
+ const target = useRef(null)
+ const view = useCodeMirrorViewContext()
+
+ const onSizeSelected = useCallback(
+ (sizeX: number, sizeY: number) => {
+ onToggle(false)
+ commands.insertTable(view, sizeX, sizeY)
+ emitToolbarEvent(view, 'table-generator-insert-table')
+ view.focus()
+ },
+ [view, onToggle]
+ )
+
+ return (
+ <>
+
+
}
+ >
+
+ {t('toolbar_table_insert_table_lowercase')}
+
+
{
+ writefullInstance?.openTableGenerator()
+ }}
+ >
+
+
+ {t('generate_from_text_or_image')}
+
+
+
{
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onClick={() => {
+ onToggle(!open)
+ }}
+ >
+ {t('select_size')}
+
+
+
onToggle(false)}
+ >
+
+
+
+ >
+ )
+})
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown-legacy.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown-legacy.tsx
new file mode 100644
index 0000000000..0756c2364d
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown-legacy.tsx
@@ -0,0 +1,128 @@
+import { FC, memo, useCallback, useRef, useState } from 'react'
+import * as commands from '../../extensions/toolbar/commands'
+import { useTranslation } from 'react-i18next'
+import useDropdown from '../../../../shared/hooks/use-dropdown'
+import { useCodeMirrorViewContext } from '../codemirror-context'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLOverlay from '@/features/ui/components/ol/ol-overlay'
+import OLPopover from '@/features/ui/components/ol/ol-popover'
+import MaterialIcon from '../../../../shared/components/material-icon'
+import classNames from 'classnames'
+import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
+
+export const LegacyTableDropdown = memo(() => {
+ const { t } = useTranslation()
+ const { open, onToggle, ref } = useDropdown()
+ const view = useCodeMirrorViewContext()
+ const target = useRef(null)
+
+ const onSizeSelected = useCallback(
+ (sizeX: number, sizeY: number) => {
+ onToggle(false)
+ commands.insertTable(view, sizeX, sizeY)
+ emitToolbarEvent(view, 'table-generator-insert-table')
+ view.focus()
+ },
+ [view, onToggle]
+ )
+
+ return (
+ <>
+ {t('toolbar_insert_table')} }
+ overlayProps={{ placement: 'bottom' }}
+ >
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onClick={() => {
+ onToggle(!open)
+ }}
+ ref={target}
+ >
+
+
+
+ onToggle(false)}
+ >
+
+
+ >
+ )
+})
+LegacyTableDropdown.displayName = 'TableInserterDropdown'
+
+const range = (start: number, end: number) =>
+ Array.from({ length: end - start + 1 }, (v, k) => k + start)
+
+const SizeGrid: FC<{
+ sizeX: number
+ sizeY: number
+ onSizeSelected: (sizeX: number, sizeY: number) => void
+}> = ({ sizeX, sizeY, onSizeSelected }) => {
+ const [currentSize, setCurrentSize] = useState<{
+ sizeX: number
+ sizeY: number
+ }>({ sizeX: 0, sizeY: 0 })
+ const { t } = useTranslation()
+ let label = t('toolbar_table_insert_table_lowercase')
+ if (currentSize.sizeX > 0 && currentSize.sizeY > 0) {
+ label = t('toolbar_table_insert_size_table', {
+ size: `${currentSize.sizeY}×${currentSize.sizeX}`,
+ })
+ }
+ return (
+ <>
+ {label}
+ {
+ setCurrentSize({ sizeX: 0, sizeY: 0 })
+ }}
+ >
+
+ {range(1, sizeY).map(y => (
+
+ {range(1, sizeX).map(x => (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
+ = x && currentSize.sizeY >= y,
+ })}
+ key={x}
+ onMouseEnter={() => {
+ setCurrentSize({ sizeX: x, sizeY: y })
+ }}
+ onMouseUp={() => onSizeSelected(x, y)}
+ />
+ ))}
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx
index 1385f9ba98..f9f81d518a 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx
@@ -1,78 +1,18 @@
-import { FC, memo, useCallback, useRef, useState } from 'react'
-import * as commands from '../../extensions/toolbar/commands'
-import { useTranslation } from 'react-i18next'
-import useDropdown from '../../../../shared/hooks/use-dropdown'
-import { useCodeMirrorViewContext } from '../codemirror-context'
-import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
-import OLOverlay from '@/features/ui/components/ol/ol-overlay'
-import OLPopover from '@/features/ui/components/ol/ol-popover'
-import MaterialIcon from '../../../../shared/components/material-icon'
+import { FC, useState } from 'react'
import classNames from 'classnames'
-import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
-
-export const TableInserterDropdown = memo(() => {
- const { t } = useTranslation()
- const { open, onToggle, ref } = useDropdown()
- const view = useCodeMirrorViewContext()
- const target = useRef(null)
-
- const onSizeSelected = useCallback(
- (sizeX: number, sizeY: number) => {
- onToggle(false)
- commands.insertTable(view, sizeX, sizeY)
- emitToolbarEvent(view, 'table-generator-insert-table')
- view.focus()
- },
- [view, onToggle]
- )
+import { useTranslation } from 'react-i18next'
+export const TableInserterDropdown = ({
+ onSizeSelected,
+}: {
+ onSizeSelected: (sizeX: number, sizeY: number) => void
+}) => {
return (
- <>
- {t('toolbar_insert_table')} }
- overlayProps={{ placement: 'bottom' }}
- >
- {
- event.preventDefault()
- event.stopPropagation()
- }}
- onClick={() => {
- onToggle(!open)
- }}
- ref={target}
- >
-
-
-
- onToggle(false)}
- >
-
-
- >
+
+
+
)
-})
+}
TableInserterDropdown.displayName = 'TableInserterDropdown'
const range = (start: number, end: number) =>
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx
index cca40f1c7f..af939a7b8c 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx
@@ -31,12 +31,12 @@ export const ToolbarButton = memo<{
}) {
const view = useCodeMirrorViewContext()
- const handleMouseDown = useCallback(event => {
+ const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault()
}, [])
const handleClick = useCallback(
- event => {
+ (event: React.MouseEvent) => {
emitToolbarEvent(view, id)
if (command) {
event.preventDefault()
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 b313bf6534..6a9509b642 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
@@ -9,8 +9,10 @@ import getMeta from '../../../../utils/meta'
import { InsertFigureDropdown } from './insert-figure-dropdown'
import { useTranslation } from 'react-i18next'
import { MathDropdown } from './math-dropdown'
-import { TableInserterDropdown } from './table-inserter-dropdown'
+import { TableDropdown } from './table-dropdown'
+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'
export const ToolbarItems: FC<{
@@ -27,12 +29,15 @@ export const ToolbarItems: FC<{
listDepth,
}) {
const { t } = useTranslation()
- const { toggleSymbolPalette, showSymbolPalette } = useEditorContext()
+ const { toggleSymbolPalette, showSymbolPalette, writefullInstance } =
+ useEditorContext()
const isActive = withinFormattingCommand(state)
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
const showGroup = (group: string) => !overflowed || overflowed.has(group)
+ const wfRebrandEnabled = isSplitTestEnabled('wf-feature-rebrand')
+
return (
<>
{showGroup('group-history') && (
@@ -142,7 +147,11 @@ export const ToolbarItems: FC<{
icon="book_5"
/>
-
+ {wfRebrandEnabled && writefullInstance ? (
+
+ ) : (
+
+ )}
)}
{showGroup('group-list') && (
diff --git a/services/web/frontend/js/features/source-editor/extensions/breadcrumbs-panel.ts b/services/web/frontend/js/features/source-editor/extensions/breadcrumbs-panel.ts
index 1207f1a54d..a3768a9d5a 100644
--- a/services/web/frontend/js/features/source-editor/extensions/breadcrumbs-panel.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/breadcrumbs-panel.ts
@@ -1,61 +1,41 @@
-import { canUseNewEditor } from '@/features/ide-redesign/utils/new-editor-utils'
-import { Compartment, Extension, TransactionSpec } from '@codemirror/state'
-import { EditorView, showPanel } from '@codemirror/view'
-
-export function createBreadcrumbsPanel() {
- const dom = document.createElement('div')
- dom.classList.add('ol-cm-breadcrumbs-portal')
- return { dom, top: true }
-}
-
-const breadcrumbsTheme = EditorView.baseTheme({
- '.ol-cm-breadcrumbs': {
- display: 'flex',
- alignItems: 'center',
- gap: 'var(--spacing-01)',
- fontSize: 'var(--font-size-01)',
- padding: 'var(--spacing-02)',
- overflow: 'auto',
- scrollbarWidth: 'thin',
- '& > *': {
- flexShrink: '0',
- },
- },
- '&light .ol-cm-breadcrumbs': {
- color: 'var(--content-secondary)',
- backgroundColor: 'var(--bg-light-primary)',
- borderBottom: '1px solid #ddd',
- },
- '&light .ol-cm-breadcrumb-chevron': {
- color: 'var(--neutral-30)',
- },
- '&dark .ol-cm-breadcrumbs': {
- color: 'var(--content-secondary-dark)',
- backgroundColor: 'var(--bg-dark-primary)',
- },
- '&dark .ol-cm-breadcrumb-chevron': {
- color: 'var(--neutral-50)',
- },
-})
-
-const breadcrumbsConf = new Compartment()
-
-const breadcrumbsEnabled: Extension = [
- showPanel.of(createBreadcrumbsPanel),
- breadcrumbsTheme,
-]
-const breadcrumbsDisabled: Extension = []
-
-export const setBreadcrumbsEnabled = (enabled: boolean): TransactionSpec => ({
- effects: breadcrumbsConf.reconfigure(
- enabled ? breadcrumbsEnabled : breadcrumbsDisabled
- ),
-})
+import { EditorView } from '@codemirror/view'
/**
* A panel which contains the editor breadcrumbs
*/
-export const breadcrumbPanel = (enableNewEditor: boolean) => {
- const enabled = canUseNewEditor() && enableNewEditor
- return breadcrumbsConf.of(enabled ? breadcrumbsEnabled : breadcrumbsDisabled)
+export function breadcrumbPanel() {
+ return [
+ EditorView.editorAttributes.of({
+ style: '--breadcrumbs-height: 28px;',
+ }),
+ EditorView.baseTheme({
+ '.ol-cm-breadcrumbs-portal': {
+ display: 'flex',
+ pointerEvents: 'none !important',
+ '& > *': {
+ pointerEvents: 'all',
+ },
+ },
+ '.ol-cm-breadcrumbs': {
+ height: 'var(--breadcrumbs-height)',
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ gap: 'var(--spacing-01)',
+ fontSize: 'var(--font-size-01)',
+ padding: 'var(--spacing-02)',
+ overflow: 'auto',
+ scrollbarWidth: 'thin',
+ '& > *': {
+ flexShrink: '0',
+ },
+ },
+ '&light .ol-cm-breadcrumb-chevron': {
+ color: 'var(--neutral-30)',
+ },
+ '&dark .ol-cm-breadcrumb-chevron': {
+ color: 'var(--neutral-50)',
+ },
+ }),
+ ]
}
diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts
index 5daa597df3..0a65739c55 100644
--- a/services/web/frontend/js/features/source-editor/extensions/index.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/index.ts
@@ -148,7 +148,7 @@ export const createExtensions = (options: Record
): Extension[] => [
mathPreview(options.settings.mathPreview),
reviewTooltip(),
toolbarPanel(),
- breadcrumbPanel(options.settings.enableNewEditor),
+ breadcrumbPanel(),
verticalOverflow(),
highlightActiveLine(options.visual.visual),
// The built-in extension that highlights the active line in the gutter.
diff --git a/services/web/frontend/js/features/source-editor/extensions/keymaps.ts b/services/web/frontend/js/features/source-editor/extensions/keymaps.ts
index 7dfaee8d02..e90cf04fd7 100644
--- a/services/web/frontend/js/features/source-editor/extensions/keymaps.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/keymaps.ts
@@ -20,6 +20,8 @@ const ignoredDefaultMacKeybindings = new Set([
// We replace these with our custom visual-line versions
'Mod-Backspace',
'Mod-Delete',
+ // Disable toggleTabFocusMode as it conflicts with ” on a Swedish keyboard layout
+ 'Shift-Alt-m',
])
const filteredDefaultKeymap = defaultKeymap.filter(
diff --git a/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts b/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts
index e293f4bd6c..0430b39eea 100644
--- a/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts
@@ -46,11 +46,11 @@ export const shortcuts = Prec.high(
keymap.of([
{
key: 'Tab',
- run: indentMore,
+ run: indentMore, // note: not using indentWithTab as the user may want to insert tab spaces within a line
},
{
key: 'Shift-Tab',
- run: indentLess,
+ run: indentLess, // note: not using indentWithTab as the user may want to insert tab spaces within a line
},
{
key: 'Mod-y',
diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx
index a38c9c9065..deac7d2956 100644
--- a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx
+++ b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx
@@ -1,3 +1,4 @@
+import { createRoot } from 'react-dom/client'
import {
StateField,
StateEffect,
@@ -12,7 +13,6 @@ import {
getSpellCheckLanguage,
} from '@/features/source-editor/extensions/spelling/index'
import { sendMB } from '@/infrastructure/event-tracking'
-import ReactDOM from 'react-dom'
import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { addLearnedWord } from '@/features/source-editor/extensions/spelling/learned-words'
@@ -159,7 +159,8 @@ const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
const dom = document.createElement('div')
dom.classList.add('ol-cm-spelling-context-menu-tooltip')
- ReactDOM.render(
+ const root = createRoot(dom)
+ root.render(
(view: EditorView) => {
})
}}
/>
- ,
- dom
+
)
const destroy = () => {
- ReactDOM.unmountComponentAtNode(dom)
+ root.unmount()
}
return { dom, destroy }
diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
index ae8dfc8578..a2fd37473f 100644
--- a/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
@@ -23,9 +23,11 @@ export function createToolbarPanel() {
}
const toolbarTheme = EditorView.theme({
- '.ol-cm-toolbar': {
+ '.ol-cm-toolbar-wrapper': {
backgroundColor: 'var(--editor-toolbar-bg)',
color: 'var(--toolbar-btn-color)',
+ },
+ '.ol-cm-toolbar': {
flex: 1,
display: 'flex',
overflowX: 'hidden',
@@ -57,6 +59,26 @@ const toolbarTheme = EditorView.theme({
},
},
},
+ '.ol-cm-toolbar-header': {
+ color: 'var(--toolbar-btn-color)',
+ },
+ '.ol-cm-toolbar-dropdown-divider': {
+ borderBottom: '1px solid',
+ borderColor: 'var(--toolbar-dropdown-divider-color)',
+ },
+ // here render both the icons, and hide one depending on if its dark or light mode with &.overall-theme-dark
+ '.ol-cm-toolbar-ai-sparkle-gradient': {
+ display: 'block',
+ },
+ '.ol-cm-toolbar-ai-sparkle-white': {
+ display: 'none',
+ },
+ '&.overall-theme-dark .ol-cm-toolbar-ai-sparkle-gradient': {
+ display: 'none',
+ },
+ '&.overall-theme-dark .ol-cm-toolbar-ai-sparkle-white': {
+ display: 'block',
+ },
'.ol-cm-toolbar-button-menu-popover': {
backgroundColor: 'initial',
'& > .popover-content, & > .popover-body': {
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
index d852c9d77c..03b9aea758 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
@@ -1066,6 +1066,44 @@ export const atomicDecorations = (options: Options) => {
)
)
}
+ } else if (
+ nodeRef.type.is('FootnoteCommand') ||
+ nodeRef.type.is('EndnoteCommand')
+ ) {
+ const textArgumentNode = nodeRef.node.getChild('TextArgument')
+ if (textArgumentNode) {
+ if (
+ state.readOnly &&
+ selectionIntersects(state.selection, nodeRef)
+ ) {
+ // a special case for a read-only document:
+ // always display the content, styled differently from the main content.
+ decorations.push(
+ ...decorateArgumentBraces(
+ new BraceWidget(),
+ textArgumentNode,
+ nodeRef.from
+ ),
+ Decoration.mark({
+ class: 'ol-cm-footnote ol-cm-footnote-view',
+ }).range(textArgumentNode.from, textArgumentNode.to)
+ )
+ } else {
+ if (shouldDecorate(state, nodeRef)) {
+ // collapse the footnote when the selection is outside it
+ decorations.push(
+ Decoration.replace({
+ widget: new FootnoteWidget(
+ nodeRef.type.is('FootnoteCommand')
+ ? 'footnote'
+ : 'endnote'
+ ),
+ }).range(nodeRef.from, nodeRef.to)
+ )
+ return false
+ }
+ }
+ }
} else if (nodeRef.type.is('UnknownCommand')) {
// a command that's not defined separately by the grammar
const commandNode = nodeRef.node
@@ -1091,43 +1129,6 @@ export const atomicDecorations = (options: Options) => {
)
return false
}
- } else if (
- commandName === '\\footnote' ||
- commandName === '\\endnote'
- ) {
- if (textArgumentNode) {
- if (
- state.readOnly &&
- selectionIntersects(state.selection, nodeRef)
- ) {
- // a special case for a read-only document:
- // always display the content, styled differently from the main content.
- decorations.push(
- ...decorateArgumentBraces(
- new BraceWidget(),
- textArgumentNode,
- nodeRef.from
- ),
- Decoration.mark({
- class: 'ol-cm-footnote ol-cm-footnote-view',
- }).range(textArgumentNode.from, textArgumentNode.to)
- )
- } else {
- if (shouldDecorate(state, nodeRef)) {
- // collapse the footnote when the selection is outside it
- decorations.push(
- Decoration.replace({
- widget: new FootnoteWidget(
- commandName === '\\footnote'
- ? 'footnote'
- : 'endnote'
- ),
- }).range(nodeRef.from, nodeRef.to)
- )
- return false
- }
- }
- }
} else if (commandName === '\\LaTeX') {
if (shouldDecorate(state, nodeRef)) {
decorations.push(
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/pasted-content.tsx b/services/web/frontend/js/features/source-editor/extensions/visual/pasted-content.tsx
index 3cb669d927..7321e74a89 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/pasted-content.tsx
+++ b/services/web/frontend/js/features/source-editor/extensions/visual/pasted-content.tsx
@@ -7,7 +7,7 @@ import {
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
import { undo } from '@codemirror/commands'
import { ancestorNodeOfType } from '../../utils/tree-operations/ancestors'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import { PastedContentMenu } from '../../components/paste-html/pasted-content-menu'
import { SplitTestProvider } from '../../../../shared/context/split-test-context'
@@ -171,7 +171,8 @@ class PastedContentMenuWidget extends WidgetType {
toDOM(view: EditorView) {
const element = document.createElement('span')
- ReactDOM.render(
+ const root = createRoot(element)
+ root.render(
- ,
- element
+
)
return element
}
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx
index 70fd240922..ce708b75c3 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx
+++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx
@@ -1,10 +1,12 @@
+import { createRoot, Root } from 'react-dom/client'
import { EditorView, WidgetType } from '@codemirror/view'
import { SyntaxNode } from '@lezer/common'
-import * as ReactDOM from 'react-dom'
import { Tabular } from '../../../components/table-generator/tabular'
import { ParsedTableData } from '../../../components/table-generator/utils'
export class TabularWidget extends WidgetType {
+ static roots: WeakMap = new WeakMap()
+
constructor(
private parsedTableData: ParsedTableData,
private tabularNode: SyntaxNode,
@@ -15,13 +17,21 @@ export class TabularWidget extends WidgetType {
super()
}
+ renderInDOMContainer(children: React.ReactNode, element: HTMLElement) {
+ const root = TabularWidget.roots.get(element) || createRoot(element)
+ if (!TabularWidget.roots.has(element)) {
+ TabularWidget.roots.set(element, root)
+ }
+ root.render(children)
+ }
+
toDOM(view: EditorView) {
const element = document.createElement('div')
element.classList.add('ol-cm-tabular')
if (this.tableNode) {
element.classList.add('ol-cm-environment-table')
}
- ReactDOM.render(
+ this.renderInDOMContainer(
{
captureException(exception, {
@@ -305,7 +308,7 @@ function useCodeMirrorScope(view: EditorView) {
spelling: spellingRef.current,
visual: visualRef.current,
projectFeatures: projectFeaturesRef.current,
- handleError,
+ showBoundary,
handleException,
}),
})
@@ -336,7 +339,7 @@ function useCodeMirrorScope(view: EditorView) {
}
// IMPORTANT: This effect must not depend on anything variable apart from currentDocument,
// as the editor state is recreated when the effect runs.
- }, [view, currentDocument, handleError, handleException])
+ }, [view, currentDocument, showBoundary, handleException])
useEffect(() => {
if (openDocName) {
@@ -436,20 +439,13 @@ function useCodeMirrorScope(view: EditorView) {
settingsRef.current.referencesSearchMode = referencesSearchMode
}, [referencesSearchMode])
- useEffect(() => {
- settingsRef.current.enableNewEditor = enableNewEditor
- window.setTimeout(() => {
- view.dispatch(setBreadcrumbsEnabled(enableNewEditor))
- })
- }, [view, enableNewEditor])
-
const emitSyncToPdf = useScopeEventEmitter('cursor:editor:syncToPdf')
// select and scroll to position on editor:gotoLine event (from synctex)
useScopeEventListener(
'editor:gotoLine',
useCallback(
- (_event, options) => {
+ (_event: any, options: GotoLineOptions) => {
setCursorLineAndScroll(
view,
options.gotoLine,
@@ -468,7 +464,7 @@ function useCodeMirrorScope(view: EditorView) {
useScopeEventListener(
'editor:gotoOffset',
useCallback(
- (_event, options) => {
+ (_event: any, options: GotoOffsetOptions) => {
setCursorPositionAndScroll(view, options.gotoOffset)
},
[view]
diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
index 8f9080807c..fb8657a48c 100644
--- a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
+++ b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
@@ -108,7 +108,9 @@
TextStrikeOutCtrlSeq,
EmphasisCtrlSeq,
UnderlineCtrlSeq,
- SetLengthCtrlSeq
+ SetLengthCtrlSeq,
+ FootnoteCtrlSeq,
+ EndnoteCtrlSeq
}
@external specialize {EnvName} specializeEnvName from "./tokens.mjs" {
@@ -410,6 +412,12 @@ KnownCommand {
} |
SetLengthCommand {
SetLengthCtrlSeq optionalWhitespace? ShortTextArgument optionalWhitespace? ShortTextArgument
+ } |
+ FootnoteCommand {
+ FootnoteCtrlSeq optionalWhitespace? OptionalArgument? optionalWhitespace? TextArgument
+ } |
+ EndnoteCommand {
+ EndnoteCtrlSeq optionalWhitespace? OptionalArgument? optionalWhitespace? TextArgument
}
}
diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
index 933f2fc9a9..1c5bf6f51b 100644
--- a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
+++ b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
@@ -99,6 +99,8 @@ import {
EmphasisCtrlSeq,
UnderlineCtrlSeq,
SetLengthCtrlSeq,
+ FootnoteCtrlSeq,
+ EndnoteCtrlSeq,
} from './latex.terms.mjs'
const MAX_ARGUMENT_LOOKAHEAD = 100
@@ -605,6 +607,8 @@ const otherKnowncommands = {
'\\emph': EmphasisCtrlSeq,
'\\underline': UnderlineCtrlSeq,
'\\setlength': SetLengthCtrlSeq,
+ '\\footnote': FootnoteCtrlSeq,
+ '\\endnote': EndnoteCtrlSeq,
}
// specializer for control sequences
// return new tokens for specific control sequences
@@ -692,6 +696,8 @@ const equationArrayEnvNames = new Set([
'rcases*',
'IEEEeqnarray',
'IEEEeqnarray*',
+ 'subeqnarray',
+ 'subeqnarray*',
])
const verbatimEnvNames = new Set([
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx b/services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx
index 7430e8cca7..5233f74d31 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx
@@ -3,8 +3,6 @@ import { useTranslation } from 'react-i18next'
import { useLocation } from '@/shared/hooks/use-location'
import MaterialIcon from '@/shared/components/material-icon'
import OLTag from '@/features/ui/components/ol/ol-tag'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { bsVersion } from '@/features/utils/bootstrap-5'
import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { sendMB } from '../../../../infrastructure/event-tracking'
import starIcon from '../../images/star-gradient.svg'
@@ -24,7 +22,7 @@ function AvailableWithGroupProfessionalBadge() {
}
contentProps={{
- className: bsVersion({ bs5: 'mw-100' }),
+ className: 'mw-100',
onClick: handleUpgradeClick,
}}
>
@@ -82,35 +80,17 @@ export function GroupSettingsButtonWithAdBadge({
useGroupSettingsButton(subscription)
return (
-
-
-
-
-
-
{heading}
-
{groupSettingRowSubText}
-
-
-
-
+
+
+
+
+
{heading}
+
{groupSettingRowSubText}
- }
- bs5={
-
-
-
-
-
{heading}
-
{groupSettingRowSubText}
-
-
-
-
-
-
- }
- />
+
+
+
+
+
)
}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx
index d56b0c80b8..99a80da3f4 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx
@@ -71,11 +71,6 @@ export default function LeaveGroupModal() {
disabled={inflight}
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
- bs3Props={{
- loading: inflight
- ? t('processing_uppercase') + '…'
- : t('leave_now'),
- }}
>
{t('leave_now')}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx b/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
index 18b43bd7da..a79cac06ac 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
@@ -7,8 +7,6 @@ import { Trans, useTranslation } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import { RowLink } from './row-link'
import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
-import { bsVersion } from '@/features/utils/bootstrap-5'
-import classnames from 'classnames'
function ManagedGroupAdministrator({
subscription,
@@ -104,9 +102,7 @@ export default function ManagedGroupSubscriptions() {
{managedGroupSubscriptions.map(subscription => {
return (
-
- {t('group_management')}
-
+
{t('group_management')}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/managed-institution.tsx b/services/web/frontend/js/features/subscription/components/dashboard/managed-institution.tsx
index a63b872919..bd5856da2f 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/managed-institution.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/managed-institution.tsx
@@ -20,7 +20,10 @@ export default function ManagedInstitution({
const { updateManagedInstitution } = useSubscriptionDashboardContext()
const changeInstitutionalEmailSubscription = useCallback(
- (e, institutionId: Institution['v1Id']) => {
+ (
+ e: React.MouseEvent
,
+ institutionId: Institution['v1Id']
+ ) => {
const updateSubscription = async (institutionId: Institution['v1Id']) => {
setSubscriptionChanging(true)
try {
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx
index 5d767d5fb6..fa47be6a5c 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx
@@ -15,7 +15,7 @@ import { debugConsole } from '@/utils/debugging'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import PauseDuck from '../../images/pause-duck.svg'
import GenericErrorAlert from './generic-error-alert'
-import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
const pauseMonthDurationOptions = [1, 2, 3]
@@ -35,13 +35,13 @@ export default function PauseSubscriptionModal() {
const location = useLocation()
function handleCancelSubscriptionClick() {
- const subscription = personalSubscription as RecurlySubscription
+ const subscription = personalSubscription as PaidSubscription
eventTracking.sendMB('subscription-page-cancel-button-click', {
plan_code: subscription?.planCode,
is_trial:
- subscription?.recurly.trialEndsAtFormatted &&
- subscription?.recurly.trial_ends_at &&
- new Date(subscription.recurly.trial_ends_at).getTime() > Date.now(),
+ subscription?.payment.trialEndsAtFormatted &&
+ subscription?.payment.trialEndsAt &&
+ new Date(subscription.payment.trialEndsAt).getTime() > Date.now(),
})
setShowCancellation(true)
}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx
index 1425609264..c518b3ca8c 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx
@@ -18,9 +18,9 @@ function PersonalSubscriptionRecurlySyncEmail() {
runAsync(postJSON('/user/subscription/account/email'))
}
- if (!personalSubscription || !('recurly' in personalSubscription)) return null
+ if (!personalSubscription || !('payment' in personalSubscription)) return null
- const recurlyEmail = personalSubscription.recurly.account.email
+ const recurlyEmail = personalSubscription.payment.accountEmail
if (!userEmail || recurlyEmail === userEmail) return null
@@ -51,9 +51,6 @@ function PersonalSubscriptionRecurlySyncEmail() {
disabled={isLoading}
isLoading={isLoading}
loadingLabel={t('updating')}
- bs3Props={{
- loading: isLoading ? t('updating') + '…' : t('update'),
- }}
>
{t('update')}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
index 7f56daf750..d6a511af75 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
@@ -1,5 +1,5 @@
import { Trans, useTranslation } from 'react-i18next'
-import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { PausedSubscription } from './states/active/paused'
import { ActiveSubscriptionNew } from '@/features/subscription/components/dashboard/states/active/active-new'
import { CanceledSubscription } from './states/canceled'
@@ -11,7 +11,7 @@ import OLNotification from '@/features/ui/components/ol/ol-notification'
function PastDueSubscriptionAlert({
subscription,
}: {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
}) {
const { t } = useTranslation()
return (
@@ -21,7 +21,7 @@ function PastDueSubscriptionAlert({
<>
{t('account_has_past_due_invoice_change_plan_warning')}{' '}
@@ -33,13 +33,34 @@ function PastDueSubscriptionAlert({
)
}
+function RedirectAlerts() {
+ const queryParams = new URLSearchParams(window.location.search)
+ const redirectReason = queryParams.get('redirect-reason')
+ const { t } = useTranslation()
+
+ if (!redirectReason) {
+ return null
+ }
+
+ let warning
+ if (redirectReason === 'writefull-entitled') {
+ warning = t('good_news_you_are_already_receiving_this_add_on_via_writefull')
+ } else if (redirectReason === 'double-buy') {
+ warning = t('good_news_you_already_purchased_this_add_on')
+ } else {
+ return null
+ }
+
+ return {warning}>} />
+}
+
function PersonalSubscriptionStates({
subscription,
}: {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
}) {
const { t } = useTranslation()
- const state = subscription?.recurly.state
+ const state = subscription?.payment.state
if (state === 'active') {
// This version handles subscriptions with and without addons
@@ -62,7 +83,7 @@ function PersonalSubscription() {
if (!personalSubscription) return null
- if (!('recurly' in personalSubscription)) {
+ if (!('payment' in personalSubscription)) {
return (
- {personalSubscription.recurly.account.has_past_due_invoice._ ===
- 'true' && (
+
+ {personalSubscription.payment.hasPastDueInvoice && (
)}
{recurlyLoadError && (
:
-}
-
-function BS3RowLink({ href, heading, subtext, icon }: RowLinkProps) {
- return (
-
-
-
-
-
-
{heading}
-
{subtext}
-
-
-
-
-
- )
-}
-
-function BS5RowLink({ href, heading, subtext, icon }: RowLinkProps) {
+export function RowLink({ href, heading, subtext, icon }: RowLinkProps) {
return (
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
index 067888f918..2bd8639f6a 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
@@ -1,7 +1,7 @@
import { useTranslation, Trans } from 'react-i18next'
import { PriceExceptions } from '../../../shared/price-exceptions'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
-import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { CancelSubscriptionButton } from './cancel-subscription-button'
import { CancelSubscription } from './cancel-plan/cancel-subscription'
import { TrialEnding } from './trial-ending'
@@ -10,9 +10,9 @@ import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import { CancelAiAddOnModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal'
+import { WritefullBundleManagementModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/writefull-bundle-management-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import isInFreeTrial from '../../../../util/is-in-free-trial'
-import { bsVersion } from '@/features/utils/bootstrap-5'
import AddOns from '@/features/subscription/components/dashboard/states/active/add-ons'
import {
AI_ADD_ON_CODE,
@@ -20,7 +20,6 @@ import {
isStandaloneAiPlanCode,
} from '@/features/subscription/data/add-on-codes'
import getMeta from '@/utils/meta'
-import classnames from 'classnames'
import SubscriptionRemainder from '@/features/subscription/components/dashboard/states/active/subscription-remainder'
import { sendMB } from '../../../../../../infrastructure/event-tracking'
import PauseSubscriptionModal from '@/features/subscription/components/dashboard/pause-modal'
@@ -35,7 +34,7 @@ import Notification from '@/shared/components/notification'
export function ActiveSubscriptionNew({
subscription,
}: {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
}) {
const { t } = useTranslation()
const {
@@ -73,20 +72,21 @@ export function ActiveSubscriptionNew({
}
const handlePlanChange = () => setModalIdShown('change-plan')
+ const handleManageOnWritefull = () => setModalIdShown('manage-on-writefull')
const handleCancelClick = (addOnCode: string) => {
if ([AI_STANDALONE_PLAN_CODE, AI_ADD_ON_CODE].includes(addOnCode)) {
setModalIdShown('cancel-ai-add-on')
}
}
const hasPendingPause = Boolean(
- subscription.recurly.state === 'active' &&
- subscription.recurly.remainingPauseCycles &&
- subscription.recurly.remainingPauseCycles > 0
+ subscription.payment.state === 'active' &&
+ subscription.payment.remainingPauseCycles &&
+ subscription.payment.remainingPauseCycles > 0
)
const isLegacyPlan =
- subscription.recurly.totalLicenses !==
- subscription.recurly.additionalLicenses
+ subscription.payment.totalLicenses !==
+ subscription.payment.additionalLicenses
return (
<>
@@ -100,14 +100,12 @@ export function ActiveSubscriptionNew({
/>
)}
-
- {t('billing')}
-
+ {t('billing')}
{subscription.plan.annual ? (
]} // eslint-disable-line react/jsx-key
/>
@@ -169,31 +179,27 @@ export function ActiveSubscriptionNew({
)}
-
- {t('plan')}
-
-
- {planName}
-
+ {t('plan')}
+ {planName}
{subscription.pendingPlan &&
subscription.pendingPlan.name !== subscription.plan.name && (
{t('want_change_to_apply_before_plan_end')}
)}
- {isInFreeTrial(subscription.recurly.trial_ends_at) &&
- subscription.recurly.trialEndsAtFormatted && (
+ {isInFreeTrial(subscription.payment.trialEndsAt) &&
+ subscription.payment.trialEndsAtFormatted && (
)}
- {subscription.recurly.totalLicenses > 0 && (
+ {subscription.payment.totalLicenses > 0 && (
- {isLegacyPlan && subscription.recurly.additionalLicenses > 0 ? (
+ {isLegacyPlan && subscription.payment.additionalLicenses > 0 ? (
]} // eslint-disable-line react/jsx-key
@@ -217,7 +223,7 @@ export function ActiveSubscriptionNew({
i18nKey="your_subscription_will_pause_on"
values={{
planName: subscription.plan.name,
- pauseDate: subscription.recurly.nextPaymentDueAt,
+ pauseDate: subscription.payment.nextPaymentDueAt,
reactivationDate: getFormattedRenewalDate(),
}}
shouldUnescape
@@ -235,10 +241,10 @@ export function ActiveSubscriptionNew({
{subscription.plan.annual
? t('x_price_per_year', {
- price: subscription.recurly.planOnlyDisplayPrice,
+ price: subscription.payment.planOnlyDisplayPrice,
})
: t('x_price_per_month', {
- price: subscription.recurly.planOnlyDisplayPrice,
+ price: subscription.payment.planOnlyDisplayPrice,
})}
)}
@@ -256,6 +262,7 @@ export function ActiveSubscriptionNew({
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handleCancelClick={handleCancelClick}
+ handleManageOnWritefull={handleManageOnWritefull}
/>
@@ -263,13 +270,14 @@ export function ActiveSubscriptionNew({
+
>
)
}
type PlanActionsProps = {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
onStandalonePlan: boolean
handlePlanChange: () => void
hasPendingPause: boolean
@@ -309,12 +317,11 @@ function PlanActions({
) : (
<>
- {!hasPendingPause &&
- subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
-
- {t('change_plan')}
-
- )}
+ {!hasPendingPause && !subscription.payment.hasPastDueInvoice && (
+
+ {t('change_plan')}
+
+ )}
>
)}
{hasPendingPause && (
@@ -343,7 +350,7 @@ function PlanActions({
function FlexibleGroupLicensingActions({
subscription,
}: {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
}) {
const { t } = useTranslation()
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
index edc9fbfc33..2fdffac311 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
@@ -1,7 +1,7 @@
import { useTranslation, Trans } from 'react-i18next'
import { PriceExceptions } from '../../../shared/price-exceptions'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
-import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { CancelSubscriptionButton } from './cancel-subscription-button'
import { CancelSubscription } from './cancel-plan/cancel-subscription'
import { PendingPlanChange } from './pending-plan-change'
@@ -27,7 +27,7 @@ import LoadingSpinner from '@/shared/components/loading-spinner'
export function ActiveSubscription({
subscription,
}: {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
}) {
const { t } = useTranslation()
const {
@@ -46,9 +46,9 @@ export function ActiveSubscription({
if (showCancellation) return
const hasPendingPause =
- subscription.recurly.state === 'active' &&
- subscription.recurly.remainingPauseCycles &&
- subscription.recurly.remainingPauseCycles > 0
+ subscription.payment.state === 'active' &&
+ subscription.payment.remainingPauseCycles &&
+ subscription.payment.remainingPauseCycles > 0
const handleCancelPendingPauseClick = async () => {
try {
@@ -96,19 +96,19 @@ export function ActiveSubscription({
>
)}
{!subscription.pendingPlan &&
- subscription.recurly.additionalLicenses > 0 && (
+ subscription.payment.additionalLicenses > 0 && (
<>
{' '}
>
)}
{!recurlyLoadError &&
!subscription.groupPlan &&
!hasPendingPause &&
- subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
+ !subscription.payment.hasPastDueInvoice && (
<>
{' '}
}
- {isInFreeTrial(subscription.recurly.trial_ends_at) &&
- subscription.recurly.trialEndsAtFormatted && (
+ {isInFreeTrial(subscription.payment.trialEndsAt) &&
+ subscription.payment.trialEndsAtFormatted && (
)}
@@ -142,7 +142,7 @@ export function ActiveSubscription({
i18nKey="your_subscription_will_pause_on"
values={{
planName: subscription.plan.name,
- pauseDate: subscription.recurly.nextPaymentDueAt,
+ pauseDate: subscription.payment.nextPaymentDueAt,
reactivationDate: getFormattedRenewalDate(),
}}
shouldUnescape
@@ -174,7 +174,7 @@ export function ActiveSubscription({
{' '}
void
+ handleManageOnWritefull: () => void
}
type AddOnProps = {
@@ -98,41 +100,98 @@ function AddOn({
)
}
+function WritefullGrantedAddOn({
+ handleManageOnWritefull,
+}: {
+ handleManageOnWritefull: () => void
+}) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
{ADD_ON_NAME}
+
+ {t('included_as_part_of_your_writefull_subscription')}
+
+
+
+
+
+
+
+
+
+
+ {t('manage_subscription')}
+
+
+
+
+
+ )
+}
+
function AddOns({
subscription,
onStandalonePlan,
handleCancelClick,
+ handleManageOnWritefull,
}: AddOnsProps) {
const { t } = useTranslation()
+ const hasAiAssistViaWritefull = getMeta('ol-hasAiAssistViaWritefull')
const addOnsDisplayPrices = onStandalonePlan
? {
- [AI_STANDALONE_PLAN_CODE]: subscription.recurly.displayPrice,
+ [AI_STANDALONE_PLAN_CODE]: subscription.payment.displayPrice,
}
- : subscription.recurly.addOnDisplayPricesWithoutAdditionalLicense
+ : subscription.payment.addOnDisplayPricesWithoutAdditionalLicense
const addOnsToDisplay = onStandalonePlan
? [{ addOnCode: AI_STANDALONE_PLAN_CODE }]
: subscription.addOns?.filter(addOn => addOn.addOnCode !== LICENSE_ADD_ON)
+ const hasAddons =
+ (addOnsToDisplay && addOnsToDisplay.length > 0) || hasAiAssistViaWritefull
return (
<>
{t('add_ons')}
- {addOnsToDisplay && addOnsToDisplay.length > 0 ? (
- addOnsToDisplay.map(addOn => (
- pendingAddOn.addOnCode !== addOn.addOnCode
- )
- }
- displayPrice={addOnsDisplayPrices[addOn.addOnCode]}
- nextBillingDate={subscription.recurly.nextPaymentDueDate}
- />
- ))
+ {hasAddons ? (
+ <>
+ {addOnsToDisplay?.map(addOn => (
+ pendingAddOn.code !== addOn.addOnCode
+ )
+ }
+ displayPrice={addOnsDisplayPrices[addOn.addOnCode]}
+ nextBillingDate={subscription.payment.nextPaymentDueDate}
+ />
+ ))}
+ {hasAiAssistViaWritefull && (
+
+ )}
+ >
) : (
{t('you_dont_have_any_add_ons_on_your_account')}
)}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx
index eddb4baafc..dc34d38693 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx
@@ -155,14 +155,14 @@ export function CancelSubscription() {
isSuccessSecondaryAction ||
isSuccessCancel
- if (!personalSubscription || !('recurly' in personalSubscription)) return null
+ if (!personalSubscription || !('payment' in personalSubscription)) return null
const showDowngrade = showDowngradeOption(
personalSubscription.plan.planCode,
personalSubscription.plan.groupPlan,
- personalSubscription.recurly.trial_ends_at,
- personalSubscription.recurly.pausedAt,
- personalSubscription.recurly.remainingPauseCycles
+ personalSubscription.payment.trialEndsAt,
+ personalSubscription.payment.pausedAt,
+ personalSubscription.payment.remainingPauseCycles
)
const planToDowngradeTo = plans.find(
plan => plan.planCode === planCodeToDowngradeTo
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx
index e0bc66ac63..c34e304e8b 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx
@@ -41,9 +41,6 @@ export default function DowngradePlanButton({
disabled={isButtonDisabled}
isLoading={isLoading}
loadingLabel={t('processing_uppercase') + '…'}
- bs3Props={{
- loading: isLoading ? t('processing_uppercase') + '…' : buttonText,
- }}
>
{buttonText}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx
index d5b2e4d95c..e108e934a0 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx
@@ -34,9 +34,6 @@ export default function ExtendTrialButton({
disabled={isButtonDisabled}
isLoading={isLoading}
loadingLabel={t('processing_uppercase') + '…'}
- bs3Props={{
- loading: isLoading ? t('processing_uppercase') + '…' : buttonText,
- }}
>
{buttonText}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
index a99f6e5762..7713b4671d 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
import OLButton from '@/features/ui/components/ol/ol-button'
-import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export function CancelSubscriptionButton() {
@@ -14,22 +14,17 @@ export function CancelSubscriptionButton() {
setShowCancellation,
} = useSubscriptionDashboardContext()
- const subscription = personalSubscription as RecurlySubscription
+ const subscription = personalSubscription as PaidSubscription
const isInTrial =
- subscription?.recurly.trialEndsAtFormatted &&
- subscription?.recurly.trial_ends_at &&
- new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
+ subscription?.payment.trialEndsAtFormatted &&
+ subscription?.payment.trialEndsAt &&
+ new Date(subscription.payment.trialEndsAt).getTime() > Date.now()
const hasPendingOrActivePause =
- subscription.recurly.state === 'paused' ||
- (subscription.recurly.state === 'active' &&
- subscription.recurly.remainingPauseCycles &&
- subscription.recurly.remainingPauseCycles > 0)
- const planIsEligibleForPause =
- !subscription.pendingPlan &&
- !subscription.groupPlan &&
- !isInTrial &&
- !subscription.planCode.includes('ann') &&
- !subscription.addOns?.length
+ subscription.payment.state === 'paused' ||
+ (subscription.payment.state === 'active' &&
+ subscription.payment.remainingPauseCycles &&
+ subscription.payment.remainingPauseCycles > 0)
+ const planIsEligibleForPause = subscription.payment.isEligibleForPause
const enablePause =
useFeatureFlag('pause-subscription') &&
!hasPendingOrActivePause &&
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx
index 2b0b153cae..bd602ccd75 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx
@@ -3,18 +3,19 @@ import { useSubscriptionDashboardContext } from '../../../../../context/subscrip
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import isInFreeTrial from '../../../../../util/is-in-free-trial'
-import { RecurlySubscription } from '../../../../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../../../../types/subscription/dashboard/subscription'
export function ChangeToGroupPlan() {
const { t } = useTranslation()
const { handleOpenModal, personalSubscription } =
useSubscriptionDashboardContext()
- // TODO: Better way to get RecurlySubscription/trial_ends_at
+ // TODO: Better way to get PaidSubscription/trialEndsAt
const subscription =
- personalSubscription && 'recurly' in personalSubscription
- ? (personalSubscription as RecurlySubscription)
+ personalSubscription && 'payment' in personalSubscription
+ ? (personalSubscription as PaidSubscription)
: null
+ const isInTrial = isInFreeTrial(subscription?.payment?.trialEndsAt)
const handleClick = () => {
handleOpenModal('change-to-group')
@@ -25,13 +26,15 @@ export function ChangeToGroupPlan() {
{t('looking_multiple_licenses')}
{t('reduce_costs_group_licenses')}
- {isInFreeTrial(subscription?.recurly?.trial_ends_at) ? (
+ {!subscription?.payment?.isEligibleForGroupPlan ? (
<>
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx
index 87d63bc44e..41c006507a 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx
@@ -107,11 +107,6 @@ export function CancelAiAddOnModal() {
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
onClick={handleConfirmChange}
- bs3Props={{
- loading: inflight
- ? t('processing_uppercase') + '…'
- : t('cancel_add_on'),
- }}
>
{t('cancel_add_on')}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx
index 010522a357..8cc992f1f9 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
-import { RecurlySubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
import getMeta from '../../../../../../../../utils/meta'
@@ -22,9 +22,7 @@ import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
import { UserProvider } from '@/shared/context/user-context'
import OLButton from '@/features/ui/components/ol/ol-button'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import OLNotification from '@/features/ui/components/ol/ol-notification'
-import { bsVersion } from '@/features/utils/bootstrap-5'
const educationalPercentDiscount = 40
@@ -79,8 +77,6 @@ function GroupPrice({
})}
-
} />
-
{t('x_price_per_user', {
@@ -119,7 +115,7 @@ export function ChangeToGroupModal() {
useContactUsModal({ autofillProjectUrl: false })
const groupPlans = getMeta('ol-groupPlans')
const showGroupDiscount = getMeta('ol-showGroupDiscount')
- const personalSubscription = getMeta('ol-subscription') as RecurlySubscription
+ const personalSubscription = getMeta('ol-subscription') as PaidSubscription
const [error, setError] = useState(false)
const [inflight, setInflight] = useState(false)
const location = useLocation()
@@ -221,10 +217,7 @@ export function ChangeToGroupModal() {
{t('plan')}
{groupPlans.plans.map(option => (
-
+
{t('upgrade_now')}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx
index 0a9a5841eb..08cbf1743f 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx
@@ -112,11 +112,6 @@ export function ConfirmChangePlanModal() {
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
onClick={handleConfirmChange}
- bs3Props={{
- loading: inflight
- ? t('processing_uppercase') + '…'
- : t('change_plan'),
- }}
>
{t('change_plan')}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx
index e6bbe4942f..793c570e25 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx
@@ -93,11 +93,6 @@ export function KeepCurrentPlanModal() {
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
onClick={confirmCancelPendingPlanChange}
- bs3Props={{
- loading: inflight
- ? t('processing_uppercase') + '…'
- : t('revert_pending_plan_change'),
- }}
>
{t('revert_pending_plan_change')}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/writefull-bundle-management-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/writefull-bundle-management-modal.tsx
new file mode 100644
index 0000000000..ef512ddec5
--- /dev/null
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/writefull-bundle-management-modal.tsx
@@ -0,0 +1,51 @@
+import { useTranslation } from 'react-i18next'
+import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
+import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
+import OLModal, {
+ OLModalBody,
+ OLModalFooter,
+ OLModalHeader,
+ OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+
+export function WritefullBundleManagementModal() {
+ const modalId: SubscriptionDashModalIds = 'manage-on-writefull'
+ const { t } = useTranslation()
+ const { handleCloseModal, modalIdShown } = useSubscriptionDashboardContext()
+
+ if (modalIdShown !== modalId) return null
+
+ return (
+
+
+ {t('manage_your_ai_assist_add_on')}
+
+
+
+ {t('ai_assist_in_overleaf_is_included_via_writefull')}
+
+
+
+
+ {t('back')}
+
+
+ {t('go_to_writefull')}
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/confirm-unpause-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/confirm-unpause-modal.tsx
index 08dc85a469..df6364fcf1 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/confirm-unpause-modal.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/confirm-unpause-modal.tsx
@@ -12,7 +12,7 @@ import OLButton from '@/features/ui/components/ol/ol-button'
import { postJSON } from '@/infrastructure/fetch-json'
import { useLocation } from '@/shared/hooks/use-location'
import OLNotification from '@/features/ui/components/ol/ol-notification'
-import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
export function ConfirmUnpauseSubscriptionModal() {
const modalId: SubscriptionDashModalIds = 'unpause-subscription'
@@ -22,7 +22,7 @@ export function ConfirmUnpauseSubscriptionModal() {
const { handleCloseModal, modalIdShown, personalSubscription } =
useSubscriptionDashboardContext()
const location = useLocation()
- const subscription = personalSubscription as RecurlySubscription
+ const subscription = personalSubscription as PaidSubscription
async function handleConfirmUnpause() {
setError(false)
@@ -70,7 +70,7 @@ export function ConfirmUnpauseSubscriptionModal() {
{
// clear any flash message IDs so they only show once
if (location.toString()) {
@@ -36,7 +36,7 @@ export function FlashMessage() {
-
- {t('update_your_billing_details')}
- {' '}
-
- {t('view_your_invoices')}
-
+ {subscription.payment.billingDetailsLink ? (
+ <>
+
+ {t('update_your_billing_details')}
+ {' '}
+
+ {t('view_your_invoices')}
+
+ >
+ ) : (
+
+ {t('view_payment_portal')}
+
+ )}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx
index 94bbd3e241..46d5a1cc90 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx
@@ -1,16 +1,16 @@
import { Trans } from 'react-i18next'
-import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
-import { PendingRecurlyPlan } from '../../../../../../../../types/subscription/plan'
+import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PendingPaymentProviderPlan } from '../../../../../../../../types/subscription/plan'
import { AI_ADD_ON_CODE, ADD_ON_NAME } from '../../../../data/add-on-codes'
export function PendingPlanChange({
subscription,
}: {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
}) {
if (!subscription.pendingPlan) return null
- const pendingPlan = subscription.pendingPlan as PendingRecurlyPlan
+ const pendingPlan = subscription.pendingPlan as PendingPaymentProviderPlan
const hasAiAddon = subscription.addOns?.some(
addOn => addOn.addOnCode === AI_ADD_ON_CODE
@@ -18,12 +18,12 @@ export function PendingPlanChange({
const pendingAiAddonCancellation =
hasAiAddon &&
- !pendingPlan.addOns?.some(addOn => addOn.add_on_code === AI_ADD_ON_CODE)
+ !pendingPlan.addOns?.some(addOn => addOn.code === AI_ADD_ON_CODE)
const pendingAdditionalLicenses =
- (subscription.recurly.pendingAdditionalLicenses &&
- subscription.recurly.pendingAdditionalLicenses > 0) ||
- subscription.recurly.additionalLicenses > 0
+ (subscription.payment.pendingAdditionalLicenses &&
+ subscription.payment.pendingAdditionalLicenses > 0) ||
+ subscription.payment.additionalLicenses > 0
return (
<>
@@ -49,8 +49,8 @@ export function PendingPlanChange({
i18nKey="pending_additional_licenses"
values={{
pendingAdditionalLicenses:
- subscription.recurly.pendingAdditionalLicenses,
- pendingTotalLicenses: subscription.recurly.pendingTotalLicenses,
+ subscription.payment.pendingAdditionalLicenses,
+ pendingTotalLicenses: subscription.payment.pendingTotalLicenses,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx
index 16544e1507..3a7b3aa2e3 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx
@@ -1,8 +1,8 @@
import { Trans } from 'react-i18next'
-import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
type SubscriptionRemainderProps = {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
hideTime?: boolean
}
@@ -11,13 +11,13 @@ function SubscriptionRemainder({
hideTime,
}: SubscriptionRemainderProps) {
const stillInATrial =
- subscription.recurly.trialEndsAtFormatted &&
- subscription.recurly.trial_ends_at &&
- new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
+ subscription.payment.trialEndsAtFormatted &&
+ subscription.payment.trialEndsAt &&
+ new Date(subscription.payment.trialEndsAt).getTime() > Date.now()
const terminationDate = hideTime
- ? subscription.recurly.nextPaymentDueDate
- : subscription.recurly.nextPaymentDueAt
+ ? subscription.payment.nextPaymentDueDate
+ : subscription.payment.nextPaymentDueAt
return stillInATrial ? (
{t('your_subscription_has_expired')}
{
+ let addOnSegmentation: Record | null = null
+ if (preview.change.type === 'add-on-purchase') {
+ addOnSegmentation = {
+ addOn: preview.change.addOn.code,
+ upgradeType: 'add-on',
+ }
+ if (purchaseReferrer) {
+ addOnSegmentation.referrer = purchaseReferrer
+ }
+ eventTracking.sendMB('subscription-change-form-submit', addOnSegmentation)
+ }
+
eventTracking.sendMB('assistant-add-on-purchase')
payNowTask
.runAsync(payNow(preview))
.then(() => {
+ if (addOnSegmentation) {
+ eventTracking.sendMB(
+ 'subscription-change-form-success',
+ addOnSegmentation
+ )
+ }
location.replace('/user/subscription/thank-you')
})
.catch(debugConsole.error)
- }, [location, payNowTask, preview])
+ }, [purchaseReferrer, location, payNowTask, preview])
const aiAddOnChange =
preview.change.type === 'add-on-purchase' &&
diff --git a/services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx b/services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx
index b8cec5b8d1..8c08f6bbb7 100644
--- a/services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx
+++ b/services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx
@@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next'
-import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
type PriceExceptionsProps = {
- subscription: RecurlySubscription
+ subscription: PaidSubscription
}
export function PriceExceptions({ subscription }: PriceExceptionsProps) {
const { t } = useTranslation()
- const { activeCoupons } = subscription.recurly
+ const { activeCoupons } = subscription.payment
return (
<>
@@ -19,7 +19,7 @@ export function PriceExceptions({ subscription }: PriceExceptionsProps) {
* {t('coupons_not_included')}:
{activeCoupons.map(coupon => (
-
+
{coupon.description || coupon.name}
))}
diff --git a/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx b/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx
index 3c64846db0..f211c85f10 100644
--- a/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx
+++ b/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx
@@ -12,7 +12,7 @@ import {
ADD_ON_NAME,
isStandaloneAiPlanCode,
} from '../../data/add-on-codes'
-import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
function SuccessfulSubscription() {
const { t } = useTranslation()
@@ -21,7 +21,7 @@ function SuccessfulSubscription() {
const postCheckoutRedirect = getMeta('ol-postCheckoutRedirect')
const { appName, adminEmail } = getMeta('ol-ExposedSettings')
- if (!subscription || !('recurly' in subscription)) return null
+ if (!subscription || !('payment' in subscription)) return null
const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
@@ -37,15 +37,15 @@ function SuccessfulSubscription() {
type="success"
content={
<>
- {subscription.recurly.trial_ends_at && (
+ {subscription.payment.trialEndsAt && (
<>
void
modalIdShown?: SubscriptionDashModalIds
- personalSubscription?: RecurlySubscription | CustomSubscription
+ personalSubscription?: PaidSubscription | CustomSubscription
hasSubscription: boolean
plans: Plan[]
planCodeToChangeTo?: string
@@ -136,21 +136,21 @@ export function SubscriptionDashboardProvider({
)
const hasValidActiveSubscription = Boolean(
- ['active', 'canceled'].includes(personalSubscription?.recurly?.state) ||
+ ['active', 'canceled'].includes(personalSubscription?.payment?.state) ||
institutionMemberships?.length > 0 ||
memberGroupSubscriptions?.length > 0
)
const getFormattedRenewalDate = useCallback(() => {
if (
- !personalSubscription.recurly.pausedAt ||
- !personalSubscription.recurly.remainingPauseCycles
+ !personalSubscription.payment.pausedAt ||
+ !personalSubscription.payment.remainingPauseCycles
) {
- return personalSubscription.recurly.nextPaymentDueAt
+ return personalSubscription.payment.nextPaymentDueAt
}
- const pausedDate = new Date(personalSubscription.recurly.pausedAt)
+ const pausedDate = new Date(personalSubscription.payment.pausedAt)
pausedDate.setMonth(
- pausedDate.getMonth() + personalSubscription.recurly.remainingPauseCycles
+ pausedDate.getMonth() + personalSubscription.payment.remainingPauseCycles
)
return formatTime(pausedDate, 'MMMM Do, YYYY')
}, [personalSubscription])
@@ -167,9 +167,9 @@ export function SubscriptionDashboardProvider({
if (
isRecurlyLoaded() &&
plansWithoutDisplayPrice &&
- personalSubscription?.recurly
+ personalSubscription?.payment
) {
- const { currency, taxRate } = personalSubscription.recurly
+ const { currency, taxRate } = personalSubscription.payment
const fetchPlansDisplayPrices = async () => {
for (const plan of plansWithoutDisplayPrice) {
try {
@@ -203,11 +203,11 @@ export function SubscriptionDashboardProvider({
groupPlanToChangeToCode &&
groupPlanToChangeToSize &&
groupPlanToChangeToUsage &&
- personalSubscription?.recurly
+ personalSubscription?.payment
) {
setQueryingGroupPlanToChangeToPrice(true)
- const { currency, taxRate } = personalSubscription.recurly
+ const { currency, taxRate } = personalSubscription.payment
const fetchGroupDisplayPrice = async () => {
setGroupPlanToChangeToPriceError(false)
let priceData
@@ -256,7 +256,7 @@ export function SubscriptionDashboardProvider({
}, [setModalIdShown, setPlanCodeToChangeTo])
const handleOpenModal = useCallback(
- (id, planCode) => {
+ (id: SubscriptionDashModalIds, planCode?: string) => {
setModalIdShown(id)
setPlanCodeToChangeTo(planCode)
},
diff --git a/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx b/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx
index 476bc83881..e7b0d96c2a 100644
--- a/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx
+++ b/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx
@@ -159,7 +159,6 @@ function SharingUpdatesRoot() {
)
}
-export default withErrorBoundary(
- SharingUpdatesRoot,
- GenericErrorBoundaryFallback
-)
+export default withErrorBoundary(SharingUpdatesRoot, () => (
+
+))
diff --git a/services/web/frontend/js/features/token-access/components/token-access-root.tsx b/services/web/frontend/js/features/token-access/components/token-access-root.tsx
index b38b3fdab4..fa3a860dd3 100644
--- a/services/web/frontend/js/features/token-access/components/token-access-root.tsx
+++ b/services/web/frontend/js/features/token-access/components/token-access-root.tsx
@@ -137,4 +137,6 @@ function TokenAccessRoot() {
)
}
-export default withErrorBoundary(TokenAccessRoot, GenericErrorBoundaryFallback)
+export default withErrorBoundary(TokenAccessRoot, () => (
+
+))
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-menu-with-ref.tsx b/services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-menu-with-ref.tsx
deleted file mode 100644
index 04807ad825..0000000000
--- a/services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-menu-with-ref.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { forwardRef, SyntheticEvent } from 'react'
-import classnames from 'classnames'
-import RootCloseWrapper from 'react-overlays/lib/RootCloseWrapper'
-import { DropdownProps } from 'react-bootstrap'
-import { MergeAndOverride } from '../../../../../../types/utils'
-
-type DropdownMenuWithRefProps = MergeAndOverride<
- Pick,
- {
- children: React.ReactNode
- bsRole: 'menu'
- menuRef: React.MutableRefObject
- className?: string
- // The props below are passed by react-bootstrap
- labelledBy?: string | undefined
- rootCloseEvent?: 'click' | 'mousedown' | undefined
- onClose?: (e: SyntheticEvent) => void
- }
->
-
-const DropdownMenuWithRef = forwardRef<
- HTMLUListElement,
- DropdownMenuWithRefProps
->(function (props, ref) {
- const {
- children,
- bsRole,
- bsClass,
- className,
- open,
- pullRight,
- labelledBy,
- menuRef,
- onClose,
- rootCloseEvent,
- ...rest
- } = props
-
- // expose the menu reference to both the `menuRef` and `ref callback` from react-bootstrap
- const handleRefs = (node: HTMLUListElement) => {
- if (typeof ref === 'function') {
- ref(node)
- }
- menuRef.current = node
- }
-
- // Implementation as suggested in
- // https://react-bootstrap-v3.netlify.app/components/dropdowns/#btn-dropdowns-props-dropdown
- return (
-
-
-
- )
-})
-DropdownMenuWithRef.displayName = 'DropdownMenuWithRef'
-
-export default DropdownMenuWithRef
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-toggle-with-tooltip.tsx b/services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-toggle-with-tooltip.tsx
deleted file mode 100644
index 9f0dedf622..0000000000
--- a/services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-toggle-with-tooltip.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { forwardRef } from 'react'
-import Tooltip from '../../../../shared/components/tooltip'
-import classnames from 'classnames'
-import { DropdownProps } from 'react-bootstrap'
-import { MergeAndOverride } from '../../../../../../types/utils'
-
-type CustomToggleProps = MergeAndOverride<
- Pick,
- {
- children: React.ReactNode
- isOpened: boolean
- bsRole: 'toggle'
- className?: string
- tooltipProps: Omit, 'children'>
- }
->
-
-const DropdownToggleWithTooltip = forwardRef<
- HTMLButtonElement,
- CustomToggleProps
->(function (props, ref) {
- const {
- tooltipProps,
- isOpened,
- children,
- bsClass,
- className,
- open,
- bsRole: _bsRole,
- ...rest
- } = props
-
- const button = (
-
- {children}
-
- )
-
- return (
- <>{isOpened ? button : {button} }>
- )
-})
-
-DropdownToggleWithTooltip.displayName = 'DropdownToggleWithTooltip'
-
-export default DropdownToggleWithTooltip
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-3/form/form-control.tsx b/services/web/frontend/js/features/ui/components/bootstrap-3/form/form-control.tsx
deleted file mode 100644
index 1cd6c617e0..0000000000
--- a/services/web/frontend/js/features/ui/components/bootstrap-3/form/form-control.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import {
- FormControl as BS3FormControl,
- FormControlProps as BS3FormControlProps,
-} from 'react-bootstrap'
-
-type FormControlProps = BS3FormControlProps & {
- prepend?: React.ReactNode
- append?: React.ReactNode
-}
-
-function FormControl({ prepend, append, ...props }: FormControlProps) {
- return (
- <>
- {prepend && {prepend}
}
-
- {append && {append}
}
- >
- )
-}
-
-export default FormControl
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-3/toast-container.tsx b/services/web/frontend/js/features/ui/components/bootstrap-3/toast-container.tsx
deleted file mode 100644
index 314de8ba77..0000000000
--- a/services/web/frontend/js/features/ui/components/bootstrap-3/toast-container.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import classNames from 'classnames'
-import { FC, HTMLProps } from 'react'
-
-export const ToastContainer: FC> = ({
- children,
- className,
- ...props
-}) => {
- return (
-
- {children}
-
- )
-}
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-3/toast.tsx b/services/web/frontend/js/features/ui/components/bootstrap-3/toast.tsx
deleted file mode 100644
index 4c35ddf4b5..0000000000
--- a/services/web/frontend/js/features/ui/components/bootstrap-3/toast.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import classNames from 'classnames'
-import { FC, useCallback, useEffect, useRef } from 'react'
-
-type ToastProps = {
- onClose?: () => void
- onExited?: () => void
- autohide?: boolean
- delay?: number
- show: boolean
- className?: string
-}
-export const Toast: FC = ({
- children,
- delay = 5000,
- onClose,
- onExited,
- autohide,
- show,
- className,
-}) => {
- const delayRef = useRef(delay)
- const onCloseRef = useRef(onClose)
- const onExitedRef = useRef(onExited)
- const shouldAutoHide = Boolean(autohide && show)
-
- const handleTimeout = useCallback(() => {
- if (shouldAutoHide) {
- onCloseRef.current?.()
- onExitedRef.current?.()
- }
- }, [shouldAutoHide])
-
- useEffect(() => {
- const timeout = window.setTimeout(handleTimeout, delayRef.current)
- return () => window.clearTimeout(timeout)
- }, [handleTimeout])
-
- if (!show) {
- return null
- }
-
- return (
-
- {children}
-
- )
-}
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-3/toggle-button-group.tsx b/services/web/frontend/js/features/ui/components/bootstrap-3/toggle-button-group.tsx
deleted file mode 100644
index 2224e6160b..0000000000
--- a/services/web/frontend/js/features/ui/components/bootstrap-3/toggle-button-group.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import {
- Children,
- cloneElement,
- isValidElement,
- useState,
- useEffect,
-} from 'react'
-import {
- ToggleButtonGroup as BS3ToggleButtonGroup,
- ToggleButtonGroupProps as BS3ToggleButtonGroupProps,
- ToggleButtonProps as BS3ToggleButtonProps,
-} from 'react-bootstrap'
-
-function ToggleButtonGroup({
- children,
- value,
- defaultValue,
- onChange,
- ...props
-}: BS3ToggleButtonGroupProps) {
- const [selectedValue, setSelectedValue] = useState(
- defaultValue || (props.type === 'checkbox' ? [] : null)
- )
- const isControlled = value !== undefined
-
- useEffect(() => {
- if (isControlled) {
- if (props.type === 'radio') {
- setSelectedValue(value)
- } else {
- if (Array.isArray(value)) {
- setSelectedValue(Array.from(value))
- } else {
- setSelectedValue([value])
- }
- }
- }
- }, [isControlled, value, props.type])
-
- const handleButtonClick = (buttonValue: T) => {
- if (props.type === 'radio') {
- if (!isControlled) {
- setSelectedValue(buttonValue)
- }
-
- onChange?.(buttonValue as any)
- } else if (props.type === 'checkbox') {
- const newValue = Array.isArray(selectedValue)
- ? selectedValue.includes(buttonValue)
- ? selectedValue.filter(val => val !== buttonValue) // Deselect
- : [...selectedValue, buttonValue] // Select
- : [buttonValue] // Initial selection if value is not array yet
-
- if (!isControlled) {
- setSelectedValue(newValue)
- }
-
- onChange?.(newValue)
- }
- }
-
- // Clone children and add custom onClick handlers
- const modifiedChildren = Children.map(children, child => {
- if (isValidElement(child)) {
- const childElement = child as React.ReactElement<
- BS3ToggleButtonProps & { active?: boolean }
- >
-
- const isActive =
- props.type === 'radio'
- ? selectedValue === childElement.props.value
- : Array.isArray(selectedValue) &&
- selectedValue.includes(childElement.props.value as T)
-
- return cloneElement(childElement, {
- onClick: () => {
- handleButtonClick(childElement.props.value as T)
- },
- active: isActive,
- })
- }
-
- return child
- })
-
- return (
- {}}
- >
- {modifiedChildren}
-
- )
-}
-
-export default ToggleButtonGroup
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/bootstrap-version-switcher.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/bootstrap-version-switcher.tsx
deleted file mode 100644
index 0bdfcca31f..0000000000
--- a/services/web/frontend/js/features/ui/components/bootstrap-5/bootstrap-version-switcher.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { isBootstrap5 } from '@/features/utils/bootstrap-5'
-
-type BootstrapVersionSwitcherProps = {
- bs3?: React.ReactNode
- bs5?: React.ReactNode
-}
-
-function BootstrapVersionSwitcher({
- bs3,
- bs5,
-}: BootstrapVersionSwitcherProps): React.ReactElement {
- return <>{isBootstrap5() ? bs5 : bs3}>
-}
-
-export default BootstrapVersionSwitcher
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer.tsx
index 361a3afbdb..c290c1128c 100644
--- a/services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer.tsx
+++ b/services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer.tsx
@@ -22,7 +22,7 @@ function FatFooter() {
links: [
{ href: '/about', label: t('footer_about_us') },
{ href: '/about/values', label: t('our_values') },
- { href: '/about/careers', label: t('careers') },
+ { href: 'https://digitalscience.pinpointhq.com/', label: t('careers') },
{ href: '/for/press', label: t('press_and_awards') },
{ href: '/blog', label: t('blog') },
],
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx
index 63618c3fad..178bab52c1 100644
--- a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx
+++ b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx
@@ -4,17 +4,14 @@ import {
FormControlProps as BS5FormControlProps,
} from 'react-bootstrap-5'
import classnames from 'classnames'
-import type { BsPrefixRefForwardingComponent } from 'react-bootstrap-5/helpers'
export type OLBS5FormControlProps = BS5FormControlProps & {
prepend?: React.ReactNode
append?: React.ReactNode
+ rows?: number
}
-const FormControl: BsPrefixRefForwardingComponent<
- 'input',
- OLBS5FormControlProps
-> = forwardRef(
+const FormControl = forwardRef(
({ prepend, append, className, ...props }, ref) => {
if (prepend || append) {
const wrapperClassNames = classnames('form-control-wrapper', {
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/icon-button.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/icon-button.tsx
index 29d6aa52ef..d738bd2cd5 100644
--- a/services/web/frontend/js/features/ui/components/bootstrap-5/icon-button.tsx
+++ b/services/web/frontend/js/features/ui/components/bootstrap-5/icon-button.tsx
@@ -6,7 +6,15 @@ import type { IconButtonProps } from '@/features/ui/components/types/icon-button
const IconButton = forwardRef(
(
- { accessibilityLabel, icon, isLoading = false, size, className, ...props },
+ {
+ accessibilityLabel,
+ icon,
+ isLoading = false,
+ size,
+ className,
+ unfilled,
+ ...props
+ },
ref
) => {
const iconButtonClassName = classNames(className, {
@@ -17,6 +25,7 @@ const IconButton = forwardRef(
const iconSizeClassName = size === 'lg' ? 'icon-large' : 'icon-small'
const materialIconClassName = classNames(iconSizeClassName, {
'button-content-hidden': isLoading,
+ unfilled,
})
return (
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx
index c50fa00911..fdc670423a 100644
--- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx
+++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx
@@ -8,6 +8,7 @@ export default function AdminMenu({
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
+ canDisplayScriptLogMenu,
adminUrl,
}: Pick<
DefaultNavbarMetadata,
@@ -15,6 +16,7 @@ export default function AdminMenu({
| 'canDisplayAdminRedirect'
| 'canDisplaySplitTestMenu'
| 'canDisplaySurveyMenu'
+ | 'canDisplayScriptLogMenu'
| 'adminUrl'
>) {
const sendProjectListMB = useSendProjectListMB()
@@ -57,6 +59,11 @@ export default function AdminMenu({
Manage Surveys
) : null}
+ {canDisplayScriptLogMenu ? (
+
+ View Script Logs
+
+ ) : null}
)
}
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 b7a843d99f..9066f5bbe7 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
@@ -22,6 +22,7 @@ function DefaultNavbar(props: DefaultNavbarMetadata) {
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
+ canDisplayScriptLogMenu,
enableUpgradeButton,
suppressNavbarRight,
suppressNavContentLinks,
@@ -101,6 +102,7 @@ function DefaultNavbar(props: DefaultNavbarMetadata) {
canDisplayAdminRedirect={canDisplayAdminRedirect}
canDisplaySplitTestMenu={canDisplaySplitTestMenu}
canDisplaySurveyMenu={canDisplaySurveyMenu}
+ canDisplayScriptLogMenu={canDisplayScriptLogMenu}
adminUrl={adminUrl}
/>
) : null}
diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx
index 3e71208d20..fa479b58c6 100644
--- a/services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx
+++ b/services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx
@@ -9,18 +9,10 @@ import { callFnsInSequence } from '@/utils/functions'
type OverlayProps = Omit
-type UpdatingTooltipProps = {
- popper: {
- scheduleUpdate: () => void
- }
- show: boolean
- [x: string]: unknown
-}
-
-const UpdatingTooltip = forwardRef(
+const UpdatingTooltip = forwardRef(
({ popper, children, show: _, ...props }, ref) => {
useEffect(() => {
- popper.scheduleUpdate()
+ popper?.scheduleUpdate?.()
}, [children, popper])
return (
diff --git a/services/web/frontend/js/features/ui/components/ol/icons/ol-tag-icon.tsx b/services/web/frontend/js/features/ui/components/ol/icons/ol-tag-icon.tsx
index 8e81387bbe..4a2fbadec9 100644
--- a/services/web/frontend/js/features/ui/components/ol/icons/ol-tag-icon.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/icons/ol-tag-icon.tsx
@@ -1,12 +1,5 @@
-import Icon from '@/shared/components/icon'
import MaterialIcon from '@/shared/components/material-icon'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
export default function OLTagIcon() {
- return (
- }
- bs5={ }
- />
- )
+ return
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-badge.tsx b/services/web/frontend/js/features/ui/components/ol/ol-badge.tsx
index 0e88529a86..7344ddb6f8 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-badge.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-badge.tsx
@@ -1,40 +1,17 @@
-import { Label } from 'react-bootstrap'
import Badge from '@/features/ui/components/bootstrap-5/badge'
-import BS3Badge from '@/shared/components/badge'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-type OLBadgeProps = React.ComponentProps & {
- bs3Props?: {
- bsStyle?: React.ComponentProps['bsStyle'] | null
- }
-}
+function OLBadge(props: React.ComponentProps) {
+ let { bg, text, ...rest } = props
-function OLBadge(props: OLBadgeProps) {
- const { bs3Props, ...rest } = props
-
- let bs3BadgeProps: React.ComponentProps = {
- prepend: rest.prepend,
- children: rest.children,
- className: rest.className,
- bsStyle: rest.bg,
+ // For warning badges, use a light background by default. We still want the
+ // Bootstrap warning colour to be dark for text though, so make an
+ // adjustment here
+ if (bg === 'warning') {
+ bg = 'warning-light-bg'
+ text = 'warning'
}
- if (bs3Props) {
- const { bsStyle, ...restBs3Props } = bs3Props
-
- bs3BadgeProps = {
- ...bs3BadgeProps,
- ...restBs3Props,
- bsStyle: 'bsStyle' in bs3Props ? bsStyle : rest.bg,
- }
- }
-
- return (
- }
- bs5={ }
- />
- )
+ return
}
export default OLBadge
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx b/services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx
index c9a9d1188b..dc26595aac 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx
@@ -1,30 +1,7 @@
import { ButtonGroup, ButtonGroupProps } from 'react-bootstrap-5'
-import {
- ButtonGroup as BS3ButtonGroup,
- ButtonGroupProps as BS3ButtonGroupProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLButtonGroupProps = ButtonGroupProps & {
- bs3Props?: Record
-}
-
-function OLButtonGroup({ bs3Props, as, ...rest }: OLButtonGroupProps) {
- const bs3ButtonGroupProps: BS3ButtonGroupProps = {
- children: rest.children,
- className: rest.className,
- vertical: rest.vertical,
- ...getAriaAndDataProps(rest),
- ...bs3Props,
- }
-
- return (
- }
- bs5={ }
- />
- )
+function OLButtonGroup({ as, ...rest }: ButtonGroupProps) {
+ return
}
export default OLButtonGroup
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx b/services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx
index 16d85c29eb..377228f72d 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx
@@ -1,31 +1,7 @@
import { ButtonToolbar, ButtonToolbarProps } from 'react-bootstrap-5'
-import {
- ButtonToolbar as BS3ButtonToolbar,
- ButtonToolbarProps as BS3ButtonToolbarProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLButtonToolbarProps = ButtonToolbarProps & {
- bs3Props?: Record
-}
-
-function OLButtonToolbar(props: OLButtonToolbarProps) {
- const { bs3Props, ...rest } = props
-
- const bs3ButtonToolbarProps: BS3ButtonToolbarProps = {
- children: rest.children,
- className: rest.className,
- ...getAriaAndDataProps(rest),
- ...bs3Props,
- }
-
- return (
- }
- bs5={ }
- />
- )
+function OLButtonToolbar(props: ButtonToolbarProps) {
+ return
}
export default OLButtonToolbar
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-button.tsx b/services/web/frontend/js/features/ui/components/ol/ol-button.tsx
index 6df9266744..3f5e06e8c4 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-button.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-button.tsx
@@ -1,132 +1,13 @@
import { forwardRef } from 'react'
-import BootstrapVersionSwitcher from '../bootstrap-5/bootstrap-version-switcher'
-import { Button as BS3Button } from 'react-bootstrap'
import type { ButtonProps } from '@/features/ui/components/types/button-props'
-import type { ButtonProps as BS3ButtonPropsBase } from 'react-bootstrap'
import Button from '../bootstrap-5/button'
-import classnames from 'classnames'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-import { callFnsInSequence } from '@/utils/functions'
-import Icon from '@/shared/components/icon'
-export type BS3ButtonSize = 'xsmall' | 'sm' | 'medium' | 'lg'
+export type OLButtonProps = ButtonProps
-export type OLButtonProps = ButtonProps & {
- bs3Props?: {
- loading?: React.ReactNode
- bsSize?: BS3ButtonSize
- block?: boolean
- className?: string
- onMouseOver?: React.MouseEventHandler
- onMouseOut?: React.MouseEventHandler
- onFocus?: React.FocusEventHandler
- onBlur?: React.FocusEventHandler
- }
-}
+const OLButton = forwardRef((props, ref) => {
+ return
+})
-// Resolve type mismatch of the onClick event handler
-export type BS3ButtonProps = Omit & {
- onClick?: React.MouseEventHandler
-}
-
-export function bs3ButtonProps(props: ButtonProps) {
- const bs3ButtonProps: BS3ButtonProps = {
- bsStyle: null,
- bsSize: props.size,
- className: classnames(`btn-${props.variant || 'primary'}`, props.className),
- disabled: props.isLoading || props.disabled,
- form: props.form,
- href: props.href,
- id: props.id,
- target: props.target,
- rel: props.rel,
- onClick: props.onClick,
- onMouseDown: props.onMouseDown as BS3ButtonProps['onMouseDown'],
- type: props.type,
- draggable: props.draggable,
- download: props.download,
- style: props.style,
- active: props.active,
- }
- return bs3ButtonProps
-}
-
-function BS3ButtonContent({
- children,
- leadingIcon,
- trailingIcon,
-}: {
- children: React.ReactNode
- leadingIcon: OLButtonProps['leadingIcon']
- trailingIcon: OLButtonProps['trailingIcon']
-}) {
- const leadingIconComponent =
- leadingIcon && typeof leadingIcon === 'string' ? (
-
- ) : (
- leadingIcon
- )
-
- const trailingIconComponent =
- trailingIcon && typeof trailingIcon === 'string' ? (
-
- ) : (
- trailingIcon
- )
-
- return (
- <>
- {leadingIconComponent ? <>{leadingIconComponent} > : null}
- {children}
- {trailingIconComponent ? <> {trailingIconComponent}> : null}
- >
- )
-}
-
-const OLButton = forwardRef(
- ({ bs3Props = {}, ...rest }, ref) => {
- const { className: _, ...restBs3Props } = bs3Props
-
- // BS3 OverlayTrigger automatically provides 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' event handlers
- const bs3FinalProps = {
- ...restBs3Props,
- onMouseOver: callFnsInSequence(bs3Props?.onMouseOver, rest.onMouseOver),
- onMouseOut: callFnsInSequence(bs3Props?.onMouseOut, rest.onMouseOut),
- onFocus: callFnsInSequence(bs3Props?.onFocus, rest.onFocus),
- onBlur: callFnsInSequence(bs3Props?.onBlur, rest.onBlur),
- }
-
- // Get all `aria-*` and `data-*` attributes
- const extraProps = getAriaAndDataProps(rest)
-
- return (
- | undefined}
- >
- {bs3Props?.loading || (
-
- {rest.children}
-
- )}
-
- }
- bs5={ }
- />
- )
- }
-)
OLButton.displayName = 'OLButton'
export default OLButton
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-card.tsx b/services/web/frontend/js/features/ui/components/ol/ol-card.tsx
index 36dfd4ecfa..1f0eb70ace 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-card.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-card.tsx
@@ -1,18 +1,14 @@
import { Card } from 'react-bootstrap-5'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { FC } from 'react'
-import classNames from 'classnames'
-const OLCard: FC<{ className?: string }> = ({ children, className }) => {
+const OLCard: FC> = ({
+ children,
+ className,
+}) => {
return (
- {children}
}
- bs5={
-
- {children}
-
- }
- />
+
+ {children}
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-close-button.tsx b/services/web/frontend/js/features/ui/components/ol/ol-close-button.tsx
index 7034392949..414a329eec 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-close-button.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-close-button.tsx
@@ -1,6 +1,4 @@
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { CloseButton, CloseButtonProps } from 'react-bootstrap-5'
-import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import { forwardRef } from 'react'
@@ -8,25 +6,7 @@ const OLCloseButton = forwardRef
(
(props, ref) => {
const { t } = useTranslation()
- const bs3CloseButtonProps: React.ButtonHTMLAttributes = {
- className: classNames('close', props.className),
- onClick: props.onClick,
- onMouseOver: props.onMouseOver,
- onMouseOut: props.onMouseOut,
-
- 'aria-label': t('close'),
- }
-
- return (
-
- ×
-
- }
- bs5={ }
- />
- )
+ return
}
)
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-col.tsx b/services/web/frontend/js/features/ui/components/ol/ol-col.tsx
index e2c7c1ce7b..dc70ca23c8 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-col.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-col.tsx
@@ -1,73 +1,7 @@
import { Col } from 'react-bootstrap-5'
-import { Col as BS3Col } from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-type OLColProps = React.ComponentProps & {
- bs3Props?: Record
-}
-
-function OLCol(props: OLColProps) {
- const { bs3Props, ...rest } = props
-
- const getBs3Sizes = (obj: typeof rest) => {
- const bs5ToBs3SizeMap = {
- xs: 'xs',
- sm: 'xs',
- md: 'sm',
- lg: 'md',
- xl: 'lg',
- xxl: undefined,
- } as const
-
- const isBs5ToBs3SizeMapKey = (
- key: string
- ): key is keyof typeof bs5ToBs3SizeMap => {
- return key in bs5ToBs3SizeMap
- }
-
- const sizes = Object.entries(obj).reduce(
- (prev, [key, value]) => {
- if (isBs5ToBs3SizeMapKey(key)) {
- const bs3Size = bs5ToBs3SizeMap[key]
-
- if (bs3Size) {
- if (typeof value === 'object') {
- prev[bs3Size] = value.span
- prev[`${bs3Size}Offset`] = value.offset
- } else {
- prev[bs3Size] = value
- }
- }
- }
-
- return prev
- },
- {} as Record
- )
-
- // Add a default sizing for `col-xs-12` if no sizing is available
- if (
- !Object.keys(sizes).some(key => ['xs', 'sm', 'md', 'lg'].includes(key))
- ) {
- sizes.xs = 12
- }
-
- return sizes
- }
-
- const bs3ColProps: React.ComponentProps = {
- children: rest.children,
- className: rest.className,
- ...getBs3Sizes(rest),
- ...bs3Props,
- }
-
- return (
- }
- bs5={ }
- />
- )
+function OLCol(props: React.ComponentProps) {
+ return
}
export default OLCol
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-dropdown-menu-item.tsx b/services/web/frontend/js/features/ui/components/ol/ol-dropdown-menu-item.tsx
index 59567ecce9..20d05d5346 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-dropdown-menu-item.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-dropdown-menu-item.tsx
@@ -1,43 +1,13 @@
-import { MenuItem, MenuItemProps } from 'react-bootstrap'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { DropdownItemProps } from '@/features/ui/components/types/dropdown-menu-props'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item'
-type OLDropdownMenuItemProps = DropdownItemProps & {
- bs3Props?: MenuItemProps
-}
-
// This represents a menu item. It wraps the item within an element.
-function OLDropdownMenuItem(props: OLDropdownMenuItemProps) {
- const { bs3Props, ...rest } = props
-
- const bs3MenuItemProps: MenuItemProps = {
- children: rest.leadingIcon ? (
- <>
- {rest.leadingIcon}
-
- {rest.children}
- >
- ) : (
- rest.children
- ),
- onClick: rest.onClick,
- href: rest.href,
- download: rest.download,
- eventKey: rest.eventKey,
- ...bs3Props,
- }
-
+function OLDropdownMenuItem(props: DropdownItemProps) {
return (
- }
- bs5={
-
-
-
- }
- />
+
+
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx
index 27de2f4ed3..d82e49ea2d 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx
@@ -1,7 +1,4 @@
import { Form, FormCheckProps } from 'react-bootstrap-5'
-import { Checkbox as BS3Checkbox } from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
import { MergeAndOverride } from '../../../../../../types/utils'
import FormText from '../bootstrap-5/form/form-text'
@@ -9,128 +6,36 @@ type OLFormCheckboxProps = MergeAndOverride<
FormCheckProps,
{
inputRef?: React.MutableRefObject
- bs3Props?: Record
} & (
| { description: string; id: string }
| { description?: undefined; id?: string }
)
>
-type RadioButtonProps = {
- checked?: boolean
- className?: string
- description?: string
- disabled?: boolean
- id: string
- name?: string
- onChange: (e: React.ChangeEvent) => void
- required?: boolean
- label: React.ReactElement | string
- value: string
-}
-
function OLFormCheckbox(props: OLFormCheckboxProps) {
- const { bs3Props, inputRef, ...rest } = props
+ const { inputRef, ...rest } = props
- const bs3FormCheckboxProps: React.ComponentProps = {
- children: rest.label,
- checked: rest.checked,
- value: rest.value,
- id: rest.id,
- name: rest.name,
- required: rest.required,
- readOnly: rest.readOnly,
- disabled: rest.disabled,
- inline: rest.inline,
- title: rest.title,
- autoComplete: rest.autoComplete,
- defaultChecked: rest.defaultChecked,
- className: rest.className,
- onChange: rest.onChange as (e: React.ChangeEvent) => void,
- inputRef: node => {
- if (inputRef) {
- inputRef.current = node
- }
- },
- ...getAriaAndDataProps(rest),
- ...bs3Props,
- }
-
- return (
-
- ) : (
-
- )
- }
- bs5={
- rest.type === 'radio' ? (
-
- {rest.label}
- {rest.description && (
-
- {rest.description}
-
- )}
- >
- }
- />
- ) : (
-
- )
+ return rest.type === 'radio' ? (
+
+ {rest.label}
+ {rest.description && (
+
+ {rest.description}
+
+ )}
+ >
}
/>
- )
-}
-
-function BS3Radio(props: RadioButtonProps) {
- const {
- label,
- checked,
- className,
- description,
- disabled,
- id,
- name,
- onChange,
- required,
- value,
- } = props
- return (
-
-
-
- {label}
- {' '}
- {description && (
-
- {description}
-
- )}
-
+ ) : (
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-control.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-control.tsx
index 398d2556b3..c9caf656ad 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form-control.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form-control.tsx
@@ -1,99 +1,28 @@
-import { forwardRef, ComponentProps, useCallback } from 'react'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+import { forwardRef } from 'react'
import FormControl, {
type OLBS5FormControlProps,
} from '@/features/ui/components/bootstrap-5/form/form-control'
-import BS3FormControl from '@/features/ui/components/bootstrap-3/form/form-control'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import OLSpinner from '@/features/ui/components/ol/ol-spinner'
-import Icon from '@/shared/components/icon'
-import type { BsPrefixRefForwardingComponent } from 'react-bootstrap-5/helpers'
type OLFormControlProps = OLBS5FormControlProps & {
- bs3Props?: Record
'data-ol-dirty'?: unknown
'main-field'?: any // For the CM6's benefit in the editor search panel
loading?: boolean
}
-type BS3FormControlProps = ComponentProps & {
- 'main-field'?: any
-}
+const OLFormControl = forwardRef(
+ (props, ref) => {
+ const { append, ...rest } = props
-const OLFormControl: BsPrefixRefForwardingComponent<
- 'input',
- OLFormControlProps
-> = forwardRef((props, ref) => {
- const { bs3Props, append, ...rest } = props
-
- // Use a callback so that the ref passed to the BS3 FormControl is stable
- const bs3InputRef = useCallback(
- (inputElement: HTMLInputElement) => {
- if (typeof ref === 'function') {
- ref(inputElement)
- } else if (ref) {
- ref.current = inputElement
- }
- },
- [ref]
- )
-
- let bs3FormControlProps: BS3FormControlProps = {
- inputRef: bs3InputRef,
- componentClass: rest.as,
- bsSize: rest.size,
- id: rest.id,
- name: rest.name,
- className: rest.className,
- style: rest.style,
- type: rest.type,
- value: rest.value,
- defaultValue: rest.defaultValue,
- required: rest.required,
- disabled: rest.disabled,
- placeholder: rest.placeholder,
- readOnly: rest.readOnly,
- autoComplete: rest.autoComplete,
- autoFocus: rest.autoFocus,
- minLength: rest.minLength,
- maxLength: rest.maxLength,
- onChange: rest.onChange as BS3FormControlProps['onChange'],
- onKeyDown: rest.onKeyDown as BS3FormControlProps['onKeyDown'],
- onFocus: rest.onFocus as BS3FormControlProps['onFocus'],
- onBlur: rest.onBlur as BS3FormControlProps['onBlur'],
- onInvalid: rest.onInvalid as BS3FormControlProps['onInvalid'],
- onPaste: rest.onPaste as BS3FormControlProps['onPaste'],
- prepend: rest.prepend,
- size: rest.htmlSize,
- 'main-field': rest['main-field'],
- children: rest.children,
- ...bs3Props,
+ return (
+ : append}
+ />
+ )
}
-
- bs3FormControlProps = {
- ...bs3FormControlProps,
- ...getAriaAndDataProps(rest),
- 'data-ol-dirty': rest['data-ol-dirty'],
- } as typeof bs3FormControlProps & Record
-
- return (
- : append}
- />
- }
- bs5={
- : append}
- />
- }
- />
- )
-})
+)
OLFormControl.displayName = 'OLFormControl'
export default OLFormControl
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx
index 2ff193e0d8..1c850c1ffc 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx
@@ -1,38 +1,14 @@
import { Form } from 'react-bootstrap-5'
-import {
- HelpBlock as BS3HelpBlock,
- HelpBlockProps as BS3HelpBlockProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { ComponentProps } from 'react'
-import classnames from 'classnames'
import FormFeedback from '@/features/ui/components/bootstrap-5/form/form-feedback'
type OLFormFeedbackProps = Pick<
ComponentProps,
'type' | 'className' | 'children'
-> & {
- bs3Props?: Record
-}
+>
function OLFormFeedback(props: OLFormFeedbackProps) {
- const { bs3Props, children, ...bs5Props } = props
-
- const bs3HelpBlockProps: BS3HelpBlockProps = {
- className: classnames(
- bs5Props.className,
- bs5Props.type === 'invalid' ? 'invalid-only' : null
- ),
- children,
- ...bs3Props,
- }
-
- return (
- }
- bs5={{children} }
- />
- )
+ return
}
export default OLFormFeedback
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx
index 1da249865b..8ccc974d4e 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx
@@ -1,42 +1,8 @@
import { FormGroupProps } from 'react-bootstrap-5'
-import {
- FormGroup as BS3FormGroup,
- FormGroupProps as BS3FormGroupProps,
- FormControl,
-} from 'react-bootstrap'
import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import classNames from 'classnames'
-type OLFormGroupProps = FormGroupProps & {
- bs3Props?: {
- withFeedback?: boolean
- hiddenLabel?: boolean
- validationState?: BS3FormGroupProps['validationState']
- }
-}
-
-function OLFormGroup(props: OLFormGroupProps) {
- const { bs3Props, className, ...rest } = props
- const { withFeedback, hiddenLabel, ...bs3PropsRest } = bs3Props || {}
-
- const bs3FormGroupProps: BS3FormGroupProps = {
- controlId: rest.controlId,
- className: classNames(className, { 'hidden-label': hiddenLabel }),
- ...bs3PropsRest,
- }
-
- return (
-
- {rest.children}
- {withFeedback ? : null}
-
- }
- bs5={ }
- />
- )
+function OLFormGroup(props: FormGroupProps) {
+ return
}
export default OLFormGroup
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-label.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-label.tsx
index 5ce2f7e556..1e1038f9e3 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form-label.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form-label.tsx
@@ -1,28 +1,7 @@
import { Form } from 'react-bootstrap-5'
-import { ControlLabel as BS3FormLabel } from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-type OLFormLabelProps = React.ComponentProps<(typeof Form)['Label']> & {
- bs3Props?: Record
-}
-
-function OLFormLabel(props: OLFormLabelProps) {
- const { bs3Props, ...rest } = props
-
- const bs3FormLabelProps: React.ComponentProps = {
- children: rest.children,
- htmlFor: rest.htmlFor,
- srOnly: rest.visuallyHidden,
- className: rest.className,
- ...bs3Props,
- }
-
- return (
- }
- bs5={ }
- />
- )
+function OLFormLabel(props: React.ComponentProps<(typeof Form)['Label']>) {
+ return
}
export default OLFormLabel
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx
index f9f757abcb..dfcd147dfd 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx
@@ -1,56 +1,9 @@
import { forwardRef } from 'react'
import { Form, FormSelectProps } from 'react-bootstrap-5'
-import {
- FormControl as BS3FormControl,
- FormControlProps as BS3FormControlProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLFormSelectProps = FormSelectProps & {
- bs3Props?: Record
-}
-
-const OLFormSelect = forwardRef(
+const OLFormSelect = forwardRef(
(props, ref) => {
- const { bs3Props, ...bs5Props } = props
-
- const bs3FormSelectProps: BS3FormControlProps = {
- children: bs5Props.children,
- bsSize: bs5Props.size,
- name: bs5Props.name,
- value: bs5Props.value,
- defaultValue: bs5Props.defaultValue,
- disabled: bs5Props.disabled,
- onChange: bs5Props.onChange as BS3FormControlProps['onChange'],
- required: bs5Props.required,
- placeholder: bs5Props.placeholder,
- className: bs5Props.className,
- inputRef: (inputElement: HTMLInputElement) => {
- if (typeof ref === 'function') {
- ref(inputElement as unknown as HTMLSelectElement)
- } else if (ref) {
- ref.current = inputElement as unknown as HTMLSelectElement
- }
- },
- ...bs3Props,
- }
-
- // Get all `aria-*` and `data-*` attributes
- const extraProps = getAriaAndDataProps(bs5Props)
-
- return (
-
- }
- bs5={ }
- />
- )
+ return
}
)
OLFormSelect.displayName = 'OLFormSelect'
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-switch.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-switch.tsx
index b751642d8b..a9a6ffe041 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form-switch.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form-switch.tsx
@@ -1,53 +1,19 @@
import { FormCheck, FormCheckProps, FormLabel } from 'react-bootstrap-5'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
type OLFormSwitchProps = FormCheckProps & {
inputRef?: React.MutableRefObject
- bs3Props?: React.InputHTMLAttributes
}
function OLFormSwitch(props: OLFormSwitchProps) {
- const { bs3Props, inputRef, label, id, ...rest } = props
-
- const bs3FormSwitchProps: React.InputHTMLAttributes = {
- id,
- checked: rest.checked,
- required: rest.required,
- readOnly: rest.readOnly,
- disabled: rest.disabled,
- autoComplete: rest.autoComplete,
- defaultChecked: rest.defaultChecked,
- onChange: rest.onChange as (e: React.ChangeEvent) => void,
- ...getAriaAndDataProps(rest),
- ...bs3Props,
- }
+ const { inputRef, label, id, ...rest } = props
return (
-
-
-
- {label}
-
-
- }
- bs5={
- <>
-
-
- {label}
-
- >
- }
- />
+ <>
+
+
+ {label}
+
+ >
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-text.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-text.tsx
index d465eb4050..d35654fa79 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form-text.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form-text.tsx
@@ -1,36 +1,9 @@
import FormText, {
FormTextProps,
- getFormTextClass,
} from '@/features/ui/components/bootstrap-5/form/form-text'
-import PolymorphicComponent from '@/shared/components/polymorphic-component'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import classnames from 'classnames'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLFormTextProps = FormTextProps & {
- bs3Props?: Record
-}
-
-function OLFormText({ as = 'div', ...props }: OLFormTextProps) {
- const { bs3Props, ...rest } = props
-
- const bs3HelpBlockProps = {
- children: rest.children,
- className: classnames('small', rest.className, getFormTextClass(rest.type)),
- ...bs3Props,
- } as const satisfies React.ComponentProps
-
- // Get all `aria-*` and `data-*` attributes
- const extraProps = getAriaAndDataProps(rest)
-
- return (
-
- }
- bs5={ }
- />
- )
+function OLFormText({ as = 'div', ...rest }: FormTextProps) {
+ return
}
export default OLFormText
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form.tsx
index 32fd5073ea..724578769e 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-form.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-form.tsx
@@ -1,45 +1,8 @@
import { Form } from 'react-bootstrap-5'
-import { Form as BS3Form, FormProps as BS3FormProps } from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { ComponentProps } from 'react'
-import classnames from 'classnames'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLFormProps = ComponentProps & {
- bs3Props?: ComponentProps
-}
-
-function OLForm(props: OLFormProps) {
- const { bs3Props, ...rest } = props
-
- const bs3FormProps: BS3FormProps = {
- componentClass: rest.as,
- children: rest.children,
- id: rest.id,
- onSubmit: rest.onSubmit as BS3FormProps['onSubmit'],
- onClick: rest.onClick as BS3FormProps['onClick'],
- name: rest.name,
- noValidate: rest.noValidate,
- role: rest.role,
- ...bs3Props,
- }
-
- const bs3ClassName = classnames(
- rest.className,
- rest.validated ? 'was-validated' : null
- )
-
- // Get all `aria-*` and `data-*` attributes
- const extraProps = getAriaAndDataProps(rest)
-
- return (
-
- }
- bs5={}
- />
- )
+function OLForm(props: ComponentProps) {
+ return
}
export default OLForm
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-icon-button.tsx b/services/web/frontend/js/features/ui/components/ol/ol-icon-button.tsx
index 5d34b1c2ff..27e5960133 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-icon-button.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-icon-button.tsx
@@ -1,54 +1,13 @@
import { forwardRef } from 'react'
-import { bs3ButtonProps, BS3ButtonSize } from './ol-button'
-import { Button as BS3Button } from 'react-bootstrap'
import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
-import BootstrapVersionSwitcher from '../bootstrap-5/bootstrap-version-switcher'
-import Icon, { IconProps } from '@/shared/components/icon'
import IconButton from '../bootstrap-5/icon-button'
-import { callFnsInSequence } from '@/utils/functions'
-export type OLIconButtonProps = IconButtonProps & {
- bs3Props?: {
- loading?: React.ReactNode
- fw?: IconProps['fw']
- className?: string
- bsSize?: BS3ButtonSize
- onMouseOver?: React.MouseEventHandler
- onMouseOut?: React.MouseEventHandler
- onFocus?: React.FocusEventHandler
- onBlur?: React.FocusEventHandler
- }
-}
+export type OLIconButtonProps = IconButtonProps
const OLIconButton = forwardRef(
(props, ref) => {
- const { bs3Props, ...rest } = props
-
- const { fw, loading, ...bs3Rest } = bs3Props || {}
-
- // BS3 OverlayTrigger automatically provides 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' event handlers
- const bs3FinalProps = {
- 'aria-label': rest.accessibilityLabel,
- ...bs3ButtonProps(rest),
- ...bs3Rest,
- onMouseOver: callFnsInSequence(bs3Props?.onMouseOver, rest.onMouseOver),
- onMouseOut: callFnsInSequence(bs3Props?.onMouseOut, rest.onMouseOut),
- onFocus: callFnsInSequence(bs3Props?.onFocus, rest.onFocus),
- onBlur: callFnsInSequence(bs3Props?.onBlur, rest.onBlur),
- }
-
- // BS3 tooltip relies on the 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' props
// BS5 tooltip relies on the ref
- return (
-
- {loading || }
-
- }
- bs5={ }
- />
- )
+ return
}
)
OLIconButton.displayName = 'OLIconButton'
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx b/services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx
index c177ee3a1c..a27457fa7a 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx
@@ -1,40 +1,13 @@
import { ListGroupItem, ListGroupItemProps } from 'react-bootstrap-5'
-import {
- ListGroupItem as BS3ListGroupItem,
- ListGroupItemProps as BS3ListGroupItemProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLListGroupItemProps = ListGroupItemProps & {
- bs3Props?: BS3ListGroupItemProps
-}
-
-function OLListGroupItem(props: OLListGroupItemProps) {
- const { bs3Props, ...rest } = props
-
- const bs3ListGroupItemProps: BS3ListGroupItemProps = {
- children: rest.children,
- active: rest.active,
- disabled: rest.disabled,
- href: rest.href,
- onClick: rest.onClick as BS3ListGroupItemProps['onClick'],
- ...bs3Props,
- }
-
- const extraProps = getAriaAndDataProps(rest)
- const as = rest.as ?? 'button'
+function OLListGroupItem(props: ListGroupItemProps) {
+ const as = props.as ?? 'button'
return (
- }
- bs5={
-
- }
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx b/services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx
index 65af7b3ec3..a28c7e977d 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx
@@ -1,34 +1,9 @@
import { ListGroup, ListGroupProps } from 'react-bootstrap-5'
-import {
- ListGroup as BS3ListGroup,
- ListGroupProps as BS3ListGroupProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLListGroupProps = ListGroupProps & {
- bs3Props?: BS3ListGroupProps
-}
+function OLListGroup(props: ListGroupProps) {
+ const as = props.as ?? 'div'
-function OLListGroup(props: OLListGroupProps) {
- const { bs3Props, ...rest } = props
-
- const bs3ListGroupProps: BS3ListGroupProps = {
- children: rest.children,
- role: rest.role,
- componentClass: rest.as,
- ...bs3Props,
- }
-
- const extraProps = getAriaAndDataProps(rest)
- const as = rest.as ?? 'div'
-
- return (
- }
- bs5={ }
- />
- )
+ return
}
export default OLListGroup
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-modal.tsx b/services/web/frontend/js/features/ui/components/ol/ol-modal.tsx
index 43dcddcf87..bf20d18eef 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-modal.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-modal.tsx
@@ -1,133 +1,37 @@
import {
- Modal as BS5Modal,
+ Modal,
ModalProps,
ModalHeaderProps,
ModalTitleProps,
- ModalBody,
ModalFooterProps,
} from 'react-bootstrap-5'
-import {
- Modal as BS3Modal,
- ModalProps as BS3ModalProps,
- ModalHeaderProps as BS3ModalHeaderProps,
- ModalTitleProps as BS3ModalTitleProps,
- ModalBodyProps as BS3ModalBodyProps,
- ModalFooterProps as BS3ModalFooterProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import AccessibleModal from '@/shared/components/accessible-modal'
+import { ModalBodyProps } from 'react-bootstrap-5/ModalBody'
type OLModalProps = ModalProps & {
- bs3Props?: Record
size?: 'sm' | 'lg'
onHide: () => void
}
-type OLModalHeaderProps = ModalHeaderProps & {
- bs3Props?: Record
-}
-
-type OLModalTitleProps = ModalTitleProps & {
- bs3Props?: Record
-}
-
-type OLModalBodyProps = React.ComponentProps & {
- bs3Props?: Record
-}
-
-type OLModalFooterProps = ModalFooterProps & {
- bs3Props?: Record
-}
-
export default function OLModal({ children, ...props }: OLModalProps) {
- const { bs3Props, ...bs5Props } = props
+ return {children}
+}
- const bs3ModalProps: BS3ModalProps = {
- bsClass: bs5Props.bsPrefix,
- bsSize: bs5Props.size,
- show: bs5Props.show,
- onHide: bs5Props.onHide,
- onExited: bs5Props.onExited,
- backdrop: bs5Props.backdrop,
- animation: bs5Props.animation,
- id: bs5Props.id,
- className: bs5Props.className,
- backdropClassName: bs5Props.backdropClassName,
- ...bs3Props,
- }
+export function OLModalHeader({ children, ...props }: ModalHeaderProps) {
+ return {children}
+}
+export function OLModalTitle({ children, ...props }: ModalTitleProps) {
return (
- {children}}
- bs5={{children} }
- />
+
+ {children}
+
)
}
-export function OLModalHeader({ children, ...props }: OLModalHeaderProps) {
- const { bs3Props, ...bs5Props } = props
-
- const bs3ModalProps: BS3ModalHeaderProps = {
- bsClass: bs5Props.bsPrefix,
- onHide: bs5Props.onHide,
- closeButton: bs5Props.closeButton,
- closeLabel: bs5Props.closeLabel,
- }
- return (
- {children}}
- bs5={{children} }
- />
- )
+export function OLModalBody({ children, ...props }: ModalBodyProps) {
+ return {children}
}
-export function OLModalTitle({ children, ...props }: OLModalTitleProps) {
- const { bs3Props, ...bs5Props } = props
-
- const bs3ModalProps: BS3ModalTitleProps = {
- componentClass: bs5Props.as,
- }
-
- return (
- {children}}
- bs5={
-
- {children}
-
- }
- />
- )
-}
-
-export function OLModalBody({ children, ...props }: OLModalBodyProps) {
- const { bs3Props, ...bs5Props } = props
-
- const bs3ModalProps: BS3ModalBodyProps = {
- componentClass: bs5Props.as,
- className: bs5Props.className,
- }
-
- return (
- {children}}
- bs5={{children} }
- />
- )
-}
-
-export function OLModalFooter({ children, ...props }: OLModalFooterProps) {
- const { bs3Props, ...bs5Props } = props
-
- const bs3ModalProps: BS3ModalFooterProps = {
- componentClass: bs5Props.as,
- className: bs5Props.className,
- }
-
- return (
- {children}}
- bs5={{children} }
- />
- )
+export function OLModalFooter({ children, ...props }: ModalFooterProps) {
+ return {children}
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-notification.tsx b/services/web/frontend/js/features/ui/components/ol/ol-notification.tsx
index e312d48c91..7ca811a796 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-notification.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-notification.tsx
@@ -1,42 +1,10 @@
import Notification from '@/shared/components/notification'
-import { Alert, AlertProps } from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import classnames from 'classnames'
-
-type OLNotificationProps = React.ComponentProps & {
- bs3Props?: {
- icon?: React.ReactElement
- className?: string
- }
-}
-
-function OLNotification(props: OLNotificationProps) {
- const { bs3Props, ...notificationProps } = props
-
- const alertProps = {
- // Map `error` to `danger`
- bsStyle:
- notificationProps.type === 'error' ? 'danger' : notificationProps.type,
- className: classnames(notificationProps.className, bs3Props?.className),
- onDismiss: notificationProps.onDismiss,
- } as const satisfies AlertProps
+function OLNotification(props: React.ComponentProps) {
return (
-
- {bs3Props?.icon}
- {bs3Props?.icon && ' '}
- {notificationProps.content}
- {notificationProps.action}
-
- }
- bs5={
-
-
-
- }
- />
+
+
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx b/services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx
index 40b7872bc8..bcf2a024c2 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx
@@ -1,61 +1,7 @@
import { Overlay, OverlayProps } from 'react-bootstrap-5'
-import {
- Overlay as BS3Overlay,
- OverlayProps as BS3OverlayProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-type OLOverlayProps = OverlayProps & {
- bs3Props?: BS3OverlayProps
-}
-
-function OLOverlay(props: OLOverlayProps) {
- const { bs3Props, ...bs5Props } = props
-
- let bs3OverlayProps: BS3OverlayProps = {
- children: bs5Props.children,
- target: bs5Props.target as BS3OverlayProps['target'],
- container: bs5Props.container,
- containerPadding: bs5Props.containerPadding,
- show: bs5Props.show,
- rootClose: bs5Props.rootClose,
- animation: bs5Props.transition,
- onHide: bs5Props.onHide as BS3OverlayProps['onHide'],
- onEnter: bs5Props.onEnter as BS3OverlayProps['onEnter'],
- onEntering: bs5Props.onEntering as BS3OverlayProps['onEntering'],
- onEntered: bs5Props.onEntered as BS3OverlayProps['onEntered'],
- onExit: bs5Props.onExit as BS3OverlayProps['onExit'],
- onExiting: bs5Props.onExiting as BS3OverlayProps['onExiting'],
- onExited: bs5Props.onExited as BS3OverlayProps['onExited'],
- }
-
- if (bs5Props.placement) {
- const bs3PlacementOptions = [
- 'top',
- 'right',
- 'bottom',
- 'left',
- ] satisfies Array<
- Extract
- >
-
- for (const placement of bs3PlacementOptions) {
- // BS5 has more placement options than BS3, such as "left-start", so these are mapped to "left" etc.
- if (bs5Props.placement.startsWith(placement)) {
- bs3OverlayProps.placement = placement
- break
- }
- }
- }
-
- bs3OverlayProps = { ...bs3OverlayProps, ...bs3Props }
-
- return (
- }
- bs5={ }
- />
- )
+function OLOverlay(props: OverlayProps) {
+ return
}
export default OLOverlay
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-page-content-card.tsx b/services/web/frontend/js/features/ui/components/ol/ol-page-content-card.tsx
index ba6fc75ff9..c10de1c0c6 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-page-content-card.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-page-content-card.tsx
@@ -1,24 +1,17 @@
import { Card, CardBody } from 'react-bootstrap-5'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { FC } from 'react'
import classNames from 'classnames'
// This wraps the Bootstrap 5 Card component but is restricted to the very
// basic way we're using it, which is as a container for page content. The
-// Bootstrap 3 equivalent in our codebase is a div with class "card"
-const OLPageContentCard: FC<{ className?: string }> = ({
- children,
- className,
-}) => {
+// Bootstrap 3 equivalent previously in our codebase is a div with class "card"
+const OLPageContentCard: FC<
+ React.PropsWithChildren<{ className?: string }>
+> = ({ children, className }) => {
return (
- {children} }
- bs5={
-
- {children}
-
- }
- />
+
+ {children}
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-popover.tsx b/services/web/frontend/js/features/ui/components/ol/ol-popover.tsx
index fbf74d3225..772084bc22 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-popover.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-popover.tsx
@@ -1,83 +1,18 @@
import { forwardRef } from 'react'
import { Popover, PopoverProps } from 'react-bootstrap-5'
-import {
- Popover as BS3Popover,
- PopoverProps as BS3PopoverProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type OLPopoverProps = Omit & {
title?: React.ReactNode
- bs3Props?: BS3PopoverProps
}
const OLPopover = forwardRef((props, ref) => {
- // BS3 passes in some props automatically so the `props`
- // type should be adjusted to reflect the actual received object
- const propsCombinedWithAutoInjectedBs3Props = props as OLPopoverProps &
- Pick<
- BS3PopoverProps,
- 'arrowOffsetLeft' | 'arrowOffsetTop' | 'positionLeft' | 'positionTop'
- >
-
- const {
- bs3Props,
- title,
- children,
- arrowOffsetLeft,
- arrowOffsetTop,
- positionLeft,
- positionTop,
- ...bs5Props
- } = propsCombinedWithAutoInjectedBs3Props
-
- let bs3PopoverProps: BS3PopoverProps = {
- children,
- arrowOffsetLeft,
- arrowOffsetTop,
- positionLeft,
- positionTop,
- title,
- id: bs5Props.id,
- className: bs5Props.className,
- style: bs5Props.style,
- }
-
- if (bs5Props.placement) {
- const bs3PlacementOptions = [
- 'top',
- 'right',
- 'bottom',
- 'left',
- ] satisfies Array<
- Extract
- >
-
- for (const placement of bs3PlacementOptions) {
- if (placement === bs5Props.placement) {
- bs3PopoverProps.placement = bs5Props.placement
- break
- }
- }
- }
-
- bs3PopoverProps = { ...bs3PopoverProps, ...bs3Props }
+ const { title, children, ...bs5Props } = props
return (
- }
- />
- }
- bs5={
-
- {title && {title} }
- {children}
-
- }
- />
+
+ {title && {title} }
+ {children}
+
)
})
OLPopover.displayName = 'OLPopover'
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-row.tsx b/services/web/frontend/js/features/ui/components/ol/ol-row.tsx
index ef68a6887f..88c05ce102 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-row.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-row.tsx
@@ -1,16 +1,7 @@
import { Row } from 'react-bootstrap-5'
-import { Row as BS3Row } from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-type OLRowProps = React.ComponentProps
-
-function OLRow(props: OLRowProps) {
- return (
- {props.children}}
- bs5={
}
- />
- )
+function OLRow(props: React.ComponentProps) {
+ return
}
export default OLRow
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx b/services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx
index 615f5acd47..4c1be6b125 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx
@@ -1,29 +1,14 @@
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import Icon from '@/shared/components/icon'
import { Spinner } from 'react-bootstrap-5'
-import classNames from 'classnames'
export type OLSpinnerSize = 'sm' | 'lg'
function OLSpinner({ size = 'sm' }: { size: OLSpinnerSize }) {
return (
-
- }
- bs5={
-
- }
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-table.tsx b/services/web/frontend/js/features/ui/components/ol/ol-table.tsx
index 3406c69624..82d2b69857 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-table.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-table.tsx
@@ -1,32 +1,7 @@
import Table from '@/features/ui/components/bootstrap-5/table'
-import { Table as BS3Table } from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLFormProps = React.ComponentProps & {
- bs3Props?: React.ComponentProps
-}
-
-function OLTable(props: OLFormProps) {
- const { bs3Props, container, ...rest } = props
-
- const bs3FormProps: React.ComponentProps = {
- bsClass: rest.className,
- id: rest.id,
- condensed: rest.size === 'sm',
- children: rest.children,
- responsive:
- typeof rest.responsive !== 'string' ? rest.responsive : undefined,
- ...getAriaAndDataProps(rest),
- ...bs3Props,
- }
-
- return (
- }
- bs5={}
- />
- )
+function OLTable(props: React.ComponentProps) {
+ return
}
export default OLTable
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-tag.tsx b/services/web/frontend/js/features/ui/components/ol/ol-tag.tsx
index 15774b9e41..bff3fdf1b9 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-tag.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-tag.tsx
@@ -1,37 +1,10 @@
import Tag from '@/features/ui/components/bootstrap-5/tag'
-import BS3Tag from '@/shared/components/tag'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { forwardRef } from 'react'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type OLTagProps = React.ComponentProps & {
- bs3Props?: React.ComponentProps
-}
+type OLTagProps = React.ComponentProps
const OLTag = forwardRef((props: OLTagProps, ref) => {
- const { bs3Props, ...rest } = props
-
- const bs3TagProps: React.ComponentProps = {
- children: rest.children,
- prepend: rest.prepend,
- closeBtnProps: rest.closeBtnProps,
- className: rest.className,
- onClick: rest.onClick,
- onFocus: rest.onFocus,
- onBlur: rest.onBlur,
- onMouseOver: rest.onMouseOver,
- onMouseOut: rest.onMouseOut,
- contentProps: rest.contentProps,
- ...getAriaAndDataProps(rest),
- ...bs3Props,
- }
-
- return (
- }
- bs5={ }
- />
- )
+ return
})
OLTag.displayName = 'OLTag'
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx
index 2934ce6b3b..ca8250bf49 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx
@@ -1,31 +1,17 @@
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-
import { CSSProperties, FC } from 'react'
import { ToastContainer as BS5ToastContainer } from 'react-bootstrap-5'
-import { ToastContainer as BS3ToastContainer } from '../bootstrap-3/toast-container'
type OLToastContainerProps = {
style?: CSSProperties
className?: string
}
-export const OLToastContainer: FC = ({
- children,
- className,
- style,
-}) => {
+export const OLToastContainer: FC<
+ React.PropsWithChildren
+> = ({ children, className, style }) => {
return (
-
- {children}
-
- }
- bs3={
-
- {children}
-
- }
- />
+
+ {children}
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx
index 893d959707..7460a6b269 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx
@@ -7,8 +7,6 @@ import {
import { useTranslation } from 'react-i18next'
import MaterialIcon from '../../../../shared/components/material-icon'
import { ReactNode, useCallback, useState } from 'react'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { Toast as BS3Toast } from '../bootstrap-3/toast'
export type OLToastProps = {
type: NotificationType
@@ -78,29 +76,14 @@ export const OLToast = ({
)
return (
-
- {toastElement}
-
- }
- bs3={
-
- {toastElement}
-
- }
- />
+
+ {toastElement}
+
)
}
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx
index ea874c48a7..35777d29a6 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx
@@ -1,42 +1,7 @@
import { ToggleButtonGroup, ToggleButtonGroupProps } from 'react-bootstrap-5'
-import BS3ToggleButtonGroup from '@/features/ui/components/bootstrap-3/toggle-button-group'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-type BS3ToggleButtonGroupProps = React.ComponentProps<
- typeof BS3ToggleButtonGroup
->
-
-type OLToggleButtonGroupProps = ToggleButtonGroupProps & {
- bs3Props?: BS3ToggleButtonGroupProps
-}
-
-function OLToggleButtonGroup(props: OLToggleButtonGroupProps) {
- const { bs3Props, ...rest } = props
-
- const bs3ToggleButtonGroupProps = {
- name: rest.name,
- type: rest.type,
- value: rest.value,
- onChange: rest.onChange,
- children: rest.children,
- className: rest.className,
- defaultValue: rest.defaultValue,
- defaultChecked: rest.defaultChecked,
- ...bs3Props,
- } as BS3ToggleButtonGroupProps
-
- // Get all `aria-*` and `data-*` attributes
- const extraProps = getAriaAndDataProps(rest)
-
- return (
-
- }
- bs5={ }
- />
- )
+function OLToggleButtonGroup(props: ToggleButtonGroupProps) {
+ return
}
export default OLToggleButtonGroup
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx
index 4415e71bbe..d56d0b216a 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx
@@ -1,48 +1,7 @@
import { ToggleButton, ToggleButtonProps } from 'react-bootstrap-5'
-import {
- ToggleButton as BS3ToggleButton,
- ToggleButtonProps as BS3ToggleButtonProps,
-} from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-import classnames from 'classnames'
-type OLToggleButtonProps = ToggleButtonProps & {
- bs3Props?: BS3ToggleButtonProps
-}
-
-function OLToggleButton(props: OLToggleButtonProps) {
- const { bs3Props, ...rest } = props
-
- const bs3ToggleButtonProps: BS3ToggleButtonProps & { active?: boolean } = {
- type: rest.type,
- name: rest.name,
- active: rest.active,
- checked: rest.checked,
- disabled: rest.disabled,
- onChange: rest.onChange as BS3ToggleButtonProps['onChange'],
- onClick: rest.onClick as BS3ToggleButtonProps['onClick'],
- value: rest.value as BS3ToggleButtonProps['value'],
- children: rest.children,
- className: classnames(`btn-${props.variant || 'primary'}`, rest.className),
- ...bs3Props,
- }
-
- // Get all `aria-*` and `data-*` attributes
- const extraProps = getAriaAndDataProps(rest)
-
- return (
-
- }
- bs5={ }
- />
- )
+function OLToggleButton(props: ToggleButtonProps) {
+ return
}
export default OLToggleButton
diff --git a/services/web/frontend/js/features/ui/components/ol/ol-tooltip.tsx b/services/web/frontend/js/features/ui/components/ol/ol-tooltip.tsx
index c3f152e44e..eca0a7d5b9 100644
--- a/services/web/frontend/js/features/ui/components/ol/ol-tooltip.tsx
+++ b/services/web/frontend/js/features/ui/components/ol/ol-tooltip.tsx
@@ -1,48 +1,7 @@
import Tooltip from '@/features/ui/components/bootstrap-5/tooltip'
-import BS3Tooltip from '@/shared/components/tooltip'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-type OLTooltipProps = React.ComponentProps & {
- bs3Props?: Record
-}
-
-function OLTooltip(props: OLTooltipProps) {
- const { bs3Props, ...bs5Props } = props
-
- type BS3TooltipProps = React.ComponentProps
-
- const bs3TooltipProps: BS3TooltipProps = {
- children: bs5Props.children,
- id: bs5Props.id,
- description: bs5Props.description,
- tooltipProps: bs5Props.tooltipProps as BS3TooltipProps,
- overlayProps: {
- placement: bs5Props.overlayProps?.placement,
- rootClose: bs5Props.overlayProps?.rootClose,
- trigger: bs5Props.overlayProps?.trigger,
- },
- ...bs3Props,
- }
-
- if ('hidden' in bs5Props) {
- bs3TooltipProps.hidden = bs5Props.hidden
- }
-
- const delay = bs5Props.overlayProps?.delay
- if (delay && typeof delay !== 'number') {
- bs3TooltipProps.overlayProps = {
- ...bs3TooltipProps.overlayProps,
- delayShow: delay.show,
- delayHide: delay.hide,
- }
- }
-
- return (
- }
- bs5={ }
- />
- )
+function OLTooltip(props: React.ComponentProps) {
+ return
}
export default OLTooltip
diff --git a/services/web/frontend/js/features/ui/components/types/button-props.ts b/services/web/frontend/js/features/ui/components/types/button-props.ts
index fad0c49c6a..e98538e168 100644
--- a/services/web/frontend/js/features/ui/components/types/button-props.ts
+++ b/services/web/frontend/js/features/ui/components/types/button-props.ts
@@ -34,5 +34,4 @@ export type ButtonProps = {
| 'premium'
| 'premium-secondary'
| 'link'
- | 'info'
}
diff --git a/services/web/frontend/js/features/ui/components/types/default-navbar-metadata.ts b/services/web/frontend/js/features/ui/components/types/default-navbar-metadata.ts
index e2970d85ec..b9d6b0c506 100644
--- a/services/web/frontend/js/features/ui/components/types/default-navbar-metadata.ts
+++ b/services/web/frontend/js/features/ui/components/types/default-navbar-metadata.ts
@@ -10,6 +10,7 @@ export type DefaultNavbarMetadata = {
canDisplayAdminRedirect: boolean
canDisplaySplitTestMenu: boolean
canDisplaySurveyMenu: boolean
+ canDisplayScriptLogMenu: boolean
enableUpgradeButton: boolean
suppressNavbarRight: boolean
suppressNavContentLinks: boolean
diff --git a/services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts b/services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
index 95cc60e393..bd7db127b7 100644
--- a/services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
+++ b/services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
@@ -30,6 +30,7 @@ export type DropdownProps = {
export type DropdownItemProps = PropsWithChildren<{
active?: boolean
as?: ElementType
+ type?: string
description?: ReactNode
disabled?: boolean
eventKey?: string | number
diff --git a/services/web/frontend/js/features/ui/components/types/icon-button-props.ts b/services/web/frontend/js/features/ui/components/types/icon-button-props.ts
index ec75bb6115..9cee07ebd9 100644
--- a/services/web/frontend/js/features/ui/components/types/icon-button-props.ts
+++ b/services/web/frontend/js/features/ui/components/types/icon-button-props.ts
@@ -1,7 +1,19 @@
+import { AvailableUnfilledIcon } from '@/shared/components/material-icon'
import { ButtonProps } from './button-props'
-export type IconButtonProps = ButtonProps & {
+type BaseIconButtonProps = ButtonProps & {
accessibilityLabel?: string
- icon: string
type?: 'button' | 'submit'
}
+
+type FilledIconButtonProps = BaseIconButtonProps & {
+ icon: string
+ unfilled?: false
+}
+
+type UnfilledIconButtonProps = BaseIconButtonProps & {
+ icon: AvailableUnfilledIcon
+ unfilled: true
+}
+
+export type IconButtonProps = FilledIconButtonProps | UnfilledIconButtonProps
diff --git a/services/web/frontend/js/features/utils/bootstrap-5.ts b/services/web/frontend/js/features/utils/bootstrap-5.ts
index 7b55c89a6a..6e8dd26076 100644
--- a/services/web/frontend/js/features/utils/bootstrap-5.ts
+++ b/services/web/frontend/js/features/utils/bootstrap-5.ts
@@ -3,24 +3,3 @@ import getMeta from '@/utils/meta'
// The reason this is a function is to ensure that the meta tag is read before
// any isBootstrap5 check is performed
export const isBootstrap5 = () => getMeta('ol-bootstrapVersion') === 5
-
-/* eslint-disable no-redeclare */
-export function bsVersion({ bs5 }: { bs5: A }): A | undefined
-export function bsVersion({ bs3 }: { bs3: B }): B | undefined
-export function bsVersion({ bs5, bs3 }: { bs5: A; bs3: B }): A | B
-export function bsVersion({ bs5, bs3 }: { bs5?: unknown; bs3?: unknown }) {
- return isBootstrap5() ? bs5 : bs3
-}
-
-// get all `aria-*` and `data-*` attributes
-export const getAriaAndDataProps = (obj: Record) => {
- return Object.entries(obj).reduce(
- (acc, [key, value]) => {
- if (key.startsWith('aria-') || key.startsWith('data-')) {
- acc[key] = value
- }
- return acc
- },
- {} as Record
- )
-}
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-button.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-button.tsx
new file mode 100644
index 0000000000..1c5f1e9072
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-button.tsx
@@ -0,0 +1,44 @@
+import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
+import { useTranslation } from 'react-i18next'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import LeftMenuButton from '@/features/editor-left-menu/components/left-menu-button'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import { memo } from 'react'
+
+export const WordCountButton = memo<{
+ handleShowModal: () => void
+}>(function WordCountButton({ handleShowModal }) {
+ const { pdfUrl } = useCompileContext()
+ const { t } = useTranslation()
+
+ const enabled = pdfUrl || isSplitTestEnabled('word-count-client')
+
+ if (!enabled) {
+ return (
+
+ {/* OverlayTrigger won't fire unless the child is a non-react html element (e.g div, span) */}
+
+
+ {t('word_count')}
+
+
+
+ )
+ }
+
+ return (
+
+ {t('word_count')}
+
+ )
+})
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx
new file mode 100644
index 0000000000..5f2660d4a8
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx
@@ -0,0 +1,105 @@
+import { FC, useEffect, useMemo, useState } from 'react'
+import { WordCountData } from '@/features/word-count-modal/components/word-count-data'
+import { WordCountLoading } from '@/features/word-count-modal/components/word-count-loading'
+import { WordCountError } from '@/features/word-count-modal/components/word-count-error'
+import { useProjectContext } from '@/shared/context/project-context'
+import useAbortController from '@/shared/hooks/use-abort-controller'
+import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
+import { debugConsole } from '@/utils/debugging'
+import { signalWithTimeout } from '@/utils/abort-signal'
+import { isMainFile } from '@/features/pdf-preview/util/editor-files'
+import { countWordsInFile } from '@/features/word-count-modal/utils/count-words-in-file'
+import { WordCounts } from '@/features/word-count-modal/components/word-counts'
+import { createSegmenters } from '@/features/word-count-modal/utils/segmenters'
+
+export const WordCountClient: FC = () => {
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [data, setData] = useState(null)
+ const { projectSnapshot, rootDocId } = useProjectContext()
+ const { spellCheckLanguage } = useProjectSettingsContext()
+ const { openDocs, currentDocument } = useEditorManagerContext()
+ const { pathInFolder } = useFileTreePathContext()
+
+ const { signal } = useAbortController()
+
+ const segmenters = useMemo(() => {
+ return createSegmenters(spellCheckLanguage?.replace(/_/, '-'))
+ }, [spellCheckLanguage])
+
+ useEffect(() => {
+ if (currentDocument && segmenters) {
+ const countWords = async () => {
+ await openDocs.awaitBufferedOps(signalWithTimeout(signal, 5000))
+ await projectSnapshot.refresh()
+
+ if (signal.aborted) return null
+
+ const currentDocSnapshot = currentDocument.getSnapshot()
+ const currentRootDocId = isMainFile(currentDocSnapshot)
+ ? currentDocument.doc_id
+ : rootDocId
+ if (!currentRootDocId) return null
+
+ const currentRootDocPath = pathInFolder(currentRootDocId)
+ if (!currentRootDocPath) return null
+
+ const data: WordCountData = {
+ encode: 'ascii',
+ textWords: 0,
+ textCharacters: 0,
+ headWords: 0,
+ headCharacters: 0,
+ abstractWords: 0,
+ abstractCharacters: 0,
+ captionWords: 0,
+ captionCharacters: 0,
+ footnoteWords: 0,
+ footnoteCharacters: 0,
+ outside: 0,
+ outsideCharacters: 0,
+ headers: 0,
+ elements: 0,
+ mathInline: 0,
+ mathDisplay: 0,
+ errors: 0,
+ messages: '',
+ }
+
+ countWordsInFile(data, projectSnapshot, currentRootDocPath, segmenters)
+
+ return data
+ }
+
+ countWords()
+ .then(data => {
+ setData(data)
+ })
+ .catch(error => {
+ debugConsole.error(error)
+ setError(true)
+ })
+ .finally(() => {
+ setLoading(false)
+ })
+ }
+ }, [
+ signal,
+ openDocs,
+ projectSnapshot,
+ segmenters,
+ currentDocument,
+ rootDocId,
+ pathInFolder,
+ ])
+
+ return (
+ <>
+ {loading && !error && }
+ {error && }
+ {data && }
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-data.ts b/services/web/frontend/js/features/word-count-modal/components/word-count-data.ts
new file mode 100644
index 0000000000..a69dc55d45
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-data.ts
@@ -0,0 +1,25 @@
+export type ServerWordCountData = {
+ encode: string
+ textWords: number
+ headWords: number
+ outside: number
+ headers: number
+ elements: number
+ mathInline: number
+ mathDisplay: number
+ errors: number
+ messages: string
+}
+
+export type WordCountData = ServerWordCountData & {
+ textCharacters: number
+ headCharacters: number
+ captionWords: number
+ captionCharacters: number
+ footnoteWords: number
+ footnoteCharacters: number
+ abstractWords: number
+ abstractCharacters: number
+ // outsideWords: number
+ outsideCharacters: number
+}
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-error.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-error.tsx
new file mode 100644
index 0000000000..18c10ff7d3
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-error.tsx
@@ -0,0 +1,10 @@
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+import { useTranslation } from 'react-i18next'
+
+export const WordCountError = () => {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx
new file mode 100644
index 0000000000..61a4263b04
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx
@@ -0,0 +1,14 @@
+import { Spinner } from 'react-bootstrap-5'
+import { useTranslation } from 'react-i18next'
+
+export const WordCountLoading = () => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {t('loading')}…
+
+ )
+}
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.jsx b/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.jsx
deleted file mode 100644
index 520b00de6e..0000000000
--- a/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.jsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import PropTypes from 'prop-types'
-import { useTranslation } from 'react-i18next'
-import { useProjectContext } from '@/shared/context/project-context'
-import { useLocalCompileContext } from '@/shared/context/local-compile-context'
-import { useWordCount } from '../hooks/use-word-count'
-import {
- OLModalBody,
- OLModalFooter,
- OLModalHeader,
- OLModalTitle,
-} from '@/features/ui/components/ol/ol-modal'
-import OLNotification from '@/features/ui/components/ol/ol-notification'
-import OLRow from '@/features/ui/components/ol/ol-row'
-import OLCol from '@/features/ui/components/ol/ol-col'
-import OLButton from '@/features/ui/components/ol/ol-button'
-import { Spinner } from 'react-bootstrap-5'
-
-// NOTE: this component is only mounted when the modal is open
-export default function WordCountModalContent({ handleHide }) {
- const { _id: projectId } = useProjectContext()
- const { clsiServerId } = useLocalCompileContext()
- const { t } = useTranslation()
- const { data, error, loading } = useWordCount(projectId, clsiServerId)
-
- return (
- <>
-
- {t('word_count')}
-
-
-
- {loading && !error && (
-
-
-
- {t('loading')}…
-
- )}
-
- {error && (
-
- )}
-
- {data && (
-
- {data.messages && (
-
-
- {data.messages}
- }
- />
-
-
- )}
-
-
-
- {t('total_words')}:
-
- {data.textWords}
-
-
-
-
- {t('headers')}:
-
- {data.headers}
-
-
-
-
- {t('math_inline')}:
-
- {data.mathInline}
-
-
-
-
- {t('math_display')}:
-
- {data.mathDisplay}
-
-
- )}
-
-
-
-
- {t('close')}
-
-
- >
- )
-}
-
-WordCountModalContent.propTypes = {
- handleHide: PropTypes.func.isRequired,
-}
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.tsx
new file mode 100644
index 0000000000..75b53eeeba
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.tsx
@@ -0,0 +1,42 @@
+import { useTranslation } from 'react-i18next'
+import {
+ OLModalBody,
+ OLModalFooter,
+ OLModalHeader,
+ OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import { WordCountServer } from './word-count-server'
+import { WordCountClient } from './word-count-client'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+
+// NOTE: this component is only mounted when the modal is open
+export default function WordCountModalContent({
+ handleHide,
+}: {
+ handleHide: () => void
+}) {
+ const { t } = useTranslation()
+
+ return (
+ <>
+
+ {t('word_count')}
+
+
+
+ {isSplitTestEnabled('word-count-client') ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {t('close')}
+
+
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-modal.jsx b/services/web/frontend/js/features/word-count-modal/components/word-count-modal.tsx
similarity index 66%
rename from services/web/frontend/js/features/word-count-modal/components/word-count-modal.jsx
rename to services/web/frontend/js/features/word-count-modal/components/word-count-modal.tsx
index 81f9685fe0..ea8371978d 100644
--- a/services/web/frontend/js/features/word-count-modal/components/word-count-modal.jsx
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-modal.tsx
@@ -1,12 +1,14 @@
-import React from 'react'
-import PropTypes from 'prop-types'
+import { memo } from 'react'
import WordCountModalContent from './word-count-modal-content'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import OLModal from '@/features/ui/components/ol/ol-modal'
-const WordCountModal = React.memo(function WordCountModal({
+const WordCountModal = memo(function WordCountModal({
show,
handleHide,
+}: {
+ show: boolean
+ handleHide: () => void
}) {
return (
@@ -15,9 +17,4 @@ const WordCountModal = React.memo(function WordCountModal({
)
})
-WordCountModal.propTypes = {
- show: PropTypes.bool,
- handleHide: PropTypes.func.isRequired,
-}
-
export default withErrorBoundary(WordCountModal)
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx
new file mode 100644
index 0000000000..fccd49f8c3
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx
@@ -0,0 +1,48 @@
+import { FC, useEffect, useState } from 'react'
+import { ServerWordCountData } from '@/features/word-count-modal/components/word-count-data'
+import { WordCountLoading } from '@/features/word-count-modal/components/word-count-loading'
+import { WordCountError } from '@/features/word-count-modal/components/word-count-error'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useLocalCompileContext } from '@/shared/context/local-compile-context'
+import useAbortController from '@/shared/hooks/use-abort-controller'
+import { getJSON } from '@/infrastructure/fetch-json'
+import { debugConsole } from '@/utils/debugging'
+import { WordCounts } from '@/features/word-count-modal/components/word-counts'
+
+export const WordCountServer: FC = () => {
+ const { _id: projectId } = useProjectContext()
+ const { clsiServerId } = useLocalCompileContext()
+
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [data, setData] = useState(null)
+
+ const { signal } = useAbortController()
+
+ useEffect(() => {
+ const url = new URL(`/project/${projectId}/wordcount`, window.location.href)
+ if (clsiServerId) {
+ url.searchParams.set('clsiserverid', clsiServerId)
+ }
+
+ getJSON(url.toString(), { signal })
+ .then(data => {
+ setData(data.texcount)
+ })
+ .catch(error => {
+ debugConsole.error(error)
+ setError(true)
+ })
+ .finally(() => {
+ setLoading(false)
+ })
+ }, [projectId, clsiServerId, signal])
+
+ return (
+ <>
+ {loading && !error && }
+ {error && }
+ {data && }
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-counts.tsx b/services/web/frontend/js/features/word-count-modal/components/word-counts.tsx
new file mode 100644
index 0000000000..dec4d2e6d8
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-counts.tsx
@@ -0,0 +1,102 @@
+import {
+ ServerWordCountData,
+ WordCountData,
+} from '@/features/word-count-modal/components/word-count-data'
+import { useTranslation } from 'react-i18next'
+import { FC } from 'react'
+import { Container, Row, Col } from 'react-bootstrap-5'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+
+export const WordCounts: FC<
+ | {
+ data: ServerWordCountData
+ source: 'server'
+ }
+ | {
+ data: WordCountData
+ source: 'client'
+ }
+> = ({ data, source }) => {
+ const { t } = useTranslation()
+
+ return (
+
+ {data.messages && (
+
+
+ {data.messages}
+ }
+ />
+
+
+ )}
+
+ {source === 'client' ? (
+ <>
+
+
+ Text:
+
+ {data.textWords}
+
+
+
+
+ Headers:
+
+ {data.headWords}
+
+
+
+
+ Captions:
+
+ {data.captionWords}
+
+
+
+
+ Footnotes:
+
+ {data.footnoteWords}
+
+ >
+ ) : (
+
+
+ {t('total_words')}:
+
+ {data.textWords}
+
+ )}
+
+ {source === 'server' && (
+ <>
+
+
+ {t('headers')}:
+
+ {data.headers}
+
+
+
+
+ {t('math_inline')}:
+
+ {data.mathInline}
+
+
+
+
+ {t('math_display')}:
+
+ {data.mathDisplay}
+
+ >
+ )}
+
+ )
+}
diff --git a/services/web/frontend/js/features/word-count-modal/hooks/use-word-count.js b/services/web/frontend/js/features/word-count-modal/hooks/use-word-count.js
deleted file mode 100644
index 51d6cba8f4..0000000000
--- a/services/web/frontend/js/features/word-count-modal/hooks/use-word-count.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import useAbortController from '../../../shared/hooks/use-abort-controller'
-import { fetchWordCount } from '../utils/api'
-import { useEffect, useState } from 'react'
-
-export function useWordCount(projectId, clsiServerId) {
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(false)
- const [data, setData] = useState()
-
- const { signal } = useAbortController()
-
- useEffect(() => {
- fetchWordCount(projectId, clsiServerId, { signal })
- .then(data => {
- setData(data.texcount)
- })
- .catch(() => {
- setError(true)
- })
- .finally(() => {
- setLoading(false)
- })
- }, [signal, clsiServerId, projectId])
-
- return { data, error, loading }
-}
diff --git a/services/web/frontend/js/features/word-count-modal/utils/api.js b/services/web/frontend/js/features/word-count-modal/utils/api.js
deleted file mode 100644
index 54818f0338..0000000000
--- a/services/web/frontend/js/features/word-count-modal/utils/api.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { getJSON } from '../../../infrastructure/fetch-json'
-
-export function fetchWordCount(projectId, clsiServerId, options) {
- let query = ''
- if (clsiServerId) {
- query = `?clsiserverid=${clsiServerId}`
- }
-
- return getJSON(`/project/${projectId}/wordcount${query}`, options)
-}
diff --git a/services/web/frontend/js/features/word-count-modal/utils/count-words-in-file.ts b/services/web/frontend/js/features/word-count-modal/utils/count-words-in-file.ts
new file mode 100644
index 0000000000..d9a9154620
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/utils/count-words-in-file.ts
@@ -0,0 +1,256 @@
+import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
+import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
+import { WordCountData } from '@/features/word-count-modal/components/word-count-data'
+import { NodeType, SyntaxNodeRef } from '@lezer/common'
+import { debugConsole } from '@/utils/debugging'
+import { findPreambleExtent } from '@/features/word-count-modal/utils/find-preamble-extent'
+import { Segmenters } from './segmenters'
+
+const whiteSpaceRe = /^\s$/
+
+type Context = 'text' | 'header' | 'abstract' | 'caption' | 'footnote'
+
+const counters: Record<
+ Context,
+ {
+ word: keyof WordCountData
+ character: keyof WordCountData
+ }
+> = {
+ text: {
+ word: 'textWords',
+ character: 'textCharacters',
+ },
+ header: {
+ word: 'headWords',
+ character: 'headCharacters',
+ },
+ abstract: {
+ word: 'abstractWords',
+ character: 'abstractCharacters',
+ },
+ caption: {
+ word: 'captionWords',
+ character: 'captionCharacters',
+ },
+ footnote: {
+ word: 'footnoteWords',
+ character: 'footnoteCharacters',
+ },
+}
+
+const replacementsMap: Map = new Map([
+ // LaTeX commands that create part of a word
+ ['aa', 'å'],
+ ['AA', 'Å'],
+ ['ae', 'æ'],
+ ['AE', 'Æ'],
+ ['oe', 'œ'],
+ ['OE', 'Œ'],
+ ['o', 'ø'],
+ ['O', 'Ø'],
+ ['ss', 'ß'],
+ ['SS', 'SS'],
+ ['l', 'ł'],
+ ['L', 'Ł'],
+ ['dh', 'ð'],
+ ['DH', 'Ð'],
+ ['dj', 'đ'],
+ ['DJ', 'Ð'],
+ ['th', 'þ'],
+ ['TH', 'Þ'],
+ ['ng', 'ŋ'],
+ ['NG', 'Ŋ'],
+ ['i', 'ı'],
+ ['j', 'ȷ'],
+ ['_', '_'],
+ // modifier commands for the character in the arguments
+ ['H', 'a'],
+ ['c', 'a'],
+ ['d', 'a'],
+ ['k', 'a'],
+ ['v', 'a'],
+ // modifier symbols for the subsequent character
+ ["'", ''],
+ ['^', ''],
+ ['"', ''],
+ ['=', ''],
+ ['.', ''],
+])
+
+type TextNode = {
+ from: number
+ to: number
+ text: string
+ context: Context
+}
+
+export const countWordsInFile = (
+ data: WordCountData,
+ projectSnapshot: ProjectSnapshot,
+ docPath: string,
+ segmenters: Segmenters
+) => {
+ debugConsole.log(`Counting words in ${docPath}`)
+
+ const content = projectSnapshot.getDocContents(docPath) // TODO: try with extensions
+ if (!content) return
+
+ // TODO: language from file extension
+ const tree = LaTeXLanguage.parser.parse(content)
+
+ let currentContext: Context = 'text'
+
+ const textNodes: TextNode[] = []
+
+ const iterateNode = (nodeRef: SyntaxNodeRef, context: Context = 'text') => {
+ const previousContext = currentContext
+ currentContext = context
+ const { node } = nodeRef
+ node.cursor().iterate(childNodeRef => {
+ // TODO: a better way to iterate only descendants?
+ if (childNodeRef.node !== node) {
+ return bodyMatcher(childNodeRef.type)?.(childNodeRef)
+ }
+ })
+ currentContext = previousContext
+ }
+
+ const headMatcher = NodeType.match<
+ (nodeRef: SyntaxNodeRef) => boolean | void
+ >({
+ Title(nodeRef) {
+ data.headers++
+ iterateNode(nodeRef, 'header')
+ return false
+ },
+ })
+
+ const bodyMatcher = NodeType.match<
+ (nodeRef: SyntaxNodeRef) => boolean | void
+ >({
+ Normal(nodeRef) {
+ textNodes.push({
+ from: nodeRef.from,
+ to: nodeRef.to,
+ text: content.substring(nodeRef.from, nodeRef.to),
+ context: currentContext,
+ })
+ },
+ Command(nodeRef) {
+ const child = nodeRef.node.getChild('UnknownCommand')
+ if (!child) return
+
+ const grandchild = child.getChild('CtrlSeq') ?? child.getChild('CtrlSym')
+ if (!grandchild) return
+
+ const commandName = content.substring(grandchild.from + 1, grandchild.to)
+ if (!commandName) return
+
+ if (!replacementsMap.has(commandName)) return
+
+ const text = replacementsMap.get(commandName)!
+ textNodes.push({
+ from: nodeRef.from,
+ to: nodeRef.to,
+ text,
+ context: currentContext,
+ })
+ return false
+ },
+ BeginEnv(nodeRef) {
+ const envName = content
+ ?.substring(nodeRef.from + '\\begin{'.length, nodeRef.to - 1)
+ .replace(/\*$/, '')
+
+ if (envName === 'abstract') {
+ data.headers++
+ iterateNode(nodeRef, 'abstract')
+ return false
+ }
+ },
+ 'ShortTextArgument ShortOptionalArg'() {
+ return false
+ },
+ SectioningArgument(nodeRef) {
+ data.headers++
+ iterateNode(nodeRef, 'header')
+ return false
+ },
+ 'DisplayMath BracketMath'() {
+ data.mathDisplay++
+ },
+ 'InlineMath ParenMath'() {
+ data.mathInline++
+ },
+ Caption(nodeRef) {
+ iterateNode(nodeRef, 'caption')
+ return false
+ },
+ 'FootnoteCommand EndnoteCommand'(nodeRef) {
+ iterateNode(nodeRef, 'footnote')
+ return false
+ },
+ 'IncludeArgument InputArgument'(nodeRef) {
+ let path = content.substring(nodeRef.from + 1, nodeRef.to - 1)
+ if (!/\.\w+$/.test(path)) {
+ path += '.tex'
+ }
+ debugConsole.log(path)
+ if (path) {
+ countWordsInFile(data, projectSnapshot, path, segmenters)
+ }
+ },
+ })
+
+ const preambleExtent = findPreambleExtent(tree)
+
+ tree.iterate({
+ from: 0,
+ to: preambleExtent.to,
+ enter(nodeRef) {
+ return headMatcher(nodeRef.type)?.(nodeRef)
+ },
+ })
+
+ tree.iterate({
+ from: preambleExtent.to,
+ enter(nodeRef) {
+ return bodyMatcher(nodeRef.type)?.(nodeRef)
+ },
+ })
+
+ const texts: Record = {
+ abstract: '',
+ header: '',
+ caption: '',
+ text: '',
+ footnote: '',
+ }
+
+ let pos = 0
+ for (const textNode of textNodes) {
+ if (textNode.from !== pos) {
+ texts[textNode.context] += ' '
+ }
+ texts[textNode.context] += textNode.text
+ pos = textNode.to
+ }
+
+ for (const [context, text] of Object.entries(texts)) {
+ const counter = counters[context as Context]
+
+ for (const value of segmenters.word.segment(text)) {
+ if (value.isWordLike) {
+ data[counter.word]++
+ }
+ }
+
+ for (const value of segmenters.character.segment(text)) {
+ // TODO: option for whether to include whitespace?
+ if (!whiteSpaceRe.test(value.segment)) {
+ data[counter.character]++
+ }
+ }
+ }
+}
diff --git a/services/web/frontend/js/features/word-count-modal/utils/find-preamble-extent.ts b/services/web/frontend/js/features/word-count-modal/utils/find-preamble-extent.ts
new file mode 100644
index 0000000000..21aafe4b96
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/utils/find-preamble-extent.ts
@@ -0,0 +1,40 @@
+import { NodeType, SyntaxNodeRef, Tree } from '@lezer/common'
+import { ancestorOfNodeWithType } from '@/features/source-editor/utils/tree-operations/ancestors'
+
+export const findPreambleExtent = (tree: Tree) => {
+ const preamble = { to: 0 }
+
+ let seenDocumentEnvironment = false
+
+ const preambleMatcher = NodeType.match<(nodeRef: SyntaxNodeRef) => void>({
+ 'Title Author Affil Affiliation'(nodeRef) {
+ preamble.to = nodeRef.node.to
+ },
+ DocumentEnvironment(nodeRef) {
+ // only count the first instance of DocumentEnvironment
+ if (!seenDocumentEnvironment) {
+ preamble.to =
+ nodeRef.node.getChild('Content')?.from ?? nodeRef.node.from
+ seenDocumentEnvironment = true
+ }
+ },
+ Maketitle(nodeRef) {
+ // count \maketitle inside DocumentEnvironment
+ if (
+ ancestorOfNodeWithType(nodeRef.node, '$Environment')?.type.is(
+ 'DocumentEnvironment'
+ )
+ ) {
+ preamble.to = nodeRef.node.from
+ }
+ },
+ })
+
+ tree.iterate({
+ enter(nodeRef) {
+ return preambleMatcher(nodeRef.type)?.(nodeRef)
+ },
+ })
+
+ return preamble
+}
diff --git a/services/web/frontend/js/features/word-count-modal/utils/segmenters.ts b/services/web/frontend/js/features/word-count-modal/utils/segmenters.ts
new file mode 100644
index 0000000000..e14c3365fd
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/utils/segmenters.ts
@@ -0,0 +1,64 @@
+const wordRe = /['\-.\p{L}]+/gu
+const wordLikeRe = /\p{L}/gu // must contain at least one "letter" to be a word
+const characterRe = /\S/gu
+
+type SegmentDataLike = {
+ segment: string
+ isWordLike?: boolean
+}
+
+type SegmenterLike = {
+ segment(input: string): {
+ [Symbol.iterator](): IterableIterator
+ }
+}
+
+export type Segmenters = {
+ word: SegmenterLike
+ character: SegmenterLike
+}
+
+export const createSegmenters = (locale?: string): Segmenters => {
+ if (!Intl.Segmenter) {
+ return genericSegmenters
+ }
+
+ try {
+ return {
+ word: new Intl.Segmenter(locale, {
+ granularity: 'word', // TODO: count hyphenated words as a single word
+ }),
+ character: new Intl.Segmenter(locale, {
+ granularity: 'grapheme',
+ }),
+ }
+ } catch {
+ return genericSegmenters
+ }
+}
+
+const genericSegmenters = {
+ word: {
+ segment(input: string) {
+ const segments: SegmentDataLike[] = []
+ for (const match of input.matchAll(wordRe)) {
+ segments.push({
+ segment: match[0],
+ isWordLike: wordLikeRe.test(match[0]),
+ })
+ }
+ return segments
+ },
+ },
+ character: {
+ segment(input: string) {
+ const segments: SegmentDataLike[] = []
+ for (const match of input.matchAll(characterRe)) {
+ segments.push({
+ segment: match[0],
+ })
+ }
+ return segments
+ },
+ },
+}
diff --git a/services/web/frontend/js/ide/connection/SocketIoShim.js b/services/web/frontend/js/ide/connection/SocketIoShim.js
index 40ada0485a..9fb57ef1f1 100644
--- a/services/web/frontend/js/ide/connection/SocketIoShim.js
+++ b/services/web/frontend/js/ide/connection/SocketIoShim.js
@@ -277,13 +277,34 @@ if (typeof io === 'undefined' || !io) {
current = SocketShimV2
}
-export class SocketIOMock extends EventEmitter {
+export class SocketIOMock extends SocketShimBase {
+ constructor() {
+ super(new EventEmitter())
+ this.socket = {
+ get connected() {
+ return false
+ },
+ get sessionid() {
+ return undefined
+ },
+ get transport() {
+ return {}
+ },
+ get transports() {
+ return []
+ },
+
+ connect() {},
+ disconnect(reason) {},
+ }
+ }
+
addListener(event, listener) {
- this.on(event, listener)
+ this._socket.on(event, listener)
}
removeListener(event, listener) {
- this.off(event, listener)
+ this._socket.off(event, listener)
}
disconnect() {
@@ -294,6 +315,10 @@ export class SocketIOMock extends EventEmitter {
// Round-trip through JSON.parse/stringify to simulate (de-)serializing on network layer.
this.emit(...JSON.parse(JSON.stringify(args)))
}
+
+ countEventListeners(event) {
+ return this._socket.events[event].length
+ }
}
export default {
diff --git a/services/web/frontend/js/infrastructure/error-boundary.jsx b/services/web/frontend/js/infrastructure/error-boundary.jsx
deleted file mode 100644
index 51f3ca7c4a..0000000000
--- a/services/web/frontend/js/infrastructure/error-boundary.jsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { captureException } from './error-reporter'
-import { ErrorBoundary } from 'react-error-boundary'
-
-function errorHandler(error, componentStack) {
- captureException(error, {
- extra: {
- componentStack,
- },
- tags: {
- handler: 'react-error-boundary',
- },
- })
-}
-
-function DefaultFallbackComponent() {
- return <>>
-}
-
-function withErrorBoundary(WrappedComponent, FallbackComponent) {
- function ErrorBoundaryWrapper(props) {
- return (
-
-
-
- )
- }
- ErrorBoundaryWrapper.propTypes = WrappedComponent.propTypes
- ErrorBoundaryWrapper.displayName = `WithErrorBoundaryWrapper${
- WrappedComponent.displayName || WrappedComponent.name || 'Component'
- }`
- return ErrorBoundaryWrapper
-}
-
-export default withErrorBoundary
diff --git a/services/web/frontend/js/infrastructure/error-boundary.tsx b/services/web/frontend/js/infrastructure/error-boundary.tsx
new file mode 100644
index 0000000000..c975a99849
--- /dev/null
+++ b/services/web/frontend/js/infrastructure/error-boundary.tsx
@@ -0,0 +1,31 @@
+import { captureException } from './error-reporter'
+import { withErrorBoundary as rebWithErrorBoundary } from 'react-error-boundary'
+import { ComponentType, ErrorInfo } from 'react'
+import { FallbackProps } from 'react-error-boundary/dist/declarations/src/types'
+
+function errorHandler(error: Error, errorInfo: ErrorInfo) {
+ captureException(error, {
+ extra: {
+ componentStack: errorInfo.componentStack,
+ },
+ tags: {
+ handler: 'react-error-boundary',
+ },
+ })
+}
+
+function DefaultFallbackComponent() {
+ return <>>
+}
+
+function withErrorBoundary(
+ WrappedComponent: ComponentType,
+ FallbackComponent?: ComponentType
+) {
+ return rebWithErrorBoundary(WrappedComponent, {
+ onError: errorHandler,
+ FallbackComponent: FallbackComponent || DefaultFallbackComponent,
+ })
+}
+
+export default withErrorBoundary
diff --git a/services/web/frontend/js/pages/compromised-password.tsx b/services/web/frontend/js/pages/compromised-password.tsx
index b18502780c..5c1303dc07 100644
--- a/services/web/frontend/js/pages/compromised-password.tsx
+++ b/services/web/frontend/js/pages/compromised-password.tsx
@@ -1,6 +1,6 @@
import '../marketing'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import { CompromisedPasswordCard } from '../features/compromised-password/components/compromised-password-root'
const compromisedPasswordContainer = document.getElementById(
@@ -8,5 +8,6 @@ const compromisedPasswordContainer = document.getElementById(
)
if (compromisedPasswordContainer) {
- ReactDOM.render( , compromisedPasswordContainer)
+ const root = createRoot(compromisedPasswordContainer)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/ide.tsx b/services/web/frontend/js/pages/ide.tsx
index a939504790..467a4625b8 100644
--- a/services/web/frontend/js/pages/ide.tsx
+++ b/services/web/frontend/js/pages/ide.tsx
@@ -1,10 +1,14 @@
import '../utils/webpack-public-path' // configure dynamically loaded assets (via webpack) to be downloaded from CDN
import '../infrastructure/error-reporter' // set up error reporting, including Sentry
import '../infrastructure/hotjar' // set up Hotjar
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import IdeRoot from '@/features/ide-react/components/ide-root'
-ReactDOM.render( , document.getElementById('ide-root'))
+const container = document.getElementById('ide-root')
+if (container) {
+ const root = createRoot(container)
+ root.render( )
+}
// work around Safari 15's incomplete support for dvh units
// https://github.com/overleaf/internal/issues/18109
diff --git a/services/web/frontend/js/pages/project-list.tsx b/services/web/frontend/js/pages/project-list.tsx
index dd24bb00f9..b5e2e2fd46 100644
--- a/services/web/frontend/js/pages/project-list.tsx
+++ b/services/web/frontend/js/pages/project-list.tsx
@@ -5,10 +5,11 @@ import '@/i18n'
import '../features/event-tracking'
import '../features/cookie-banner'
import '../features/link-helpers/slow-link'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import ProjectListRoot from '../features/project-list/components/project-list-root'
const element = document.getElementById('project-list-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/sharing-updates.tsx b/services/web/frontend/js/pages/sharing-updates.tsx
index 63219542ef..ec4c974ea0 100644
--- a/services/web/frontend/js/pages/sharing-updates.tsx
+++ b/services/web/frontend/js/pages/sharing-updates.tsx
@@ -2,10 +2,11 @@ import './../utils/meta'
import '../utils/webpack-public-path'
import './../infrastructure/error-reporter'
import '@/i18n'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import SharingUpdatesRoot from '../features/token-access/components/sharing-updates-root'
const element = document.getElementById('sharing-updates-page')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/socket-diagnostics.tsx b/services/web/frontend/js/pages/socket-diagnostics.tsx
index 982209b378..bd4f2e522a 100644
--- a/services/web/frontend/js/pages/socket-diagnostics.tsx
+++ b/services/web/frontend/js/pages/socket-diagnostics.tsx
@@ -1,10 +1,11 @@
import '../marketing'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import { SocketDiagnostics } from '@/features/socket-diagnostics/components/socket-diagnostics'
const socketDiagnosticsContainer = document.getElementById('socket-diagnostics')
if (socketDiagnosticsContainer) {
- ReactDOM.render( , socketDiagnosticsContainer)
+ const root = createRoot(socketDiagnosticsContainer)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/token-access.tsx b/services/web/frontend/js/pages/token-access.tsx
index 97026d93f4..72a208a0fe 100644
--- a/services/web/frontend/js/pages/token-access.tsx
+++ b/services/web/frontend/js/pages/token-access.tsx
@@ -2,10 +2,11 @@ import './../utils/meta'
import '../utils/webpack-public-path'
import './../infrastructure/error-reporter'
import '@/i18n'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import TokenAccessRoot from '../features/token-access/components/token-access-root'
const element = document.getElementById('token-access-page')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/add-secondary-email.tsx b/services/web/frontend/js/pages/user/add-secondary-email.tsx
index 8297d319af..7c9d5bced9 100644
--- a/services/web/frontend/js/pages/user/add-secondary-email.tsx
+++ b/services/web/frontend/js/pages/user/add-secondary-email.tsx
@@ -1,6 +1,6 @@
import '../../marketing'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import { AddSecondaryEmailPrompt } from '../../features/settings/components/emails/add-secondary-email-prompt'
const addSecondaryEmailContainer = document.getElementById(
@@ -8,5 +8,6 @@ const addSecondaryEmailContainer = document.getElementById(
)
if (addSecondaryEmailContainer) {
- ReactDOM.render( , addSecondaryEmailContainer)
+ const root = createRoot(addSecondaryEmailContainer)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/confirm-secondary-email.tsx b/services/web/frontend/js/pages/user/confirm-secondary-email.tsx
index 405aaf0d30..f30203a08d 100644
--- a/services/web/frontend/js/pages/user/confirm-secondary-email.tsx
+++ b/services/web/frontend/js/pages/user/confirm-secondary-email.tsx
@@ -1,10 +1,11 @@
import '../../marketing'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import ConfirmSecondaryEmailForm from '../../features/settings/components/emails/confirm-secondary-email-form'
const confirmEmailContainer = document.getElementById('confirm-secondary-email')
if (confirmEmailContainer) {
- ReactDOM.render( , confirmEmailContainer)
+ const root = createRoot(confirmEmailContainer)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/settings.jsx b/services/web/frontend/js/pages/user/settings.jsx
index bd55a175d9..189799fe7c 100644
--- a/services/web/frontend/js/pages/user/settings.jsx
+++ b/services/web/frontend/js/pages/user/settings.jsx
@@ -4,7 +4,7 @@ import '../../utils/webpack-public-path'
import './../../infrastructure/error-reporter'
import '@/i18n'
import '../../features/settings/components/root'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import SettingsPageRoot from '../../features/settings/components/root.tsx'
const element = document.getElementById('settings-page-root')
@@ -14,5 +14,6 @@ window.recaptchaOptions = {
useRecaptchaNet: true,
}
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/canceled-subscription.jsx b/services/web/frontend/js/pages/user/subscription/canceled-subscription.jsx
index 98fe7d178e..fb95674195 100644
--- a/services/web/frontend/js/pages/user/subscription/canceled-subscription.jsx
+++ b/services/web/frontend/js/pages/user/subscription/canceled-subscription.jsx
@@ -1,8 +1,9 @@
import './base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import Root from '../../../features/subscription/components/canceled-subscription/root'
const element = document.getElementById('subscription-canceled-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/dashboard.jsx b/services/web/frontend/js/pages/user/subscription/dashboard.jsx
index 96ea6d75a1..66ffcfe604 100644
--- a/services/web/frontend/js/pages/user/subscription/dashboard.jsx
+++ b/services/web/frontend/js/pages/user/subscription/dashboard.jsx
@@ -1,8 +1,9 @@
import './base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import Root from '../../../features/subscription/components/dashboard/root'
const element = document.getElementById('subscription-dashboard-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-invites.tsx b/services/web/frontend/js/pages/user/subscription/group-invites.tsx
index 35e559c110..fd0c803531 100644
--- a/services/web/frontend/js/pages/user/subscription/group-invites.tsx
+++ b/services/web/frontend/js/pages/user/subscription/group-invites.tsx
@@ -1,8 +1,9 @@
import './base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import GroupInvitesRoot from '@/features/subscription/components/group-invites/group-invites-root'
const element = document.getElementById('group-invites-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/add-seats.tsx b/services/web/frontend/js/pages/user/subscription/group-management/add-seats.tsx
index b62b93e5e2..7c7374dfa4 100644
--- a/services/web/frontend/js/pages/user/subscription/group-management/add-seats.tsx
+++ b/services/web/frontend/js/pages/user/subscription/group-management/add-seats.tsx
@@ -1,8 +1,9 @@
import '../base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import Root from '@/features/group-management/components/add-seats/root'
const element = document.getElementById('add-seats-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/group-managers.jsx b/services/web/frontend/js/pages/user/subscription/group-management/group-managers.jsx
index 70533ccc44..1d538320ca 100644
--- a/services/web/frontend/js/pages/user/subscription/group-management/group-managers.jsx
+++ b/services/web/frontend/js/pages/user/subscription/group-management/group-managers.jsx
@@ -1,8 +1,9 @@
import '../base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import Root from '../../../../features/group-management/components/group-managers'
const element = document.getElementById('subscription-manage-group-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/group-members.jsx b/services/web/frontend/js/pages/user/subscription/group-management/group-members.jsx
index 446220c702..b9a412e5ef 100644
--- a/services/web/frontend/js/pages/user/subscription/group-management/group-members.jsx
+++ b/services/web/frontend/js/pages/user/subscription/group-management/group-members.jsx
@@ -1,17 +1,17 @@
import '../base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import GroupMembers from '../../../../features/group-management/components/group-members'
import { GroupMembersProvider } from '../../../../features/group-management/context/group-members-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
const element = document.getElementById('subscription-manage-group-root')
if (element) {
- ReactDOM.render(
+ const root = createRoot(element)
+ root.render(
- ,
- element
+
)
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/institution-managers.jsx b/services/web/frontend/js/pages/user/subscription/group-management/institution-managers.jsx
index 2ea1d2d7cf..d8fa8f24db 100644
--- a/services/web/frontend/js/pages/user/subscription/group-management/institution-managers.jsx
+++ b/services/web/frontend/js/pages/user/subscription/group-management/institution-managers.jsx
@@ -1,8 +1,9 @@
import '../base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import Root from '../../../../features/group-management/components/institution-managers'
const element = document.getElementById('subscription-manage-group-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx b/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx
index ce7f0a9a4a..49aee93ca0 100644
--- a/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx
+++ b/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx
@@ -1,8 +1,9 @@
import '../base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import ManuallyCollectedSubscription from '@/features/group-management/components/manually-collected-subscription'
const element = document.getElementById('manually-collected-subscription-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/missing-billing-information.tsx b/services/web/frontend/js/pages/user/subscription/group-management/missing-billing-information.tsx
index 449e77f9b6..48b145dc7b 100644
--- a/services/web/frontend/js/pages/user/subscription/group-management/missing-billing-information.tsx
+++ b/services/web/frontend/js/pages/user/subscription/group-management/missing-billing-information.tsx
@@ -1,8 +1,9 @@
import '../base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import MissingBillingInformation from '@/features/group-management/components/missing-billing-information'
const element = document.getElementById('missing-billing-information-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/publisher-managers.jsx b/services/web/frontend/js/pages/user/subscription/group-management/publisher-managers.jsx
index f323afa459..9a4be834c5 100644
--- a/services/web/frontend/js/pages/user/subscription/group-management/publisher-managers.jsx
+++ b/services/web/frontend/js/pages/user/subscription/group-management/publisher-managers.jsx
@@ -1,8 +1,9 @@
import '../base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import Root from '../../../../features/group-management/components/publisher-managers'
const element = document.getElementById('subscription-manage-group-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx b/services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx
index 18abd65543..d8d1cc3e28 100644
--- a/services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx
+++ b/services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx
@@ -1,8 +1,9 @@
import '../base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import Root from '@/features/group-management/components/upgrade-subscription/root'
const element = document.getElementById('upgrade-group-subscription-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/invite-managed.jsx b/services/web/frontend/js/pages/user/subscription/invite-managed.jsx
index 4a593aea17..ecb1d4d3b0 100644
--- a/services/web/frontend/js/pages/user/subscription/invite-managed.jsx
+++ b/services/web/frontend/js/pages/user/subscription/invite-managed.jsx
@@ -1,8 +1,9 @@
import './base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import InvitedManagedRoot from '../../../features/subscription/components/invite-managed-root'
const element = document.getElementById('invite-managed-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/invite.tsx b/services/web/frontend/js/pages/user/subscription/invite.tsx
index 119f9e867f..151619a643 100644
--- a/services/web/frontend/js/pages/user/subscription/invite.tsx
+++ b/services/web/frontend/js/pages/user/subscription/invite.tsx
@@ -1,9 +1,10 @@
import './base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import InviteRoot from '@/features/subscription/components/invite-root'
const element = document.getElementById('invite-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/preview-change.tsx b/services/web/frontend/js/pages/user/subscription/preview-change.tsx
index 0f3804ecdc..7b4b4c68b0 100644
--- a/services/web/frontend/js/pages/user/subscription/preview-change.tsx
+++ b/services/web/frontend/js/pages/user/subscription/preview-change.tsx
@@ -1,8 +1,9 @@
import '@/marketing'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import PreviewSubscriptionChange from '@/features/subscription/components/preview-subscription-change/root'
const element = document.getElementById('subscription-preview-change')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/pages/user/subscription/successful-subscription.jsx b/services/web/frontend/js/pages/user/subscription/successful-subscription.jsx
index fde2a1aefc..bd75c3799c 100644
--- a/services/web/frontend/js/pages/user/subscription/successful-subscription.jsx
+++ b/services/web/frontend/js/pages/user/subscription/successful-subscription.jsx
@@ -1,8 +1,9 @@
import './base'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import Root from '../../../features/subscription/components/successful-subscription/root'
const element = document.getElementById('subscription-success-root')
if (element) {
- ReactDOM.render( , element)
+ const root = createRoot(element)
+ root.render( )
}
diff --git a/services/web/frontend/js/shared/components/accessible-modal.tsx b/services/web/frontend/js/shared/components/accessible-modal.tsx
deleted file mode 100644
index dbd8269f9e..0000000000
--- a/services/web/frontend/js/shared/components/accessible-modal.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useCallback } from 'react'
-import { Modal, ModalProps } from 'react-bootstrap'
-
-// A wrapper for the v0.33 React Bootstrap Modal component,
-// which ensures that the `aria-hidden` attribute is not set on the modal when it's visible,
-// and that role="dialog" is not duplicated.
-// https://github.com/react-bootstrap/react-bootstrap/issues/4790
-// There are other ARIA attributes on these modals which could be improved,
-// but this at least makes them accessible for tests.
-function AccessibleModal(props: ModalProps) {
- const modalRef = useCallback(
- element => {
- const modalNode = element?._modal?.modalNode
- if (modalNode) {
- if (props.show) {
- modalNode.removeAttribute('role')
- modalNode.removeAttribute('aria-hidden')
- } else {
- // NOTE: possibly not ever used, as the modal is only rendered when shown
- modalNode.setAttribute('aria-hidden', 'true')
- }
- }
- },
- [props.show]
- )
-
- return
-}
-
-export default AccessibleModal
diff --git a/services/web/frontend/js/shared/components/badge.tsx b/services/web/frontend/js/shared/components/badge.tsx
deleted file mode 100644
index 4970d9d2ad..0000000000
--- a/services/web/frontend/js/shared/components/badge.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Label } from 'react-bootstrap'
-import classnames from 'classnames'
-import { MergeAndOverride } from '../../../../types/utils'
-
-type BadgeProps = MergeAndOverride<
- React.ComponentProps<'span'>,
- {
- prepend?: React.ReactNode
- children: React.ReactNode
- className?: string
- bsStyle?: React.ComponentProps['bsStyle'] | null
- }
->
-
-function Badge({ prepend, children, bsStyle, className, ...rest }: BadgeProps) {
- const classNames =
- bsStyle === null
- ? className
- : classnames('label', `label-${bsStyle}`, className)
-
- return (
-
- {prepend && {prepend} }
- {children}
-
- )
-}
-
-export default Badge
diff --git a/services/web/frontend/js/shared/components/beta-badge-icon.tsx b/services/web/frontend/js/shared/components/beta-badge-icon.tsx
index 7e60253d5b..b331c90175 100644
--- a/services/web/frontend/js/shared/components/beta-badge-icon.tsx
+++ b/services/web/frontend/js/shared/components/beta-badge-icon.tsx
@@ -1,14 +1,11 @@
import type { FC } from 'react'
-import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import OLBadge from '@/features/ui/components/ol/ol-badge'
-function BS5BetaBadgeIcon({
- badgeClass,
-}: {
- badgeClass: ReturnType
-}) {
+const BetaBadgeIcon: FC<{
+ phase?: string
+}> = ({ phase = 'beta' }) => {
+ const badgeClass = chooseBadgeClass(phase)
if (badgeClass === 'info-badge') {
return
} else if (badgeClass === 'alpha-badge') {
@@ -18,26 +15,10 @@ function BS5BetaBadgeIcon({
)
} else {
- return (
-
- β
-
- )
+ return β
}
}
-const BetaBadgeIcon: FC<{
- phase?: string
-}> = ({ phase = 'beta' }) => {
- const badgeClass = chooseBadgeClass(phase)
- return (
- }
- bs5={ }
- />
- )
-}
-
function chooseBadgeClass(phase?: string) {
switch (phase) {
case 'release':
diff --git a/services/web/frontend/js/shared/components/beta-badge.tsx b/services/web/frontend/js/shared/components/beta-badge.tsx
index 63a31be402..514ff10041 100644
--- a/services/web/frontend/js/shared/components/beta-badge.tsx
+++ b/services/web/frontend/js/shared/components/beta-badge.tsx
@@ -1,6 +1,5 @@
import type { FC, MouseEventHandler, ReactNode } from 'react'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
-import { bsVersion } from '@/features/utils/bootstrap-5'
import BetaBadgeIcon from '@/shared/components/beta-badge-icon'
type TooltipProps = {
@@ -40,9 +39,7 @@ const BetaBadge: FC<{
href={href || defaultHref}
{...linkProps}
>
-
- {description || tooltip?.text}
-
+ {description || tooltip?.text}
)
diff --git a/services/web/frontend/js/shared/components/close.tsx b/services/web/frontend/js/shared/components/close.tsx
index ecf2bf6e63..17c24ce092 100644
--- a/services/web/frontend/js/shared/components/close.tsx
+++ b/services/web/frontend/js/shared/components/close.tsx
@@ -1,6 +1,5 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type CloseProps = {
onDismiss: React.MouseEventHandler
@@ -16,15 +15,10 @@ function Close({ onDismiss, variant = 'light' }: CloseProps) {
className={`close pull-right ${variant}`}
onClick={onDismiss}
>
- ×}
- bs5={
-
- }
+
{t('close')}
diff --git a/services/web/frontend/js/shared/components/controlled-dropdown.tsx b/services/web/frontend/js/shared/components/controlled-dropdown.tsx
deleted file mode 100644
index 04685124a9..0000000000
--- a/services/web/frontend/js/shared/components/controlled-dropdown.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React, {
- Children,
- cloneElement,
- type FC,
- isValidElement,
- useCallback,
-} from 'react'
-import { Dropdown, DropdownProps } from 'react-bootstrap'
-import useDropdown from '../hooks/use-dropdown'
-
-type ControlledDropdownProps = DropdownProps & {
- defaultOpen?: boolean
- onMainButtonClick?: (dropdownOpen: boolean) => void
-}
-
-const ControlledDropdown: FC = ({
- defaultOpen,
- onMainButtonClick,
- ...props
-}) => {
- const { onClick, ...dropdownProps } = useDropdown(Boolean(defaultOpen))
-
- const handleClick = useCallback(
- (e: React.MouseEvent) => {
- onClick(e)
-
- if (onMainButtonClick) {
- onMainButtonClick(dropdownProps.open)
- }
- },
- [onClick, onMainButtonClick, dropdownProps.open]
- )
-
- return (
-
- {Children.map(props.children, child => {
- if (!isValidElement(child)) {
- return child
- }
-
- // Dropdown.Menu
- if ('open' in child.props) {
- return cloneElement(child, { open: dropdownProps.open })
- }
-
- // Overlay
- if ('show' in child.props) {
- return cloneElement(child, { show: dropdownProps.open })
- }
-
- // anything else
- return cloneElement(child)
- })}
-
- )
-}
-
-export default ControlledDropdown
diff --git a/services/web/frontend/js/shared/components/copy-to-clipboard.tsx b/services/web/frontend/js/shared/components/copy-to-clipboard.tsx
index 517d8ba9ff..e119e3b146 100644
--- a/services/web/frontend/js/shared/components/copy-to-clipboard.tsx
+++ b/services/web/frontend/js/shared/components/copy-to-clipboard.tsx
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
-import { bsVersion } from '@/features/utils/bootstrap-5'
export const CopyToClipboard = memo<{
content: string
@@ -39,7 +38,6 @@ export const CopyToClipboard = memo<{
size="sm"
variant="secondary"
className="copy-button"
- bs3Props={{ bsSize: 'xsmall' }}
>
{t('copy')}
@@ -50,11 +48,7 @@ export const CopyToClipboard = memo<{
size="sm"
accessibilityLabel={t('copy')}
className="copy-button"
- bs3Props={{ bsSize: 'xsmall' }}
- icon={bsVersion({
- bs5: copied ? 'check' : 'content_copy',
- bs3: copied ? 'check' : 'clipboard',
- })}
+ icon={copied ? 'check' : 'content_copy'}
/>
)}
diff --git a/services/web/frontend/js/shared/components/error-boundary-fallback.tsx b/services/web/frontend/js/shared/components/error-boundary-fallback.tsx
index d3e73b2da7..58d90a1aaf 100644
--- a/services/web/frontend/js/shared/components/error-boundary-fallback.tsx
+++ b/services/web/frontend/js/shared/components/error-boundary-fallback.tsx
@@ -2,10 +2,9 @@ import { FC, ReactNode } from 'react'
import { DefaultMessage } from './default-message'
import OLNotification from '@/features/ui/components/ol/ol-notification'
-export const ErrorBoundaryFallback: FC<{ modal?: ReactNode }> = ({
- children,
- modal,
-}) => {
+export const ErrorBoundaryFallback: FC<
+ React.PropsWithChildren<{ modal?: ReactNode }>
+> = ({ children, modal }) => {
return (
} />
diff --git a/services/web/frontend/js/shared/components/generic-error-boundary-fallback.tsx b/services/web/frontend/js/shared/components/generic-error-boundary-fallback.tsx
index ecac2fad06..fe3d87a464 100644
--- a/services/web/frontend/js/shared/components/generic-error-boundary-fallback.tsx
+++ b/services/web/frontend/js/shared/components/generic-error-boundary-fallback.tsx
@@ -1,37 +1,24 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
-import Icon from './icon'
import { useLocation } from '../hooks/use-location'
import { DefaultMessage } from './default-message'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from './material-icon'
import OLButton from '@/features/ui/components/ol/ol-button'
-export const GenericErrorBoundaryFallback: FC = ({ children }) => {
+export const GenericErrorBoundaryFallback: FC
= ({
+ children,
+}) => {
const { t } = useTranslation()
const { reload: handleClick } = useLocation()
return (
-
- }
- bs5={
-
- }
+
{children || (
diff --git a/services/web/frontend/js/shared/components/icon.tsx b/services/web/frontend/js/shared/components/icon.tsx
index 49679efcfe..052b05edd2 100644
--- a/services/web/frontend/js/shared/components/icon.tsx
+++ b/services/web/frontend/js/shared/components/icon.tsx
@@ -1,5 +1,4 @@
import classNames from 'classnames'
-import { bsVersion } from '@/features/utils/bootstrap-5'
type IconOwnProps = {
type: string
@@ -36,9 +35,7 @@ function Icon({
<>
{accessibilityLabel && (
-
- {accessibilityLabel}
-
+
{accessibilityLabel}
)}
>
)
diff --git a/services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx b/services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx
index cb53bdc984..596749770e 100644
--- a/services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx
+++ b/services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx
@@ -5,7 +5,6 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { postJSON } from '@/infrastructure/fetch-json'
import OLButton from '@/features/ui/components/ol/ol-button'
import getMeta from '@/utils/meta'
-import { isBootstrap5 } from '@/features/utils/bootstrap-5'
type IntegrationLinkingWidgetProps = {
logo: ReactNode
@@ -117,17 +116,14 @@ function ActionButton({
)
} else if (disabled) {
- const tooltipableButton = isBootstrap5() ? (
+ const tooltipableButton = (
- ) : (
-
- {t('turn_on')}
-
)
+
return (
+
{loadingText || `${t('loading')}…`}
diff --git a/services/web/frontend/js/shared/components/material-icon.tsx b/services/web/frontend/js/shared/components/material-icon.tsx
index 3f5a499853..43a789b498 100644
--- a/services/web/frontend/js/shared/components/material-icon.tsx
+++ b/services/web/frontend/js/shared/components/material-icon.tsx
@@ -1,6 +1,5 @@
import classNames from 'classnames'
import React from 'react'
-import { bsVersion } from '@/features/utils/bootstrap-5'
import unfilledIconTypes from '../../../fonts/material-symbols/unfilled-symbols.mjs'
export type AvailableUnfilledIcon = (typeof unfilledIconTypes)[number]
@@ -43,9 +42,7 @@ function MaterialIcon({
{type}
{accessibilityLabel && (
-
- {accessibilityLabel}
-
+
{accessibilityLabel}
)}
>
)
diff --git a/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx b/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx
index b69c838f83..3cb495f0dd 100644
--- a/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx
+++ b/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx
@@ -18,17 +18,13 @@ type MenuBarDropdownProps = {
align?: 'start' | 'end'
}
-export const MenuBarDropdown: FC
= ({
- title,
- children,
- id,
- className,
- align = 'start',
-}) => {
+export const MenuBarDropdown: FC<
+ React.PropsWithChildren
+> = ({ title, children, id, className, align = 'start' }) => {
const { menuId, selected, setSelected } = useNestableDropdown()
const onToggle = useCallback(
- show => {
+ (show: boolean) => {
setSelected(show ? id : null)
},
[id, setSelected]
@@ -65,11 +61,9 @@ export const MenuBarDropdown: FC = ({
)
}
-const NestableDropdownMenu: FC = ({
- children,
- id,
- ...props
-}) => {
+const NestableDropdownMenu: FC<
+ React.PropsWithChildren
+> = ({ children, id, ...props }) => {
return (
@@ -79,43 +73,42 @@ const NestableDropdownMenu: FC = ({
)
}
-const NestedDropdownToggle: FC = forwardRef(
- function NestedDropdownToggle(
- { children, className, onMouseEnter, id },
- ref
- ) {
- return (
- // eslint-disable-next-line jsx-a11y/anchor-is-valid
-
- {children}
-
-
- )
- }
-)
+const NestedDropdownToggle: FC = forwardRef<
+ HTMLAnchorElement,
+ AnchorProps
+>(function NestedDropdownToggle(
+ { children, className, onMouseEnter, id },
+ ref
+) {
+ return (
+ // eslint-disable-next-line jsx-a11y/anchor-is-valid
+
+ {children}
+
+
+ )
+})
-export const NestedMenuBarDropdown: FC<{ id: string; title: string }> = ({
- children,
- id,
- title,
-}) => {
+export const NestedMenuBarDropdown: FC<
+ React.PropsWithChildren<{ id: string; title: string }>
+> = ({ children, id, title }) => {
const { menuId, selected, setSelected } = useNestableDropdown()
const select = useCallback(() => {
setSelected(id)
}, [id, setSelected])
const onToggle = useCallback(
- show => {
+ (show: boolean) => {
setSelected(show ? id : null)
},
[setSelected, id]
diff --git a/services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx b/services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx
index d553a3d263..692f555ea7 100644
--- a/services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx
+++ b/services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx
@@ -9,6 +9,8 @@ type MenuBarOptionProps = {
disabled?: boolean
trailingIcon?: ReactNode
href?: string
+ target?: string
+ rel?: string
}
export const MenuBarOption = ({
@@ -17,6 +19,8 @@ export const MenuBarOption = ({
href,
disabled,
trailingIcon,
+ target,
+ rel,
}: MenuBarOptionProps) => {
const { setSelected } = useNestableDropdown()
return (
@@ -27,6 +31,8 @@ export const MenuBarOption = ({
disabled={disabled}
trailingIcon={trailingIcon}
href={href}
+ rel={rel}
+ target={target}
>
{title}
diff --git a/services/web/frontend/js/shared/components/menu-bar/menu-bar.tsx b/services/web/frontend/js/shared/components/menu-bar/menu-bar.tsx
index 4236bfa5e8..a05fb4eff8 100644
--- a/services/web/frontend/js/shared/components/menu-bar/menu-bar.tsx
+++ b/services/web/frontend/js/shared/components/menu-bar/menu-bar.tsx
@@ -1,11 +1,9 @@
import { NestableDropdownContextProvider } from '@/shared/context/nestable-dropdown-context'
import { FC, HTMLProps } from 'react'
-export const MenuBar: FC & { id: string }> = ({
- children,
- id,
- ...props
-}) => {
+export const MenuBar: FC<
+ React.PropsWithChildren & { id: string }>
+> = ({ children, id, ...props }) => {
return (
diff --git a/services/web/frontend/js/shared/components/panel-heading.tsx b/services/web/frontend/js/shared/components/panel-heading.tsx
index bdec33779a..356fbaa904 100644
--- a/services/web/frontend/js/shared/components/panel-heading.tsx
+++ b/services/web/frontend/js/shared/components/panel-heading.tsx
@@ -3,12 +3,14 @@ import SplitTestBadge from '@/shared/components/split-test-badge'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
-export const PanelHeading: FC<{
- title: string
- splitTestName?: string
- children?: React.ReactNode
- handleClose(): void
-}> = ({ title, splitTestName, children, handleClose }) => {
+export const PanelHeading: FC<
+ React.PropsWithChildren<{
+ title: string
+ splitTestName?: string
+ children?: React.ReactNode
+ handleClose(): void
+ }>
+> = ({ title, splitTestName, children, handleClose }) => {
const { t } = useTranslation()
return (
diff --git a/services/web/frontend/js/shared/components/select.tsx b/services/web/frontend/js/shared/components/select.tsx
index 66b360480f..23f7d03521 100644
--- a/services/web/frontend/js/shared/components/select.tsx
+++ b/services/web/frontend/js/shared/components/select.tsx
@@ -1,5 +1,3 @@
-/* eslint-disable jsx-a11y/label-has-for */
-/* eslint-disable jsx-a11y/label-has-associated-control */
import {
useRef,
useEffect,
@@ -10,9 +8,7 @@ import {
} from 'react'
import classNames from 'classnames'
import { useSelect } from 'downshift'
-import Icon from './icon'
import { useTranslation } from 'react-i18next'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { Form, Spinner } from 'react-bootstrap-5'
import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
import MaterialIcon from '@/shared/components/material-icon'
@@ -91,6 +87,7 @@ export const Select = ({
} = useSelect({
items: items ?? [],
itemToString,
+ isItemDisabled: item => itemToDisabled?.(item) || false,
selectedItem: selected || defaultItem,
onSelectedItemChange: changes => {
if (onSelectedItemChanged) {
@@ -126,22 +123,18 @@ export const Select = ({
}
}, [name, itemToString, selectedItem, defaultItem])
- const handleMenuKeyDown = (event: React.KeyboardEvent) => {
- if (event.key === 'Escape' && isOpen) {
- event.stopPropagation()
- closeMenu()
- }
- }
-
- const onKeyDown: KeyboardEventHandler = useCallback(
+ const onKeyDown: KeyboardEventHandler = useCallback(
event => {
if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
event.preventDefault()
;(event.nativeEvent as any).preventDownshiftDefault = true
openMenu()
+ } else if (event.key === 'Escape' && isOpen) {
+ event.stopPropagation()
+ closeMenu()
}
},
- [isOpen, openMenu]
+ [closeMenu, isOpen, openMenu]
)
let value: string | undefined
@@ -150,150 +143,80 @@ export const Select = ({
} else {
value = defaultText
}
- return (
-
-
- {label ? (
-
- {label}{' '}
- {optionalLabel && (
-
- ({t('optional')})
-
- )}{' '}
- {loading && (
-
- )}
-
- ) : null}
-
-
{value}
-
- {isOpen ? (
-
- ) : (
-
- )}
-
-
-
-
- {isOpen &&
- items?.map((item, index) => {
- const isDisabled = itemToDisabled && itemToDisabled(item)
- return (
-
-
- {selectedIcon && (
-
- {(selectedItem === item ||
- (!selectedItem && defaultItem === item)) && (
-
- )}
-
- )}
- {itemToString(item)}
-
- {itemToSubtitle ? (
-
- {itemToSubtitle(item)}
-
- ) : null}
-
- )
- })}
-
-
- }
- bs5={
-
- {label ? (
-
- {label}{' '}
- {optionalLabel && (
- ({t('optional')})
- )}{' '}
- {loading && (
-
-
-
- )}
-
- ) : null}
-
+ {label ? (
+
+ {label}{' '}
+ {optionalLabel && (
+ ({t('optional')})
+ )}{' '}
+ {loading && (
+
+
- }
+
+ )}
+
+ ) : null}
+
-
- {isOpen &&
- items?.map((item, index) => {
- const isDisabled = itemToDisabled && itemToDisabled(item)
- return (
-
-
- {itemToString(item)}
-
-
- )
- })}
-
-
- }
- />
+ }
+ />
+
+ {isOpen &&
+ items?.map((item, index) => {
+ // We're using an actual disabled button so we don't need the
+ // aria-disabled prop
+ const { 'aria-disabled': disabled, ...itemProps } = getItemProps({
+ item,
+ index,
+ })
+ return (
+
+
+ {itemToString(item)}
+
+
+ )
+ })}
+
+
)
}
diff --git a/services/web/frontend/js/shared/components/start-free-trial-button.tsx b/services/web/frontend/js/shared/components/start-free-trial-button.tsx
index 3389ebd2af..2a6ea73dee 100644
--- a/services/web/frontend/js/shared/components/start-free-trial-button.tsx
+++ b/services/web/frontend/js/shared/components/start-free-trial-button.tsx
@@ -1,4 +1,4 @@
-import { MouseEventHandler, useCallback, useEffect } from 'react'
+import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { startFreeTrial } from '@/main/account-upgrade'
import * as eventTracking from '../../infrastructure/event-tracking'
@@ -9,7 +9,7 @@ type StartFreeTrialButtonProps = {
variant?: string
buttonProps?: React.ComponentProps
children?: React.ReactNode
- handleClick?: MouseEventHandler
+ handleClick?: React.ComponentProps['onClick']
}
export default function StartFreeTrialButton({
@@ -34,7 +34,7 @@ export default function StartFreeTrialButton({
}, [source, variant])
const onClick = useCallback(
- event => {
+ (event: React.MouseEvent) => {
event.preventDefault()
if (handleClick) {
diff --git a/services/web/frontend/js/shared/components/tag.tsx b/services/web/frontend/js/shared/components/tag.tsx
deleted file mode 100644
index e704936d0e..0000000000
--- a/services/web/frontend/js/shared/components/tag.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import { Label } from 'react-bootstrap'
-import { MergeAndOverride } from '../../../../types/utils'
-import classnames from 'classnames'
-
-type TagProps = MergeAndOverride<
- React.ComponentProps<'span'>,
- {
- prepend?: React.ReactNode
- children: React.ReactNode
- contentProps?: React.ComponentProps<'button'>
- closeBtnProps?: React.ComponentProps<'button'>
- className?: string
- bsStyle?: React.ComponentProps['bsStyle'] | null
- }
->
-
-function Tag({
- prepend,
- children,
- contentProps,
- closeBtnProps,
- bsStyle,
- className,
- ...rest
-}: TagProps) {
- const { t } = useTranslation()
-
- return (
-
-
- {prepend && {prepend} }
- {children}
-
- {closeBtnProps && (
-
- ×
-
- )}
-
- )
-}
-
-export default Tag
diff --git a/services/web/frontend/js/shared/components/tooltip.tsx b/services/web/frontend/js/shared/components/tooltip.tsx
deleted file mode 100644
index b86e963c8b..0000000000
--- a/services/web/frontend/js/shared/components/tooltip.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { cloneElement } from 'react'
-import {
- OverlayTrigger,
- OverlayTriggerProps,
- Tooltip as BSTooltip,
-} from 'react-bootstrap'
-import { callFnsInSequence } from '../../utils/functions'
-
-type OverlayProps = Omit & {
- shouldUpdatePosition?: boolean // Not officially documented https://stackoverflow.com/a/43138470
-}
-
-export type TooltipProps = {
- description: React.ReactNode
- id: string
- overlayProps?: OverlayProps
- tooltipProps?: BSTooltip.TooltipProps
- hidden?: boolean
- children: React.ReactElement
-}
-
-function Tooltip({
- id,
- description,
- children,
- tooltipProps,
- overlayProps,
- hidden,
-}: TooltipProps) {
- const hideTooltip = (e: React.MouseEvent) => {
- if (e.currentTarget instanceof HTMLElement) {
- e.currentTarget.blur()
- }
- }
-
- return (
-
- {description}
-
- }
- {...overlayProps}
- placement={overlayProps?.placement || 'top'}
- >
- {cloneElement(children, {
- onClick: callFnsInSequence(children.props.onClick, hideTooltip),
- })}
-
- )
-}
-
-export default Tooltip
diff --git a/services/web/frontend/js/shared/components/upgrade-benefits.jsx b/services/web/frontend/js/shared/components/upgrade-benefits.jsx
index 4f8001f7c8..535b0c8a5f 100644
--- a/services/web/frontend/js/shared/components/upgrade-benefits.jsx
+++ b/services/web/frontend/js/shared/components/upgrade-benefits.jsx
@@ -1,16 +1,9 @@
-import Icon from './icon'
import MaterialIcon from '@/shared/components/material-icon'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
function Check() {
- return (
- }
- bs5={ }
- />
- )
+ return
}
function UpgradeBenefits() {
diff --git a/services/web/frontend/js/shared/context/detach-compile-context.tsx b/services/web/frontend/js/shared/context/detach-compile-context.tsx
index 56f5130955..6cb0ca3170 100644
--- a/services/web/frontend/js/shared/context/detach-compile-context.tsx
+++ b/services/web/frontend/js/shared/context/detach-compile-context.tsx
@@ -9,7 +9,9 @@ export const DetachCompileContext = createContext(
undefined
)
-export const DetachCompileProvider: FC = ({ children }) => {
+export const DetachCompileProvider: FC = ({
+ children,
+}) => {
const localCompileContext = useLocalCompileContext()
if (!localCompileContext) {
throw new Error(
diff --git a/services/web/frontend/js/shared/context/detach-context.tsx b/services/web/frontend/js/shared/context/detach-context.tsx
index dd0f6acc91..2a45b198af 100644
--- a/services/web/frontend/js/shared/context/detach-context.tsx
+++ b/services/web/frontend/js/shared/context/detach-context.tsx
@@ -40,7 +40,7 @@ export const detachChannel =
? new BroadcastChannel(detachChannelId)
: undefined
-export const DetachProvider: FC = ({ children }) => {
+export const DetachProvider: FC = ({ children }) => {
const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState()
const [role, setRole] = useState(() => getMeta('ol-detachRole') || null)
const {
diff --git a/services/web/frontend/js/shared/context/editor-context.tsx b/services/web/frontend/js/shared/context/editor-context.tsx
index f059f14319..b08f05450a 100644
--- a/services/web/frontend/js/shared/context/editor-context.tsx
+++ b/services/web/frontend/js/shared/context/editor-context.tsx
@@ -61,7 +61,7 @@ export const EditorContext = createContext<
| undefined
>(undefined)
-export const EditorProvider: FC = ({ children }) => {
+export const EditorProvider: FC = ({ children }) => {
const { socket } = useIdeContext()
const { id: userId, featureUsage } = useUserContext()
const { role } = useDetachContext()
@@ -125,7 +125,7 @@ export const EditorProvider: FC = ({ children }) => {
)
const deactivateTutorial = useCallback(
- tutorialKey => {
+ (tutorialKey: string) => {
setInactiveTutorials([...inactiveTutorials, tutorialKey])
},
[inactiveTutorials]
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 13c3e18e23..37de214301 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
@@ -176,7 +176,9 @@ export function useFileTreeData() {
return context
}
-export const FileTreeDataProvider: FC = ({ children }) => {
+export const FileTreeDataProvider: FC = ({
+ children,
+}) => {
const [project] = useScopeValue('project')
const [currentDocumentId] = useScopeValue('editor.open_doc_id')
const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name')
@@ -227,14 +229,17 @@ export const FileTreeDataProvider: FC = ({ children }) => {
})
}, [rootFolder])
- const dispatchCreateFolder = useCallback((parentFolderId, entity) => {
- entity.type = 'folder'
- dispatch({
- type: ACTION_TYPES.CREATE,
- parentFolderId,
- entity,
- })
- }, [])
+ const dispatchCreateFolder = useCallback(
+ (parentFolderId: string, entity: any) => {
+ entity.type = 'folder'
+ dispatch({
+ type: ACTION_TYPES.CREATE,
+ parentFolderId,
+ entity,
+ })
+ },
+ []
+ )
const dispatchCreateDoc = useCallback(
(parentFolderId: string, entity: any) => {
diff --git a/services/web/frontend/js/shared/context/ide-context.tsx b/services/web/frontend/js/shared/context/ide-context.tsx
index f83686df9d..3eef2bbdbb 100644
--- a/services/web/frontend/js/shared/context/ide-context.tsx
+++ b/services/web/frontend/js/shared/context/ide-context.tsx
@@ -15,11 +15,13 @@ type IdeContextValue = Ide & {
export const IdeContext = createContext(undefined)
-export const IdeProvider: FC<{
- ide: Ide
- scopeStore: ScopeValueStore
- scopeEventEmitter: ScopeEventEmitter
-}> = ({ ide, scopeStore, scopeEventEmitter, children }) => {
+export const IdeProvider: FC<
+ React.PropsWithChildren<{
+ ide: Ide
+ scopeStore: ScopeValueStore
+ scopeEventEmitter: ScopeEventEmitter
+ }>
+> = ({ ide, scopeStore, scopeEventEmitter, children }) => {
/**
* Expose scopeStore via `window.overleaf.unstable.store`, so it can be accessed by external extensions.
*
diff --git a/services/web/frontend/js/shared/context/layout-context.tsx b/services/web/frontend/js/shared/context/layout-context.tsx
index 145d840540..bcd0167812 100644
--- a/services/web/frontend/js/shared/context/layout-context.tsx
+++ b/services/web/frontend/js/shared/context/layout-context.tsx
@@ -21,6 +21,7 @@ import useEventListener from '@/shared/hooks/use-event-listener'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { isMac } from '@/shared/utils/os'
import { sendSearchEvent } from '@/features/event-tracking/search-events'
+import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
export type IdeLayout = 'sideBySide' | 'flat'
export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
@@ -70,11 +71,13 @@ function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
)
}
-export const LayoutProvider: FC = ({ children }) => {
+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 historyToggleEmitter = useScopeEventEmitter('history:toggle', true)
+ const { isOpen: railIsOpen, setIsOpen: setRailIsOpen } = useRailContext()
+ const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen)
const setView = useCallback(
(value: IdeView | null) => {
@@ -84,6 +87,15 @@ export const LayoutProvider: FC = ({ children }) => {
historyToggleEmitter()
}
+ if (value === 'history') {
+ setPrevRailIsOpen(railIsOpen)
+ setRailIsOpen(true)
+ }
+
+ if (oldValue === 'history') {
+ 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
@@ -95,7 +107,15 @@ export const LayoutProvider: FC = ({ children }) => {
return value
})
},
- [_setView, openFile, historyToggleEmitter]
+ [
+ _setView,
+ setRailIsOpen,
+ openFile,
+ historyToggleEmitter,
+ prevRailIsOpen,
+ setPrevRailIsOpen,
+ railIsOpen,
+ ]
)
// whether the chat pane is open
@@ -119,8 +139,8 @@ export const LayoutProvider: FC = ({ children }) => {
useEventListener(
'ui.toggle-left-menu',
useCallback(
- event => {
- setLeftMenuShown((event as CustomEvent).detail)
+ (event: CustomEvent) => {
+ setLeftMenuShown(event.detail)
},
[setLeftMenuShown]
)
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 e673ba237d..ba63b406bd 100644
--- a/services/web/frontend/js/shared/context/local-compile-context.tsx
+++ b/services/web/frontend/js/shared/context/local-compile-context.tsx
@@ -34,16 +34,22 @@ import { buildFileList } from '../../features/pdf-preview/util/file-list'
import { useLayoutContext } from './layout-context'
import { useUserContext } from './user-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import { useDetachContext } from '@/shared/context/detach-context'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
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 { getJSON } from '@/infrastructure/fetch-json'
import { CompileResponseData } from '../../../../types/compile'
import {
PdfScrollPosition,
usePdfScrollPosition,
} from '@/shared/hooks/use-pdf-scroll-position'
import { PdfFileDataList } from '@/features/pdf-preview/util/types'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import { captureException } from '@/infrastructure/error-reporter'
+import OError from '@overleaf/o-error'
+import getMeta from '@/utils/meta'
type PdfFile = Record
@@ -112,11 +118,20 @@ export const LocalCompileContext = createContext(
undefined
)
-export const LocalCompileProvider: FC = ({ children }) => {
+export const LocalCompileProvider: FC = ({
+ children,
+}) => {
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
const { openDocWithId, openDocs, currentDocument } = useEditorManagerContext()
+ const { role } = useDetachContext()
- const { _id: projectId, rootDocId } = useProjectContext()
+ const {
+ _id: projectId,
+ rootDocId,
+ joinedOnce,
+ imageName,
+ compiler: compilerName,
+ } = useProjectContext()
const { pdfPreviewOpen } = useLayoutContext()
@@ -186,6 +201,18 @@ export const LocalCompileProvider: FC = ({ children }) => {
// whether the project has been compiled yet
const [compiledOnce, setCompiledOnce] = useState(false)
+ // fetch initial compile response from cache
+ const [initialCompileFromCache, setInitialCompileFromCache] = useState(
+ getMeta('ol-projectOwnerHasPremiumOnPageLoad') &&
+ isSplitTestEnabled('initial-compile-from-clsi-cache') &&
+ // Avoid fetching the initial compile from cache in PDF detach tab
+ role !== 'detached'
+ )
+ // fetch of initial compile from cache is pending
+ const [pendingInitialCompileFromCache, setPendingInitialCompileFromCache] =
+ useState(false)
+ // Raw data from clsi-cache, will need post-processing and check settings
+ const [dataFromCache, setDataFromCache] = useState()
// whether the cache is being cleared
const [clearingCache, setClearingCache] = useState(false)
@@ -271,7 +298,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
}, [compiling])
const _buildLogEntryAnnotations = useCallback(
- entries =>
+ (entries: any) =>
buildLogEntryAnnotations(entries, fileTreeData, lastCompileRootDocId),
[fileTreeData, lastCompileRootDocId]
)
@@ -327,13 +354,94 @@ export const LocalCompileProvider: FC = ({ children }) => {
setEditedSinceCompileStarted(changedAt > 0)
}, [setEditedSinceCompileStarted, changedAt])
+ // try to fetch the last compile result after opening the project, potentially before joining the project.
+ useEffect(() => {
+ if (initialCompileFromCache && !pendingInitialCompileFromCache) {
+ setPendingInitialCompileFromCache(true)
+ getJSON(`/project/${projectId}/output/cached/output.overleaf.json`)
+ .then((data: any) => {
+ // Hand data over to next effect, it will wait for project/doc loading.
+ setDataFromCache(data)
+ })
+ .catch(() => {
+ // Let the isAutoCompileOnLoad effect take over
+ setInitialCompileFromCache(false)
+ setPendingInitialCompileFromCache(false)
+ })
+ }
+ }, [projectId, initialCompileFromCache, pendingInitialCompileFromCache])
+
+ // Maybe adopt the compile from cache
+ useEffect(() => {
+ if (!dataFromCache) return // no compile from cache available
+ if (!joinedOnce) return // wait for joinProject, it populates the file-tree.
+ if (!currentDocument) return // wait for current doc to load, it affects the rootDoc override
+ if (compiledOnce) return // regular compile triggered
+
+ // Gracefully access file-tree and getRootDocOverride
+ let settingsUpToDate = false
+ try {
+ dataFromCache.rootDocId = findEntityByPath(
+ dataFromCache.options?.rootResourcePath || ''
+ )?.entity?._id
+ const rootDocOverride = compiler.getRootDocOverrideId() || rootDocId
+ settingsUpToDate =
+ rootDocOverride === dataFromCache.rootDocId &&
+ dataFromCache.options.imageName === imageName &&
+ dataFromCache.options.compiler === compilerName &&
+ dataFromCache.options.draft === draft &&
+ // Allow stopOnFirstError to be enabled in the compile from cache and disabled locally.
+ // Compiles that passed with stopOnFirstError=true will also pass with stopOnFirstError=false. The inverse does not hold, and we need to recompile.
+ !!dataFromCache.options.stopOnFirstError >= stopOnFirstError
+ } catch (err) {
+ captureException(
+ OError.tag(err as unknown as Error, 'validate compile options', {
+ options: dataFromCache.options,
+ })
+ )
+ }
+
+ if (settingsUpToDate) {
+ sendMB('compile-from-cache', { projectId })
+ setData(dataFromCache)
+ setCompiledOnce(true)
+ }
+ setDataFromCache(undefined)
+ setInitialCompileFromCache(false)
+ setPendingInitialCompileFromCache(false)
+ }, [
+ projectId,
+ dataFromCache,
+ joinedOnce,
+ currentDocument,
+ compiledOnce,
+ rootDocId,
+ findEntityByPath,
+ compiler,
+ compilerName,
+ imageName,
+ stopOnFirstError,
+ draft,
+ ])
+
// always compile the PDF once after opening the project, after the doc has loaded
useEffect(() => {
- if (!compiledOnce && currentDocument) {
+ if (
+ !compiledOnce &&
+ currentDocument &&
+ !initialCompileFromCache &&
+ !pendingInitialCompileFromCache
+ ) {
setCompiledOnce(true)
compiler.compile({ isAutoCompileOnLoad: true })
}
- }, [compiledOnce, currentDocument, compiler])
+ }, [
+ compiledOnce,
+ currentDocument,
+ initialCompileFromCache,
+ pendingInitialCompileFromCache,
+ compiler,
+ ])
useEffect(() => {
setHasShortCompileTimeout(
@@ -370,6 +478,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
// note: this should _only_ run when `data` changes,
// the other dependencies must all be static
useEffect(() => {
+ if (!joinedOnce) return // wait for joinProject, it populates the premium flags.
const abortController = new AbortController()
const recordedActions = recordedActionsRef.current
@@ -519,6 +628,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
abortController.abort()
}
}, [
+ joinedOnce,
data,
alphaProgram,
labsProgram,
@@ -578,10 +688,11 @@ export const LocalCompileProvider: FC = ({ children }) => {
// start a compile manually
const startCompile = useCallback(
- options => {
+ (options: any) => {
+ setCompiledOnce(true)
compiler.compile(options)
},
- [compiler]
+ [compiler, setCompiledOnce]
)
// stop a compile manually
@@ -605,7 +716,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
}, [compiler])
const syncToEntry = useCallback(
- (entry, keepCurrentView = false) => {
+ (entry: any, keepCurrentView = false) => {
const result = findEntityByPath(entry.file)
if (result && result.type === 'doc') {
diff --git a/services/web/frontend/js/shared/context/nestable-dropdown-context.tsx b/services/web/frontend/js/shared/context/nestable-dropdown-context.tsx
index 3f1b7f9c0f..7f949f4ebe 100644
--- a/services/web/frontend/js/shared/context/nestable-dropdown-context.tsx
+++ b/services/web/frontend/js/shared/context/nestable-dropdown-context.tsx
@@ -10,10 +10,9 @@ export const NestableDropdownContext = createContext<
NestableDropdownContextType | undefined
>(undefined)
-export const NestableDropdownContextProvider: FC<{ id: string }> = ({
- id,
- children,
-}) => {
+export const NestableDropdownContextProvider: FC<
+ React.PropsWithChildren<{ id: string }>
+> = ({ id, children }) => {
const [selected, setSelected] = useState(null)
return (
{
+export const ProjectProvider: FC = ({ children }) => {
const [project] = useScopeValue('project')
+ const joinedOnce = !!project
const {
_id,
compiler,
+ imageName,
name,
rootDoc_id: rootDocId,
members,
@@ -58,6 +60,7 @@ export const ProjectProvider: FC = ({ children }) => {
return {
_id,
compiler,
+ imageName,
name,
rootDocId,
members,
@@ -69,10 +72,12 @@ export const ProjectProvider: FC = ({ children }) => {
trackChangesState,
mainBibliographyDocId,
projectSnapshot,
+ joinedOnce,
}
}, [
_id,
compiler,
+ imageName,
name,
rootDocId,
members,
@@ -84,6 +89,7 @@ export const ProjectProvider: FC = ({ children }) => {
trackChangesState,
mainBibliographyDocId,
projectSnapshot,
+ joinedOnce,
])
return (
diff --git a/services/web/frontend/js/shared/context/split-test-context.tsx b/services/web/frontend/js/shared/context/split-test-context.tsx
index 4902dadcde..446b8a3b42 100644
--- a/services/web/frontend/js/shared/context/split-test-context.tsx
+++ b/services/web/frontend/js/shared/context/split-test-context.tsx
@@ -10,7 +10,9 @@ export const SplitTestContext = createContext<
| undefined
>(undefined)
-export const SplitTestProvider: FC = ({ children }) => {
+export const SplitTestProvider: FC = ({
+ children,
+}) => {
const value = useMemo(
() => ({
splitTestVariants: getMeta('ol-splitTestVariants') || {},
diff --git a/services/web/frontend/js/shared/context/types/project-context.tsx b/services/web/frontend/js/shared/context/types/project-context.tsx
index 91419ed06f..18eb42010c 100644
--- a/services/web/frontend/js/shared/context/types/project-context.tsx
+++ b/services/web/frontend/js/shared/context/types/project-context.tsx
@@ -18,6 +18,7 @@ export type ProjectContextValue = {
rootDocId?: string
mainBibliographyDocId?: string
compiler: string
+ imageName: string
members: ProjectContextMember[]
invites: ProjectContextMember[]
features: {
@@ -49,6 +50,7 @@ export type ProjectContextValue = {
}[]
trackChangesState: boolean | Record
projectSnapshot: ProjectSnapshot
+ joinedOnce: boolean
}
export type ProjectContextUpdateValue = Partial
diff --git a/services/web/frontend/js/shared/context/types/writefull-instance.ts b/services/web/frontend/js/shared/context/types/writefull-instance.ts
index 8dd0023f07..213ab67f18 100644
--- a/services/web/frontend/js/shared/context/types/writefull-instance.ts
+++ b/services/web/frontend/js/shared/context/types/writefull-instance.ts
@@ -5,6 +5,7 @@ export interface WritefullEvents {
'writefull-received-suggestions': { numberOfSuggestions: number }
'writefull-register-as-auto-account': { email: string }
'writefull-shared-analytics': { eventName: string; segmentation: object }
+ 'writefull-ai-assist-show-paywall': { origin?: string }
}
type InsertPosition = {
@@ -18,14 +19,26 @@ export interface WritefullAPI {
iconPosition,
hasAgreedToTOS,
overleafUserId,
+ overleafLabels,
}: {
toolbarPosition: InsertPosition
iconPosition: InsertPosition
hasAgreedToTOS: boolean
overleafUserId: string
+ overleafLabels: {
+ autoImport: boolean
+ autoCreatedAccount: boolean
+ splitTests: Record
+ }
}): Promise
addEventListener(
name: eventName,
callback: (detail: WritefullEvents[eventName]) => void
): void
+ removeEventListener(
+ name: eventName,
+ callback: (detail: WritefullEvents[eventName]) => void
+ ): void
+ openTableGenerator(): void
+ openEquationGenerator(): void
}
diff --git a/services/web/frontend/js/shared/context/user-context.tsx b/services/web/frontend/js/shared/context/user-context.tsx
index 598455a5a9..97518a66cf 100644
--- a/services/web/frontend/js/shared/context/user-context.tsx
+++ b/services/web/frontend/js/shared/context/user-context.tsx
@@ -6,7 +6,7 @@ export const UserContext = createContext(
undefined
)
-export const UserProvider: FC = ({ children }) => {
+export const UserProvider: FC = ({ children }) => {
const user = useMemo(() => getMeta('ol-user'), [])
return {children}
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 b9b6f22ad1..b0bce5bf5c 100644
--- a/services/web/frontend/js/shared/context/user-settings-context.tsx
+++ b/services/web/frontend/js/shared/context/user-settings-context.tsx
@@ -13,6 +13,7 @@ import { UserSettings, Keybindings } 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'
const defaultSettings: UserSettings = {
pdfViewer: 'pdfjs',
@@ -43,13 +44,16 @@ type ScopeSettings = {
fontSize: number
fontFamily: string
lineHeight: number
+ isNewEditor: boolean
}
export const UserSettingsContext = createContext<
UserSettingsContextValue | undefined
>(undefined)
-export const UserSettingsProvider: FC = ({ children }) => {
+export const UserSettingsProvider: FC = ({
+ children,
+}) => {
const [userSettings, setUserSettings] = useState(
() => getMeta('ol-userSettings') || defaultSettings
)
@@ -64,6 +68,7 @@ export const UserSettingsProvider: FC = ({ children }) => {
fontFamily,
lineHeight,
fontSize: userSettings.fontSize,
+ isNewEditor: canUseNewEditor() && userSettings.enableNewEditor,
})
}, [setScopeSettings, userSettings])
diff --git a/services/web/frontend/js/shared/hooks/use-detach-layout.ts b/services/web/frontend/js/shared/hooks/use-detach-layout.ts
index 3b9bad8b7d..dbb19e0a90 100644
--- a/services/web/frontend/js/shared/hooks/use-detach-layout.ts
+++ b/services/web/frontend/js/shared/hooks/use-detach-layout.ts
@@ -100,7 +100,7 @@ export default function useDetachLayout() {
}, [setRole, setIsLinked])
const handleEventForDetacherFromDetached = useCallback(
- message => {
+ (message: any) => {
switch (message.event) {
case 'connected':
broadcastEvent('up')
@@ -118,7 +118,7 @@ export default function useDetachLayout() {
)
const handleEventForDetachedFromDetacher = useCallback(
- message => {
+ (message: any) => {
switch (message.event) {
case 'connected':
broadcastEvent('up')
@@ -140,7 +140,7 @@ export default function useDetachLayout() {
)
const handleEventForDetachedFromDetached = useCallback(
- message => {
+ (message: any) => {
switch (message.event) {
case 'closed':
broadcastEvent('up')
@@ -151,7 +151,7 @@ export default function useDetachLayout() {
)
const handleEvent = useCallback(
- message => {
+ (message: any) => {
if (role === 'detacher') {
if (message.role === 'detacher') {
handleEventForDetacherFromDetacher()
diff --git a/services/web/frontend/js/shared/hooks/use-dropdown.ts b/services/web/frontend/js/shared/hooks/use-dropdown.ts
index d5f7366dfb..2acea3085f 100644
--- a/services/web/frontend/js/shared/hooks/use-dropdown.ts
+++ b/services/web/frontend/js/shared/hooks/use-dropdown.ts
@@ -9,7 +9,7 @@ export default function useDropdown(defaultOpen = false) {
// react-bootstrap v0.x passes `component` instead of `node` to the ref callback
const handleRef = useCallback(
- component => {
+ (component: any) => {
if (component) {
// eslint-disable-next-line react/no-find-dom-node
ref.current = findDOMNode(component)
@@ -19,18 +19,18 @@ export default function useDropdown(defaultOpen = false) {
)
// prevent a click on the dropdown toggle propagating to the original handler
- const handleClick = useCallback(event => {
+ const handleClick = useCallback((event: any) => {
event.stopPropagation()
}, [])
// handle dropdown toggle
- const handleToggle = useCallback(value => {
+ const handleToggle = useCallback((value: any) => {
setOpen(Boolean(value))
}, [])
// close the dropdown on click outside the dropdown
const handleDocumentClick = useCallback(
- event => {
+ (event: any) => {
if (ref.current && !ref.current.contains(event.target)) {
setOpen(false)
}
diff --git a/services/web/frontend/js/shared/hooks/use-location.ts b/services/web/frontend/js/shared/hooks/use-location.ts
index 9f4b3b470f..10df73455d 100644
--- a/services/web/frontend/js/shared/hooks/use-location.ts
+++ b/services/web/frontend/js/shared/hooks/use-location.ts
@@ -6,7 +6,7 @@ export const useLocation = () => {
const isMounted = useIsMounted()
const assign = useCallback(
- url => {
+ (url: string) => {
if (isMounted.current) {
location.assign(url)
}
@@ -15,7 +15,7 @@ export const useLocation = () => {
)
const replace = useCallback(
- url => {
+ (url: string) => {
if (isMounted.current) {
location.replace(url)
}
diff --git a/services/web/frontend/js/shared/hooks/user-channel/use-user-channel.ts b/services/web/frontend/js/shared/hooks/user-channel/use-user-channel.ts
index 29ae421a12..b00041c539 100644
--- a/services/web/frontend/js/shared/hooks/user-channel/use-user-channel.ts
+++ b/services/web/frontend/js/shared/hooks/user-channel/use-user-channel.ts
@@ -8,7 +8,7 @@ export const useUserChannel = (): BroadcastChannel | null => {
}
useEffect(() => {
- return () => channelRef.current?.close()
+ return () => channelRef.current?.close?.()
}, [])
return channelRef.current
diff --git a/services/web/frontend/js/shared/svgs/dropbox-logo-black.tsx b/services/web/frontend/js/shared/svgs/dropbox-logo-black.tsx
index 8c75e7cfd7..b93e22c112 100644
--- a/services/web/frontend/js/shared/svgs/dropbox-logo-black.tsx
+++ b/services/web/frontend/js/shared/svgs/dropbox-logo-black.tsx
@@ -1,8 +1,8 @@
-function DropboxlLogoBlack() {
+function DropboxLogoBlack({ size = 20 }: { size?: number }) {
return (
-
+
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
- )
-}
-
-export default GitBridgeLogo
diff --git a/services/web/frontend/js/shared/svgs/git-logo-orange.tsx b/services/web/frontend/js/shared/svgs/git-logo-orange.tsx
new file mode 100644
index 0000000000..2d242141e7
--- /dev/null
+++ b/services/web/frontend/js/shared/svgs/git-logo-orange.tsx
@@ -0,0 +1,29 @@
+function GitLogoOrange({ size = 40 }: { size?: number }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default GitLogoOrange
diff --git a/services/web/frontend/js/shared/svgs/git-logo-white.tsx b/services/web/frontend/js/shared/svgs/git-logo-white.tsx
new file mode 100644
index 0000000000..8119be30dc
--- /dev/null
+++ b/services/web/frontend/js/shared/svgs/git-logo-white.tsx
@@ -0,0 +1,29 @@
+function GitLogoWhite({ size = 40 }: { size?: number }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default GitLogoWhite
diff --git a/services/web/frontend/js/shared/svgs/github-logo-black.tsx b/services/web/frontend/js/shared/svgs/github-logo-black.tsx
index f28ac61e0d..9d782aef4c 100644
--- a/services/web/frontend/js/shared/svgs/github-logo-black.tsx
+++ b/services/web/frontend/js/shared/svgs/github-logo-black.tsx
@@ -1,8 +1,8 @@
-function GithubLogoBlack() {
+function GithubLogoBlack({ size = 20 }: { size?: number }) {
return (
+
+
+
+
+
+
+
+
diff --git a/services/web/frontend/js/shared/svgs/sparkle-small-white.svg b/services/web/frontend/js/shared/svgs/sparkle-small-white.svg
new file mode 100644
index 0000000000..343a85d778
--- /dev/null
+++ b/services/web/frontend/js/shared/svgs/sparkle-small-white.svg
@@ -0,0 +1 @@
+
diff --git a/services/web/frontend/js/utils/abort-signal.ts b/services/web/frontend/js/utils/abort-signal.ts
index 98e9ce49ab..c6c9cb9883 100644
--- a/services/web/frontend/js/utils/abort-signal.ts
+++ b/services/web/frontend/js/utils/abort-signal.ts
@@ -1,25 +1,5 @@
-export const supportsModernAbortSignal =
- typeof AbortSignal.any === 'function' &&
- typeof AbortSignal.timeout === 'function'
+import './abortsignal-polyfill'
export const signalWithTimeout = (signal: AbortSignal, timeout: number) => {
- if (supportsModernAbortSignal) {
- return AbortSignal.any([signal, AbortSignal.timeout(timeout)])
- }
-
- const abortController = new AbortController()
-
- const abort = () => {
- window.clearTimeout(timer)
- signal.removeEventListener('abort', abort)
- abortController.abort()
- }
-
- // abort after timeout has expired
- const timer = window.setTimeout(abort, timeout)
-
- // abort when the original signal is aborted
- signal.addEventListener('abort', abort)
-
- return abortController.signal
+ return AbortSignal.any([signal, AbortSignal.timeout(timeout)])
}
diff --git a/services/web/frontend/js/utils/abortsignal-polyfill.ts b/services/web/frontend/js/utils/abortsignal-polyfill.ts
new file mode 100644
index 0000000000..00b214a560
--- /dev/null
+++ b/services/web/frontend/js/utils/abortsignal-polyfill.ts
@@ -0,0 +1,54 @@
+if (typeof AbortSignal.timeout !== 'function') {
+ AbortSignal.timeout = (time: number) => {
+ const controller = new AbortController()
+
+ function abort() {
+ controller.abort(new DOMException('Timed out', 'TimeoutError'))
+ }
+
+ function clean() {
+ window.clearTimeout(timer)
+ controller.signal.removeEventListener('abort', clean)
+ }
+
+ controller.signal.addEventListener('abort', clean)
+
+ const timer = window.setTimeout(abort, time)
+
+ return controller.signal
+ }
+}
+
+if (typeof AbortSignal.any !== 'function') {
+ AbortSignal.any = (signals: AbortSignal[]) => {
+ const controller = new AbortController()
+
+ // return immediately if any of the signals are already aborted.
+ for (const signal of signals) {
+ if (signal.aborted) {
+ controller.abort(signal.reason)
+ return controller.signal
+ }
+ }
+
+ function abort() {
+ controller.abort()
+ clean()
+ }
+
+ function clean() {
+ for (const signal of signals) {
+ signal.removeEventListener('abort', abort)
+ }
+ }
+
+ // abort the controller (and clean up) when any of the signals aborts
+ for (const signal of signals) {
+ signal.addEventListener('abort', abort)
+ }
+
+ return controller.signal
+ }
+}
+
+export default null // show that this is a module
diff --git a/services/web/frontend/js/utils/labs-utils.ts b/services/web/frontend/js/utils/labs-utils.ts
new file mode 100644
index 0000000000..1cfabafd87
--- /dev/null
+++ b/services/web/frontend/js/utils/labs-utils.ts
@@ -0,0 +1,10 @@
+import getMeta from './meta'
+
+// Should be `never` when no experiments are active. Otherwise it should be a
+// union of active experiment names e.g. `'experiment1' | 'experiment2'`
+export type ActiveExperiment = 'editor-redesign'
+
+export const isInExperiment = (experiment: ActiveExperiment): boolean => {
+ const experiments = getMeta('ol-labsExperiments')
+ return Boolean(experiments?.includes(experiment))
+}
diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts
index 0f188c0976..7aab88b050 100644
--- a/services/web/frontend/js/utils/meta.ts
+++ b/services/web/frontend/js/utils/meta.ts
@@ -51,6 +51,8 @@ import { Publisher } from '../../../types/subscription/dashboard/publisher'
import { SubscriptionChangePreview } from '../../../types/subscription/subscription-change-preview'
import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
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'
export interface Meta {
'ol-ExposedSettings': ExposedSettings
'ol-addonPrices': Record
@@ -113,6 +115,7 @@ export interface Meta {
'ol-groupSsoSetupSuccess': boolean
'ol-groupSubscriptionsPendingEnrollment': PendingGroupSubscriptionEnrollment[]
'ol-groupsAndEnterpriseBannerVariant': GroupsAndEnterpriseBannerVariant
+ 'ol-hasAiAssistViaWritefull': boolean
'ol-hasGroupSSOFeature': boolean
'ol-hasIndividualRecurlySubscription': boolean
'ol-hasManagedUsersFeature': boolean
@@ -126,6 +129,7 @@ export interface Meta {
'ol-institutionLinked': InstitutionLink | undefined
'ol-inviteToken': string
'ol-inviterName': string
+ 'ol-isCollectionMethodManual': boolean
'ol-isExternalAuthenticationSystemUsed': boolean
'ol-isManagedAccount': boolean
'ol-isPaywallChangeCompileTimeoutEnabled': boolean
@@ -138,6 +142,7 @@ export interface Meta {
'ol-itm_content': string
'ol-itm_referrer': string
'ol-labs': boolean
+ 'ol-labsExperiments': ActiveExperiment[] | undefined
'ol-languages': SpellCheckLanguage[]
'ol-learnedWords': string[]
'ol-legacyEditorThemes': string[]
@@ -162,6 +167,7 @@ export interface Meta {
'ol-oauthProviders': OAuthProviders
'ol-odcRole': string
'ol-overallThemes': OverallThemeMeta[]
+ 'ol-pages': number
'ol-passwordStrengthOptions': PasswordStrengthOptions
'ol-paywallPlans': { [key: string]: string }
'ol-personalAccessTokens': AccessToken[] | undefined
@@ -178,9 +184,11 @@ export interface Meta {
'ol-project': any // TODO
'ol-projectHistoryBlobsEnabled': boolean
'ol-projectName': string
+ 'ol-projectOwnerHasPremiumOnPageLoad': boolean
'ol-projectSyncSuccessMessage': string
'ol-projectTags': Tag[]
'ol-project_id': string
+ 'ol-purchaseReferrer': string
'ol-recommendedCurrency': CurrencyCode
'ol-reconfirmationRemoveEmail': string
'ol-reconfirmedViaSAML': string
@@ -188,6 +196,8 @@ export interface Meta {
'ol-recurlySubdomain': string
'ol-ro-mirror-on-client-no-local-storage': boolean
'ol-samlError': SAMLError | undefined
+ 'ol-script-log': ScriptLogType
+ 'ol-script-logs': ScriptLogType[]
'ol-settingsGroupSSO': { enabled: boolean } | undefined
'ol-settingsPlans': Plan[]
'ol-shouldAllowEditingDetails': boolean
@@ -225,7 +235,6 @@ export interface Meta {
'ol-translationUnableToJoin': string
'ol-usGovBannerVariant': USGovBannerVariant
'ol-useShareJsHash': boolean
- 'ol-usedLatex': 'never' | 'occasionally' | 'often' | undefined
'ol-user': User
'ol-userAffiliations': Affiliation[]
'ol-userCanExtendTrial': boolean
diff --git a/services/web/frontend/js/utils/react.ts b/services/web/frontend/js/utils/react.ts
index c399409d0c..3f837c520e 100644
--- a/services/web/frontend/js/utils/react.ts
+++ b/services/web/frontend/js/utils/react.ts
@@ -1,16 +1,23 @@
-import { forwardRef } from 'react'
+import {
+ forwardRef,
+ PropsWithoutRef,
+ ReactElement,
+ Ref,
+ RefAttributes,
+ FunctionComponent,
+} from 'react'
export const fixedForwardRef = <
T,
P = object,
- A extends Record = Record<
+ A extends Record = Record<
string,
- React.FunctionComponent
+ FunctionComponent
>,
>(
- render: (props: P, ref: React.Ref) => React.ReactElement | null,
+ render: (props: PropsWithoutRef, ref: Ref) => ReactElement | null,
propsToAttach: A = {} as A
-): ((props: P & React.RefAttributes) => React.ReactElement | null) & A => {
+): ((props: P & RefAttributes) => ReactElement | null) & A => {
const ForwardReferredComponent = forwardRef(render) as any
for (const i in propsToAttach) {
diff --git a/services/web/frontend/js/vendor/libs/sharejs.js b/services/web/frontend/js/vendor/libs/sharejs.js
index a8bbdfaa78..accc2b5b04 100644
--- a/services/web/frontend/js/vendor/libs/sharejs.js
+++ b/services/web/frontend/js/vendor/libs/sharejs.js
@@ -1008,8 +1008,8 @@ export const { Doc } = (() => {
this.type = type;
if (type.api) {
- for (var k in type.api) {
- var v = type.api[k];this[k] = v;
+ for (const k of ['insert', 'del', 'getText', 'getLength', '_register']) {
+ this[k] = type.api[k]
}
return typeof this._register === 'function' ? this._register() : undefined;
} else {
@@ -1322,7 +1322,8 @@ export const { Doc } = (() => {
var needToRecomputeHash = !this.__lastSubmitTimestamp || (age > RECOMPUTE_HASH_INTERVAL) || (age < 0)
if (needToRecomputeHash || debugging) {
// send git hash of current snapshot
- var sha1 = generateSHA1Hash("blob " + this.snapshot.length + "\x00" + this.snapshot)
+ const str = this.getText()
+ var sha1 = generateSHA1Hash("blob " + str.length + "\x00" + str)
this.__lastSubmitTimestamp = now;
}
}
diff --git a/services/web/frontend/stories/contact-us-modal.stories.tsx b/services/web/frontend/stories/contact-us-modal.stories.tsx
index 94f67874ea..b1c0e2c431 100644
--- a/services/web/frontend/stories/contact-us-modal.stories.tsx
+++ b/services/web/frontend/stories/contact-us-modal.stories.tsx
@@ -3,40 +3,10 @@ import useFetchMock from './hooks/use-fetch-mock'
import ContactUsModal from '../../modules/support/frontend/js/components/contact-us-modal'
import fixedHelpSuggestionSearch from '../../modules/support/test/frontend/util/fixed-help-suggestion-search'
import { ScopeDecorator } from './decorators/scope'
-import { StoryObj } from '@storybook/react'
import OLButton from '@/features/ui/components/ol/ol-button'
-import { bsVersionDecorator } from '../../.storybook/utils/with-bootstrap-switcher'
-type Story = StoryObj
type ContactUsModalProps = ComponentProps
-function bootstrap3Story(render: Story['render']): Story {
- return {
- render,
- decorators: [
- story => {
- return ScopeDecorator(story)
- },
- ],
- }
-}
-
-function bootstrap5Story(render: Story['render']): Story {
- return {
- render,
- decorators: [
- story => {
- return ScopeDecorator(story, undefined, {
- 'ol-bootstrapVersion': 5,
- })
- },
- ],
- parameters: {
- bootstrap5: true,
- },
- }
-}
-
function GenericContactUsModal(args: ContactUsModalProps) {
useFetchMock(fetchMock => {
fetchMock.post('/support', { status: 200 }, { delay: 1000 })
@@ -50,13 +20,9 @@ function GenericContactUsModal(args: ContactUsModalProps) {
)
}
-export const Generic: Story = bootstrap3Story(args => (
+export const Generic = (args: ContactUsModalProps) => (
-))
-
-export const GenericBootstrap5: Story = bootstrap5Story(args => (
-
-))
+)
const ContactUsModalWithRequestError = (args: ContactUsModalProps) => {
useFetchMock(fetchMock => {
@@ -71,18 +37,10 @@ const ContactUsModalWithRequestError = (args: ContactUsModalProps) => {
)
}
-const renderContactUsModalWithRequestError = (args: ContactUsModalProps) => (
+export const RequestError = (args: ContactUsModalProps) => (
)
-export const RequestError: Story = bootstrap3Story(
- renderContactUsModalWithRequestError
-)
-
-export const RequestErrorBootstrap5: Story = bootstrap5Story(
- renderContactUsModalWithRequestError
-)
-
const ContactUsModalWithAcknowledgement = (
args: Omit
) => {
@@ -110,19 +68,11 @@ const ContactUsModalWithAcknowledgement = (
)
}
-const renderContactUsModalWithAcknowledgement = (args: ContactUsModalProps) => {
+export const WithAcknowledgement = (args: ContactUsModalProps) => {
const { show, handleHide, ...rest } = args
return
}
-export const WithAcknowledgement: Story = bootstrap3Story(
- renderContactUsModalWithAcknowledgement
-)
-
-export const WithAcknowledgementBootstrap5: Story = bootstrap5Story(
- renderContactUsModalWithAcknowledgement
-)
-
export default {
title: 'Shared / Modals / Contact Us',
component: ContactUsModal,
@@ -133,6 +83,6 @@ export default {
},
argTypes: {
handleHide: { action: 'close modal' },
- ...bsVersionDecorator.argTypes,
},
+ decorators: [ScopeDecorator],
}
diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx
index 473aa037de..e69ebd8d21 100644
--- a/services/web/frontend/stories/decorators/scope.tsx
+++ b/services/web/frontend/stories/decorators/scope.tsx
@@ -89,6 +89,7 @@ const initialize = () => {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
+ hasBufferedOps: () => false,
},
open_doc_name: 'testfile.tex',
},
@@ -158,7 +159,7 @@ export const ScopeDecorator = (
)
}
-const ConnectionProvider: FC = ({ children }) => {
+const ConnectionProvider: FC = ({ children }) => {
const [value] = useState(() => {
const connectionState: ConnectionState = {
readyState: WebSocket.OPEN,
@@ -200,7 +201,7 @@ const ConnectionProvider: FC = ({ children }) => {
)
}
-const IdeReactProvider: FC = ({ children }) => {
+const IdeReactProvider: FC = ({ children }) => {
const projectId = 'project-123'
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
diff --git a/services/web/frontend/stories/dropdown.stories.jsx b/services/web/frontend/stories/dropdown.stories.jsx
deleted file mode 100644
index 57a3996983..0000000000
--- a/services/web/frontend/stories/dropdown.stories.jsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { Dropdown, MenuItem } from 'react-bootstrap'
-import ControlledDropdown from '../js/shared/components/controlled-dropdown'
-
-export const Customized = args => {
- return (
-
-
- {args.title}
-
-
- Action
- Another action
-
- Active Item
-
-
- Separated link
-
-
- )
-}
-Customized.args = {
- title: 'Toggle & Menu used separately',
-}
-
-export default {
- title: 'Shared / Components / Dropdown',
- component: ControlledDropdown,
- args: {
- bsStyle: 'default',
- title: 'Dropdown',
- pullRight: false,
- noCaret: false,
- className: '',
- defaultOpen: true,
- },
-}
diff --git a/services/web/frontend/stories/editor/synctex-toasts.stories.tsx b/services/web/frontend/stories/editor/synctex-toasts.stories.tsx
new file mode 100644
index 0000000000..0e4a55c5f6
--- /dev/null
+++ b/services/web/frontend/stories/editor/synctex-toasts.stories.tsx
@@ -0,0 +1,26 @@
+import { Meta, StoryObj } from '@storybook/react'
+import { OLToast } from '@/features/ui/components/ol/ol-toast'
+import { SynctexFileErrorToast } from '@/features/pdf-preview/components/synctex-toasts'
+
+const meta = {
+ title: 'Editor/ Synctex File Error Toast',
+ component: SynctexFileErrorToast,
+ decorators: [
+ Story => (
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const WithoutFile = {
+ args: { data: {} },
+} satisfies Story
+
+export const WithFile = {
+ args: { data: { filePath: 'references.bbl' } },
+} satisfies Story
diff --git a/services/web/frontend/stories/feedback-badge.stories.tsx b/services/web/frontend/stories/feedback-badge.stories.tsx
index db48e0d097..560adaf1a8 100644
--- a/services/web/frontend/stories/feedback-badge.stories.tsx
+++ b/services/web/frontend/stories/feedback-badge.stories.tsx
@@ -1,6 +1,5 @@
import { ScopeDecorator } from './decorators/scope'
import { FeedbackBadge } from '@/shared/components/feedback-badge'
-import { bsVersionDecorator } from '../../.storybook/utils/with-bootstrap-switcher'
export const WithDefaultText = () => {
return (
@@ -33,7 +32,4 @@ export default {
title: 'Shared / Components / Feedback Badge',
component: FeedbackBadge,
decorators: [ScopeDecorator],
- argTypes: {
- ...bsVersionDecorator.argTypes,
- },
}
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 bad7002755..5622e6eb52 100644
--- a/services/web/frontend/stories/file-view/file-view.stories.jsx
+++ b/services/web/frontend/stories/file-view/file-view.stories.jsx
@@ -158,11 +158,9 @@ ImageFile.args = {
export const TextFile = args => {
useFetchMock(fetchMock =>
- setupFetchMock(fetchMock).get(
- 'express:/project/:project_id/blob/:hash',
- { body: bodies.text },
- { overwriteRoutes: true }
- )
+ setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', {
+ body: bodies.text,
+ })
)
return
}
@@ -180,11 +178,9 @@ TextFile.args = {
export const UploadedFile = args => {
useFetchMock(fetchMock =>
- setupFetchMock(fetchMock).head(
- 'express:/project/:project_id/blob/:hash',
- { status: 500 },
- { overwriteRoutes: true }
- )
+ setupFetchMock(fetchMock).head('express:/project/:project_id/blob/:hash', {
+ status: 500,
+ })
)
return
}
diff --git a/services/web/frontend/stories/fixtures/compile.js b/services/web/frontend/stories/fixtures/compile.js
index 595998d3da..9471ff04ff 100644
--- a/services/web/frontend/stories/fixtures/compile.js
+++ b/services/web/frontend/stories/fixtures/compile.js
@@ -58,7 +58,7 @@ export const mockCompile = (fetchMock, delay = 1000) =>
outputFiles: cloneDeep(outputFiles),
},
},
- { delay, overwriteRoutes: true }
+ { delay }
)
export const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
@@ -91,27 +91,24 @@ export const mockCompileValidationIssues = (
},
}
},
- { delay, overwriteRoutes: true }
+ { delay }
)
export const mockClearCache = fetchMock =>
fetchMock.delete('express:/project/:projectId/output', 204, {
delay: 1000,
- overwriteRoutes: true,
})
export const mockBuildFile = fetchMock =>
- fetchMock.get(
- 'express:/build/:file',
- (url, options, request) => {
- const { pathname } = new URL(url, 'https://example.com')
+ fetchMock.get('express:/build/:file', (url, options, request) => {
+ const { pathname } = new URL(url, 'https://example.com')
- switch (pathname) {
- case '/build/output.blg':
- return 'This is BibTeX, Version 4.0' // FIXME
+ switch (pathname) {
+ case '/build/output.blg':
+ return 'This is BibTeX, Version 4.0' // FIXME
- case '/build/output.log':
- return `
+ case '/build/output.log':
+ return `
The LaTeX compiler output
* With a lot of details
@@ -134,31 +131,29 @@ LaTeX Font Info: External font \`cmex10' loaded for size
`
- case '/build/output.pdf':
- return new Promise(resolve => {
- const xhr = new XMLHttpRequest()
- xhr.addEventListener('load', () => {
- resolve({
- status: 200,
- headers: {
- 'Content-Length': xhr.getResponseHeader('Content-Length'),
- 'Content-Type': xhr.getResponseHeader('Content-Type'),
- },
- body: xhr.response,
- })
+ case '/build/output.pdf':
+ return new Promise(resolve => {
+ const xhr = new XMLHttpRequest()
+ xhr.addEventListener('load', () => {
+ resolve({
+ status: 200,
+ headers: {
+ 'Content-Length': xhr.getResponseHeader('Content-Length'),
+ 'Content-Type': xhr.getResponseHeader('Content-Type'),
+ },
+ body: xhr.response,
})
- xhr.open('GET', examplePdf)
- xhr.responseType = 'arraybuffer'
- xhr.send()
})
+ xhr.open('GET', examplePdf)
+ xhr.responseType = 'arraybuffer'
+ xhr.send()
+ })
- default:
- console.log(pathname)
- return 404
- }
- },
- { sendAsJson: false, overwriteRoutes: true }
- )
+ default:
+ console.log(pathname)
+ return 404
+ }
+ })
const mockHighlights = [
{
@@ -195,29 +190,25 @@ export const mockEventTracking = fetchMock =>
fetchMock.get('express:/event/:event', 204)
export const mockValidPdf = fetchMock =>
- fetchMock.get(
- 'express:/build/output.pdf',
- (url, options, request) => {
- return new Promise(resolve => {
- const xhr = new XMLHttpRequest()
- xhr.addEventListener('load', () => {
- resolve({
- status: 200,
- headers: {
- 'Content-Length': xhr.getResponseHeader('Content-Length'),
- 'Content-Type': xhr.getResponseHeader('Content-Type'),
- 'Accept-Ranges': 'bytes',
- },
- body: xhr.response,
- })
+ fetchMock.get('express:/build/output.pdf', (url, options, request) => {
+ return new Promise(resolve => {
+ const xhr = new XMLHttpRequest()
+ xhr.addEventListener('load', () => {
+ resolve({
+ status: 200,
+ headers: {
+ 'Content-Length': xhr.getResponseHeader('Content-Length'),
+ 'Content-Type': xhr.getResponseHeader('Content-Type'),
+ 'Accept-Ranges': 'bytes',
+ },
+ body: xhr.response,
})
- xhr.open('GET', examplePdf)
- xhr.responseType = 'arraybuffer'
- xhr.send()
})
- },
- { sendAsJson: false, overwriteRoutes: true }
- )
+ xhr.open('GET', examplePdf)
+ xhr.responseType = 'arraybuffer'
+ xhr.send()
+ })
+ })
export const mockSynctex = fetchMock =>
fetchMock
diff --git a/services/web/frontend/stories/fixtures/document.ts b/services/web/frontend/stories/fixtures/document.ts
index 2ff0466a72..ad2995a4dc 100644
--- a/services/web/frontend/stories/fixtures/document.ts
+++ b/services/web/frontend/stories/fixtures/document.ts
@@ -2,6 +2,7 @@ export function mockDocument(text: string) {
return {
doc_id: 'story-doc',
getSnapshot: () => text,
+ hasBufferedOps: () => false,
}
}
diff --git a/services/web/frontend/stories/hooks/use-fetch-mock.tsx b/services/web/frontend/stories/hooks/use-fetch-mock.tsx
index c5a527c81d..7f00118aac 100644
--- a/services/web/frontend/stories/hooks/use-fetch-mock.tsx
+++ b/services/web/frontend/stories/hooks/use-fetch-mock.tsx
@@ -1,18 +1,19 @@
import { useLayoutEffect } from 'react'
import fetchMock from 'fetch-mock'
-fetchMock.config.fallbackToNetwork = true
/**
- * Run callback to mock fetch routes, call restore() when unmounted
+ * Run callback to mock fetch routes, call removeRoutes() and unmockGlobal() when unmounted
*/
export default function useFetchMock(
callback: (value: typeof fetchMock) => void
) {
+ fetchMock.mockGlobal()
+
useLayoutEffect(() => {
callback(fetchMock)
-
return () => {
- fetchMock.restore()
+ fetchMock.removeRoutes()
+ fetchMock.unmockGlobal()
}
}, [callback])
}
diff --git a/services/web/frontend/stories/input-switch.stories.tsx b/services/web/frontend/stories/input-switch.stories.tsx
index 7d7707716c..8c3b97354d 100644
--- a/services/web/frontend/stories/input-switch.stories.tsx
+++ b/services/web/frontend/stories/input-switch.stories.tsx
@@ -1,5 +1,4 @@
import OLFormSwitch from '@/features/ui/components/ol/ol-form-switch'
-import { bsVersionDecorator } from '../../.storybook/utils/with-bootstrap-switcher'
import { disableControlsOf } from './utils/arg-types'
export const Unchecked = () => {
@@ -22,10 +21,6 @@ export default {
title: 'Shared / Components / Input Switch',
component: OLFormSwitch,
argTypes: {
- ...bsVersionDecorator.argTypes,
- ...disableControlsOf('inputRef', 'bs3Props'),
- },
- args: {
- ...bsVersionDecorator.args,
+ ...disableControlsOf('inputRef'),
},
}
diff --git a/services/web/frontend/stories/loading/loading.stories.tsx b/services/web/frontend/stories/loading/loading.stories.tsx
index b4c9068737..2fed18a368 100644
--- a/services/web/frontend/stories/loading/loading.stories.tsx
+++ b/services/web/frontend/stories/loading/loading.stories.tsx
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'
import { LoadingUI } from '@/features/ide-react/components/loading'
import { EditorProviders } from '../../../test/frontend/helpers/editor-providers'
-import { bsVersionDecorator } from '../../../.storybook/utils/with-bootstrap-switcher'
import { PartialMeta } from '@/utils/meta'
const meta: Meta = {
@@ -19,7 +18,6 @@ const meta: Meta = {
],
},
progress: { control: { type: 'range', min: 0, max: 100 } },
- ...bsVersionDecorator.argTypes,
},
}
diff --git a/services/web/frontend/stories/menu-bar.stories.tsx b/services/web/frontend/stories/menu-bar.stories.tsx
index b7126ce374..1fe5628bec 100644
--- a/services/web/frontend/stories/menu-bar.stories.tsx
+++ b/services/web/frontend/stories/menu-bar.stories.tsx
@@ -30,9 +30,6 @@ const meta: Meta = {
title: 'Shared / Components / MenuBar',
component: MenuBar,
argTypes: {},
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx b/services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx
index 1ff2b46c79..ba5ad45d22 100644
--- a/services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx
+++ b/services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx
@@ -37,9 +37,7 @@ export const ErrorImportingFileFromExternalURL = args => {
useFetchMock(fetchMock => {
mockCreateFileModalFetch(fetchMock)
- fetchMock.post('express:/project/:projectId/linked_file', 500, {
- overwriteRoutes: true,
- })
+ fetchMock.post('express:/project/:projectId/linked_file', 500)
})
getMeta('ol-ExposedSettings').hasLinkUrlFeature = true
@@ -52,9 +50,7 @@ export const ErrorImportingFileFromReferenceProvider = args => {
useFetchMock(fetchMock => {
mockCreateFileModalFetch(fetchMock)
- fetchMock.post('express:/project/:projectId/linked_file', 500, {
- overwriteRoutes: true,
- })
+ fetchMock.post('express:/project/:projectId/linked_file', 500)
})
return
diff --git a/services/web/frontend/stories/modals/unsaved-docs-locked-modal.stories.tsx b/services/web/frontend/stories/modals/unsaved-docs-locked-modal.stories.tsx
index 44092f2218..324721e94d 100644
--- a/services/web/frontend/stories/modals/unsaved-docs-locked-modal.stories.tsx
+++ b/services/web/frontend/stories/modals/unsaved-docs-locked-modal.stories.tsx
@@ -1,18 +1,11 @@
import { Meta, StoryObj } from '@storybook/react'
import { UnsavedDocsLockedAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-locked-alert'
import { ScopeDecorator } from '../decorators/scope'
-import { bsVersionDecorator } from '../../../.storybook/utils/with-bootstrap-switcher'
export default {
title: 'Editor / Modals / Unsaved Docs Locked',
component: UnsavedDocsLockedAlert,
decorators: [Story => ScopeDecorator(Story)],
- argTypes: {
- ...bsVersionDecorator.argTypes,
- },
- parameters: {
- bootstrap5: true,
- },
} satisfies Meta
type Story = StoryObj
diff --git a/services/web/frontend/stories/notification.stories.tsx b/services/web/frontend/stories/notification.stories.tsx
index 321e7e3e33..0c55606278 100644
--- a/services/web/frontend/stories/notification.stories.tsx
+++ b/services/web/frontend/stories/notification.stories.tsx
@@ -315,11 +315,7 @@ export const OverlayedWithCustomClass = (args: Args) => {
export const SuccessFlow = (args: Args) => {
console.log('.....render')
- fetchMock.post(
- 'express:/test-success',
- { status: 200 },
- { delay: 250, overwriteRoutes: true }
- )
+ fetchMock.post('express:/test-success', { status: 200 }, { delay: 250 })
const { isLoading, isSuccess, runAsync } = useAsync()
function handleClick() {
diff --git a/services/web/frontend/stories/pdf-log-entry.stories.tsx b/services/web/frontend/stories/pdf-log-entry.stories.tsx
index 561402dcb8..d0a1e7d870 100644
--- a/services/web/frontend/stories/pdf-log-entry.stories.tsx
+++ b/services/web/frontend/stories/pdf-log-entry.stories.tsx
@@ -59,7 +59,9 @@ export default meta
type Story = StoryObj
-const Provider: FC<{ children: ReactNode }> = ({ children }) => {
+const Provider: FC> = ({
+ children,
+}) => {
useMeta({ 'ol-showAiErrorAssistant': true })
useScope({ 'editor.view': new EditorView({ doc: '\\begin{document' }) })
return {children}
diff --git a/services/web/frontend/stories/pdf-preview.stories.jsx b/services/web/frontend/stories/pdf-preview.stories.jsx
index ec66594f69..0233006a00 100644
--- a/services/web/frontend/stories/pdf-preview.stories.jsx
+++ b/services/web/frontend/stories/pdf-preview.stories.jsx
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react'
import useFetchMock from './hooks/use-fetch-mock'
-import { Button } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
import PdfPreviewPane from '../js/features/pdf-preview/components/pdf-preview-pane'
import PdfPreview from '../js/features/pdf-preview/components/pdf-preview'
import PdfFileList from '../js/features/pdf-preview/components/pdf-file-list'
@@ -123,10 +123,12 @@ export const Interactive = () => {
margin: '10px 0',
}}
>
- trigger doc change
-
+
+ trigger doc change
+
+
toggle linting error
-
+
diff --git a/services/web/frontend/stories/project-list/add-affiliation.stories.tsx b/services/web/frontend/stories/project-list/add-affiliation.stories.tsx
index d1732bc5d9..75adc967e1 100644
--- a/services/web/frontend/stories/project-list/add-affiliation.stories.tsx
+++ b/services/web/frontend/stories/project-list/add-affiliation.stories.tsx
@@ -26,7 +26,4 @@ export const Add = (args: any) => {
export default {
title: 'Project List / Affiliation',
component: AddAffiliation,
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/color-picker.stories.tsx b/services/web/frontend/stories/project-list/color-picker.stories.tsx
index ade57f772a..b7141c2098 100644
--- a/services/web/frontend/stories/project-list/color-picker.stories.tsx
+++ b/services/web/frontend/stories/project-list/color-picker.stories.tsx
@@ -12,7 +12,4 @@ export const Select = (args: any) => {
export default {
title: 'Project List / Color Picker',
component: ColorPicker,
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx b/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx
index 422a494649..53fa31cdb0 100644
--- a/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx
+++ b/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx
@@ -62,7 +62,4 @@ export default {
),
],
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/current-plan-widget.stories.tsx b/services/web/frontend/stories/project-list/current-plan-widget.stories.tsx
index 3827853a67..be9a3b1cb1 100644
--- a/services/web/frontend/stories/project-list/current-plan-widget.stories.tsx
+++ b/services/web/frontend/stories/project-list/current-plan-widget.stories.tsx
@@ -72,7 +72,4 @@ export const PausedPlan = (args: any) => {
export default {
title: 'Project List / Current Plan Widget',
component: CurrentPlanWidget,
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/helpers/emails.ts b/services/web/frontend/stories/project-list/helpers/emails.ts
index 06c12eb577..83c09fd527 100644
--- a/services/web/frontend/stories/project-list/helpers/emails.ts
+++ b/services/web/frontend/stories/project-list/helpers/emails.ts
@@ -1,5 +1,5 @@
import { merge, cloneDeep } from 'lodash'
-import { FetchMockStatic } from 'fetch-mock'
+import { type FetchMock } from 'fetch-mock'
import { UserEmailData } from '../../../../types/user-email'
import {
Institution,
@@ -32,7 +32,7 @@ export const fakeReconfirmationUsersData = {
default: false,
} as DeepReadonly
-export function defaultSetupMocks(fetchMock: FetchMockStatic) {
+export function defaultSetupMocks(fetchMock: FetchMock) {
// at least one project is required to show some notifications
const projects = [{}] as Project[]
fetchMock.post(/\/api\/project/, {
@@ -54,7 +54,7 @@ export function setDefaultMeta() {
window.metaAttributesCache.set('ol-userEmails', [])
}
-export function errorsMocks(fetchMock: FetchMockStatic) {
+export function errorsMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.post(/\/user\/emails\/*/, 500, { delay: MOCK_DELAY })
fetchMock.post(
@@ -74,7 +74,7 @@ export function setInstitutionMeta(institutionData: Partial) {
])
}
-export function institutionSetupMocks(fetchMock: FetchMockStatic) {
+export function institutionSetupMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.delete(/\/notifications\/*/, 200, { delay: MOCK_DELAY })
}
@@ -84,7 +84,7 @@ export function setCommonMeta(notificationData: DeepPartial) {
window.metaAttributesCache.set('ol-notifications', [notificationData])
}
-export function commonSetupMocks(fetchMock: FetchMockStatic) {
+export function commonSetupMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.post(
/\/project\/[A-Za-z0-9]+\/invite\/token\/[A-Za-z0-9]+\/accept/,
@@ -98,7 +98,7 @@ export function setReconfirmationMeta() {
window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData])
}
-export function reconfirmationSetupMocks(fetchMock: FetchMockStatic) {
+export function reconfirmationSetupMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.post(/\/user\/emails\/resend_confirmation/, 200, {
delay: MOCK_DELAY,
@@ -113,19 +113,15 @@ export function setReconfirmAffiliationMeta() {
)
}
-export function reconfirmAffiliationSetupMocks(fetchMock: FetchMockStatic) {
+export function reconfirmAffiliationSetupMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
- fetchMock.post(
- /\/api\/project/,
- {
- status: 200,
- body: {
- projects: [{}],
- totalSize: 0,
- },
+ fetchMock.post(/\/api\/project/, {
+ status: 200,
+ body: {
+ projects: [{}],
+ totalSize: 0,
},
- { overwriteRoutes: true }
- )
+ })
fetchMock.post(/\/user\/emails\/send-reconfirmation/, 200, {
delay: MOCK_DELAY,
})
diff --git a/services/web/frontend/stories/project-list/inr-banner.stories.tsx b/services/web/frontend/stories/project-list/inr-banner.stories.tsx
index 4e970afae4..a59cf12492 100644
--- a/services/web/frontend/stories/project-list/inr-banner.stories.tsx
+++ b/services/web/frontend/stories/project-list/inr-banner.stories.tsx
@@ -7,7 +7,4 @@ export const Default = () => {
export default {
title: 'Project List / INR Banner',
component: INRBanner,
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/new-project-button.stories.tsx b/services/web/frontend/stories/project-list/new-project-button.stories.tsx
index 6338f5d969..9d2e03ad2d 100644
--- a/services/web/frontend/stories/project-list/new-project-button.stories.tsx
+++ b/services/web/frontend/stories/project-list/new-project-button.stories.tsx
@@ -104,7 +104,4 @@ export const Error = () => {
export default {
title: 'Project List / New Project Button',
component: NewProjectButton,
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/notifications.stories.tsx b/services/web/frontend/stories/project-list/notifications.stories.tsx
index 773dcebe5d..aaabe2ba5b 100644
--- a/services/web/frontend/stories/project-list/notifications.stories.tsx
+++ b/services/web/frontend/stories/project-list/notifications.stories.tsx
@@ -343,7 +343,4 @@ export const ReconfirmedAffiliationSuccess = (args: any) => {
export default {
title: 'Project List / Notifications',
component: UserNotifications,
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/project-list.stories.tsx b/services/web/frontend/stories/project-list/project-list.stories.tsx
index 066092c835..af058548cb 100644
--- a/services/web/frontend/stories/project-list/project-list.stories.tsx
+++ b/services/web/frontend/stories/project-list/project-list.stories.tsx
@@ -57,7 +57,4 @@ export default {
),
],
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/project-search.stories.tsx b/services/web/frontend/stories/project-list/project-search.stories.tsx
index bb727cfb13..cf39be2285 100644
--- a/services/web/frontend/stories/project-list/project-search.stories.tsx
+++ b/services/web/frontend/stories/project-list/project-search.stories.tsx
@@ -24,7 +24,4 @@ export default {
args: {
inputValue: '',
},
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/survey-widget.stories.tsx b/services/web/frontend/stories/project-list/survey-widget.stories.tsx
index e386bd1121..c1437a789e 100644
--- a/services/web/frontend/stories/project-list/survey-widget.stories.tsx
+++ b/services/web/frontend/stories/project-list/survey-widget.stories.tsx
@@ -28,7 +28,4 @@ export const EmptySurvey = (args: any) => {
export default {
title: 'Project List / Survey Widget',
component: SurveyWidget,
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/project-list/system-messages.stories.tsx b/services/web/frontend/stories/project-list/system-messages.stories.tsx
index e76330788f..d97ca4537c 100644
--- a/services/web/frontend/stories/project-list/system-messages.stories.tsx
+++ b/services/web/frontend/stories/project-list/system-messages.stories.tsx
@@ -1,9 +1,9 @@
import SystemMessages from '@/shared/components/system-messages'
import useFetchMock from '../hooks/use-fetch-mock'
-import { FetchMockStatic } from 'fetch-mock'
+import { type FetchMock } from 'fetch-mock'
export const SystemMessage = (args: any) => {
- useFetchMock((fetchMock: FetchMockStatic) => {
+ useFetchMock((fetchMock: FetchMock) => {
fetchMock.get(/\/system\/messages/, [
{
_id: 1,
@@ -23,7 +23,7 @@ export const SystemMessage = (args: any) => {
}
export const TranslationMessage = (args: any) => {
- useFetchMock((fetchMock: FetchMockStatic) => {
+ useFetchMock((fetchMock: FetchMock) => {
fetchMock.get(/\/system\/messages/, [])
})
@@ -39,7 +39,4 @@ export const TranslationMessage = (args: any) => {
export default {
title: 'Project List / System Messages',
component: SystemMessages,
- parameters: {
- bootstrap5: true,
- },
}
diff --git a/services/web/frontend/stories/settings/helpers/emails.js b/services/web/frontend/stories/settings/helpers/emails.js
index 2afe6a3014..546ccb2d96 100644
--- a/services/web/frontend/stories/settings/helpers/emails.js
+++ b/services/web/frontend/stories/settings/helpers/emails.js
@@ -174,7 +174,6 @@ export function reconfirmationSetupMocks(fetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.get(/\/user\/emails/, fakeReconfirmationUsersData, {
delay: MOCK_DELAY,
- overwriteRoutes: true,
})
}
@@ -186,7 +185,6 @@ export function emailLimitSetupMocks(fetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.get(/\/user\/emails/, userData, {
delay: MOCK_DELAY,
- overwriteRoutes: true,
})
}
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 53cf2b7eea..d87179b65f 100644
--- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx
+++ b/services/web/frontend/stories/source-editor/source-editor.stories.tsx
@@ -6,7 +6,7 @@ import { FC } from 'react'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import RangesTracker from '@overleaf/ranges-tracker'
-const FileTreePathProvider: FC = ({ children }) => (
+const FileTreePathProvider: FC
= ({ children }) => (
null,
@@ -211,6 +211,7 @@ const mockDoc = (content: string, changes: Array> = []) => {
return null
},
ranges: new RangesTracker(changes, []),
+ hasBufferedOps: () => false,
}
}
diff --git a/services/web/frontend/stories/start-free-trial-button.stories.jsx b/services/web/frontend/stories/start-free-trial-button.stories.jsx
index d21c143fdc..6da53dc4cd 100644
--- a/services/web/frontend/stories/start-free-trial-button.stories.jsx
+++ b/services/web/frontend/stories/start-free-trial-button.stories.jsx
@@ -1,6 +1,5 @@
import StartFreeTrialButton from '../js/shared/components/start-free-trial-button'
import { ScopeDecorator } from './decorators/scope'
-import { bsVersionDecorator } from '../../.storybook/utils/with-bootstrap-switcher'
export const Default = args => {
return
@@ -31,7 +30,4 @@ export default {
source: 'storybook',
},
decorators: [ScopeDecorator],
- argTypes: {
- ...bsVersionDecorator.argTypes,
- },
}
diff --git a/services/web/frontend/stories/style-guide.stories.jsx b/services/web/frontend/stories/style-guide.stories.jsx
deleted file mode 100644
index bb8755cf9a..0000000000
--- a/services/web/frontend/stories/style-guide.stories.jsx
+++ /dev/null
@@ -1,962 +0,0 @@
-/* eslint-disable jsx-a11y/anchor-is-valid */
-
-import { Grid, Row, Col, Button, Alert, ProgressBar } from 'react-bootstrap'
-import Notification from '../js/shared/components/notification'
-
-export const Colors = () => {
- return (
-
-
-
-
- Colours
-
-
-
-
-
-
- )
-}
-
-export const Headings = () => {
- return (
-
-
-
-
- Headings
- Here are our heading styles:
- Heading level 1
-
- Lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem
- ipsum
-
- Heading level 2
-
- Lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem
- ipsum lorem ipsum
-
- Heading level 3
-
- Lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem
- ipsum lorem ipsum
-
- Heading level 4
-
- Lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem
- ipsum lorem ipsum
-
-
-
-
-
- )
-}
-
-const ButtonsTemplate = (args, { globals: { theme } }) => {
- return (
-
-
-
-
-
- Buttons
-
- Primary Button
-
- Primary Button
-
-
- Secondary Button
- {theme.includes('main') ? (
- <>
-
-
- Secondary Button
-
-
- Deprecated Styles
-
- These are being transitioned to the new secondary style
- above
-
- >
- ) : (
- ''
- )}
- Our secondary button is blue or dark gray:
-
- Info Button
- Default Button
-
-
- All button styles
- {theme.includes('main') ? (
- Includes styles being deprecated
- ) : (
- ''
- )}
-
- Primary
- {theme.includes('main') ? (
- Secondary
- ) : (
- ''
- )}
- Info
- Default
- Success
- Warning
- Danger
-
- Danger Ghost
-
-
- Info Ghost
-
-
- Background (bg) Ghost
-
-
- Premium
-
-
-
- Sizes
-
- Extra Small
- Small
- Default
- Large
- Extra Large
-
-
- Hyperlinks
-
- Hyperlinks are highlighted as shown .
-
-
-
-
-
-
- )
-}
-
-export const Buttons = ButtonsTemplate.bind({})
-Buttons.args = {
- disabled: false,
-}
-
-export const Alerts = () => {
- return (
-
-
-
-
- Alerts / Notifications
- See Notification in shared components for options
-
-
- .notitifcation .notification-type-info
-
-
- }
- />
-
-
- .notitifcation .notification-type-success
-
-
- }
- />
-
-
- .notitifcation .notification-type-warning
-
-
- }
- />
-
-
-
- .notitifcation .notification-type-error
-
-
- }
- />
-
-
-
- .notitifcation .notification-type-offer
-
-
- }
- />
-
-
- Note: these styles below will be deprecated since there are new
- alert styles rolling out as part of the new design system
-
-
-
- An .alert-danger
alert
-
-
- An .alert-success
alert
-
-
- An .alert-info
alert
-
-
- An .alert-warning
alert
-
-
-
-
-
- )
-}
-
-export const ProgressBars = () => {
- return (
-
-
- {' '}
-
-
- Progress bars
-
-
-
-
-
-
-
-
- )
-}
-
-export const Cards = () => {
- return (
- <>
-
-
-
-
-
-
- Cards look best on a .content.content-alt
{' '}
- background
-
-
-
-
-
-
-
-
-
-
- New card styles
-
-
Header 2
-
Header 3
-
Header 4
-
Dolor sit amet, consectetur adipiscing elit.
-
-
-
-
-
- >
- )
-}
-
-export const Forms = () => {
- return (
-
- )
-}
-
-export default {
- title: 'Style Guide',
-}
diff --git a/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx b/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx
index bfbf54362e..1021f770e9 100644
--- a/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx
+++ b/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx
@@ -2,7 +2,6 @@ import GroupInvites from '@/features/subscription/components/group-invites/group
import type { TeamInvite } from '../../../../types/team-invite'
import { useMeta } from '../../hooks/use-meta'
import { ScopeDecorator } from '../../decorators/scope'
-import { bsVersionDecorator } from '../../../../.storybook/utils/with-bootstrap-switcher'
export const GroupInvitesDefault = () => {
const teamInvites: TeamInvite[] = [
@@ -40,7 +39,6 @@ export default {
argTypes: {
handleHide: { action: 'close modal' },
onDisableSSO: { action: 'callback' },
- ...bsVersionDecorator.argTypes,
},
decorators: [ScopeDecorator],
}
diff --git a/services/web/frontend/stories/ui/badge-bs3.stories.tsx b/services/web/frontend/stories/ui/badge-bs3.stories.tsx
deleted file mode 100644
index 050a90ecda..0000000000
--- a/services/web/frontend/stories/ui/badge-bs3.stories.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import BS3Badge from '@/shared/components/badge'
-import Icon from '@/shared/components/icon'
-import type { Meta, StoryObj } from '@storybook/react'
-
-const meta: Meta
= {
- title: 'Shared / Components / Badge / Bootstrap 3',
- component: BS3Badge,
- parameters: {
- bootstrap5: false,
- },
- args: {
- children: 'Badge',
- },
- argTypes: {
- prepend: {
- table: {
- disable: true,
- },
- },
- bsStyle: {
- options: ['info', 'primary', 'warning', 'danger'],
- control: { type: 'radio' },
- },
- className: {
- table: {
- disable: true,
- },
- },
- },
-}
-export default meta
-
-type Story = StoryObj
-
-export const BadgeDefault: Story = {
- render: args => {
- return (
-
-
-
- )
- },
-}
-BadgeDefault.args = {
- bsStyle: meta.argTypes!.bsStyle!.options![0],
-}
-
-export const BadgePrepend: Story = {
- render: args => {
- return (
-
- } {...args} />
-
- )
- },
-}
-BadgePrepend.args = {
- bsStyle: meta.argTypes!.bsStyle!.options![0],
-}
diff --git a/services/web/frontend/stories/ui/badge-bs5.stories.tsx b/services/web/frontend/stories/ui/badge.stories.tsx
similarity index 92%
rename from services/web/frontend/stories/ui/badge-bs5.stories.tsx
rename to services/web/frontend/stories/ui/badge.stories.tsx
index 7eeca79921..be4d3a84ab 100644
--- a/services/web/frontend/stories/ui/badge-bs5.stories.tsx
+++ b/services/web/frontend/stories/ui/badge.stories.tsx
@@ -4,11 +4,8 @@ import type { Meta, StoryObj } from '@storybook/react'
import classnames from 'classnames'
const meta: Meta = {
- title: 'Shared / Components / Badge / Bootstrap 5',
+ title: 'Shared / Components / Badge',
component: Badge,
- parameters: {
- bootstrap5: true,
- },
args: {
children: 'Badge',
},
diff --git a/services/web/frontend/stories/ui/button.stories.tsx b/services/web/frontend/stories/ui/button.stories.tsx
index 27f113dcdb..a5dbbeb7e0 100644
--- a/services/web/frontend/stories/ui/button.stories.tsx
+++ b/services/web/frontend/stories/ui/button.stories.tsx
@@ -20,10 +20,10 @@ export const ButtonWithIcons = (args: Args) => {
}
const meta: Meta = {
- title: 'Shared / Components / Bootstrap 5 / Button',
+ title: 'Shared / Components / Button',
component: Button,
args: {
- children: 'A Bootstrap 5 Button',
+ children: 'A Button',
disabled: false,
isLoading: false,
},
@@ -45,9 +45,6 @@ const meta: Meta = {
],
},
},
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/ui/dropdown-menu.stories.tsx b/services/web/frontend/stories/ui/dropdown-menu.stories.tsx
index fcd0cf9855..5d1ac376bb 100644
--- a/services/web/frontend/stories/ui/dropdown-menu.stories.tsx
+++ b/services/web/frontend/stories/ui/dropdown-menu.stories.tsx
@@ -219,7 +219,7 @@ export const TrailingIcon = (args: Args) => {
}
const meta: Meta = {
- title: 'Shared / Components / Bootstrap 5 / DropdownMenu',
+ title: 'Shared / Components / DropdownMenu',
component: DropdownMenu,
argTypes: {
disabled: {
@@ -231,9 +231,6 @@ const meta: Meta = {
},
},
},
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx
index 0596aea5cd..82ba8eba13 100644
--- a/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx
+++ b/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx
@@ -3,11 +3,8 @@ import { Form } from 'react-bootstrap-5'
import type { Meta, StoryObj } from '@storybook/react'
const meta: Meta<(typeof Form)['Check']> = {
- title: 'Shared / Components / Bootstrap 5 / Form',
+ title: 'Shared / Components / Form',
component: Form.Check,
- parameters: {
- bootstrap5: true,
- },
argTypes: {
id: {
table: {
diff --git a/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx
index 82b9125fcd..65c1121ea3 100644
--- a/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx
+++ b/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx
@@ -7,11 +7,8 @@ import MaterialIcon from '@/shared/components/material-icon'
import FormFeedback from '@/features/ui/components/bootstrap-5/form/form-feedback'
const meta: Meta> = {
- title: 'Shared / Components / Bootstrap 5 / Form / Input',
+ title: 'Shared / Components / Form / Input',
component: FormControl,
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx
index 73e10b0bab..95c15dc41e 100644
--- a/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx
+++ b/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx
@@ -2,11 +2,8 @@ import { Form } from 'react-bootstrap-5'
import type { Meta, StoryObj } from '@storybook/react'
const meta: Meta<(typeof Form)['Check']> = {
- title: 'Shared / Components / Bootstrap 5 / Form',
+ title: 'Shared / Components / Form',
component: Form.Check,
- parameters: {
- bootstrap5: true,
- },
argTypes: {
id: {
table: {
diff --git a/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx
index 9f9985a4b6..61e96c7aa2 100644
--- a/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx
+++ b/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx
@@ -4,11 +4,8 @@ import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
const meta: Meta = {
- title: 'Shared / Components / Bootstrap 5 / Form / Select',
+ title: 'Shared / Components / Form / Select',
component: Form.Select,
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx
index cd95984175..898ec97960 100644
--- a/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx
+++ b/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx
@@ -5,11 +5,8 @@ import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
const meta: Meta> = {
- title: 'Shared / Components / Bootstrap 5 / Form / Textarea',
+ title: 'Shared / Components / Form / Textarea',
component: FormControl,
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/ui/icon-button.stories.tsx b/services/web/frontend/stories/ui/icon-button.stories.tsx
index 4f99324d9d..51554ecd24 100644
--- a/services/web/frontend/stories/ui/icon-button.stories.tsx
+++ b/services/web/frontend/stories/ui/icon-button.stories.tsx
@@ -11,7 +11,7 @@ export const Icon = (args: Args) => {
}
const meta: Meta = {
- title: 'Shared / Components / Bootstrap 5 / IconButton',
+ title: 'Shared / Components / IconButton',
component: IconButton,
args: {
disabled: false,
@@ -35,9 +35,6 @@ const meta: Meta = {
],
},
},
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/ui/row.stories.tsx b/services/web/frontend/stories/ui/row.stories.tsx
index 7dd24c4d9a..4b6411619c 100644
--- a/services/web/frontend/stories/ui/row.stories.tsx
+++ b/services/web/frontend/stories/ui/row.stories.tsx
@@ -22,11 +22,8 @@ export const ColumnRowCell = (args: Args) => {
}
const meta: Meta = {
- title: 'Shared / Components / Bootstrap 5 / Column-Row-Cell',
+ title: 'Shared / Components / Column-Row-Cell',
component: Row,
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/ui/split-button.stories.tsx b/services/web/frontend/stories/ui/split-button.stories.tsx
index c1423bbb80..d78643b4cd 100644
--- a/services/web/frontend/stories/ui/split-button.stories.tsx
+++ b/services/web/frontend/stories/ui/split-button.stories.tsx
@@ -51,14 +51,11 @@ export const Sizes = (args: Args) => {
))
}
const meta: Meta = {
- title: 'Shared/Components/Bootstrap 5/SplitButton',
+ title: 'Shared/Components/SplitButton',
component: Dropdown,
args: {
align: { sm: 'start' },
},
- parameters: {
- bootstrap5: true,
- },
}
export default meta
diff --git a/services/web/frontend/stories/ui/tag-bs3.stories.tsx b/services/web/frontend/stories/ui/tag-bs3.stories.tsx
deleted file mode 100644
index 8281f05650..0000000000
--- a/services/web/frontend/stories/ui/tag-bs3.stories.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
-import BS3Tag from '@/shared/components/tag'
-import type { Meta, StoryObj } from '@storybook/react'
-
-const meta: Meta = {
- title: 'Shared / Components / Tag / Bootstrap 3',
- component: BS3Tag,
- parameters: {
- bootstrap5: false,
- },
- args: {
- children: 'Tag',
- },
- argTypes: {
- prepend: {
- table: {
- disable: true,
- },
- },
- className: {
- table: {
- disable: true,
- },
- },
- closeBtnProps: {
- table: {
- disable: true,
- },
- },
- },
-}
-export default meta
-
-type Story = StoryObj
-
-export const TagDefault: Story = {
- render: args => {
- return (
-
-
-
- )
- },
-}
-
-export const TagPrepend: Story = {
- render: args => {
- return (
-
- } {...args} />
-
- )
- },
-}
-
-export const TagWithCloseButton: Story = {
- render: args => {
- return (
-
- }
- closeBtnProps={{
- onClick: () => alert('Close triggered!'),
- }}
- {...args}
- />
-
- )
- },
-}
diff --git a/services/web/frontend/stories/ui/tag-bs5.stories.tsx b/services/web/frontend/stories/ui/tag.stories.tsx
similarity index 93%
rename from services/web/frontend/stories/ui/tag-bs5.stories.tsx
rename to services/web/frontend/stories/ui/tag.stories.tsx
index e4f09c033d..37bbd39022 100644
--- a/services/web/frontend/stories/ui/tag-bs5.stories.tsx
+++ b/services/web/frontend/stories/ui/tag.stories.tsx
@@ -3,11 +3,8 @@ import Tag from '@/features/ui/components/bootstrap-5/tag'
import type { Meta, StoryObj } from '@storybook/react'
const meta: Meta = {
- title: 'Shared / Components / Tag / Bootstrap 5',
+ title: 'Shared / Components / Tag',
component: Tag,
- parameters: {
- bootstrap5: true,
- },
args: {
children: 'Tag',
},
diff --git a/services/web/frontend/stories/ui/tooltip.stories.tsx b/services/web/frontend/stories/ui/tooltip.stories.tsx
index 916ca6397c..2a2b07df71 100644
--- a/services/web/frontend/stories/ui/tooltip.stories.tsx
+++ b/services/web/frontend/stories/ui/tooltip.stories.tsx
@@ -1,5 +1,5 @@
-import { Button } from 'react-bootstrap-5'
-import Tooltip from '@/features/ui/components/bootstrap-5/tooltip'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { Meta } from '@storybook/react'
export const Tooltips = () => {
@@ -17,25 +17,22 @@ export const Tooltips = () => {
}}
>
{placements.map(placement => (
-
- Tooltip on {placement}
-
+ Tooltip on {placement}
+
))}
)
}
-const meta: Meta = {
- title: 'Shared / Components / Bootstrap 5 / Tooltip',
- component: Tooltip,
- parameters: {
- bootstrap5: true,
- },
+const meta: Meta = {
+ title: 'Shared / Components / Tooltip',
+ component: OLTooltip,
}
export default meta
diff --git a/services/web/frontend/stories/upgrade-prompt.stories.tsx b/services/web/frontend/stories/upgrade-prompt.stories.tsx
index 1d7d6cfe60..0e7de8de76 100644
--- a/services/web/frontend/stories/upgrade-prompt.stories.tsx
+++ b/services/web/frontend/stories/upgrade-prompt.stories.tsx
@@ -1,6 +1,5 @@
import { UpgradePrompt } from '@/shared/components/upgrade-prompt'
import { StoryObj } from '@storybook/react/*'
-import { bsVersionDecorator } from '../../.storybook/utils/with-bootstrap-switcher'
type Story = StoryObj
@@ -17,8 +16,4 @@ export const Generic: Story = {
export default {
title: 'Shared / Components / Upgrade Prompt',
component: UpgradePrompt,
- ...bsVersionDecorator,
- args: {
- bootstrapVersion: 5,
- },
}
diff --git a/services/web/frontend/stories/word-count-modal.stories.jsx b/services/web/frontend/stories/word-count-modal.stories.jsx
deleted file mode 100644
index 7742815ea6..0000000000
--- a/services/web/frontend/stories/word-count-modal.stories.jsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import useFetchMock from './hooks/use-fetch-mock'
-import WordCountModal from '../js/features/word-count-modal/components/word-count-modal'
-import { ScopeDecorator } from './decorators/scope'
-
-const counts = {
- headers: 4,
- mathDisplay: 40,
- mathInline: 400,
- textWords: 4000,
-}
-
-const messages = [
- 'Lorem ipsum dolor sit amet.',
- 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
-].join('\n')
-
-export const WordCount = args => {
- useFetchMock(fetchMock => {
- fetchMock.get(
- 'express:/project/:projectId/wordcount',
- { status: 200, body: { texcount: counts } },
- { delay: 500 }
- )
- })
-
- return
-}
-
-export const WordCountWithMessages = args => {
- useFetchMock(fetchMock => {
- fetchMock.get(
- 'express:/project/:projectId/wordcount',
- { status: 200, body: { texcount: { ...counts, messages } } },
- { delay: 500 }
- )
- })
-
- return
-}
-
-export const ErrorResponse = args => {
- useFetchMock(fetchMock => {
- fetchMock.get(
- 'express:/project/:projectId/wordcount',
- { status: 500 },
- { delay: 500 }
- )
- })
-
- return
-}
-
-export default {
- title: 'Editor / Modals / Word Count',
- component: WordCountModal,
- args: {
- show: true,
- },
- argTypes: {
- handleHide: { action: 'close modal' },
- },
- decorators: [ScopeDecorator],
-}
diff --git a/services/web/frontend/stories/word-count-modal.stories.tsx b/services/web/frontend/stories/word-count-modal.stories.tsx
new file mode 100644
index 0000000000..17b548791b
--- /dev/null
+++ b/services/web/frontend/stories/word-count-modal.stories.tsx
@@ -0,0 +1,80 @@
+import { Meta, StoryObj } from '@storybook/react'
+import WordCountModal from '@/features/word-count-modal/components/word-count-modal'
+import { ScopeDecorator } from './decorators/scope'
+import useFetchMock from './hooks/use-fetch-mock'
+
+export default {
+ title: 'Editor / Modals / Word Count',
+ component: WordCountModal,
+ args: {
+ show: true,
+ },
+ argTypes: {
+ handleHide: {
+ action: 'close modal',
+ },
+ },
+ decorators: [Story => ScopeDecorator(Story)],
+} satisfies Meta
+
+type Story = StoryObj
+
+const counts = {
+ headers: 4,
+ mathDisplay: 40,
+ mathInline: 400,
+ textWords: 4000,
+}
+
+const messages = [
+ 'Lorem ipsum dolor sit amet.',
+ 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
+].join('\n')
+
+export const WordCount: Story = {
+ decorators: [
+ Story => {
+ useFetchMock(fetchMock => {
+ fetchMock.get(
+ 'express:/project/:projectId/wordcount',
+ { status: 200, body: { texcount: counts } },
+ { delay: 500 }
+ )
+ })
+
+ return
+ },
+ ],
+}
+
+export const WordCountWithMessages: Story = {
+ decorators: [
+ Story => {
+ useFetchMock(fetchMock => {
+ fetchMock.get(
+ 'express:/project/:projectId/wordcount',
+ { status: 200, body: { texcount: { ...counts, messages } } },
+ { delay: 500 }
+ )
+ })
+
+ return
+ },
+ ],
+}
+
+export const ErrorResponse: Story = {
+ decorators: [
+ Story => {
+ useFetchMock(fetchMock => {
+ fetchMock.get(
+ 'express:/project/:projectId/wordcount',
+ { status: 500 },
+ { delay: 500 }
+ )
+ })
+
+ return
+ },
+ ],
+}
diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less
index 544fbb7b38..3139c98678 100644
--- a/services/web/frontend/stylesheets/app/editor.less
+++ b/services/web/frontend/stylesheets/app/editor.less
@@ -83,10 +83,6 @@
margin-left: 20px;
}
-#synctex-more-info-button {
- margin-left: 20px;
-}
-
#ide-body {
background-color: @pdf-bg;
.full-size;
diff --git a/services/web/frontend/stylesheets/app/editor/toolbar.less b/services/web/frontend/stylesheets/app/editor/toolbar.less
index 4fd7c1b954..dd38ef99a0 100644
--- a/services/web/frontend/stylesheets/app/editor/toolbar.less
+++ b/services/web/frontend/stylesheets/app/editor/toolbar.less
@@ -333,16 +333,6 @@
}
}
-.editor-switch-tooltip .tooltip-inner {
- text-align: left;
- max-width: 325px;
- padding: 10px;
-
- .tooltip-title {
- font-weight: bold;
- line-height: 22px;
- }
-}
/**************************************
Formatting buttons
***************************************/
diff --git a/services/web/frontend/stylesheets/app/login-register.less b/services/web/frontend/stylesheets/app/login-register.less
index 5e58c46f2e..97790b9a5d 100644
--- a/services/web/frontend/stylesheets/app/login-register.less
+++ b/services/web/frontend/stylesheets/app/login-register.less
@@ -3,12 +3,6 @@
@brand-twitter-color: #1da1f2;
@brand-orcid-color: #a6ce39;
-.login-register-alternatives {
- .form-group:last-child {
- margin-bottom: 0;
- }
-}
-
.login-register-container {
max-width: 400px;
margin: 0 auto;
@@ -86,22 +80,6 @@
}
}
-.login-btn-icon-twitter {
- background-image: url(../../../public/img/other-brands/logo_twitter.svg);
-}
-
-.registration-message-heading {
- color: @text-color;
-}
-
-.registration-message-details {
- font-size: 90%;
-}
-
-.registration-block-separator {
- margin-bottom: 0px;
-}
-
.website-redesign {
.login-register-container {
max-width: 320px;
diff --git a/services/web/frontend/stylesheets/app/plans.less b/services/web/frontend/stylesheets/app/plans.less
index 391a752436..1870b77636 100644
--- a/services/web/frontend/stylesheets/app/plans.less
+++ b/services/web/frontend/stylesheets/app/plans.less
@@ -391,7 +391,7 @@
}
&.plans-new-table-icon-cta-cell,
- &.plans-subheader-monthly-cta-swapped {
+ &.plans-subheader-monthly-cta {
vertical-align: bottom;
}
}
@@ -1154,6 +1154,7 @@
.plans-faq-support {
margin-top: var(--spacing-06);
+ margin-bottom: var(--spacing-06);
display: flex;
flex-direction: column;
align-items: center;
diff --git a/services/web/frontend/stylesheets/app/templates-v2.less b/services/web/frontend/stylesheets/app/templates-v2.less
index 9fb6f56276..67edecb323 100644
--- a/services/web/frontend/stylesheets/app/templates-v2.less
+++ b/services/web/frontend/stylesheets/app/templates-v2.less
@@ -1,3 +1,5 @@
+// some still used by portals
+
.gallery-item-title {
* {
vertical-align: middle;
diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
index ce53fcfa23..d1a823a120 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
@@ -60,6 +60,17 @@
}
}
+@mixin labs-button {
+ @include ol-button-variant(
+ $color: var(--content-positive),
+ $background: var(--bg-accent-03),
+ $border: var(--green-40),
+ $hover-background: var(--bg-accent-03),
+ $hover-border: var(--green-40),
+ $borderless: false
+ );
+}
+
@mixin reset-button() {
padding: 0;
cursor: pointer;
diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss
index c2a56d053e..353a8fdc2e 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss
@@ -162,10 +162,24 @@ $primary: $bg-accent-01;
$secondary: $bg-light-primary;
$success: $bg-accent-01;
$info: $bg-info-01;
-$warning: $bg-warning-03;
+$warning: $content-warning;
$danger: $bg-danger-01;
$light: $bg-light-tertiary;
$dark: $neutral-90;
+$warning-light-gb: $bg-warning-03;
+
+// Theme colors
+$theme-colors: (
+ 'primary': $primary,
+ 'secondary': $secondary,
+ 'success': $success,
+ 'info': $info,
+ 'warning': $warning,
+ 'warning-light-bg': $warning-light-gb,
+ 'danger': $danger,
+ 'light': $light,
+ 'dark': $dark,
+);
// Body colors
$body-color: $content-primary;
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/accordion.scss b/services/web/frontend/stylesheets/bootstrap-5/components/accordion.scss
new file mode 100644
index 0000000000..47d6d610c1
--- /dev/null
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/accordion.scss
@@ -0,0 +1,50 @@
+.ol-accordions-container :last-child {
+ border: 0 !important;
+}
+
+.ol-accordions-container {
+ .custom-accordion-item {
+ width: 100%;
+ padding-top: var(--spacing-08);
+ padding-bottom: var(--spacing-09);
+ border-bottom: 1px solid var(--border-divider);
+
+ .custom-accordion-header {
+ text-align: left;
+ width: 100%;
+ font-size: var(--font-size-04);
+ font-weight: 600;
+ line-height: var(--line-height-03);
+ color: var(--content-primary);
+ background-color: unset;
+ border: unset;
+ display: flex;
+ justify-content: space-between;
+ padding: unset;
+
+ .custom-accordion-icon {
+ display: flex;
+ align-items: center;
+ transition: transform 0.35s ease;
+ margin-left: var(--spacing-08);
+ }
+
+ &:not(.collapsed) {
+ .custom-accordion-icon {
+ transform: rotate(180deg);
+ transition: transform 0.35s ease;
+ }
+ }
+ }
+
+ .custom-accordion-body {
+ @include body-base;
+
+ background-color: unset;
+ text-align: left;
+ padding: unset;
+ padding-right: 2rem;
+ margin-top: var(--spacing-04);
+ }
+ }
+}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss
index 57c823fd52..c2aeb28516 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss
@@ -1,3 +1,4 @@
+@import 'accordion';
@import 'button';
@import 'button-group';
@import 'dropdown-menu';
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss b/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss
index c6f09687f0..a3b2f13d0f 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss
@@ -10,10 +10,6 @@ $max-width: 160px;
&:not(.badge-tag) {
max-width: $max-width;
}
-
- &.bg-warning {
- --bs-badge-color: var(--content-warning);
- }
}
.badge-prepend {
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/button.scss b/services/web/frontend/stylesheets/bootstrap-5/components/button.scss
index 2cab3ac231..3b782ed1f8 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/button.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/button.scss
@@ -78,14 +78,6 @@
);
}
- &.btn-info-ghost {
- @include ol-button-variant(
- $color: var(--blue-50),
- $background: transparent,
- $hover-background: var(--neutral-10)
- );
- }
-
&.btn-premium {
@include ol-button-variant(
$color: var(--content-primary-dark),
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/panel-heading.scss b/services/web/frontend/stylesheets/bootstrap-5/components/panel-heading.scss
index 22125e36e6..71df17e601 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/panel-heading.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/panel-heading.scss
@@ -11,6 +11,7 @@
}
.panel-heading {
+ color: var(--panel-heading-color);
display: flex;
align-items: center;
padding: var(--spacing-03) var(--spacing-02);
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss b/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss
index ac39f28c81..a307d2bc39 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss
@@ -15,7 +15,7 @@
gap: var(--spacing-04);
margin: 0 auto;
padding: 0;
- border-bottom: var(--border-width-base) solid var(--neutral-20);
+ border-bottom: var(--border-width-base) solid var(--border-divider);
text-align: center;
border-top: 2px solid transparent; // so that top focus border is visible
min-width: max-content; // This is for horizontal scrolling
@@ -33,7 +33,7 @@
button {
border: 0;
border-radius: 0;
- color: var(--neutral-70);
+ color: var(--content-secondary);
margin-right: unset;
padding: var(--spacing-04);
line-height: var(--line-height-03);
@@ -51,7 +51,7 @@
}
&:hover {
- background-color: var(--neutral-10);
+ background-color: var(--bg-light-secondary);
text-decoration: none;
}
@@ -63,10 +63,10 @@
li > a.active,
li > button.active {
- background-color: transparent !important;
- border: 0 !important;
- border-bottom: 3px solid var(--green-50) !important;
- color: var(--neutral-90) !important;
+ background-color: transparent;
+ border: 0;
+ border-bottom: 3px solid var(--green-50);
+ color: var(--content-primary);
}
&.align-left {
diff --git a/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss b/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss
index 84a169b0eb..73e93273c4 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss
@@ -199,6 +199,7 @@ $link-ui-visited-dark: $blue-40;
--border-active: var(--blue-50);
--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);
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
index fe2fbc7962..a3adc98819 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
@@ -36,6 +36,7 @@
@import 'editor/table-generator-column-width-modal';
@import 'editor/math-preview';
@import 'editor/references-search';
+@import 'editor/editor-survey';
@import 'website-redesign';
@import 'group-settings';
@import 'templates-v2';
@@ -44,6 +45,7 @@
@import 'login-register';
@import 'login';
@import 'register';
+@import 'plans';
@import 'onboarding-confirm-email';
@import 'secondary-confirm-email';
@import 'onboarding';
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 faaab50b43..b0e50cd95d 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/chat.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/chat.scss
@@ -37,7 +37,7 @@
.ide-redesign-main {
--chat-bg: var(--white);
- --chat-color: var(--neutral-70);
+ --chat-color: var(--content-primary);
--chat-instructions-color: var(--neutral-70);
--chat-new-message-bg: var(--neutral-10);
--chat-new-message-textarea-color: var(--neutral-90);
@@ -257,6 +257,11 @@
text-transform: uppercase;
}
+ .message-avatar .message-avatar-deleted-user-icon {
+ line-height: 24px;
+ font-size: 16px;
+ }
+
.message-author,
.message-container {
flex: 1 1 auto;
@@ -269,6 +274,7 @@
}
.message-author {
+ color: var(--chat-message-name-color);
font-size: var(--font-size-01);
line-height: var(--line-height-01);
}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/editor-survey.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/editor-survey.scss
new file mode 100644
index 0000000000..5041becc5a
--- /dev/null
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/editor-survey.scss
@@ -0,0 +1,73 @@
+.editor-survey-toast {
+ position: fixed;
+ bottom: 12px;
+ right: 12px;
+ z-index: 1000;
+}
+
+.editor-survey-question-toast {
+ .notification-icon {
+ display: none;
+ }
+}
+
+.editor-survey-question {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ font-size: var(--font-size-02);
+
+ .btn {
+ align-self: flex-end;
+ }
+
+ .form-group {
+ margin-bottom: 0;
+ }
+
+ .form-check {
+ display: flex;
+ flex-direction: column-reverse;
+ align-items: center;
+ padding-left: 0;
+ gap: 2px;
+ }
+
+ .form-check-input {
+ margin: 4px;
+ }
+}
+
+.editor-survey-question-top-line {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .btn-ghost {
+ --bs-btn-bg: transparent;
+
+ margin-left: 8px;
+ }
+}
+
+.editor-survey-question-label {
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.editor-survey-question-form {
+ display: flex;
+}
+
+.editor-survey-question-options {
+ display: flex;
+ gap: 32px;
+ margin: auto;
+}
+
+.editor-survey-option-labels {
+ display: flex;
+ justify-content: space-between;
+ color: var(--content-secondary);
+ margin-top: 4px;
+}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide-redesign-switcher-modal.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide-redesign-switcher-modal.scss
index ba2f2aa49d..4bba755e44 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide-redesign-switcher-modal.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide-redesign-switcher-modal.scss
@@ -12,6 +12,10 @@
border: 1px solid var(--border-divider);
padding: var(--spacing-05);
margin: var(--spacing-05) 0;
+
+ ul li:not(:last-child) {
+ margin-bottom: var(--spacing-04);
+ }
}
.ide-redesign-switcher-modal-leave-text {
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 8a8fa49f97..8649eacd1c 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss
@@ -193,6 +193,7 @@
div.pdf-canvas {
background: white;
box-shadow: 0 0 10px rgb(0 0 0 / 50%);
+ will-change: transform;
}
div.pdf-canvas.pdfng-empty {
@@ -318,7 +319,6 @@
gap: var(--spacing-02);
input {
- color: initial;
border: 1px solid var(--neutral-60);
width: 32px;
height: 24px;
@@ -443,3 +443,8 @@
top: var(--spacing-10);
z-index: 1;
}
+
+.synctex-error-toast-content {
+ display: flex;
+ gap: 20px;
+}
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 6118473ef5..96df113261 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss
@@ -4,6 +4,8 @@
--ide-rail-link-background: var(--bg-dark-primary);
--ide-rail-link-active-color: var(--green-10);
--ide-rail-link-active-background: var(--green-70);
+ --ide-rail-link-hover-color: var(--content-primary-dark);
+ --ide-rail-link-hover-background: var(--bg-dark-secondary);
--ide-rail-border-colour: var(--border-divider-dark);
--ide-rail-header-subdued-button-color: var(--content-primary-dark);
--ide-rail-header-subdued-button-hover-background: var(--bg-dark-tertiary);
@@ -15,6 +17,8 @@
--ide-rail-link-background: #fff;
--ide-rail-link-active-color: var(--green-70);
--ide-rail-link-active-background: var(--bg-accent-03);
+ --ide-rail-link-hover-color: var(--content-primary);
+ --ide-rail-link-hover-background: var(--bg-light-secondary);
--ide-rail-border-colour: var(--border-divider);
--ide-rail-header-subdued-button-color: var(--content-primary);
--ide-rail-header-subdued-button-hover-background: var(--bg-light-tertiary);
@@ -63,12 +67,20 @@
position: relative;
overflow-y: hidden;
- &:hover,
&:visited,
&:focus {
color: var(--ide-rail-color);
}
+ &:focus-visible {
+ background-color: transparent;
+ }
+
+ &:hover {
+ color: var(--ide-rail-link-hover-color);
+ background-color: var(--ide-rail-link-hover-background);
+ }
+
.ide-rail-tab-link-icon {
line-height: 32px;
font-size: 20px;
@@ -106,6 +118,7 @@
padding: var(--spacing-02);
background: var(--ide-rail-background);
border-right: 1px solid var(--ide-rail-border-colour);
+ width: 40px;
}
.ide-rail-content {
@@ -131,7 +144,6 @@
display: flex;
flex-direction: column;
gap: var(--spacing-02);
- width: 40px;
}
.ide-rail-tab-dropdown {
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 f3bb875a02..074870d0f7 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
@@ -16,6 +16,8 @@ $rp-type-blue: #6b7797;
--review-panel-empty-state-bg-color: var(--bg-light-primary);
--review-panel-button-hover-bg-color: var(--bg-light-tertiary);
--review-panel-border-color: var(--border-divider);
+ --review-panel-width: 230px;
+ --review-panel-width-mini: 24px;
@include theme('default') {
.ide-redesign-main {
@@ -32,6 +34,42 @@ $rp-type-blue: #6b7797;
}
}
+.ide-redesign-main {
+ .review-panel-container {
+ order: -1;
+ }
+
+ .review-panel-inner {
+ border-left: none;
+ border-right: 1px solid var(--border-divider);
+ }
+
+ .review-panel-header {
+ border-bottom: none;
+ flex: 0 0 var(--review-panel-width);
+ }
+
+ .review-panel-more-comments-button-container {
+ &.upwards {
+ top: calc(var(--review-panel-top) + 16px);
+ }
+ }
+
+ .review-panel-mini {
+ // This needs to have a higher z-index than the gutter
+ // so that the comment/change hover previews appear in
+ // front of the gutter
+ z-index: 201;
+
+ .review-panel-entry-hover {
+ .review-panel-entry-content {
+ left: auto;
+ right: -200px;
+ }
+ }
+ }
+}
+
.review-panel-container {
height: 100%;
flex-shrink: 0;
@@ -645,8 +683,12 @@ del.review-panel-content-highlight {
.review-panel-mini {
overflow: visible !important;
+ .review-panel-entry-header .review-panel-entry-user {
+ width: 130px;
+ }
+
.review-panel-inner {
- width: 24px;
+ width: var(--review-panel-width-mini);
}
.review-panel-entry {
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 87185ab3ed..e77ad32a51 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
@@ -35,6 +35,7 @@
justify-content: space-between;
box-sizing: border-box;
height: $toolbar-height;
+ padding: 0 var(--spacing-02);
.ide-redesign-toolbar-menu {
display: flex;
@@ -44,7 +45,7 @@
.ide-redesign-toolbar-home-button {
width: $home-button-size;
height: $home-button-size;
- margin: math.div($toolbar-height - $home-button-size, 2);
+ margin: math.div($toolbar-height - $home-button-size, 2) 0;
}
.ide-redesign-toolbar-button-subdued {
@@ -58,6 +59,27 @@
);
text-decoration: none;
+
+ &.ide-redesign-toolbar-button-icon {
+ @include ol-button-variant(
+ var(--redesign-subdued-button-color),
+ var(--redesign-toolbar-background),
+ transparent,
+ var(--redesign-subdued-button-hover-background),
+ transparent,
+ true
+ );
+
+ border-radius: var(--border-radius-full);
+ display: flex;
+ justify-content: center;
+ padding: var(--spacing-02);
+
+ &:visited,
+ &:focus {
+ color: var(--redesign-subdued-button-color);
+ }
+ }
}
.ide-redesign-toolbar-home-link {
@@ -132,7 +154,7 @@
.ide-redesign-toolbar-actions {
display: flex;
gap: var(--spacing-04);
- padding: 0 var(--spacing-05);
+ padding-left: var(--spacing-05);
}
.ide-redesign-toolbar-button-container {
@@ -160,13 +182,6 @@
}
}
-.ide-redesign-labs-button.btn.btn-info {
- @include ol-button-variant(
- var(--content-positive),
- var(--bg-accent-03),
- var(--green-40),
- var(--bg-accent-03),
- var(--green-40),
- false
- );
+.ide-redesign-labs-button.btn.btn-secondary {
+ @include labs-button;
}
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 d3d2dca6fd..7b3decc101 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss
@@ -22,6 +22,7 @@
--editor-header-logo-background: url(../../../../../public/img/ol-brand/overleaf-o-white.svg)
center / contain no-repeat;
--editor-toolbar-bg: var(--neutral-80);
+ --toolbar-dropdown-divider-color: var(--border-dark-divider);
}
.ide-redesign-main {
@@ -32,8 +33,23 @@
--editor-toolbar-bg: var(--bg-dark-primary);
--toolbar-filetree-bg-color: var(--neutral-80);
- .cm-panels-top {
- border-bottom: none;
+ .ol-cm-toolbar-portal {
+ display: flex;
+ align-items: center;
+ background-color: var(--editor-toolbar-bg);
+
+ .review-panel-header {
+ align-self: flex-start;
+ }
+ }
+
+ .ol-cm-toolbar-wrapper {
+ flex: 1;
+ width: 100%;
+ }
+
+ .ol-cm-toolbar-wrapper-indented {
+ width: calc(100% - var(--review-panel-width));
}
}
@@ -56,6 +72,7 @@
--editor-header-logo-background: url(../../../../../public/img/ol-brand/overleaf-o.svg)
center / contain no-repeat;
--editor-toolbar-bg: var(--white);
+ --toolbar-dropdown-divider-color: var(--border-divider);
.ide-redesign-main {
--toolbar-alt-bg-color: var(--bg-light-secondary);
@@ -64,10 +81,6 @@
--toolbar-btn-hover-color: var(--white);
--editor-toolbar-bg: var(--white);
--toolbar-filetree-bg-color: var(--white);
-
- .ol-cm-toolbar-portal {
- border-bottom: 1px solid #ddd;
- }
}
}
@@ -520,15 +533,10 @@
border-right: 1px solid var(--formatting-btn-border);
}
-.toolbar-experiment-button.btn.btn-info {
- @include ol-button-variant(
- var(--content-positive),
- var(--bg-accent-03),
- var(--green-40),
- var(--bg-accent-03),
- var(--green-40),
- false
- );
+// Override a secondary button to ensure that the border is visible because
+// overriding a borderless button will not add a border.
+.toolbar-experiment-button.btn-secondary {
+ @include labs-button;
max-height: 39px;
font-size: var(--font-size-01);
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/onboarding.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/onboarding.scss
index 1244850279..616f6e498b 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/onboarding.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/onboarding.scss
@@ -59,10 +59,6 @@
display: flex;
align-items: center;
gap: var(--spacing-04);
-
- button.btn-info-ghost {
- color: var(--neutral-90);
- }
}
}
}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss
new file mode 100644
index 0000000000..84c72c7208
--- /dev/null
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss
@@ -0,0 +1,1254 @@
+@use 'sass:math';
+
+$container-plans-responsive-width: 95%;
+$container-xl: 1280px;
+$z-index-plans-new-tabs-content: 0;
+$z-index-plans-new-tabs: 1;
+$border-radius-full-new: 9999px;
+$highlighted-heading-line-height: 24px; // this is ported directly from the .less file as value
+$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;
+$table-4-column-width: 25%;
+$table-5-column-width: 20%;
+$nondiscounted-price-element-height: var(--line-height-02);
+$group-member-picker-height: 24px;
+$group-member-picker-top-height: 36px;
+$z-index-group-member-picker-list: 1;
+
+.plans-new-design {
+ --border-width-base: 3px; // TODO: put this variable in a decent location
+ --border-radius-large-new: 16px; // TODO: put this variable in a decent location
+
+ padding-top: $header-height;
+
+ .container {
+ padding: 0 var(--spacing-06);
+
+ @include media-breakpoint-up(md) {
+ width: $container-plans-responsive-width;
+ }
+
+ @include media-breakpoint-up(xxl) {
+ 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;
+
+ @include media-breakpoint-down(md) {
+ text-align: left;
+ padding: 0 16px;
+ }
+
+ .plans-page-heading {
+ margin-top: 8px;
+ margin-bottom: unset;
+ font-size: 3rem;
+ font-weight: 600;
+ line-height: 64px;
+
+ @include media-breakpoint-down(md) {
+ 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;
+ position: relative;
+
+ @include media-breakpoint-up(md) {
+ border-left: 1px solid var(--border-divider);
+ border-right: 1px solid var(--border-divider);
+ border-bottom: 1px solid var(--border-divider);
+ 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(--border-divider);
+
+ @include media-breakpoint-up(md) {
+ 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(--border-divider);
+
+ .plans-new-tab {
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: 600;
+ border-top-right-radius: 8px;
+ border-top-left-radius: 8px;
+ margin-bottom: -1px; // to overlap with the border on .plans-new-tabs to avoid double border
+
+ .plans-new-tab-link {
+ display: flex;
+ align-items: center;
+ color: var(--content-secondary);
+ margin: 0;
+ border-top-right-radius: 8px;
+ border-top-left-radius: 8px;
+ /* stylelint-disable-next-line declaration-block-no-duplicate-properties */
+ border: 1px solid var(--border-divider);
+ padding: var(--spacing-05) var(--spacing-08);
+ gap: var(--spacing-04);
+ text-decoration: unset;
+
+ &:focus {
+ background-color: unset;
+ outline: 0;
+ }
+
+ &:hover {
+ background-color: var(--neutral-20);
+ }
+
+ // TODO: this is copied directly from the `.less` file, migrate this to scss
+ // 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: $border-radius-full-new; // TODO: this is ported direction as a scss variable, change this to a css variable
+ font-family: 'DM Mono', monospace;
+ font-feature-settings: 'ss05';
+ font-size: var(--font-size-01);
+ line-height: var(--line-height-01);
+ font-weight: 500;
+
+ @include media-breakpoint-down(md) {
+ display: none;
+ }
+ }
+
+ @include media-breakpoint-down(md) {
+ font-size: var(--font-size-02);
+ line-height: var(--line-height-02);
+ padding: var(--spacing-05);
+ gap: var(--spacing-02);
+ }
+ }
+
+ &:has(.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%
+ );
+ /* stylelint-disable-next-line property-no-vendor-prefix */
+ -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);
+ /* stylelint-disable-next-line property-no-vendor-prefix */
+ -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;
+ /* stylelint-disable-next-line declaration-block-no-redundant-longhand-properties */
+ 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(--bg-light-secondary);
+ border-radius: $border-radius-full-new; // TODO: this is ported direction as a scss variable, change this to a css variable
+ padding: var(--spacing-03);
+ margin-top: var(--spacing-09);
+ margin-bottom: $switcher-container-margin-bottom;
+ gap: var(--spacing-04);
+
+ @include media-breakpoint-down(md) {
+ 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: $border-radius-full-new; // TODO: this is ported direction as a scss variable, change this to a css variable
+
+ &:hover {
+ background-color: var(--neutral-20);
+ cursor: pointer;
+ }
+ }
+
+ input[type='radio'] {
+ position: absolute;
+ left: -9999px;
+
+ &:focus,
+ &:focus-visible {
+ outline: 0;
+ }
+ }
+
+ // TODO: this is copied directly from the `.less` file, migrate this to scss
+ // 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;
+ /* stylelint-disable-next-line length-zero-no-unit, color-function-notation, alpha-value-notation */
+ 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;
+
+ @include media-breakpoint-down(md) {
+ padding: 0;
+ }
+ }
+
+ .plans-new-mobile {
+ display: none;
+
+ @include media-breakpoint-down(md) {
+ display: block;
+ }
+ }
+
+ .plans-new-desktop {
+ display: block;
+
+ @include media-breakpoint-down(md) {
+ 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: math.div($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(--content-primary);
+ text-align: center;
+
+ @include media-breakpoint-down(md) {
+ padding: var(--spacing-05) var(--spacing-05) 0 var(--spacing-05);
+ }
+ }
+
+ .plans-new-table-subheader {
+ vertical-align: top;
+ padding: 0 var(--spacing-08);
+
+ @include media-breakpoint-down(xl) {
+ 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);
+
+ @include media-breakpoint-down(xl) {
+ 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;
+ }
+ }
+
+ .plans-cta + .plans-cta {
+ 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(--content-primary);
+ }
+
+ .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(--content-primary);
+ }
+ }
+
+ .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
+ /* stylelint-disable-next-line length-zero-no-unit, color-function-notation, alpha-value-notation */
+ 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(--bg-light-secondary);
+ }
+ }
+
+ input[type='radio'] {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+
+ // TODO: this is copied directly from the `.less` file, migrate this to scss
+ // &: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%);
+
+ @include media-breakpoint-down(md) {
+ 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);
+
+ // TODO: this is copied directly from the `.less` file, migrate this to scss
+ // &: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(--content-primary);
+ }
+
+ small {
+ color: var(--content-secondary);
+ 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(--bg-light-secondary);
+ }
+ }
+
+ .plans-new-table-section-without-header-row {
+ &:nth-child(odd):not(.plans-new-table-heading-row) {
+ background-color: var(--bg-light-secondary);
+ }
+
+ &: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(--content-primary);
+ 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);
+ }
+
+ .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;
+
+ @include media-breakpoint-down(xl) {
+ 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(--border-divider);
+ width: 100%; // might need max-width
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+
+ .plans-card-title-mobile {
+ color: var(--content-primary);
+ 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(--content-primary);
+ }
+ }
+
+ 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(--content-primary);
+ 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(--content-secondary);
+ 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%;
+
+ .plans-cta + .plans-cta {
+ 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(--content-primary);
+ 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(--bg-light-secondary);
+ }
+
+ .plans-group-features-table-header-item-content-mobile {
+ padding: var(--spacing-04);
+ text-align: center;
+ background-color: var(--bg-light-secondary);
+ height: 64px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .plans-features-table-header-item-title-mobile {
+ color: var(--content-primary);
+ 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(--content-secondary);
+ 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 {
+ background-color: var(--neutral-80);
+
+ .plans-features-table-header-item-title-mobile,
+ .plans-features-table-header-item-price-mobile {
+ color: var(--white);
+ }
+ }
+ }
+
+ 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(--content-primary);
+ }
+ }
+
+ // .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(--bg-light-secondary);
+ }
+
+ &:nth-child(4n - 1) {
+ background-color: var(--white);
+ }
+ }
+
+ &:nth-child(4n - 2) {
+ background-color: var(--bg-light-secondary);
+ }
+
+ &: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(--bg-light-secondary);
+ }
+
+ &:nth-child(4n) {
+ background-color: var(--white);
+ }
+ }
+
+ &:nth-child(4n - 3) {
+ background-color: var(--white);
+ }
+
+ &:nth-child(4n - 1) {
+ background-color: var(--bg-light-secondary);
+ }
+
+ .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(--content-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: var(--spacing-04) var(--spacing-08);
+ text-align: center;
+ gap: var(--spacing-06);
+
+ @include media-breakpoint-down(md) {
+ 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);
+
+ @include media-breakpoint-down(md) {
+ 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: $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;
+ }
+ }
+}
+
+.plans-cta {
+ display: block;
+}
+
+.plans-faq-tabs {
+ max-width: 800px;
+ margin: 0 auto;
+
+ .nav-tabs-container {
+ margin-bottom: var(--spacing-08);
+ }
+
+ .tab-content {
+ margin-top: 0;
+ }
+}
diff --git a/services/web/locales/da.json b/services/web/locales/da.json
index f00e21c8a9..6746c028b7 100644
--- a/services/web/locales/da.json
+++ b/services/web/locales/da.json
@@ -72,7 +72,7 @@
"add_email_address": "Tilføj e-mailaddresse",
"add_email_to_claim_features": "Tilføj en institutionel e-mailadresse for at gøre krav på dine funktioner.",
"add_files": "Tilføj filer",
- "add_more_editors": "Tilføj flere redaktører",
+ "add_more_collaborators": "Tilføj flere samarbejdspartnere",
"add_more_managers": "Tilføj flere gruppeadministratorer",
"add_new_email": "Tilføj ny e-mailaddresse",
"add_or_remove_project_from_tag": "Tilføj projekt til, eller fjern projekt fra, tagget __tagName__",
@@ -273,8 +273,6 @@
"code_check_failed": "Kodetjek fejlede",
"code_check_failed_explanation": "Din kode har fejl, der skal rettes før auto-kompileren kan køre",
"code_editor": "Kodeeditor",
- "code_editor_tooltip_message": "Du kan se koden bag dit projekt (og redigere den) i kodeeditoren.",
- "code_editor_tooltip_title": "Vil du gerne se og redigere LaTeX koden?",
"collaborate_easily_on_your_projects": "Samarbejd nemt på dine projekter. Arbejd på længere eller mere komplekse dokumenter.",
"collaborate_online_and_offline": "Samarbejd online og offline, med dit eget workflow",
"collaboration": "Samarbejde",
@@ -433,6 +431,7 @@
"disable_single_sign_on": "Deaktiver single sign-on",
"disable_sso": "Deaktiver SSO",
"disable_stop_on_first_error": "Slå “Stop ved første fejl” fra",
+ "disabling": "Deaktiverer",
"disconnected": "Forbindelsen blev afbrudt",
"discount_of": "Rabat på __amount__",
"discover_latex_templates_and_examples": "Opdag LaTeX skabeloner og eksempler til at hjælpe med alt fra at skrive en artikel til at bruge en specifik LaTeX pakke.",
@@ -543,6 +542,7 @@
"enable_sso": "Aktiver SSO",
"enable_stop_on_first_error_under_recompile_dropdown_menu": "Slå <0>“Stop ved første fejl”0> til under <1>Genkompilér1> menuen for at hjælpe dig med at finde og rette fejl med det samme.",
"enabled": "Aktiveret",
+ "enabling": "Aktiverer",
"end_of_document": "Slutningen af dokumentet",
"enter_6_digit_code": "Indtast 6-cifret kode",
"enter_any_size_including_units_or_valid_latex_command": "Indtast en størrelse (inklusiv enhed) eller en gyldig LaTeX kommando",
@@ -1243,7 +1243,6 @@
"open_link": "Gå til side",
"open_path": "Åbn __path__",
"open_project": "Åben projekt",
- "open_survey": "Åbn spørgeskema",
"open_target": "Følg henvisning",
"opted_out_linking": "Du har fravalgt at forbinde din __appName__ -konto for __email__ til din institutionelle konto.",
"optional": "Valgfrit",
@@ -1252,7 +1251,6 @@
"organization_name": "Organisationsnavn",
"organization_or_company_name": "Organisations- eller virksomhedsnavn",
"organization_or_company_type": "Organisations- eller virksomhedstype",
- "organize_projects": "Organisationsprojekter",
"original_price": "Original pris",
"other": "Andet",
"other_actions": "Andre handlinger",
@@ -1331,7 +1329,6 @@
"plans_amper_pricing": "Abonnementer & priser",
"plans_and_pricing": "Abonnementer og priser",
"plans_and_pricing_lowercase": "abonnementer og priser",
- "please_ask_the_project_owner_to_upgrade_more_editors": "Venligst foreslå projektets ejer at opgradere deres abonnement for at tillade flere redaktører.",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "Du må bede projektets ejer om at opgradere, for at kunne bruge “Følg ændringer”",
"please_change_primary_to_remove": "Skift din primære e-mailadresse for at kunne fjerne denne",
"please_check_your_inbox_to_confirm": "Kig i din indbakke for at bekræfte din tilslutning til <0>__institutionName__0>.",
@@ -1975,6 +1972,7 @@
"transfer_management_resolve_following_issues": "For at overdrage styring af din konto, skal du løse de følgende problemer:",
"transfer_this_users_projects": "Overdrag denne brugers projekter",
"transfer_this_users_projects_description": "Denne brugers projekter bliver overdraget til en ny ejer.",
+ "transferring": "Overdrager",
"trash": "Kassér",
"trash_projects": "Kassér projekter",
"trashed": "Kasséret",
@@ -2050,7 +2048,6 @@
"upgrade_cc_btn": "Opgrader nu, betal efter 7 dage",
"upgrade_for_12x_more_compile_time": "Opgrader for at få 12x mere kompileringstid",
"upgrade_now": "Opgrader nu",
- "upgrade_to_add_more_editors_and_access_collaboration_features": "Opgrader for at tilføje flere redaktører og få samarbejdsfunktioner som fulgte ændringer og fuld projekthistorik.",
"upgrade_to_get_feature": "Opgrader for at få __feature__, plus:",
"upgrade_to_track_changes": "Opgrader til “Følg ændringer”",
"upload": "Upload",
@@ -2116,7 +2113,6 @@
"visual_editor_is_only_available_for_tex_files": "Den visuelle editor er kun tilgængelig for TeX filer",
"want_access_to_overleaf_premium_features_through_your_university": "Vil du have adgang til __appName__ Premium-funktioner gennem dit universitet?",
"want_change_to_apply_before_plan_end": "Hvis du ønsker at denne ændring skal tage effekt før slutningen på din nuværende faktureringsperiode, kontakt os venligst.",
- "we_are_testing_a_new_reference_search": "Vi tester en ny henvisningssøgning.",
"we_are_unable_to_opt_you_into_this_experiment": "Vi kan ikke melde dig til dette eksperiment lige nu. Tjek venligst at din organisation tillader denne funktion, eller prøv igen senere.",
"we_cant_confirm_this_email": "Vi kan ikke bekræfte denne e-mailadresse",
"we_cant_find_any_sections_or_subsections_in_this_file": "Vi kan ikke finde nogen sektioner eller undersektioner i denne fil",
@@ -2182,8 +2178,6 @@
"you_can_now_log_in_sso": "Du kan nu logge ind gennem din institution of hvis du er kvalificeret får du <0>__appName__ Professionel-funktioner0>.",
"you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "Du kan på denne side til enhver tid <0>til- og framelde dig0> programmet",
"you_can_request_a_maximum_of_limit_fixes_per_day": "Du kan anmode om maksimalt __limit__ rettelser om dagen. Prøv venligst igen i morgen.",
- "you_can_select_or_invite": "Du kan vælge eller invitere __count__ redaktør på dit nuværende abonnement, eller opgradere for at få flere.",
- "you_can_select_or_invite_plural": "Du kan vælge eller invitere __count__ redaktører på dit nuværende abonnement, eller opgradere for at få flere.",
"you_cant_add_or_change_password_due_to_sso": "Du kan ikke tilføje eller ændre dit kodeord fordi din gruppe eller organisation bruger <0>single sign-on (SSO)0>.",
"you_cant_join_this_group_subscription": "Du kan ikke tilslutte dig dette gruppeabonnement",
"you_cant_reset_password_due_to_sso": "Du kan ikke nulstille dit kodeord, da din gruppe eller organisation bruger SSO. <0>Log ind med SSO0>.",
@@ -2222,7 +2216,6 @@
"your_plan_is_limited_to_n_editors": "Dit abonnement tillader __count__ samarbejdspartner med skriveadgang samt et ubegrænset antal læsere.",
"your_plan_is_limited_to_n_editors_plural": "Dit abonnement tillader __count__ samarbejdspartnere med skriveadgang samt et ubegrænset antal læsere.",
"your_project_exceeded_compile_timeout_limit_on_free_plan": "Dit projekt overskred kompileringstidsgrænsen for vores gratis abonnement.",
- "your_project_exceeded_editor_limit": "Dit projekt overskred grænsen for antal redaktører, og adgangsniveauer er blevet ændret. Vælg et nyt adgangsniveau for dine samarbejdspartnere, eller opgrader for at tilføje flere redaktører.",
"your_project_near_compile_timeout_limit": "Dit projekt er tæt på kompileringstidsgrænsen for vores gratis abonnement.",
"your_projects": "Dine projekter",
"your_questions_answered": "Svar til dine spørgsmål",
@@ -2239,7 +2232,6 @@
"youre_on_free_trial_which_ends_on": "Du er på en gratis prøveperiode som slutter d. <0>__date__0>.",
"youre_signed_in_as_logout": "Du er logget ind som <0>__email__0>. <1>Log ud.1>",
"youre_signed_up": "Du er oprettet",
- "youve_lost_edit_access": "Du har mistet skriveadgang",
"youve_unlinked_all_users": "Du har afkoblet alle brugere",
"zh-CN": "Kinesisk",
"zip_contents_too_large": "For stort indhold i zip-fil",
diff --git a/services/web/locales/de.json b/services/web/locales/de.json
index de9b463ce3..51ad4355e5 100644
--- a/services/web/locales/de.json
+++ b/services/web/locales/de.json
@@ -391,6 +391,7 @@
"empty_zip_file": "ZIP enthält keine Datei",
"en": "Englisch",
"enable_managed_users": "Aktiviere Verwaltete Benutzer",
+ "enabling": "Wird aktiviert",
"end_of_document": "Ende des Dokuments",
"enter_image_url": "Bild-URL eingeben",
"enter_your_email_address": "Gib deine E-Mail-Adresse ein",
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index be05835080..389e3078b5 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -52,6 +52,7 @@
"access_denied": "Access Denied",
"access_edit_your_projects": "Access and edit your projects",
"access_levels_changed": "Access levels changed",
+ "access_your_favourite_features_faster_with_our_new_streamlined_editor": "Access your favourite features faster with our new, streamlined editor.",
"account": "Account",
"account_already_managed": "Your account is already managed.",
"account_billed_manually": "Account billed manually",
@@ -74,6 +75,7 @@
"add_add_on_to_your_plan": "Add __addOnName__ to your plan",
"add_additional_certificate": "Add another certificate",
"add_affiliation": "Add Affiliation",
+ "add_ai_assist": "Add AI Assist",
"add_another_address_line": "Add another address line",
"add_another_email": "Add another email",
"add_another_token": "Add another token",
@@ -88,21 +90,20 @@
"add_error_assist_annual_to_your_projects": "Add Error Assist Annual to your projects and get unlimited AI help to fix LaTeX errors faster.",
"add_error_assist_to_your_projects": "Add Error Assist to your projects and get unlimited AI help to fix LaTeX errors faster.",
"add_files": "Add Files",
- "add_more_editors": "Add more editors",
+ "add_more_collaborators": "Add more collaborators",
"add_more_licenses_to_my_plan": "Add more licenses to my plan",
"add_more_managers": "Add more managers",
"add_new_email": "Add new email",
"add_on": "Add-on",
"add_ons": "Add-ons",
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
- "add_overleaf_assist": "Add Overleaf Assist",
"add_overleaf_assist_to_your_group_subscription": "Add Overleaf Assist to your group subscription",
"add_overleaf_assist_to_your_institution": "Add Overleaf Assist to your institution",
- "add_overleaf_assist_to_your_plan": "Add Overelaf assist to your __planName__ plan",
"add_people": "Add people",
"add_role_and_department": "Add role and department",
"add_to_dictionary": "Add to Dictionary",
"add_to_tag": "Add to tag",
+ "add_unlimited_ai_to_your_overleaf_plan": "Add unlimited AI* to your Overleaf __planName__ plan",
"add_your_comment_here": "Add your comment here",
"add_your_first_group_member_now": "Add your first group members now",
"added": "added",
@@ -123,7 +124,9 @@
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "After that, we’ll bill you __totalAmount__ (__subtotalAmount__ + __taxAmount__ tax) annually on __date__, unless you cancel.",
"aggregate_changed": "Changed",
"aggregate_to": "to",
+ "agree": "Agree",
"agree_with_the_terms": "I agree with the Overleaf terms",
+ "ai_assist_in_overleaf_is_included_via_writefull": "AI Assist in Overleaf is included as part of your Writefull subscription. You can cancel or manage your access to AI Assist in your Writefull subscription settings.",
"ai_assistance_to_help_you": "AI assistance to help you fix LaTeX errors",
"ai_based_language_tools": "AI-based language tools tailored to research writing",
"ai_can_make_mistakes": "AI can make mistakes. Review fixes before you apply them.",
@@ -148,6 +151,7 @@
"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 here0>.",
"already_have_an_account": "Already have an account?",
"already_have_sl_account": "Already have an __appName__ account?",
"already_subscribed_try_refreshing_the_page": "Already subscribed? Try refreshing the page.",
@@ -235,9 +239,11 @@
"blank_project": "Blank Project",
"blocked_filename": "This file name is blocked.",
"blog": "Blog",
+ "bold": "Bold",
"brl_discount_offer_plans_page_banner": "__flag__ Great news! We’ve applied a 50% discount to premium plans on this page for our users in Brazil. Check out the new lower prices.",
"browser": "Browser",
"built_in": "Built-In",
+ "bullet_list": "Bullet list",
"buy_licenses": "Buy licenses",
"buy_more_licenses": "Buy more licenses",
"buy_now_no_exclamation_mark": "Buy now",
@@ -311,6 +317,7 @@
"choose_how_you_search_your_references": "Choose how you search your references",
"choose_which_experiments": "Choose which experiments you’d like to try.",
"choose_your_plan": "Choose your plan",
+ "citation": "Citation",
"city": "City",
"clear_cached_files": "Clear cached files",
"clear_search": "clear search",
@@ -331,8 +338,6 @@
"code_check_failed": "Code check failed",
"code_check_failed_explanation": "Your code has errors that need to be fixed before the auto-compile can run",
"code_editor": "Code Editor",
- "code_editor_tooltip_message": "You can see the code behind your project (and make edits to it) in the Code Editor",
- "code_editor_tooltip_title": "Want to view and edit the LaTeX code?",
"collaborate_easily_on_your_projects": "Collaborate easily on your projects. Work on longer or more complex docs.",
"collaborate_online_and_offline": "Collaborate online and offline, using your own workflow",
"collaboration": "Collaboration",
@@ -395,7 +400,7 @@
"confirming": "Confirming",
"conflicting_paths_found": "Conflicting Paths Found",
"congratulations_youve_successfully_join_group": "Congratulations! You‘ve successfully joined the group subscription.",
- "connect_overleaf_with_github": "Connect __appName__ with Github for easy project syncing and real-time version control.",
+ "connect_overleaf_with_github": "Connect __appName__ with GitHub for easy project syncing and real-time version control.",
"connected_users": "Connected Users",
"connecting": "Connecting",
"connection_lost_with_unsaved_changes": "Connection lost with unsaved changes.",
@@ -442,6 +447,7 @@
"created_at": "Created at",
"creating": "Creating",
"credit_card": "Credit Card",
+ "cross_reference": "Cross reference",
"cs": "Czech",
"currency": "Currency",
"current_file": "Current file",
@@ -466,6 +472,7 @@
"de": "German",
"dealing_with_errors": "Dealing with errors",
"december": "December",
+ "decrease_indent": "Decrease indentation",
"dedicated_account_manager": "Dedicated account manager",
"default": "Default",
"delete": "Delete",
@@ -498,6 +505,7 @@
"deleted_by_id": "Deleted By ID",
"deleted_by_ip": "Deleted By IP",
"deleted_by_on": "Deleted by __name__ on __date__",
+ "deleted_user": "Deleted user",
"deleting": "Deleting",
"demonstrating_git_integration": "Demonstrating Git integration",
"demonstrating_track_changes_feature": "Demonstrating Track Changes feature",
@@ -517,6 +525,7 @@
"disable_stop_on_first_error": "Disable “Stop on first error”",
"disabled": "Disabled",
"disabling": "Disabling",
+ "disagree": "Disagree",
"disconnected": "Disconnected",
"discount": "Discount",
"discount_of": "Discount of __amount__",
@@ -524,7 +533,9 @@
"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.",
"dismiss_error_popup": "Dismiss first error alert",
+ "display": "Display",
"display_deleted_user": "Display deleted users",
+ "display_math": "Display math",
"do_not_have_acct_or_do_not_want_to_link": "If you don’t have an __appName__ account, or if you don’t want to link to your __institutionName__ account, please click __clickText__ .",
"do_not_link_accounts": "Don’t link accounts",
"do_you_need_edit_access": "Do you need edit access?",
@@ -603,7 +614,7 @@
"editing": "Editing",
"editing_and_collaboration": "Editing and collaboration",
"editing_captions": "Editing captions",
- "editing_tools_to_paraphrase_change_style_and_more": "<0>Editing tools0> to paraphrase, change style and more",
+ "editing_tools": "Editing tools",
"editor": "Editor",
"editor_and_pdf": "Editor & PDF",
"editor_disconected_click_to_reconnect": "Editor disconnected, click anywhere to reconnect.",
@@ -666,7 +677,7 @@
"equation_preview": "Equation preview",
"error": "Error",
"error_assist": "Error Assist",
- "error_assist_to_help_fixing_latex_errors": "<0>Error Assist0> for help fixing LaTeX errors",
+ "error_log": "Error log",
"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.",
@@ -712,6 +723,7 @@
"features_and_benefits": "Features & Benefits",
"features_like_track_changes": "Features like real-time track changes",
"february": "February",
+ "figure": "Figure",
"file": "File",
"file_action_created": "Created",
"file_action_deleted": "Deleted",
@@ -734,6 +746,7 @@
"files_selected": "files selected.",
"filter_projects": "Filter projects",
"filters": "Filters",
+ "find": "Find",
"find_out_more": "Find out More",
"find_out_more_about_institution_login": "Find out more about institutional login",
"find_out_more_about_the_file_outline": "Find out more about the file outline",
@@ -798,6 +811,7 @@
"gallery_page_title": "Gallery - Templates, Examples and Articles written in LaTeX",
"gallery_show_more_tags": "Show more",
"general": "General",
+ "generate_from_text_or_image": "From text or image",
"generate_token": "Generate token",
"generic_if_problem_continues_contact_us": "If the problem continues please contact us",
"generic_linked_file_compile_error": "This project’s output files are not available because it failed to compile. Please open the project to see the compilation error details.",
@@ -875,6 +889,9 @@
"go_to_previous_page": "Go to previous page",
"go_to_settings": "Go to settings",
"go_to_subscriptions": "Go to Subscriptions",
+ "go_to_writefull": "Go to Writefull",
+ "good_news_you_already_purchased_this_add_on": "Good news! You already have this add-on, so no need to pay again.",
+ "good_news_you_are_already_receiving_this_add_on_via_writefull": "Good news! You already have this add-on via your Writefull subscription. No need to pay again.",
"great_for_getting_started": "Great for getting started",
"great_for_small_teams_and_departments": "Great for small teams and departments",
"group": "Group",
@@ -968,6 +985,7 @@
"how_we_use_your_data_explanation": "<0>Please help us continue to improve Overleaf by answering a few quick questions. Your answers will help us and our corporate group understand more about our user base. We may use this information to improve your Overleaf experience, for example by providing personalized onboarding, upgrade prompts, help suggestions, and tailored marketing communications (if you’ve opted-in to receive them).0><1>For more details on how we use your personal data, please see our <0>Privacy Notice0>.1>",
"hundreds_templates_info": "Produce beautiful documents starting from our gallery of LaTeX templates for journals, conferences, theses, reports, CVs and much more.",
"i_confirm_am_student": "I confirm that I am currently a student.",
+ "i_want_to_add_a_po_number": "I want to add a PO number",
"i_want_to_stay": "I want to stay",
"id": "ID",
"identify_errors_with_your_compile": "Identify errors with your compile",
@@ -996,6 +1014,7 @@
"imported_from_zotero_at_date": "Imported from Zotero at __formattedDate__ __relativeDate__",
"importing": "Importing",
"importing_and_merging_changes_in_github": "Importing and merging changes in GitHub",
+ "improved_dark_mode": "Improved dark mode",
"in_order_to_have_a_secure_account_make_sure_your_password": "To help keep your account secure, make sure your new password:",
"in_order_to_match_institutional_metadata_2": "In order to match your institutional metadata, we’ve linked your account using <0>__email__0>.",
"in_order_to_match_institutional_metadata_associated": "In order to match your institutional metadata, your account is associated with the email __email__ .",
@@ -1004,9 +1023,13 @@
"include_results_from_your_reference_manager": "Include results from your reference manager",
"include_results_from_your_x_account": "Include results from your __provider__ account",
"include_the_error_message_and_ai_response": "Include the error message and AI response",
+ "included_as_part_of_your_writefull_subscription": "Included as part of your Writefull subscription",
+ "increase_indent": "Increase indentation",
"increased_compile_timeout": "Increased compile timeout",
"individuals": "Individuals",
"info": "Info",
+ "inline": "Inline",
+ "inline_math": "Inline math",
"inr_discount_modal_info": "Get document history, track changes, additional collaborators, and more at Purchasing Power Parity prices.",
"inr_discount_modal_title": "70% off all Overleaf premium plans for users in India",
"inr_discount_offer_plans_page_banner": "__flag__ Great news! We’ve applied a 70% discount to premium plans for our users in India. Check out the new lower prices below.",
@@ -1083,6 +1106,7 @@
"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 touch0> 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 touch0> 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 information0>, or <1>get in touch1> with our Support team for more help.",
+ "italics": "Italics",
"ja": "Japanese",
"january": "January",
"join_beta_program": "Join beta program",
@@ -1112,7 +1136,7 @@
"labs": "Labs",
"labs_program_benefits": "By signing up for Overleaf Labs you can get your hands on in-development features and try them out as much as you like. All we ask in return is your honest feedback to help us develop and improve. It’s important to note that features available in this program are still being tested and actively developed. This means they could change, be removed, or become part of a premium plan.",
"language": "Language",
- "language_suggestions_for_texts_in_any_language": ">Language suggestions/> for texts in any 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 them0>.",
"large_or_high_resolution_images_taking_too_long_to_process": "Large or high-resolution images taking too long to process.",
"last_active": "Last Active",
@@ -1170,11 +1194,9 @@
"license": "License",
"licenses": "Licenses",
"limited_document_history": "Limited document history",
+ "limited_to_n_collaborators_per_project": "Limited to __count__ collaborator per project",
+ "limited_to_n_collaborators_per_project_plural": "Limited to __count__ collaborators per project",
"limited_to_n_editors": "Limited to __count__ editor",
- "limited_to_n_editors_or_reviewers": "Limited to __count__ editor or reviewer",
- "limited_to_n_editors_or_reviewers_per_project": "Limited to __count__ editor or reviewer per project",
- "limited_to_n_editors_or_reviewers_per_project_plural": "Limited to __count__ editors or reviewers per project",
- "limited_to_n_editors_or_reviewers_plural": "Limited to __count__ editors or reviewers",
"limited_to_n_editors_per_project": "Limited to __count__ editor per project",
"limited_to_n_editors_per_project_plural": "Limited to __count__ editors per project",
"limited_to_n_editors_plural": "Limited to __count__ editors",
@@ -1272,6 +1294,7 @@
"manage_publisher_managers": "Manage publisher managers",
"manage_sessions": "Manage Your Sessions",
"manage_subscription": "Manage subscription",
+ "manage_your_ai_assist_add_on": "Manage your AI Assist add-on",
"managed": "Managed",
"managed_user_accounts": "Managed user accounts",
"managed_user_invite_has_been_sent_to_email": "Managed User invite has been sent to <0>__email__0>",
@@ -1286,6 +1309,7 @@
"managing_your_subscription": "Managing your subscription",
"march": "March",
"marked_as_resolved": "Marked as resolved",
+ "math": "Math",
"math_display": "Math Display",
"math_inline": "Math Inline",
"maximum_files_uploaded_together": "Maximum __max__ files uploaded together",
@@ -1319,9 +1343,11 @@
"monthly": "Monthly",
"more": "More",
"more_actions": "More actions",
+ "more_changes_based_on_your_feedback": "More changes based on your feedback!",
"more_collabs_per_project": "More collaborators per project",
"more_comments": "More comments",
"more_compile_time": "More compile time",
+ "more_editor_toolbar_item": "More editor toolbar items",
"more_info": "More Info",
"more_options": "More options",
"more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
@@ -1351,11 +1377,16 @@
"need_more_than_x_licenses": "Need more than __x__ licenses?",
"need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.",
"need_to_leave": "Need to leave?",
+ "neither_agree_nor_disagree": "Neither agree nor disagree",
"new_compile_domain_notice": "We’ve recently migrated PDF downloads to a new domain. Something might be blocking your browser from accessing that new domain, <0>__compilesUserContentDomain__0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide1>.",
"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",
+ "new_navigation_introducing_left_hand_side_rail_and_top_menus": "New navigation - introducing left-hand side rail and top menus",
+ "new_overleaf_editor": "New Overleaf editor",
"new_password": "New password",
"new_project": "New Project",
"new_snippet_project": "Untitled",
@@ -1422,10 +1453,12 @@
"notification_project_invite_accepted_message": "You’ve joined __projectName__ ",
"notification_project_invite_message": "__userName__ would like you to join __projectName__ ",
"november": "November",
+ "now_you_can_search_your_whole_project_not_just_this_file": "Now you can search your whole project (not just this file!)",
"number_collab": "Number of collaborators",
"number_collab_info": "The number of people you can invite to work on a project with you. The limit is per project, so you can invite different people to each project.",
"number_of_projects": "Number of projects",
"number_of_users": "Number of users",
+ "numbered_list": "Numbered list",
"oauth_orcid_description": " Securely establish your identity by linking your ORCID iD to your __appName__ account . Submissions to participating publishers will automatically include your ORCID iD for improved workflow and visibility. ",
"october": "October",
"off": "Off",
@@ -1464,7 +1497,6 @@
"organization_name": "Organization name",
"organization_or_company_name": "Organization or company name",
"organization_or_company_type": "Organization or company type",
- "organize_projects": "Organize Projects",
"organize_tags": "Organize Tags",
"original_price": "Original price",
"other": "Other",
@@ -1483,14 +1515,15 @@
"over_n_users_at_research_institutions_and_business": "Over __userCountMillion__ million users at research institutions and businesses worldwide love __appName__",
"overall_theme": "Overall theme",
"overleaf": "Overleaf",
- "overleaf_assist_streamline_your_workflow": "Streamline your workflow with unlimited access to Overleaf and Writefull AI features.",
"overleaf_group_plans": "Overleaf group plans",
"overleaf_history_system": "Overleaf History System",
"overleaf_individual_plans": "Overleaf individual plans",
+ "overleaf_is_easy_to_use": "Overleaf is easy to use.",
"overleaf_labs": "Overleaf Labs",
"overleaf_logo": "Overleaf logo",
"overleaf_plans_and_pricing": "overleaf plans and pricing",
"overleaf_template_gallery": "overleaf template gallery",
+ "overleafs_functionality_meets_my_needs": "Overleaf’s functionality meets my needs.",
"overview": "Overview",
"overwrite": "Overwrite",
"overwriting_the_original_folder": "Overwriting the original folder will delete it and all the files it contains.",
@@ -1512,6 +1545,7 @@
"papers_sync_description": "With the Papers integration you can import your references from Papers into your __appName__ projects.",
"papers_upgrade_prompt_content": "Link your Papers account to search and add your references from Papers directly in your project—they’ll automatically be added to your .bib file. Or import them as a file into your __appName__ projects.",
"papers_upgrade_prompt_title": "Cite from Papers",
+ "paragraph_styles": "Paragraph styles",
"partial_outline_warning": "The File outline is out of date. It will update itself as you edit the document",
"password": "Password",
"password_cant_be_the_same_as_current_one": "Password can’t be the same as current one",
@@ -1545,6 +1579,7 @@
"pdf_in_separate_tab": "PDF in separate tab",
"pdf_only": "PDF only",
"pdf_only_hide_editor": "PDF only <0>(hide editor)0>",
+ "pdf_preview": "PDF preview",
"pdf_preview_error": "There was a problem displaying the compilation results for this project.",
"pdf_rendering_error": "PDF Rendering Error",
"pdf_unavailable_for_download": "PDF unavailable for download",
@@ -1573,7 +1608,7 @@
"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_editors": "Please ask the project owner to upgrade their plan to allow more editors.",
+ "please_ask_the_project_owner_to_upgrade_more_collaborators": "Please ask the project owner to upgrade their plan to allow more collaborators.",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "Please ask the project owner to upgrade to use track changes",
"please_change_primary_to_remove": "Please change your primary email in order to remove",
"please_check_your_inbox_to_confirm": "Please check your email inbox to confirm your <0>__institutionName__0> affiliation.",
@@ -1591,6 +1626,7 @@
"please_link_before_making_primary": "Please confirm your email by linking to your institutional account before making it the primary email.",
"please_provide_a_message": "Please provide a message",
"please_provide_a_subject": "Please provide a subject",
+ "please_provide_a_valid_email_address": "Please provide a valid email address",
"please_reconfirm_institutional_email": "Please take a moment to confirm your institutional email address or <0>remove it0> from your account.",
"please_reconfirm_your_affiliation_before_making_this_primary": "Please confirm your affiliation before making this the primary.",
"please_refresh": "Please refresh the page to continue.",
@@ -1605,6 +1641,7 @@
"plus_additional_collaborators_document_history_track_changes_and_more": "(plus additional collaborators, document history, track changes, and more).",
"plus_more": "plus more",
"plus_x_additional_licenses_for_a_total_of_y_licenses": "Plus <0>__additionalLicenses__0> additional license(s) for a total of <1>__count__ licenses1>",
+ "po_number": "PO Number",
"popular_tags": "Popular Tags",
"portal_add_affiliation_to_join": "It looks like you are already logged in to __appName__. If you have a __portalTitle__ email you can add it now.",
"position": "Position",
@@ -1723,6 +1760,7 @@
"redirect_to_editor": "Redirect to editor",
"redirect_url": "Redirect URL",
"redirecting": "Redirecting",
+ "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_searched_groups": "__provider__ search groups",
@@ -1833,7 +1871,8 @@
"reverse_x_sort_order": "Reverse __x__ sort order",
"revert_pending_plan_change": "Revert scheduled plan change",
"review": "Review",
- "review_panel_comments_and_track_changes": "Review panel – Comments & track changes",
+ "review_panel": "Review panel",
+ "review_panel_and_error_logs_moved_to_the_left": "Review panel and error logs moved to the left",
"review_your_peers_work": "Review your peers’ work",
"reviewer": "Reviewer",
"reviewer_dropbox_sync_message": "As a reviewer you can sync the current project version to Dropbox, but changes made in Dropbox will <0>not0> sync back to Overleaf.",
@@ -1872,7 +1911,6 @@
"search": "Search",
"search_all_project_files": "Search all project files",
"search_bib_files": "Search by author, title, year",
- "search_by_author_journal_title_and_more_link_to_zotero_mendeley": "Search by author, journal, title, and more. Link to Zotero or Mendeley to search and add references from your libraries directly in your project.",
"search_by_author_journal_title_and_more_link_to_zotero_mendeley_papers": "Search by author, journal, title, and more. Link to Zotero, Mendeley, or Papers to search and add references from your libraries directly in your project.",
"search_by_citekey_author_year_title": "Search by citation key, author, title, year",
"search_command_find": "Find",
@@ -1931,6 +1969,7 @@
"select_image_from_project_files": "Select image from project files",
"select_project": "Select __project__",
"select_projects": "Select Projects",
+ "select_size": "Select size",
"select_tag": "Select tag __tagName__",
"select_user": "Select user",
"selected": "Selected",
@@ -1960,7 +1999,6 @@
"set_up_single_sign_on": "Set up single sign-on (SSO)",
"set_up_sso": "Set up SSO",
"settings": "Settings",
- "settings_for_git_github_and_dropbox_integrations": "Settings for Git, Github & Dropbox integrations",
"setup_another_account_under_a_personal_email_address": "Set up another Overleaf account under a personal email address.",
"share": "Share",
"share_project": "Share Project",
@@ -1969,6 +2007,7 @@
"shortcut_to_open_advanced_reference_search": "(__ctrlSpace__ or __altSpace__ )",
"show_all_projects": "Show all projects",
"show_document_preamble": "Show document preamble",
+ "show_equation_preview": "Show equation preview",
"show_file_tree": "Show file tree",
"show_hotkeys": "Show Hotkeys",
"show_in_code": "Show in code",
@@ -2013,6 +2052,7 @@
"sorry_there_was_an_issue_upgrading_your_subscription": "Sorry, there was an issue upgrading your subscription. Please <0>contact our Support team0> for help.",
"sorry_this_account_has_been_suspended": "Sorry, this account has been suspended.",
"sorry_you_can_only_change_to_group_from_trial_via_support": "Sorry, you can only change to a group plan during a free trial by contacting support.",
+ "sorry_you_can_only_change_to_group_via_support": "Sorry, you can only change to a group plan by contacting support.",
"sorry_your_table_cant_be_displayed_at_the_moment": "Sorry, your table can’t be displayed at the moment.",
"sorry_your_token_expired": "Sorry, your token expired",
"sort_by": "Sort by",
@@ -2094,6 +2134,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",
+ "strongly_agree": "Strongly agree",
+ "strongly_disagree": "Strongly disagree",
"student": "Student",
"student_disclaimer": "The educational discount applies to all students at secondary and postsecondary institutions (schools and universities). We may contact you to confirm that you’re eligible for the discount.",
"student_verification_required": "Student verification required",
@@ -2135,6 +2177,7 @@
"switch_to_old_editor": "Switch to old editor",
"switch_to_pdf": "Switch to PDF",
"switch_to_standard_plan": "Switch to Standard plan",
+ "symbol": "Symbol",
"symbol_palette": "Symbol palette",
"symbol_palette_highlighted": "<0>Symbol0> palette",
"symbol_palette_info_new": "Insert math symbols into your document with the click of a button.",
@@ -2147,6 +2190,7 @@
"syntax_validation": "Code check",
"tab_connecting": "Connecting with the editor",
"tab_no_longer_connected": "This tab is no longer connected with the editor",
+ "table": "Table",
"table_generator": "Table Generator",
"tag_color": "Tag color",
"tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
@@ -2178,7 +2222,7 @@
"test_configuration": "Test configuration",
"test_configuration_successful": "Test configuration successful",
"tex_live_version": "TeX Live version",
- "texgpt_for_help_writing_latex": "<0>TeXGPT0> for help writing LaTeX",
+ "texgpt": "TexGPT",
"thank_you": "Thank you!",
"thank_you_email_confirmed": "Thank you, your email is now confirmed",
"thank_you_exclamation": "Thank you!",
@@ -2237,10 +2281,10 @@
"this_is_a_new_feature": "This is a new feature",
"this_is_the_file_that_references_pulled_from_your_reference_manager_will_be_added_to": "This is the file that references pulled from your reference manager will be added to.",
"this_is_your_template": "This is your template from your project",
- "this_project_already_has_maximum_editors": "This project already has the maximum number of editors permitted on the owner’s plan. This means you can view but not edit the project.",
+ "this_project_already_has_maximum_collaborators": "This project already has the maximum number of collaborators permitted on the owner’s plan. This means you can view but not edit or review the project.",
"this_project_contains_a_file_called_output": "This project contains a file called output.pdf. If that file exists, please rename it and compile again.",
+ "this_project_exceeded_collaborator_limit": "This project exceeded the collaborator limit for your plan. All other users now have view-only access.",
"this_project_exceeded_compile_timeout_limit_on_free_plan": "This project exceeded the compile timeout limit on our free plan.",
- "this_project_exceeded_editor_limit": "This project exceeded the editor limit for your plan. All collaborators now have view-only access.",
"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",
@@ -2296,6 +2340,8 @@
"toolbar_editor": "Editor tools",
"toolbar_format_bold": "Format Bold",
"toolbar_format_italic": "Format Italic",
+ "toolbar_generate_math": "Generate Math",
+ "toolbar_generate_table": "Generate Table",
"toolbar_increase_indent": "Increase Indent",
"toolbar_insert_citation": "Insert Citation",
"toolbar_insert_cross_reference": "Insert Cross-reference",
@@ -2305,6 +2351,7 @@
"toolbar_insert_link": "Insert Link",
"toolbar_insert_math": "Insert Math",
"toolbar_insert_math_and_symbols": "Insert Math and Symbols",
+ "toolbar_insert_math_lowercase": "Insert math",
"toolbar_insert_misc": "Insert Misc (links, citations, cross-references, figures, tables)",
"toolbar_insert_table": "Insert Table",
"toolbar_list_indentation": "List and Indentation",
@@ -2366,6 +2413,7 @@
"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 guide0>.",
"try_relinking_provider": "It looks like you need to re-link your __provider__ account.",
@@ -2385,6 +2433,7 @@
"undelete": "Undelete",
"undeleting": "Undeleting",
"understanding_labels": "Understanding labels",
+ "undo": "Undo",
"unfold_line": "Unfold line",
"unique_identifier_attribute": "Unique identifier attribute",
"university": "University",
@@ -2437,7 +2486,7 @@
"upgrade_now": "Upgrade now",
"upgrade_plan": "Upgrade plan",
"upgrade_summary": "Upgrade summary",
- "upgrade_to_add_more_editors_and_access_collaboration_features": "Upgrade to add more editors and access collaboration features like track changes and full project history.",
+ "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_track_changes": "Upgrade to track changes",
@@ -2509,6 +2558,7 @@
"view_only_downgraded": "View only. Upgrade to restore edit access.",
"view_only_reviewer_downgraded": "View only. Upgrade to restore review access.",
"view_options": "View options",
+ "view_payment_portal": "View invoices and billing details",
"view_pdf": "View PDF",
"view_source": "View Source",
"view_your_invoices": "View your invoices",
@@ -2566,6 +2616,7 @@
"work_or_university_sso": "Work/university single sign-on",
"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",
"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",
@@ -2601,10 +2652,11 @@
"you_can_manage_your_reference_manager_integrations_from_your_account_settings_page": "You can manage your reference manager integrations from your <0>account settings page0>.",
"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 features0>.",
+ "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_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out0> 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": "You can select or invite __count__ editor on your current plan, or upgrade to get more.",
- "you_can_select_or_invite_plural": "You can select or invite __count__ editors on your current plan, or upgrade to get more.",
+ "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.",
+ "you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.",
"you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.",
"you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)0>.",
"you_cant_join_this_group_subscription": "You can’t join this group subscription",
@@ -2639,6 +2691,7 @@
"your_current_plan_gives_you": "By pausing your subscription, you’ll be able to access your premium features faster when you need them again.",
"your_current_plan_supports_up_to_x_licenses": "Your current plan supports up to __users__ licenses.",
"your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
+ "your_feedback_matters_answer_two_quick_questions": "Your feedback matters! Answer two quick questions.",
"your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",
"your_git_access_info_bullet_2": "If you reach the maximum limit, you’ll need to delete a token before you can generate a new one.",
@@ -2656,8 +2709,8 @@
"your_plan_is_limited_to_n_editors": "Your plan allows __count__ collaborator with edit access and unlimited viewers.",
"your_plan_is_limited_to_n_editors_plural": "Your plan allows __count__ collaborators with edit access and unlimited viewers.",
"your_premium_plan_is_paused": "Your Premium plan is <0>paused0>.",
+ "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_exceeded_editor_limit": "Your project exceeded the editor limit and access levels were changed. Select a new access level for your collaborators, or upgrade to add more editors.",
"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",
@@ -2681,7 +2734,7 @@
"youre_signed_up": "You’re signed up",
"youve_added_more_licenses": "You’ve added more license(s)!",
"youve_added_x_more_licenses_to_your_subscription_invite_people": "You’ve added __users__ more license(s) to your subscription. <0>Invite people0>.",
- "youve_lost_edit_access": "You’ve lost edit access",
+ "youve_lost_collaboration_access": "You’ve lost collaboration access",
"youve_paused_your_subscription": "Your <0>__planName__0> subscription is paused until <0>__reactivationDate__0>, then it’ll automatically unpause. You can unpause early at any time.",
"youve_unlinked_all_users": "You’ve unlinked all users",
"youve_upgraded_your_plan": "You’ve upgraded your plan!",
diff --git a/services/web/locales/es.json b/services/web/locales/es.json
index 36391d3bac..ec1a9d59bc 100644
--- a/services/web/locales/es.json
+++ b/services/web/locales/es.json
@@ -67,7 +67,7 @@
"add_email_address": "Añadir dirección de correo",
"add_email_to_claim_features": "Añade tu correo institucional para reclamar funcionalidades.",
"add_files": "Añadir archivos",
- "add_more_editors": "Añadir más editores",
+ "add_more_collaborators": "Añadir más colaboradores",
"add_more_managers": "Añadir más administradores",
"add_new_email": "Añadir nuevo correo",
"add_or_remove_project_from_tag": "Añadir o eliminar proyecto de la etiqueta __tagName__",
@@ -680,7 +680,6 @@
"youre_already_setup_for_sso": "Ya está configurado para SSO",
"youre_on_free_trial_which_ends_on": "Estás en una prueba gratuita que termina en <0>__date__0>.",
"youre_signed_up": "Estás inscrito",
- "youve_lost_edit_access": "Has perdido el acceso de edición",
"youve_unlinked_all_users": "Has desvinculado a todos los usuarios",
"zh-CN": "Chino",
"zoom_in": "Ampliar",
diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json
index 4830945afe..2b006f75ad 100644
--- a/services/web/locales/fr.json
+++ b/services/web/locales/fr.json
@@ -68,7 +68,7 @@
"add_email_address": "Ajouter une adresse e-mail",
"add_email_to_claim_features": "Ajouter votre adresse courriel institutionnelle pour obtenir ces fonctionnalités.",
"add_files": "Ajouter des fichiers",
- "add_more_editors": "Ajouter plus de collaborateurs",
+ "add_more_collaborators": "Ajouter plus de collaborateur·rice·s",
"add_more_managers": "Ajouter plus de gestionnaires",
"add_new_email": "Ajouter l’adresse",
"add_or_remove_project_from_tag": "Ajouter ou supprimer un projet du l’étiquette __tagName__",
@@ -812,7 +812,6 @@
"opted_out_linking": "Vous avez choisi de ne pas lier votre compte __appName__ __email__ à votre compte institutionnel.",
"optional": "Optionnel",
"or": "ou",
- "organize_projects": "Organiser les projets",
"organize_tags": "Organiser les étiquettes",
"other_actions": "Autres actions",
"other_logs_and_files": "Autres journaux et fichiers",
@@ -1258,7 +1257,6 @@
"youre_on_free_trial_which_ends_on": "Vous bénéficiez d’un essai gratuit qui se termine le <0>__date__0>.",
"youre_signed_in_as_logout": "Vous êtes connecté·e avec <0>__email__0>. <1>Déconnectez-vous.1>",
"youre_signed_up": "Vous êtes inscrit·e",
- "youve_lost_edit_access": "Vous avez perdu l’accès à la modification",
"zh-CN": "Chinois",
"zip_contents_too_large": "Contenu de l’archive trop volumineux",
"zoom_in": "Zoomer",
diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json
index 012c24d73b..ed77938c53 100644
--- a/services/web/locales/ru.json
+++ b/services/web/locales/ru.json
@@ -1,5 +1,6 @@
{
"12x_basic": "12х базовый",
+ "12x_more_compile_time": "В 12 раз больше времени компиляции на наших быстрейших серверах",
"1_2_width": "½ ширины",
"1_4_width": "¼ ширины",
"3_4_width": "¾ ширины",
@@ -12,8 +13,11 @@
"Subscription": "Подписка",
"Terms": "Условия",
"Universities": "Университеты",
+ "a_custom_size_has_been_used_in_the_latex_code": "Пользовательский размер был использован в коде LaTeX.",
+ "a_fatal_compile_error_that_completely_blocks_compilation": "<0>Фатальная ошибка компиляции0> полностью заблокировала компиляцию.",
"a_file_with_that_name_already_exists_and_will_be_overriden": "Файл с таким именем уже существует. Этот файл будет перезаписан.",
- "about": "О проекте",
+ "a_more_comprehensive_list_of_keyboard_shortcuts": "Более полный лист горячих клавиш находится в <0>этом шаблоне проекта __appName__0>",
+ "about": "О",
"about_to_archive_projects": "Вы собираетесь архивировать следующие проекты:",
"about_to_delete_cert": "Вы собираетесь удалить следующий сертификат:",
"about_to_delete_projects": "Следующие проекты будут удалены:",
diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json
index e477cc66bd..4f9399d132 100644
--- a/services/web/locales/zh-CN.json
+++ b/services/web/locales/zh-CN.json
@@ -1,5 +1,6 @@
{
"12x_basic": "12倍 免费时长 (240s)",
+ "12x_more_compile_time": "在更快的服务器上获取12倍的编译时间",
"1_2_width": "½ 宽度",
"1_4_width": "¼ 宽度",
"3_4_width": "¾ 宽度",
@@ -16,7 +17,12 @@
"a_fatal_compile_error_that_completely_blocks_compilation": "一个<0>严重编译错误0>阻止了编译。",
"a_file_with_that_name_already_exists_and_will_be_overriden": "同名文件已存在,该文件会被覆盖。",
"a_more_comprehensive_list_of_keyboard_shortcuts": "在<0>此__appName__项目模板0>中可以找到更完整的键盘快捷键列表",
+ "a_new_reference_was_added": "添加了新的参考文献",
+ "a_new_reference_was_added_from_provider": "从 __provider__ 添加了新的参考文献",
+ "a_new_reference_was_added_to_file": "已向 <0>__filePath__0> 添加了新的参考文献",
+ "a_new_reference_was_added_to_file_from_provider": "从 __provider__ 向 <0>__filePath__0> 添加了新的参考文献",
"about": "关于",
+ "about_error_assist": "关于错误辅助助理",
"about_to_archive_projects": "您将要归档以下项目:",
"about_to_delete_cert": "您将要删除以下证书:",
"about_to_delete_projects": "您将删除下面的项目:",
@@ -37,14 +43,18 @@
"accept_change_error_title": "接受错误修改",
"accept_invitation": "接受邀请",
"accept_or_reject_each_changes_individually": "接受或拒绝修改意见",
+ "accept_or_reject_individual_edits": "接受或拒绝个别修改",
"accept_selected_changes": "接受选定修改",
"accept_terms_and_conditions": "接受条款和条件",
"accepted_invite": "已接受的邀请",
"accepting_invite_as": "接受邀请",
+ "access_all_premium_features": "访问所有高级功能,包括更多合作者、完整的项目历史记录和更长的编译时间。",
"access_denied": "访问被拒绝",
"access_edit_your_projects": "访问并编辑您的项目",
"access_levels_changed": "访问级别已更改",
"account": "账户",
+ "account_already_managed": "您的帐户已被管理。",
+ "account_billed_manually": "帐户手动计费",
"account_has_been_link_to_institution_account": "您在 __appName__ 上的 __email__ 帐户已链接到您的 __institutionName__ 机构帐户。",
"account_has_past_due_invoice_change_plan_warning": "您的帐户当前有逾期账单。在这个问题解决之前,你不能改变你的计划。",
"account_linking": "帐户链接",
@@ -69,15 +79,24 @@
"add_another_token": "添加另外一个令牌",
"add_comma_separated_emails_help": "使用逗号(,)字符分隔多个电子邮件地址。",
"add_comment": "添加评论",
+ "add_comment_error_message": "添加您的评论时出错。请稍后重试。",
+ "add_comment_error_title": "添加评论错误",
"add_company_details": "添加公司详细信息",
"add_email": "添加电子邮件",
"add_email_address": "添加邮件地址",
"add_email_to_claim_features": "添加一个机构电子邮件地址来声明您的功能。",
+ "add_error_assist_annual_to_your_projects": "将 Error Assist Annual 添加到您的项目中并获得无限的 AI 修复帮助,以更快地修复 LaTeX 错误。",
+ "add_error_assist_to_your_projects": "将错误辅助 添加到您的项目中并获得无限的 AI 帮助,以更快地修复 LaTeX 错误。",
"add_files": "添加文件",
- "add_more_editors": "添加更多编辑者",
+ "add_more_collaborators": "添加更多协作者",
+ "add_more_licenses_to_my_plan": "为我的计划添加更多许可证",
"add_more_managers": "添加更多管理者",
"add_new_email": "添加新电子邮件",
+ "add_on": "插件",
+ "add_ons": "插件",
"add_or_remove_project_from_tag": "根据标记 __tagName__ 来添加或移除项目",
+ "add_overleaf_assist_to_your_group_subscription": "将 Overleaf Assist 添加到您的团体订阅",
+ "add_overleaf_assist_to_your_institution": "将 Overleaf Assist 添加到您的机构",
"add_people": "添加人员",
"add_role_and_department": "添加角色和部门",
"add_to_dictionary": "添加到词典",
@@ -99,10 +118,15 @@
"administration_and_security": "管理和安全",
"advanced_reference_search": "高级<0>引用搜索0>",
"advanced_reference_search_mode": "高级引文搜索",
+ "after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "此后,我们将于每年 __date__ 向您收取 __totalAmount__ (__subtotalAmount__ + __taxAmount__ 税额),除非您取消。",
"aggregate_changed": "替换",
"aggregate_to": "为",
"agree_with_the_terms": "我同意Overleaf的条款",
+ "ai_assistance_to_help_you": "AI 将辅助您修复 LaTeX 错误",
+ "ai_based_language_tools": "专为研究写作而设计的基于人工智能的语言工具",
"ai_can_make_mistakes": "AI 可能会犯错。在确定修复之前,请先检查修复内容。",
+ "ai_features": "AI 特性",
+ "ai_feedback_please_provide_more_detail": "请提供更多有关错误的详细信息(可选)",
"ai_feedback_tell_us_what_was_wrong_so_we_can_improve": "告诉我们哪里出了问题,以便我们改进。",
"ai_feedback_the_answer_was_too_long": "答案太长了",
"ai_feedback_the_answer_wasnt_detailed_enough": "答案不够详细",
@@ -112,6 +136,7 @@
"alignment": "对齐",
"all": "全部",
"all_borders": "全边框",
+ "all_features_in_group_standard_plus": "Group Standard 中的所有功能,以及:",
"all_premium_features": "所有高级付费功能",
"all_premium_features_including": "所有高级功能,包括:",
"all_prices_displayed_are_in_currency": "所有展示的价格都以__recommendedCurrency__计。",
@@ -120,6 +145,8 @@
"all_templates": "所有模板",
"all_the_pros_of_our_standard_plan_plus_unlimited_collab": "包含我们标准计划的所有功能,另外每个项目还可拥有无限的合作者。",
"all_these_experiments_are_available_exclusively": "所有这些实验仅对实验室计划的成员开放。如果您注册,您可以选择要尝试的实验。",
+ "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "允许按作者、标题等进行搜索。可以直接从参考文献管理器中提取结果(如果已连接)。",
+ "already_have_a_papers_account": "现在可以更轻松的在 Overleaf 中管理你的引文和参考书目!已有 Papers 帐户了吗?<0>在此关联你的帐户0>。",
"already_have_an_account": "已经有一个账户啦?",
"already_have_sl_account": "已经拥有 __appName__ 账户了吗?",
"already_subscribed_try_refreshing_the_page": "已经订阅啦?请刷新界面哦。",
@@ -130,11 +157,15 @@
"an_error_occurred_when_verifying_the_coupon_code": "验证优惠券代码时出错",
"and": "和",
"annual": "每年",
+ "annual_discount": "年度折扣",
"anonymous": "匿名",
"anyone_with_link_can_edit": "任何人可以通过此链接编辑此项目。",
"anyone_with_link_can_view": "任何人可以通过此链接浏览此项目。",
"app_on_x": "__appName__ 在 __social__",
+ "appearance": "外观",
"apply_educational_discount": "使用教育折扣",
+ "apply_educational_discount_description": "使用 __appName__ 进行教学的团体可享受 40% 折扣",
+ "apply_educational_discount_description_with_group_discount": "使用 __appName__ 进行教学的团体可享受总计 40% 的折扣",
"apply_suggestion": "使用建议修改",
"april": "四月",
"archive": "归档",
@@ -145,6 +176,7 @@
"are_you_affiliated_with_an_institution": "您隶属于某个机构吗?",
"are_you_getting_an_undefined_control_sequence_error": "您是否看到未定义的控制序列错误?如果是,请确保您已在文档的序言部分(代码的第一部分)中加载 Graphicx 包:<0>\\usepackage{graphicx}0>。 <1>了解更多1>",
"are_you_still_at": "你还在<0>__institutionName__0>吗?",
+ "are_you_sure_you_want_to_cancel_add_on": "您确定要取消 __addOnName__ 附加组件吗?",
"article": "文章",
"articles": "文章",
"as_a_member_of_sso_required": "作为 __institutionName__ 的成员,您必须通过您的机构门户网站登录到 __appName__ 。",
@@ -155,24 +187,31 @@
"ask_proj_owner_to_upgrade_for_references_search": "请要求项目所有者升级以使用参考文献搜索功能。",
"ask_repo_owner_to_reconnect": "请求 GitHub 存储库所有者 (<0>__repoOwnerEmail__0>) 重新连接该项目。",
"ask_repo_owner_to_renew_overleaf_subscription": "请求 GitHub 存储库所有者 (<0>__repoOwnerEmail__0>) 续订其 __appName__ 订阅并重新链接项目。",
+ "at_most_x_libraries_can_be_selected": "最多可以选择 __maxCount__ 个库",
"august": "八月",
"author": "作者",
"auto_close_brackets": "自动补全括号",
"auto_compile": "自动编译",
"auto_complete": "自动补全",
+ "autocompile": "自动编译",
"autocompile_disabled": "自动编译已关闭",
"autocompile_disabled_reason": "由于服务器过载,暂时无法自动实时编译,请点击上方按钮进行编译",
"autocomplete": "自动补全",
"autocomplete_references": "参考文献自动补全(在 \\cite{}
中)",
"automatic_user_registration_uppercase": "自动用户注册",
+ "automatically_insert_closing_brackets_and_parentheses": "自动插入右括号和圆括号",
+ "automatically_recompile_the_project_as_you_edit": "编辑时自动重新编译项目",
+ "available_with_group_professional": "适用于 Group Professional",
"back": "返回",
"back_to_account_settings": "返回帐户设置",
+ "back_to_all_posts": "回到所有帖子",
"back_to_configuration": "返回配置",
"back_to_editor": "回到编辑器",
"back_to_log_in": "返回登录",
"back_to_subscription": "返回到订阅",
"back_to_your_projects": "返回您的项目",
"basic": "免费时长 (20s)",
+ "basic_compile_time": "基本编译时长",
"basic_compile_timeout_on_fast_servers": "在快速服务器上的基本编译时限",
"become_an_advisor": "成为__appName__顾问",
"before_you_use_error_assistant": "使用错误助手之前",
@@ -186,29 +225,41 @@
"beta_program_opt_out_action": "退出 Beta 计划",
"bibliographies": "参考文献",
"billed_annually": "每年计费",
+ "billed_annually_at": "按年计费,价格为<0>__price__0><1>(包含套餐和任何附加组件)1>",
+ "billed_monthly_at": "按月计费,价格为<0>__price__0><1>(包含套餐和任何附加组件)1>",
"billed_yearly": "按年计费",
+ "billing": "订单",
+ "billing_period_sentence_case": "订单周期",
"binary_history_error": "预览不适用于此文件类型",
"blank_project": "空白项目",
"blocked_filename": "此文件名被阻止。",
"blog": "博客",
+ "bold": "粗体",
"brl_discount_offer_plans_page_banner": "__flag__好消息 我们为巴西用户在本页面上的高级计划提供了50%的折扣。看看新的低价。",
"browser": "浏览器",
"built_in": "内嵌",
+ "bullet_list": "项目符号列表",
+ "buy_licenses": "购买许可证",
+ "buy_more_licenses": "购买更多许可证",
"buy_now_no_exclamation_mark": "现在购买",
"by": "由",
"by_joining_labs": "加入实验室即表示您同意接收 Overleaf 不定期发送的电子邮件和更新信息(例如,征求您的反馈)。您还同意我们的<0>服务条款0>和<1>隐私声明1>。",
"by_registering_you_agree_to_our_terms_of_service": "注册即表示您同意我们的 <0>服务条款0> 和 <1>隐私条款1>。",
"by_subscribing_you_agree_to_our_terms_of_service": "订阅即表示您同意我们的<0>服务条款0>。",
+ "can_edit_content": "允许编辑",
"can_link_institution_email_acct_to_institution_acct": "您现在可以将您的 __appName__ 账户 __email__ 与您的 __institutionName__ 机构账户关联。",
"can_link_institution_email_by_clicking": "您可以通过单击 __clickText__ 将您的 __email__ __appName__ 账户链接到您的 __institutionName__ 帐户。",
"can_link_institution_email_to_login": "您可以将您的 __email__ __appName__ 账户链接到你的 __institutionName__ 账户,这将允许您通过机构门户登录到__appName__ 。",
"can_link_your_institution_acct_2": "您可以现在 <0>链接0> 您的 <0>__appName__0> 账户到您的<0>__institutionName__0> 机构账户。",
"can_now_relink_dropbox": "您现在可以<0>重新关联您的 Dropbox 帐户0>。",
+ "can_view_content": "允许查看",
"cancel": "取消",
+ "cancel_add_on": "取消插件",
"cancel_anytime": "我们相信您会喜欢 __appName__,但如果不喜欢,您可以随时取消。如果您在30天内通知我们,我们无理由退款。",
"cancel_my_account": "取消我的订购",
"cancel_my_subscription": "取消我的订阅",
"cancel_personal_subscription_first": "您已经有个人订阅,您希望我们在加入团体许可之前先取消该订阅吗?",
+ "cancel_subscription": "取消订阅",
"cancel_your_subscription": "取消您的订购",
"cannot_invite_non_user": "无法发送邀请。 收件人必须已有 __appName__ 帐户",
"cannot_invite_self": "不能向自己发送邀请哦",
@@ -223,6 +274,7 @@
"card_must_be_authenticated_by_3dsecure": "在继续之前,您的卡必须通过3D安全验证",
"card_payment": "信用卡支付",
"careers": "工作与职业",
+ "categories": "类别",
"category_arrows": "箭头字符",
"category_greek": "希腊字符",
"category_misc": "杂项",
@@ -232,6 +284,7 @@
"certificate": "证书",
"change": "修改",
"change_currency": "更改货币",
+ "change_language": "更改语言",
"change_or_cancel-cancel": "取消",
"change_or_cancel-change": "修改",
"change_or_cancel-or": "或者",
@@ -239,6 +292,7 @@
"change_password": "更换密码",
"change_password_in_account_settings": "在帐户设置中更改密码",
"change_plan": "改变套餐",
+ "change_primary_email": "更改主电子邮件",
"change_primary_email_address_instructions": "要更改您的主电子邮件地址,请先添加您的新主电子邮件地址(点击<0>添加其他电子邮件0>)并确认。 然后单击<0>设为主账户0>按钮。 <1>详细了解1>如何管理您的 __appName__ 电子邮件。",
"change_project_owner": "变更项目所有者",
"change_the_ownership_of_your_personal_projects": "将您的个人项目的所有权更改为新帐户。 <0>了解如何更改项目所有者。0>",
@@ -255,8 +309,10 @@
"checking_project_github_status": "正在检查GitHub中的项目状态",
"choose_a_custom_color": "选择自定义颜色",
"choose_from_group_members": "从团队成员中选择",
+ "choose_how_you_search_your_references": "选择如何搜索参考文献",
"choose_which_experiments": "选择您想要尝试的实验。",
"choose_your_plan": "选择您的支付方案",
+ "citation": "引文",
"city": "城市",
"clear_cached_files": "清除缓存文件",
"clear_search": "清除搜索",
@@ -266,6 +322,8 @@
"clearing": "正在清除",
"click_here_to_view_sl_in_lng": "点击以<0>__lngName__0> 使用 __appName__",
"click_link_to_proceed": "单击下面的 __clickText__ 继续。",
+ "click_to_give_feedback": "点击即可提供反馈。",
+ "click_to_unpause": "单击即可取消暂停并重新激活您的 Overleaf 高级功能。",
"clicking_delete_will_remove_sso_config_and_clear_saml_data": "点击<0>删除0>将删除您的 SSO 配置并取消所有用户的链接。 仅当您的组设置中禁用 SSO 时,您才能执行此操作。",
"clone_with_git": "用Git克隆",
"close": "关闭",
@@ -275,12 +333,11 @@
"code_check_failed": "代码检查失败",
"code_check_failed_explanation": "您的代码有问题,无法自动编译",
"code_editor": "源代码编辑器",
- "code_editor_tooltip_message": "您可以在代码编辑器中查看项目中的代码(并对其进行编辑)",
- "code_editor_tooltip_title": "想要查看并编辑 LaTeX 代码?",
"collaborate_easily_on_your_projects": "轻松协作您的项目。处理更长或更复杂的文档。",
"collaborate_online_and_offline": "使用自己的工作流进行在线和离线协作",
"collaboration": "合作",
"collaborator": "合作者",
+ "collaborator_chat": "协作者聊天",
"collabratec_account_not_registered": "未注册 IEEE Collabratec™ 帐户。请从IEEE Collabratec™连接到Overleaf 或者使用其他帐户登录。",
"collabs_per_proj": "每个项目 __collabcount__ 个合作者",
"collabs_per_proj_single": "__collabcount__ 个合作者每个项目",
@@ -289,12 +346,18 @@
"column_width_is_custom_click_to_resize": "列宽为默认值,单击以调整大小",
"column_width_is_x_click_to_resize": "列宽为 __width__。 单击以调整大小",
"comment": "评论",
+ "comment_only": "仅评论",
+ "comment_only_upgrade_for_track_changes": "仅评论。升级可跟踪更改。",
+ "comment_only_upgrade_to_enable_track_changes": "仅评论。<0>升级0>以启用跟踪更改。",
"common": "通用",
"common_causes_of_compile_timeouts_include": "常见的导致编译超时的原因包括",
"commons_plan_tooltip": "由于您与 __institution__ 的隶属关系,您加入了 __plan__ 计划。 单击以了解如何充分利用 Overleaf 高级功能。",
+ "community_articles": "社区文章",
+ "community_articles_lowercase": "社区文章",
"compact": "紧凑的",
"company_name": "公司名称",
"compare": "比较",
+ "compare_all_plans": "在我们的<0>定价页面0>上比较所有计划",
"compare_features": "比较功能",
"comparing_from_x_to_y": "从 <0>__startTime__0> 到 <0>__endTime__0> 进行比较",
"compile_error_entry_description": "一个阻止此项目编译的错误",
@@ -314,21 +377,28 @@
"configure_sso": "配置 SSO",
"configured": "已配置",
"confirm": "确认",
+ "confirm_accept_selected_changes": "您确定要接受所选的更改吗?",
+ "confirm_accept_selected_changes_plural": "您确定要接受选定的__count__个更改吗?",
"confirm_affiliation": "确认从属关系",
"confirm_affiliation_to_relink_dropbox": "请确认您仍在该机构并持有他们的许可证,或升级您的帐户以重新关联您的 Dropbox 帐户。",
"confirm_delete_user_type_email_address": "确认您要删除 __userName__,请输入与其帐户关联的电子邮件地址",
"confirm_email": "确认电子邮件",
"confirm_new_password": "确认新密码",
"confirm_primary_email_change": "确认主电子邮件更改",
+ "confirm_reject_selected_changes": "您确定要拒绝所选的更改吗?",
+ "confirm_reject_selected_changes_plural": "您确定要拒绝选定的__count__个更改吗?",
"confirm_remove_sso_config_enter_email": "要确认您要删除 SSO 配置,请输入您的电子邮件地址:",
+ "confirm_secondary_email": "确认辅助电子邮件",
"confirm_your_email": "确认您的电子邮件地址",
"confirmation_link_broken": "抱歉,您的确认链接有问题。请尝试复制并粘贴邮件底部的链接。",
"confirmation_token_invalid": "抱歉,您的确认令牌无效或已过期。请请求新的电子邮件确认链接。",
"confirming": "确认",
"conflicting_paths_found": "发现冲突路径",
"congratulations_youve_successfully_join_group": "恭喜!您已经成功的加入到团队订阅中。",
+ "connect_overleaf_with_github": "将 __appName__ 与 GitHub 连接,以便轻松同步项目和实时版本控制。",
"connected_users": "已连接的用户",
"connecting": "正在连接",
+ "connection_lost_with_unsaved_changes": "连接丢失,更改未保存。",
"contact": "联系",
"contact_group_admin": "请联系你的群组管理员。",
"contact_message_label": "信息",
@@ -341,6 +411,7 @@
"continue": "继续",
"continue_github_merge": "我已经手动合并。继续",
"continue_to": "返回 __appName__",
+ "continue_using_free_features": "继续使用我们的免费功能",
"continue_with_free_plan": "继续使用免费计划",
"continue_with_service": "以 __service__ 继续",
"copied": "已复制",
@@ -349,6 +420,7 @@
"copy_project": "复制项目",
"copy_response": "复制响应内容",
"copying": "正在复制",
+ "cost_summary": "成本汇总",
"could_not_connect_to_collaboration_server": "无法连接到协作服务器",
"could_not_connect_to_websocket_server": "无法连接到WebSocket服务器",
"could_not_load_translations": "无法加载翻译",
@@ -370,9 +442,11 @@
"created_at": "创建于",
"creating": "正在创建",
"credit_card": "信用卡",
+ "cross_reference": "交叉引用",
"cs": "捷克语",
"currency": "货币",
"current_file": "当前文件",
+ "current_page_page": "当前页,第 __page__ 页",
"current_password": "正在使用的密码",
"current_price": "当前价格",
"current_session": "当前会话",
@@ -387,11 +461,13 @@
"customizing_figures": "定制图片",
"customizing_tables": "定制表格",
"da": "丹麦语",
+ "dark_mode": "暗黑模式",
"date": "日期",
"date_and_owner": "日期和所有者",
"de": "德语",
"dealing_with_errors": "处理错误",
"december": "十二月",
+ "decrease_indent": "减少缩进",
"dedicated_account_manager": "专属客服",
"default": "默认",
"delete": "删除",
@@ -406,6 +482,7 @@
"delete_certificate": "删除证书",
"delete_comment": "删除评论",
"delete_comment_error_message": "删除您的评论时遇到错误。请稍后重试。",
+ "delete_comment_error_title": "删除评论错误",
"delete_comment_message": "您无法撤销此操作",
"delete_comment_thread": "删除评论线程流",
"delete_comment_thread_message": "这将删除整个评论线程。此操作无法撤消。",
@@ -432,13 +509,26 @@
"details_provided_by_google_explanation": "您的详细信息是由您的 Google 帐户提供的。请检查一下哦。",
"dictionary": "字典",
"did_you_know_institution_providing_professional": "你知道吗__institutionName__向__institutionName__的每个人提供<0>免费的 __appName__ 专业功能0>吗?",
+ "disable": "禁用",
+ "disable_ai_features": "禁用 AI 功能",
+ "disable_equation_preview": "禁用公式预览",
+ "disable_equation_preview_confirm": "这将在所有项目中禁用公式预览。",
+ "disable_equation_preview_enable": "您可以从菜单 再次启用它。",
"disable_single_sign_on": "禁用 单点登录(SSO)",
"disable_sso": "关闭 SSO",
"disable_stop_on_first_error": "禁用 “出现第一个错误时停止”",
+ "disabled": "已禁用",
+ "disabling": "禁用",
"disconnected": "连接已断开",
+ "discount": "折扣",
"discount_of": "__amount__的折扣",
+ "discover_latex_templates_and_examples": "探索 LaTeX 模板和示例,以帮助完成从撰写期刊文章到使用特定 LaTeX 包的所有工作。",
+ "discover_the_fastest_way_to_search_and_cite": "探索搜索和引用的最快方法",
+ "discover_why_over_people_worldwide_trust_overleaf": "了解为什么全世界有超过__count__万人信任 Overleaf 并把工作交给它。",
"dismiss_error_popup": "忽略第一个错误提示",
+ "display": "显示",
"display_deleted_user": "显示已删除的用户",
+ "display_math": "显示数学公式",
"do_not_have_acct_or_do_not_want_to_link": "如果您没有 __appName__ 帐户,或者您不想链接到您的 __institutionName__ 帐户,请单击 __clickText__ 。",
"do_not_link_accounts": "不链接帐户",
"do_you_need_edit_access": "您需要编辑权限吗?",
@@ -458,9 +548,13 @@
"doing_this_allow_log_in_through_institution_2": "执行此操作将允许您通过您的机构登录<0>__appName__0>,并重新确认您的机构电子邮件地址。",
"doing_this_will_verify_affiliation_and_allow_log_in_2": "这样做将验证您与__institutionName__ 的关系,并将允许您通过您的机构登录到 __appName__ 。",
"done": "完成",
+ "dont_forget_you_currently_have": "别忘了,您目前拥有:",
"dont_have_account": "还没有账户?",
+ "dont_reload_or_close_this_tab": "请勿刷新或关闭此选项卡。",
"download": "下载",
"download_all": "下载全部",
+ "download_as_pdf": "下载 PDF",
+ "download_as_source_zip": "下载源代码 (.zip)",
"download_metadata": "下载 Overleaf 元数据",
"download_pdf": "下载PDF",
"download_zip_file": "下载 ZIP 格式文件",
@@ -494,27 +588,38 @@
"dropbox_unlinked_premium_feature": "<0>您的 Dropbox 帐户已取消关联0>,因为 Dropbox Sync 是您通过机构许可获得的一项高级功能。",
"due_date": "到期 __date__",
"due_today": "今天截止",
+ "duplicate": "复制",
"duplicate_file": "重复文件",
"duplicate_projects": "该用户有名称重复的项目",
"each_user_will_have_access_to": "每个用户都可以访问",
"easily_manage_your_project_files_everywhere": "随时随地轻松管理您的项目文件",
"easy_collaboration_for_students": "方便学生协作。支持更长或更复杂的项目。",
"edit": "编辑",
+ "edit_comment_error_message": "编辑评论时出错。请稍后重试。",
+ "edit_comment_error_title": "编辑评论错误",
"edit_dictionary": "编辑词典",
"edit_dictionary_empty": "您的自定义词典为空。",
"edit_dictionary_remove": "从字典中删除",
"edit_figure": "编辑图片",
"edit_sso_configuration": "编辑 SSO 配置",
"edit_tag": "编辑标签",
- "editing": "正在编辑",
+ "edit_your_custom_dictionary": "编辑您的自定义词典",
+ "editing": "编辑",
"editing_and_collaboration": "编辑与协作",
"editing_captions": "编辑 captions",
- "editor": "编辑器",
+ "editor": "编辑者(可编辑)",
"editor_and_pdf": "编辑器 & PDF",
"editor_disconected_click_to_reconnect": "编辑器与网络的连接已经断开,重新连接请点击任何位置。",
+ "editor_font_family": "编辑器字体系列",
+ "editor_font_size": "编辑器字体大小",
"editor_limit_exceeded_in_this_project": "此项目中的编辑者过多",
+ "editor_line_height": "编辑器行高",
+ "editor_only": "仅编辑器",
"editor_only_hide_pdf": "仅编辑器 <0>(隐藏 PDF)0>",
"editor_theme": "编辑器主题",
+ "edits_become_suggestions": "编辑成建议修改",
+ "educational_disclaimer": "我确认用户是主要使用 Overleaf 进行学习和教学的学生或教师,并且可以在被要求时提供证据。",
+ "educational_disclaimer_heading": "教育折扣确认",
"educational_percent_discount_applied": "应用 __percent__% 教育折扣!",
"email": "电子邮件",
"email_address": "邮件地址",
@@ -531,6 +636,7 @@
"email_must_be_linked_to_institution": "作为 __institutionName__ 的成员,此电子邮件地址只能通过您的<0>帐户设置0>页面上的单点登录添加。 请添加不同的辅助邮箱地址。",
"email_or_password_wrong_try_again": "您的邮件地址或密码不正确。请重试",
"email_or_password_wrong_try_again_or_reset": "您的电子邮件或密码不正确。请重试,或者<0>重置您的密码0>。",
+ "email_remove_by_date": "如果在__date__之前没有完成,它将从帐户中删除。",
"email_required": "需要电子邮件",
"email_sent": "邮件已发送",
"emails": "邮箱",
@@ -539,21 +645,31 @@
"empty": "空",
"empty_zip_file": "Zip压缩包中没有任何文件",
"en": "英语",
+ "enable_ai_features": "启用 AI 功能",
"enable_managed_users": "启用托管用户",
"enable_single_sign_on": "开启单点登录",
"enable_sso": "开启 SSO",
"enable_stop_on_first_error_under_recompile_dropdown_menu": "在<1>重新编译1>下拉菜单下启用<0>“第一次出现错误时停止”0>,以帮助您立即查找并修复错误。",
+ "enable_stop_on_first_error_under_recompile_dropdown_menu_v2": "在<0>重新编译0>下拉菜单下启用<1>在第一个错误时停止1>,以帮助您立即查找和修复错误。",
"enabled": "已启用",
+ "enables_real_time_syntax_checking_in_the_editor": "在编辑器中启用实时语法检查",
+ "enabling": "开启",
"end_of_document": "文档末尾",
+ "ensure_recover_account": "这将确保在您无法访问主电子邮件地址时可以使用它来恢复您的__appName__帐户。",
"enter_6_digit_code": "输入6位数验证码",
"enter_any_size_including_units_or_valid_latex_command": "输入任意大小(包括单位)或有效的 LaTeX 命令",
"enter_image_url": "输入图片 URL",
+ "enter_the_code": "输入发送至__email__的6位数代码。",
"enter_the_confirmation_code": "输入发送到 __email__ 的六位验证码。",
+ "enter_the_number_of_licenses_youd_like_to_add_to_see_the_cost_breakdown": "输入您想要添加的许可证数量以查看成本明细。",
"enter_your_email_address": "输入你的电子邮件",
"enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "在下面输入您的电子邮件地址,我们将向您发送重置密码的链接",
"enter_your_new_password": "输入你的新密码",
+ "equation_generator": "方程生成器",
"equation_preview": "公式预览",
"error": "错误",
+ "error_assist": "错误辅助助理",
+ "error_log": "错误日志",
"error_opening_document": "打开文档错误",
"error_opening_document_detail": "很抱歉,打开此文档时出现问题。请再试一次。",
"error_performing_request": "执行请求时出错。",
@@ -568,6 +684,8 @@
"example": "样例",
"example_project": "样例项目",
"examples": "样例",
+ "examples_lowercase": "案例",
+ "examples_to_help_you_learn": "示例可帮助您学习如何使用强大的 LaTeX 包和技术。",
"exclusive_access_with_labs": "独家获取早期实验阶段功能",
"existing_plan_active_until_term_end": "您的现有计划及其功能将保持活动状态,直到当前计费周期结束。",
"expand": "展开",
@@ -578,13 +696,16 @@
"expires_in_days": "在 __days__ 天后过期",
"expires_on": "过期日期:__date__",
"expiry": "过期日期",
+ "explore_all_plans": "探索所有订阅计划",
"export_csv": "导出CSV",
"export_project_to_github": "将项目导出到GitHub",
"failed_to_send_group_invite_to_email": "未能向<0>__email__0>发送团队邀请。请稍后再试。",
"failed_to_send_managed_user_invite_to_email": "无法将托管用户邀请发送至 <0>__email__0>。 请稍后再试。",
"failed_to_send_sso_link_invite_to_email": "无法向<0>__email__0>发送SSO邀请提醒。请稍后再试。",
+ "fair_usage_policy_applies": "适用正当使用政策。",
"faq_how_does_free_trial_works_answer": "在为期__len__天的免费试用期间,您可以完全访问所选的__appName__计划。试用结束后不能继续免费。您的卡将在试用期结束时收费,除非您在此之前取消。您可以通过订阅设置取消。",
"fast": "快速",
+ "fast_draft": "快速 [草稿]",
"fastest": "最快",
"feature_included": "包含的功能",
"feature_not_included": "不包含的功能",
@@ -592,7 +713,10 @@
"featured_latex_templates": "特色LaTeX模板",
"features": "功能",
"features_and_benefits": "功能 & 优势",
+ "features_like_track_changes": "实时跟踪更改等功能",
"february": "二月",
+ "figure": "图片",
+ "file": "文件",
"file_action_created": "创建",
"file_action_deleted": "删除",
"file_action_edited": "编辑",
@@ -609,10 +733,12 @@
"file_outline": "文件大纲",
"file_size": "文件大小",
"file_too_large": "文件太大",
+ "file_tree": "文件树",
"files_cannot_include_invalid_characters": "文件名为空或包含无效字符",
"files_selected": "个文件被选中。",
"filter_projects": "过滤项目",
"filters": "筛选器",
+ "find": "查找",
"find_out_more": "了解更多",
"find_out_more_about_institution_login": "了解有关机构登录的更多信息",
"find_out_more_about_the_file_outline": "了解有关文件大纲的更多信息",
@@ -669,20 +795,28 @@
"full_doc_history": "完整的文档历史",
"full_document_history": "完整的文档<0>历史0>",
"full_width": "全宽",
+ "future_payments": "未来付款",
"gallery": "模版集",
+ "gallery_back_to_all": "返回所有 __itemPlural__",
"gallery_find_more": "查找更多__itemPlural__",
+ "gallery_page_items_lowercase": "gallery 项目",
"gallery_page_title": "模版集 - 用LaTeX编写的模板、示例和文章",
+ "gallery_show_more_tags": "显示更多",
"general": "常用",
+ "generate_from_text_or_image": "来自文本或图像",
"generate_token": "生成令牌",
"generic_if_problem_continues_contact_us": "如果问题仍然存在,请与我们联系",
"generic_linked_file_compile_error": "此项目的输出文件不可用,因为它未能成功编译。请打开项目以查看编译错误的详细信息。",
"generic_something_went_wrong": "抱歉,出错了",
"get_collaborative_benefits": "从 __appName__ 获得协作优势,即使你喜欢离线工作",
"get_discounted_plan": "获得折扣计划",
+ "get_error_assist": "获取错误帮助",
"get_exclusive_access_to_labs": "加入 Overleaf Labs 后,即可获得早期实验的独家访问权。我们唯一的要求就是您提供真实的反馈,以帮助我们发展和改进。",
"get_in_touch": "联系",
"get_in_touch_having_problems": "如果遇到问题,请与支持部门联系 ",
"get_involved": "加入我们",
+ "get_most_subscription_discover_premium_features": "充分利用您的 __appName__ 订阅。<0>探索高级功能0>。",
+ "get_real_time_track_changes": "获取实时跟踪更改",
"get_the_best_overleaf_experience": "获取最佳的 Overleaf 体验",
"get_the_most_out_headline": "通过以下功能充分利用__appName__:",
"git": "Git",
@@ -694,8 +828,10 @@
"git_bridge_modal_git_clone_your_project": "使用下面的链接和 Git 身份验证令牌来克隆你的项目",
"git_bridge_modal_learn_more_about_authentication_tokens": "了解有关Git集成身份验证令牌的更多信息。",
"git_bridge_modal_read_only": "您对此项目具有只读访问权限 这意味着您可以从__appName__中提取,但不能将您所做的任何更改推送回该项目。",
+ "git_bridge_modal_review_access": "<0>您拥有此项目的审核权限。0>这意味着您可以从 __appName__ 中提取,但不能将所做的任何更改推送回此项目。",
"git_bridge_modal_see_once": "您只能看到此令牌一次。要删除或生成新帐户,请访问“帐户设置”。有关详细说明和故障排除,请阅读我们的<0>帮助页面0>。",
"git_bridge_modal_use_previous_token": "如果系统提示您输入密码,您可以使用以前生成的Git身份验证令牌。或者,您可以在“帐户设置”中生成一个新帐户。有关更多支持,请阅读我们的<0>帮助页面0>。",
+ "git_gitHub_dropbox_mendeley_papers_and_zotero_integrations": "Git、GitHub、Dropbox、Mendeley、Papers 和 Zotero 集成",
"git_integration": "Git 集成",
"git_integration_info": "通过Git集成,你可以用Git克隆你的Overleaf项目。有关完整教程, 请阅读 <0>我们的帮助页面0>。",
"github": "GitHub",
@@ -736,9 +872,17 @@
"go_prev_page": "转到上一页",
"go_to_account_settings": "前往账户设置",
"go_to_code_location_in_pdf": "转到PDF中的位置",
+ "go_to_first_page": "回到第一页",
+ "go_to_last_page": "回到最后一页",
+ "go_to_next_page": "下一页",
"go_to_overleaf": "前往 Overleaf",
+ "go_to_page_x": "转至第 __page__ 页",
"go_to_pdf_location_in_code": "转到代码中对应 PDF 的位置(提示:双击 PDF 以获得最佳结果)",
+ "go_to_previous_page": "回到前一页",
"go_to_settings": "转到“设置”",
+ "go_to_subscriptions": "前往订阅",
+ "good_news_you_already_purchased_this_add_on": "好消息!您已经拥有此附加组件,因此无需再次付费。",
+ "good_news_you_are_already_receiving_this_add_on_via_writefull": "好消息!您已经通过 Writefull 订阅获得了此附加组件。无需再次付费。",
"great_for_getting_started": "非常适合入门",
"great_for_small_teams_and_departments": "非常适合小型团队和部门",
"group": "团队",
@@ -748,10 +892,15 @@
"group_invite_has_been_sent_to_email": "团队邀请已发送至<0>__email__0>",
"group_libraries": "团队库",
"group_managed_by_group_administrator": "此团队中的用户帐户由团队管理员管理。",
+ "group_management": "群组管理",
+ "group_managers": "群组管理员",
+ "group_members": "团队成员",
"group_plan_admins_can_easily_add_and_remove_users_from_a_group": "群组计划管理员可以轻松添加和删除群组中的用户。对于全站计划,用户在注册或将电子邮件地址添加到 Overleaf(基于域的注册或 SSO)时会自动升级。",
"group_plan_tooltip": "您作为团体订阅的成员加入了 __plan__ 计划。 单击以了解如何充分利用 Overleaf 高级功能。",
+ "group_plan_upgrade_description": "您当前使用的是 <0>__currentPlan__0> 方案,并且正在升级到 <0>__nextPlan__0> 方案。如果您对站点级别的 Overleaf Commons 方案感兴趣,请<1>联系我们1>。",
"group_plan_with_name_tooltip": "您作为团体订阅 __groupName__ 的成员加入了 __plan__ 计划。 单击以了解如何充分利用 Overleaf 高级功能。",
"group_professional": "团队专业版",
+ "group_settings": "团队设置",
"group_sso_configuration_idp_metadata": "此处提供的信息来自您的身份提供商(IdP)。这通常被称为其SAML元数据。对于某些IdP,您必须将Overleaf配置为服务提供商,才能获得填写此表格所需的数据。有关更多指导,请参阅<0>我们的文档0>。",
"group_sso_configure_service_provider_in_idp": "对于某些 IdP,您必须将 Overleaf 配置为服务提供商才能获取填写此表单所需的数据。 为此,您需要下载 Overleaf 元数据。",
"group_sso_documentation_links": "请参阅我们的<0>文档0>和<1>问题排查指南1>以获取更多帮助。",
@@ -765,10 +914,12 @@
"help_articles_matching": "符合你的主题的帮助文章",
"help_improve_overleaf_fill_out_this_survey": "如果您想帮助我们改进Overleaf,请花费一点您的宝贵时间填写<0>此调查0>哦。",
"help_improve_screen_reader_fill_out_this_survey": "填写此简易调查,帮助我们改善您使用 __appName__ 屏幕阅读器的体验。",
+ "help_shape_the_future_of_overleaf": "帮助塑造 Overleaf 的未来",
"hide": "隐藏",
"hide_configuration": "隐藏配置",
"hide_deleted_user": "隐藏已删除的用户",
"hide_document_preamble": "隐藏文档导言部分",
+ "hide_file_tree": "隐藏文件树",
"hide_local_file_contents": "隐藏本地文件内容",
"hide_outline": "隐藏文件大纲",
"history": "历史记录",
@@ -816,18 +967,23 @@
"hotkey_toggle_track_changes": "切换历史记录",
"hotkey_undo": "撤销",
"hotkeys": "快捷键",
- "how_it_works": "工作原理",
+ "how_it_works": "原理简介",
+ "how_many_licenses_do_you_want_to_buy": "您想购买多少个许可证?",
"how_many_users_do_you_need": "你需要多少用户",
"how_to_create_tables": "如何创建表格",
"how_to_insert_images": "如何插入图片",
"how_we_use_your_data": "我们如何使用您的数据",
"how_we_use_your_data_explanation": "<0>请回答几个简短的问题,帮助我们继续改进Overleaf。您的回答将帮助我们和我们的企业集团更多地了解我们的用户群体。我们可能会使用这些信息来改善您的 Overleaf 体验,例如提供个性化的入门、升级提示、帮助建议和量身定制的营销沟通(如果您选择接收这些信息)0><1>有关我们如何使用您的个人数据的更多详细信息,请参阅我们的<0>隐私声明0>1>",
"hundreds_templates_info": "从我们的 LaTeX 模板库开始,为期刊、会议、论文、报告、简历等制作漂亮的文档。",
+ "i_confirm_am_student": "我确认我目前是一名学生。",
"i_want_to_stay": "我要留下",
"id": "ID",
+ "identify_errors_with_your_compile": "识别编译时的错误",
"if_have_existing_can_link": "如果您在另一封电子邮件中有一个现有的 __appName__ 帐户,您可以通过单击 __clickText__ 将其链接到您的 __institutionName__ 账户。",
"if_owner_can_link": "如果您在__appName__ 拥有账户__email__ ,您可以将其链接到您的 __institutionName__ 机构帐户。",
"if_you_need_to_customize_your_table_further_you_can": "如果您需要进一步自定义表也是可以的哦。使用LaTeX代码,您可以更改从表格样式和边框样式,到颜色和列宽等任何内容<0>阅读我们的指南0>在LaTeX中使用表格以帮助您入门。",
+ "if_you_want_more_than_x_licenses_on_your_plan_we_need_to_add_them_for_you": "如果您希望在订阅中添加超过__count__个许可证,我们需要为您添加。只需点击下方的<0>发送请求0>,我们很乐意为您提供帮助。",
+ "if_you_want_to_reduce_the_number_of_licenses_please_contact_support": "如果您想减少计划中的许可证数量,请<0>联系客户支持0>。",
"if_your_occupation_not_listed_type_full_name": "如果您的__occupation__未列出,您可以键入全名。",
"ignore_and_continue_institution_linking": "您也可以忽略此项,然后继续在 __appName__ 上使用您的 __email__ 帐户 。",
"ignore_validation_errors": "忽略语法检查",
@@ -843,6 +999,7 @@
"imported_from_another_project_at_date": "于 __formattedDate__ __relativeDate__,从<0>另一个项目0>/__sourceEntityPathHTML__导入",
"imported_from_external_provider_at_date": "于 __formattedDate__ __relativeDate__,从<0>__shortenedUrlHTML__0>导入",
"imported_from_mendeley_at_date": "于 __formattedDate__ __relativeDate__,从Mendeley导入",
+ "imported_from_papers_at_date": "从 __formattedDate__ __relativeDate__ 的论文导入",
"imported_from_the_output_of_another_project_at_date": "于 __formattedDate__ __relativeDate__,从<0>另一个项目0>的输出导入: __sourceOutputFilePathHTML__",
"imported_from_zotero_at_date": "于 __formattedDate__ __relativeDate__,从Zotero导入",
"importing": "正在倒入",
@@ -852,10 +1009,15 @@
"in_order_to_match_institutional_metadata_associated": "为了匹配您的机构元数据,您的帐户与电子邮件 __email__ 相关联。",
"include_caption": "添加 caption",
"include_label": "添加 label",
+ "include_results_from_your_reference_manager": "包括参考文献管理器的结果",
+ "include_results_from_your_x_account": "包含来自您的 __provider__ 帐户的结果",
"include_the_error_message_and_ai_response": "包含错误信息和 AI 响应",
+ "increase_indent": "增加缩进",
"increased_compile_timeout": "延长的编译时限",
"individuals": "个人",
"info": "信息",
+ "inline": "行内",
+ "inline_math": "行内数学公式",
"inr_discount_modal_info": "以平价获取文档历史记录、跟踪更改、更多协作者等功能。",
"inr_discount_modal_title": "面向印度用户的所有 Overleaf 高级计划七折优惠",
"inr_discount_offer_plans_page_banner": "__flag__ 好消息! 我们已为印度用户的高级计划提供70% 折扣 折扣。 查看下面的最新低价。",
@@ -888,7 +1050,9 @@
"institutional": "机构",
"institutional_leavers_survey_notification": "提供一些快速反馈,即可获得年度订阅25%的折扣!",
"institutional_login_unknown": "抱歉,我们不知道是哪个机构发的那个电子邮件地址。您可以浏览我们的\n机构列表 找到您的机构,也可以在此处使用您的电子邮件地址和密码注册。",
+ "integrate_overleaf_with_dropbox": "将 __appName__ 与 Dropbox 集成以同步您的 LaTeX 项目并自动保持文件更新。",
"integrations": "集成",
+ "integrations_like_github": "GitHub Sync 等集成",
"interested_in_cheaper_personal_plan": "你会对更便宜的<0>__price__0>个人计划感兴趣吗?",
"invalid_certificate": "证书无效,请检查证书,然后重试。",
"invalid_confirmation_code": "无效!请检查代码,然后重试。",
@@ -903,11 +1067,13 @@
"invalid_password_too_long": "超过最大密码长度 __maxLength__",
"invalid_password_too_short": "密码太短,最短 __minLength__ 位",
"invalid_password_too_similar": "密码与电子邮件地址过于相似",
+ "invalid_regular_expression": "无效的正则表达式",
"invalid_request": "无效的请求。请更正数据并重试。",
"invalid_zip_file": "zip文件无效",
"invite": "邀请",
"invite_expired": "此邀请已经过期",
"invite_more_collabs": "邀请更多的协作者",
+ "invite_more_members": "邀请更多成员",
"invite_not_accepted": "邀请尚未接受",
"invite_not_valid": "项目邀请无效",
"invite_not_valid_description": "邀请已经过期。请联系项目所有者",
@@ -918,12 +1084,17 @@
"invited_to_group_login_benefits": "作为该小组的一员,您将可以使用 __appName__ 高级功能,例如额外的协作者、更长的最大编译时间和实时跟踪更改。",
"invited_to_group_register": "要接受 __inviterName__ 的邀请,您需要创建一个帐户。",
"invited_to_group_register_benefits": "__appName__ 是一个协作式在线 LaTeX 编辑器,拥有数千个即用型模板和一系列 LaTeX 学习资源,可帮助您入门。",
+ "inviting": "邀请",
"ip_address": "IP地址",
"is_email_affiliated": "你的邮件附属于某个机构的吗? ",
"is_longer_than_n_characters": "至少要 __n__ 个字符长",
"is_not_used_on_any_other_website": "未在任何其他网站上使用",
"issued_on": "发布于:__date__",
"it": "意大利语",
+ "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "看来此方法无效。您可以重试,或<0>联系0>我们的支持团队以获取更多帮助。",
+ "it_looks_like_your_account_is_billed_manually": "您的帐户似乎正在手动计费 - 添加席位或升级订阅只能由支持团队完成。请<0>联系我们0>寻求帮助。",
+ "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "您的付款详情似乎缺失。请<0>更新您的账单信息0>,或<1>联系1>我们的支持团队以获取更多帮助。",
+ "italics": "斜体",
"ja": "日语",
"january": "一月",
"join_beta_program": "加入beta计划",
@@ -945,13 +1116,16 @@
"keep_your_account_safe_add_another_email": "确保您的帐户安全,并确保您不会因添加其他电子邮件地址而失去对该帐户的访问权限。",
"keep_your_email_updated": "保持您的电子邮件更新,这样您就不会失去对帐户和数据的访问权限。",
"keybindings": "组合键",
+ "keyboard_shortcuts": "键盘快捷键",
"knowledge_base": "知识库",
"ko": "韩语",
"labels_help_you_to_easily_reference_your_figures": "标签可以帮助您轻松地在整个文档中引用您的图片。要引用文档中的图片,请使用<0> ef{…} 0>命令引用标签。这使得引用图形变得容易,而无需手动记住图形编号<1> 了解更多信息1>",
"labels_help_you_to_reference_your_tables": "标签可以帮助您轻松地在整个文档中引用表。要引用文本中的表,请使用<0>ef{…}0>命令引用标签。这样就可以很容易地引用表格,而无需手动记住表格编号<1> 阅读标签和交叉引用1>。",
+ "labs": "Labs",
"labs_program_benefits": "__appName__ 一直在寻找新的方法来帮助用户更快、更有效地工作。 通过加入 Overleaf Labs,您可以参与探索协作写作和出版领域创新想法的实验。",
"language": "语言",
"large_or_high-resolution_images_taking_too_long": "大型或高分辨率图像的处理时间过长。 您也许能够<0>优化一下0>。",
+ "large_or_high_resolution_images_taking_too_long_to_process": "大型或高分辨率图像需要太长时间来处理。",
"last_active": "最后活跃于",
"last_active_description": "最近项目打开时间",
"last_edit": "最近编辑",
@@ -973,13 +1147,18 @@
"latex_places_figures_according_to_a_special_algorithm": "LaTeX 根据特殊算法放置图形。 您可以使用“放置参数”来调整图形的位置。 <0>了解具体方法0>",
"latex_places_tables_according_to_a_special_algorithm": "LaTeX根据一种特殊的算法放置表格。可以使用“放置参数”来调整表格的位置<0>这篇文章0>解释了如何做到这一点。",
"latex_templates": "LaTeX模板",
+ "latex_templates_and_examples": "LaTeX 模板和示例",
+ "latex_templates_for_journal_articles": "适用于期刊文章、学术论文、简历、演示文稿等的 LaTeX 模板。",
+ "latex_templates_sentence_case": "LaTeX 模板",
"layout": "布局",
+ "layout_options": "布局选项",
"layout_processing": "布局处理中",
"ldap": "LDAP",
"ldap_create_admin_instructions": "输入邮箱,创建您的第一个__appName__管理员账户。这个账户对应您在LDAP系统中的账户,请使用此账户登陆系统。",
"learn": "学习",
"learn_more": "了解更多",
"learn_more_about_account": "<0>详细了解0>如何管理您的 __appName__ 帐户。",
+ "learn_more_about_compile_timeouts": "<0>详细了解0>编译超时。",
"learn_more_about_emails": "<0>详细了解0>如何管理您的 __appName__ 电子邮件。",
"learn_more_about_link_sharing": "了解分享链接",
"learn_more_about_managed_users": "学习关于管理用户",
@@ -996,8 +1175,14 @@
"let_us_know": "让我们知道",
"let_us_know_how_we_can_help": "告诉我们您需要什么帮助",
"let_us_know_what_you_think": "让我们知道您的想法",
+ "lets_get_those_premium_features": "让我们立即为您启用这些高级功能。我们将使用您掌握的付款信息向您收取 <0>__paymentAmount__0> 的费用。",
+ "libraries": "库",
"library": "库",
"license": "许可",
+ "licenses": "许可证",
+ "limited_document_history": "有限的文档历史记录",
+ "limited_to_n_collaborators_per_project": "每个项目仅限 __count__ 位合作者",
+ "limited_to_n_collaborators_per_project_plural": "每个项目仅限 __count__ 位合作者",
"limited_to_n_editors": "仅限 __count__ 个编辑",
"limited_to_n_editors_per_project": "每个项目仅限 __count__ 个编辑者",
"limited_to_n_editors_per_project_plural": "每个项目最多可有 __count__ 名编辑者",
@@ -1009,12 +1194,14 @@
"link_accounts": "链接帐户",
"link_accounts_and_add_email": "链接帐户并添加电子邮件",
"link_institutional_email_get_started": "将机构电子邮件地址链接到您的帐户以开始。",
+ "link_overleaf_with_git": "将 __appName__ 与 Git 链接,以便在您的存储库之间实现无缝的项目同步和版本控制。",
"link_sharing": "分享链接",
"link_sharing_is_off_short": "链接共享已关闭",
"link_sharing_is_on": "通过链接分享功能已开启。",
"link_to_github": "建立与您的GitHub账户的关联",
"link_to_github_description": "您需要授权 __appName__ 访问您的GitHub账户,从而允许我们同步您的项目。",
"link_to_mendeley": "关联至Mendeley",
+ "link_to_papers": "链接到 Papers",
"link_to_zotero": "关联至Zotero",
"link_your_accounts": "链接您的帐户",
"linked_accounts": "关联账户",
@@ -1063,6 +1250,7 @@
"login_to_accept_invitation": "登录以接受邀请",
"login_to_overleaf": "登录到Overleaf",
"login_with_service": "使用__service__登录",
+ "login_with_sso": "通过 SSO 登录到 Overleaf",
"logs_and_output_files": "日志和生成的文件",
"longer_compile_timeout": "更长的 <0>编译时间0>",
"longer_compile_timeout_on_faster_servers": "在更快的服务器上拥有更长编译时限",
@@ -1070,6 +1258,7 @@
"looks_like_logged_in_with_email": "您似乎已经使用 __email__ 登录到 __appName__ 。",
"looks_like_youre_at": "看起来你在<0>__institutionName__0>!",
"lost_connection": "网络连接已断开",
+ "main_bibliography_file_for_this_project": "该项目的主bib文件",
"main_document": "主文档 (main tex)",
"main_file_not_found": "未知主文件",
"main_navigation": "主导航栏",
@@ -1106,6 +1295,7 @@
"managing_your_subscription": "管理您的订阅",
"march": "三月",
"marked_as_resolved": "标记为已解决",
+ "math": "数学公式",
"math_display": "数学表达式",
"math_inline": "行内数学符号",
"maximum_files_uploaded_together": "最多可同时上传__max__个文件",
@@ -1114,6 +1304,7 @@
"member_picker": "选择团体计划的用户数量",
"members_management": "成员管理",
"mendeley": "Mendeley",
+ "mendeley_dynamic_sync_description": "通过 Mendeley 集成,您可以将参考文献导入 __appName__。您可以一次性导入所有参考文献,也可以直接从 __appName__ 动态搜索您的 Mendeley 文献库。",
"mendeley_groups_loading_error": "从 Mendeley 加载群组时出错",
"mendeley_groups_relink": "访问您的 Mendeley 数据时出错。 这可能是由于缺乏权限造成的。 请重新关联您的帐户并重试。",
"mendeley_integration": "Mendeley 集成",
@@ -1122,6 +1313,8 @@
"mendeley_reference_loading_error_expired": "Mendeley令牌过期,请重新关联您的账户",
"mendeley_reference_loading_error_forbidden": "无法加载Mendeley的参考文献,请重新关联您的账户后重试",
"mendeley_sync_description": "集成 Mendeley 后,您可以将 mendeley 的参考文献导入 __appName__ 项目。",
+ "mendeley_upgrade_prompt_content": "关联您的 Mendeley 帐户,即可直接在项目中搜索并添加 Mendeley 中的参考文献——它们将自动添加到您的 .bib 文件中。或者,您也可以将它们作为文件导入到您的 __appName__ 项目中。",
+ "mendeley_upgrade_prompt_title": "引用自 Mendeley",
"menu": "菜单",
"merge": "合并",
"merge_cells": "合并单元格",
@@ -1129,11 +1322,16 @@
"message_received": "收到消息",
"missing_field_for_entry": "缺少字段",
"missing_fields_for_entry": "缺少字段",
+ "missing_payment_details": "缺少付款详情",
"money_back_guarantee": "30天无理由退款",
"month": "月",
+ "month_plural": "个月",
"monthly": "每个月",
"more": "更多",
"more_actions": "更多操作",
+ "more_collabs_per_project": "每个项目有更多合作者",
+ "more_comments": "更多评论",
+ "more_compile_time": "更长的编译时间",
"more_info": "更多信息",
"more_options": "更多选择",
"more_options_for_border_settings_coming_soon": "更多的边框设置选项即将推出。",
@@ -1156,6 +1354,7 @@
"navigate_log_source": "导航到源代码中的日志位置:__location__",
"navigation": "导航",
"nearly_activated": "还有一步您的 __appName__ 账户就会被激活了!",
+ "need_20_plus_users_discount": "20 位以上用户?<0>联系销售人员0>获取最佳折扣。",
"need_anything_contact_us_at": "您有任何需要,请直接联系我们",
"need_contact_group_admin_to_make_changes": "如果您想对帐户进行某些更改,则需要联系群组管理员。 <0>了解有关托管用户的更多信息。0>",
"need_make_changes": "你需要做一些修改",
@@ -1165,6 +1364,7 @@
"new_compile_domain_notice": "我们最近将 PDF 下载迁移到了新域,可能会阻止您的浏览器访问新域 <0>__compilesUserContentDomain__0>。 这可能是由网络阻止或严格的浏览器插件规则引起的。 请查阅我们的<1>问题排查指南1>。",
"new_file": "新建文件",
"new_folder": "新建目录",
+ "new_font_open_dyslexic": "新字体:OpenDyslexic Mono 旨在提高阅读障碍患者的可读性。",
"new_name": "新名字",
"new_password": "新密码",
"new_project": "创建新项目",
@@ -1193,8 +1393,10 @@
"no_featured_templates": "无特色模板",
"no_folder": "没有文件夹",
"no_image_files_found": "没有找到图片文件",
+ "no_libraries_selected": "未选择任何库",
"no_members": "没有成员",
"no_messages": "无消息",
+ "no_messages_yet": "还没有任何消息",
"no_new_commits_in_github": "自上次合并后GitHub未收到新的提交",
"no_one_has_commented_or_left_any_suggestions_yet": "目前还没有人发表评论或留下任何建议。",
"no_other_projects_found": "找不到其他项目,请先创建另一个项目",
@@ -1213,9 +1415,11 @@
"no_symbols_found": "找不到符号",
"no_thanks_cancel_now": "不,谢谢,我还是想取消",
"no_update_email": "不,更新邮件",
+ "non_deletable_entity": "指定的条目不能被删除",
"normal": "常规",
"normally_x_price_per_month": "通常每月__price__",
"normally_x_price_per_year": "通常每年__price__",
+ "not_a_student": "不是学生?",
"not_found_error_from_the_supplied_url": "在Overleaf打开此内容的链接指向找不到的文件。如果某个网站的链接经常出现这种情况,请向他们报告。",
"not_managed": "未被托管",
"not_now": "稍后",
@@ -1232,6 +1436,7 @@
"number_collab_info": "您可以邀请与您一起处理项目的人数。每个项目都有限制,因此您可以邀请不同的人参与每个项目。",
"number_of_projects": "项目的数量",
"number_of_users": "用户数量",
+ "numbered_list": "有序列表",
"oauth_orcid_description": " 通过将您的 ORCID iD 链接到您的__appName__帐户 ,安全地建立您的身份。提交给参与发布者的文件将自动包含您的ORCID iD,以改进工作流和可见性。 ",
"october": "十月",
"off": "关闭",
@@ -1253,28 +1458,33 @@
"only_group_admin_or_managers_can_delete_your_account_4": "一旦您成为托管用户,就无法再更改回来。 <0>了解有关托管 Overleaf 帐户的更多信息。0>",
"only_group_admin_or_managers_can_delete_your_account_5": "有关更多信息,请参阅我们的使用条款中的“托管帐户”部分,您可以通过单击“接受邀请”来同意该条款",
"only_importer_can_refresh": "只有最初导入此 __provider__ 文件的人才能刷新它。",
+ "open_action_menu": "打开__name__操作菜单",
"open_advanced_reference_search": "打开高级引用搜索",
"open_as_template": "作为模版打开",
"open_file": "编辑文件",
"open_link": "前往页面",
"open_path": "打开 __path__",
+ "open_pdf_in_separate_tab": "在单独的选项卡中打开 PDF",
"open_project": "打开项目",
"open_target": "前往目标",
"opted_out_linking": "您已选择取消将您的 __email__ __appName__ 帐户绑定到您的机构帐户。",
"optional": "选填",
"or": "或者",
"organization": "组织",
+ "organization_does_not_support_sso": "您的组织尚不支持 SSO,但您仍然可以使用您的电子邮件地址进行注册。",
"organization_name": "组织名",
"organization_or_company_name": "组织或公司名称",
"organization_or_company_type": "组织或公司类型",
- "organize_projects": "分类管理项目",
+ "organize_tags": "管理标签",
"original_price": "原价",
"other": "其他",
"other_actions": "其他",
+ "other_causes_of_compile_timeouts": "编译超时的其他原因",
"other_logs_and_files": "其他日志和文件",
"other_output_files": "下载其他输出文件",
"other_sessions": "其他会话",
"other_ways_to_log_in": "其他登录方式",
+ "our_team_will_get_back_to_you_shortly": "我们的团队将尽快回复您。",
"our_values": "我们的价值观",
"out_of_sync": "同步失败",
"out_of_sync_detail": "很抱歉,此文件无法同步,我们需要刷新整个页面。<0>0><1>有关详细信息,请参阅本帮助指南1>",
@@ -1287,7 +1497,9 @@
"overleaf_history_system": "Overleaf 历史跟踪系统",
"overleaf_individual_plans": "Overleaf 个人计划",
"overleaf_labs": "Overleaf Labs",
+ "overleaf_logo": "Overleaf Logo",
"overleaf_plans_and_pricing": "overleaf 计划和价格",
+ "overleaf_template_gallery": "Overleaf 模板库",
"overview": "概览",
"overwrite": "覆盖",
"overwriting_the_original_folder": "覆盖原始文件夹将删除它及其包含的所有文件。",
@@ -1296,6 +1508,20 @@
"page_current": "页面 __page__,当前页面",
"page_not_found": "找不到页面",
"pagination_navigation": "分页导航",
+ "papers": "Papers",
+ "papers_dynamic_sync_description": "通过 Papers 集成,您可以将参考文献导入 __appName__。您可以一次性导入所有参考文献,也可以直接从 __appName__ 动态搜索 Papers 库。",
+ "papers_groups_loading_error": "从 Papers 加载库时出错",
+ "papers_groups_relink": "访问您的 Papers 数据时出错。这可能是由于权限不足造成的。请重新关联您的帐户并重试。",
+ "papers_integration": "Papers 基成",
+ "papers_is_premium": "Papers 集成是一项付费功能",
+ "papers_presentations_reports_and_more": "论文、演示文稿、报告等,以 LaTeX 编写并由我们的社区发布。",
+ "papers_reference_loading_error": "无法从 Papers 中加载引文",
+ "papers_reference_loading_error_expired": "Papers 令牌已过期,请重新链接您的帐户",
+ "papers_reference_loading_error_forbidden": "无法从 Papers 中加载参考文献,请重新链接您的帐户并重试",
+ "papers_sync_description": "通过 Papers 集成,您可以将 Papers 中的参考资料导入到您的 __appName__ 项目中。",
+ "papers_upgrade_prompt_content": "关联您的 Papers 帐户,即可直接在项目中搜索并添加 Papers 中的参考文献——它们将自动添加到您的 .bib 文件中。或者,您也可以将它们作为文件导入到您的 __appName__ 项目中。",
+ "papers_upgrade_prompt_title": "从 Papers 引用",
+ "paragraph_styles": "段落样式",
"partial_outline_warning": "文件大纲已过期。它将在您编辑文档时自行更新",
"password": "密码",
"password_cant_be_the_same_as_current_one": "密码不能和当前的完全一样",
@@ -1307,6 +1533,7 @@
"password_managed_externally": "密码设置由外部管理",
"password_reset": "重置密码",
"password_reset_email_sent": "已给您发送邮件以完成密码重置",
+ "password_reset_sentence_case": "重设密码",
"password_reset_token_expired": "您的密码重置链接已过期。请申请新的密码重置email,并按照email中的链接操作。",
"password_too_long_please_reset": "超过最大密码长度限制。请重新设置密码。",
"password_updated": "密码已更新",
@@ -1314,14 +1541,21 @@
"paste_options": "粘贴选项",
"paste_with_formatting": "粘贴并附带格式",
"paste_without_formatting": "粘贴纯文本",
+ "pause_subscription": "暂停订阅",
+ "pause_subscription_for": "暂停订阅",
+ "pay_now": "现在付款",
"payment_method_accepted": "__paymentMethod__ 已接受",
"payment_provider_unreachable_error": "抱歉,与我们的支付提供商交谈时出错。请稍后再试。\n如果您在浏览器中使用任何广告或脚本阻止扩展,则可能需要暂时禁用它们。",
"payment_summary": "付款摘要",
+ "pdf": "PDF",
"pdf_compile_in_progress_error": "之前的编译仍在运行。 请稍等片刻,然后再尝试编译。",
"pdf_compile_rate_limit_hit": "编译率达到限制",
"pdf_compile_try_again": "请等待其他项目编译完成后再试",
+ "pdf_couldnt_compile": "无法编译 PDF",
"pdf_in_separate_tab": "PDF 为单独的选项卡",
+ "pdf_only": "仅 PDF",
"pdf_only_hide_editor": "仅 PDF <0>(隐藏编辑器)0>",
+ "pdf_preview": "PDF 预览",
"pdf_preview_error": "显示此项目的编译结果时出现问题。",
"pdf_rendering_error": "PDF渲染错误",
"pdf_unavailable_for_download": "PDF 无法下载",
@@ -1329,13 +1563,20 @@
"pdf_viewer_error": "显示此项目的PDF时出现问题。",
"pending": "待定",
"pending_additional_licenses": "您的订阅正在更改为包括<0>__pendingAdditionalLicenses__0>个附加许可证,总共有<1>__pendingTotalLicenses__1>个许可证。",
+ "pending_addon_cancellation": "您的订阅将会在当前结算期结束时更改为删除 __addOnName__ 附加组件。",
"pending_invite": "等待中的邀请",
+ "per_license": "每个许可证",
"per_month": "每个月",
+ "per_month_billed_annually": "每月,每年计费",
+ "per_user_month": "每用户/月",
"per_user_year": "每个用户 / 每年",
"per_year": "每年",
"percent_is_the_percentage_of_the_line_width": "% 是行宽的百分比",
+ "permanently_disables_the_preview": "永久禁用预览",
"personal": "个人",
+ "personal_library": "个人库",
"personalized_onboarding": "个性化入门",
+ "pick_up_where_you_left_off": "从上次中断的地方继续",
"pl": "波兰语",
"plan": "计划",
"plan_tooltip": "你在__plan__计划中。点击了解如何充分利用您的 Overleaf 高级功能。",
@@ -1343,13 +1584,15 @@
"plans_amper_pricing": "套餐 & 价格",
"plans_and_pricing": "计划与定价",
"plans_and_pricing_lowercase": "套餐 & 价格",
- "please_ask_the_project_owner_to_upgrade_more_editors": "请要求项目所有者升级他们的计划,以允许更多的编辑者。",
+ "please_ask_the_project_owner_to_upgrade_more_collaborators": "请要求项目所有者升级他们的计划以允许更多的合作者。",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "请要求项目所有者升级以使用历史查询功能。",
"please_change_primary_to_remove": "请更改您的主要电子邮件以删除它",
"please_check_your_inbox_to_confirm": "请检查您的电子邮件收件箱以确认您属于<0>__institutionName__0> 。",
"please_compile_pdf_before_download": "请在下载PDF之前编译您的项目",
"please_compile_pdf_before_word_count": "请您在统计字数之前先编译您的的项目",
"please_confirm_email": "请点击电子邮件中的链接确认您的电子邮件地址 __emailAddress__ ",
+ "please_confirm_primary_email": "请点击确认电子邮件中的链接来确认您的主电子邮件地址__emailAddress__。",
+ "please_confirm_secondary_email": "请点击确认电子邮件中的链接来确认您的辅助电子邮件地址__emailAddress__。",
"please_confirm_your_email_before_making_it_default": "请先确认您的电子邮件,然后再将其作为主要邮件。",
"please_contact_support_to_makes_change_to_your_plan": "请<0>联系支持0>以更改您的计划",
"please_contact_us_if_you_think_this_is_in_error": "如果您认为此信息有误,请<0>联系我们0>。",
@@ -1359,6 +1602,7 @@
"please_link_before_making_primary": "请确认您的电子邮件链接到您的机构帐户,然后再将其作为主要电子邮件。",
"please_provide_a_message": "请提供消息",
"please_provide_a_subject": "请提供主题",
+ "please_provide_a_valid_email_address": "请提供有效的电子邮件地址",
"please_reconfirm_institutional_email": "请花点时间确认您的机构电子邮件地址,或<0>将其从您的帐户中删除0>。",
"please_reconfirm_your_affiliation_before_making_this_primary": "请确认您的从属关系,然后再将此作为主要。",
"please_refresh": "请刷新页面以继续",
@@ -1372,10 +1616,12 @@
"please_wait": "请稍后",
"plus_additional_collaborators_document_history_track_changes_and_more": "(以及更多协作者、文档历史记录、跟踪更改等付费功能)。",
"plus_more": "加上更多",
+ "plus_x_additional_licenses_for_a_total_of_y_licenses": "另加 <0>__additionalLicenses__0> 个附加许可证,总计 <1>__count__ 个许可证1>",
"popular_tags": "热门标签",
"portal_add_affiliation_to_join": "您似乎已经登录到 __appName__!如果你有一封 __portalTitle__ 邮件,现在就可以添加了。",
"position": "职位",
"postal_code": "邮政编码",
+ "premium": "Premium",
"premium_feature": "Premium 功能",
"premium_features": "高级功能",
"premium_plan_label": "您正在使用 Overleaf Premium ",
@@ -1409,17 +1655,24 @@
"progress_bar_percentage": "进度条从 0 到 100%",
"project": "项目",
"project_approaching_file_limit": "此项目已接近文件限制",
+ "project_failed_to_compile": "您的项目编译失败",
"project_figure_modal": "项目",
+ "project_files": "项目文件",
"project_flagged_too_many_compiles": "因频繁编译,项目被标旗。编译上限会稍后解除。",
"project_has_too_many_files": "此项目已达到 2000 个文件限制",
"project_last_published_at": "您的项目最近一次被发布在",
"project_layout_sharing_submission": "项目布局、分享和提交",
+ "project_linked_to": "该项目链接到",
"project_name": "项目名称",
"project_not_linked_to_github": "该项目未与GitHub任一存储库关联。您可以在GitHub中为该项目创建一个存储库:",
"project_ownership_transfer_confirmation_1": "是否确定要将 <0>__user__0> 设为 <1>__project__1> 的所有者?",
"project_ownership_transfer_confirmation_2": "此操作无法撤消。新所有者将收到通知,并可以更改项目访问权限设置(包括删除您自己的访问权限)。",
"project_renamed_or_deleted": "项目已重命名或删除",
"project_renamed_or_deleted_detail": "该项目已被外部数据源(例如 Dropbox)重命名或删除。 我们不想删除您在 Overleaf 上的数据,因此该项目仍然包含您的历史记录和合作者。 如果项目已重命名,请在项目列表中查找新名称下的新项目。",
+ "project_search_file_count": "在 __count__ 个文件",
+ "project_search_file_count_plural": "在 __count__ 个文件",
+ "project_search_result_count": "__count__ 个结果",
+ "project_search_result_count_plural": "__count__ 个结果",
"project_synchronisation": "项目同步",
"project_timed_out_enable_stop_on_first_error": "<0>启用“出现第一个错误时停止”0>可帮助您立即查找并修复错误。",
"project_timed_out_fatal_error": "<0>致命编译错误0>可能会彻底阻止编译。",
@@ -1442,6 +1695,7 @@
"publishing": "正在发表",
"pull_github_changes_into_sharelatex": "将GitHub中的更改调入 __appName__",
"push_sharelatex_changes_to_github": "将 __appName__ 中的更改推送到GitHub",
+ "push_to_github_pull_to_overleaf": "推送到 GitHub,拉取到 __appName__",
"quoted_text": "引用文本",
"raw_logs": "原始日志",
"raw_logs_description": "来自 LaTeX 编译器的原始日志",
@@ -1451,6 +1705,7 @@
"read_lines_from_path": "从 __path__ 读取行",
"read_more": "阅读更多",
"read_more_about_free_compile_timeouts_servers": "阅读有关免费计划编译超时和服务器更改的更多信息",
+ "read_only_dropbox_sync_message": "作为只读查看者,您可以将当前项目版本同步到 Dropbox,但在 Dropbox 中所做的更改<0>不会0>同步回 Overleaf。",
"read_only_token": "只读令牌",
"read_write_token": "可读写令牌",
"ready_to_join_x": "您已加入 __inviterName__",
@@ -1464,11 +1719,14 @@
"recaptcha_conditions": "本网站受reCAPTCHA保护,谷歌<1>隐私政策1>和<2>服务条款2>适用。",
"recent": "最近的",
"recent_commits_in_github": "GitHub中最近的提交",
+ "recommended": "推荐的",
"recompile": "重新编译",
"recompile_from_scratch": "从头开始重新编译",
"recompile_pdf": "重新编译该PDF",
"reconfirm": "再次确认",
+ "reconfirm_account": "重新确认账户",
"reconfirm_explained": "我们需要再次确认你的帐户。请通过以下表格申请密码重置链接,以重新确认您的帐户。如果您在重新确认您的帐户时有任何问题,请联系我们",
+ "reconfirm_secondary_email": "为了增强您的__appName__账户的安全性,请重新确认您的辅助电子邮件地址__emailAddress__。",
"reconnect": "重试",
"reconnecting": "正在重新连接",
"reconnecting_in_x_secs": "__seconds__ 秒后重新连接",
@@ -1477,12 +1735,18 @@
"redirect_to_editor": "重定向到编辑器",
"redirect_url": "重定向 URL",
"redirecting": "重定向中",
+ "redo": "撤销",
"reduce_costs_group_licenses": "您可以通过我们的团体优惠许可证减少工作并降低成本。",
"reference_error_relink_hint": "如果仍出现此错误,请尝试在此重新关联您的账户:",
+ "reference_manager_searched_groups": "__provider__ 搜索组",
"reference_managers": "引文管理",
"reference_search": "高级搜索",
"reference_search_info_new": "轻松查找您的参考文献——按作者、标题、年份或期刊搜索。",
+ "reference_search_setting": "引文搜索",
+ "reference_search_settings": "引文搜索设置",
+ "reference_search_style": "参考搜索样式",
"reference_sync": "同步参考文献",
+ "references_from_these_libraries_will_be_included_in_your_reference_search_results": "这些库的参考文献将包含在您的参考文献搜索结果中。",
"refresh": "刷新",
"refresh_page_after_linking_dropbox": "请在将您的帐户链接到Dropbox后刷新此页。",
"refresh_page_after_starting_free_trial": "请在您开始免费试用之后刷新此页面",
@@ -1499,6 +1763,7 @@
"registration_error": "注册错误",
"reject": "不要",
"reject_change": "拒绝修改",
+ "reject_selected_changes": "拒绝选定的更改",
"related_tags": "相关标签",
"relink_your_account": "重新链接您的帐户",
"reload_editor": "重新加载编辑器",
@@ -1506,6 +1771,7 @@
"remote_service_error": "远程服务产生错误",
"remove": "删除",
"remove_access": "移除权限",
+ "remove_email_address": "移除邮件地址",
"remove_from_group": "从群组中移除",
"remove_link": "移除链接",
"remove_manager": "删除管理者",
@@ -1519,7 +1785,10 @@
"rename": "重命名",
"rename_project": "重命名项目",
"renaming": "重命名中",
+ "renews_on": "续订日期:<0>__date__0>",
"reopen": "重新打开",
+ "reopen_comment_error_message": "重新打开您的评论时出错。请稍后重试。",
+ "reopen_comment_error_title": "重新打开评论错误",
"replace_figure": "替换图片",
"replace_from_another_project": "从另一个项目替换",
"replace_from_computer": "从本地计算机替换",
@@ -1527,6 +1796,7 @@
"replace_from_url": "从 URL 替换",
"reply": "回复",
"repository_name": "存储库名称",
+ "repository_visibility": "仓库可见性",
"republish": "重新发布",
"request_new_password_reset_email": "请求发送重置密码电子邮件",
"request_overleaf_common": "请求 Overleaf Commons",
@@ -1547,11 +1817,15 @@
"resending_confirmation_email": "重新发送确认电子邮件",
"reset_password": "重置密码",
"reset_password_link": "单击此链接重置您的密码",
+ "reset_password_sentence_case": "重设密码",
"reset_your_password": "重置您的密码",
"resize": "调整大小",
"resolve": "解决",
"resolve_comment": "解决评论",
+ "resolve_comment_error_message": "解析评论时出错。请稍后重试。",
+ "resolve_comment_error_title": "解决评论错误",
"resolved_comments": "已折叠的评论",
+ "resources": "资源",
"restore": "恢复",
"restore_file": "恢复文件",
"restore_file_confirmation_message": "您当前的文件将恢复到 __date__ __time__ 的版本。",
@@ -1572,7 +1846,11 @@
"reverse_x_sort_order": "反向__x__排序顺序",
"revert_pending_plan_change": "撤销计划的套餐更改",
"review": "审阅",
+ "review_panel": "审阅面板",
"review_your_peers_work": "同行评议",
+ "reviewer": "审阅者",
+ "reviewer_dropbox_sync_message": "作为审阅者,您可以将当前项目版本同步到 Dropbox,但在 Dropbox 中所做的更改<0>不会0>同步回 Overleaf。",
+ "reviewing": "审阅",
"revoke": "撤回",
"revoke_invite": "撤销邀请",
"right": "右对齐",
@@ -1582,6 +1860,7 @@
"saml": "SAML",
"saml_auth_error": "很抱歉,您的身份提供程序响应时出错。有关详细信息,请与管理员联系。",
"saml_authentication_required_error": "其他登录方法已被您的群组管理员禁用。 请使用您的群组 SSO 登录。",
+ "saml_commons_unavailable": "机构单点登录 (SSO) 目前不可用。更多详情,请参阅 <0>__linkText__0>。",
"saml_create_admin_instructions": "输入邮箱,创建您的第一个__appName__管理员账户。这个账户对应您在SAML系统中的账户,请使用此账户登陆系统。",
"saml_email_not_recognized_error": "此电子邮件地址未设置为SSO。请检查并重试,或者与管理员联系。",
"saml_identity_exists_error": "很抱歉,您的身份提供商返回的身份已链接到另一个Overleaf帐户。有关详细信息,请与您的管理员联系。",
@@ -1592,17 +1871,21 @@
"saml_login_identity_not_found_error": "抱歉,我们无法找到为此身份提供商设置单点登录的 Overleaf 帐户。",
"saml_metadata": "Overleaf SAML 元数据",
"saml_missing_signature_error": "抱歉,从您的身份提供商收到的信息未签名(响应和断言签名都是必需的)。 请联系您的管理员以获取更多信息。",
+ "saml_missing_user_attribute": "您的单点登录身份验证出现问题。您的组织未提供完成身份验证所需的信息。请联系您的管理员获取更多信息。",
"saml_response": "SAML 响应:",
"save": "保存",
"save_20_percent": "节省 20%",
+ "save_20_percent_when_you_switch_to_annual": "切换到年度计划可节省 20%",
"save_or_cancel-cancel": "取消",
"save_or_cancel-or": "或者",
"save_or_cancel-save": "保存",
+ "save_x_or_more": "节省 __percentage__ 或更多",
"saving": "正在保存",
"saving_notification_with_seconds": "保存 __docname__... (剩余 __seconds__ 秒)",
"search": "搜索",
"search_all_project_files": "搜索所有的项目文件",
"search_bib_files": "按作者、标题、年份搜索",
+ "search_by_author_journal_title_and_more_link_to_zotero_mendeley_papers": "按作者、期刊、标题等进行搜索。链接到 Zotero、Mendeley 或 Papers,直接在您的项目中搜索和添加您图书馆中的参考文献。",
"search_by_citekey_author_year_title": "通过引文的关键词、作者、标题、年份搜索",
"search_command_find": "查找",
"search_command_replace": "替换",
@@ -1613,7 +1896,9 @@
"search_in_your_projects": "在您的项目中搜索",
"search_match_case": "区分大小写",
"search_next": "下一个",
+ "search_only_the_bib_files_in_your_project_only_by_citekeys": "仅通过 citekeys 搜索项目中的 .bib 文件。",
"search_previous": "上一个",
+ "search_project": "搜索项目",
"search_projects": "搜索项目",
"search_references": "搜索此项目中的.bib文件",
"search_regexp": "正则表达式",
@@ -1621,12 +1906,14 @@
"search_replace_all": "全部替换",
"search_replace_with": "以...替换",
"search_search_for": "搜索",
+ "search_this_file": "在当前文件搜索",
"search_whole_word": "完整词组",
"search_within_selection": "在选择范围内",
"searched_path_for_lines_containing": "在 __path__ 中搜索包含“__query__”的行",
"secondary_email_password_reset": "该电子邮件已注册为辅助电子邮件。请输入您帐户的主要电子邮件。",
"security": "安全性",
"see_changes_in_your_documents_live": "实时查看文档修改情况",
+ "see_suggestions_from_collaborators": "查看合作者的建议",
"select_a_column_or_a_merged_cell_to_align": "选择要对齐的列或合并的单元格",
"select_a_column_to_adjust_column_width": "选择一列来调整列宽",
"select_a_file": "选择一个文件",
@@ -1644,6 +1931,7 @@
"select_all_projects": "全选",
"select_an_output_file": "选择输出文件",
"select_an_output_file_figure_modal": "选择一个输出文件",
+ "select_bib_file": "选择 .bib 文件",
"select_cells_in_a_single_row_to_merge": "在一行中选择单元格合并",
"select_color": "选择颜色 __name__",
"select_folder_from_project": "从项目中选择文件夹",
@@ -1653,9 +1941,9 @@
"select_from_your_computer": "从您的电脑文件中选择",
"select_github_repository": "选取要导入 __appName__ 的GitHub存储库",
"select_image_from_project_files": "从项目文件中选择图片",
- "select_monthly_plans": "选择用于月计划",
"select_project": "选择 __project__",
"select_projects": "选择项目",
+ "select_size": "选择大小",
"select_tag": "选择标签__tagName__",
"select_user": "选择用户",
"selected": "选择的",
@@ -1664,6 +1952,7 @@
"send": "发送",
"send_first_message": "向你的合作者发送第一条信息",
"send_message": "发送消息",
+ "send_request": "发送请求",
"send_test_email": "发送测试邮件",
"sending": "发送中",
"sent": "发送",
@@ -1692,13 +1981,17 @@
"shortcut_to_open_advanced_reference_search": "(__ctrlSpace__ 或 __altSpace__ )",
"show_all_projects": "显示全部项目",
"show_document_preamble": "显示文档导言部分",
+ "show_equation_preview": "显示公式预览",
+ "show_file_tree": "显示文件树",
"show_hotkeys": "显示快捷键",
"show_in_code": "在代码中显示",
"show_in_pdf": "在 PDF 中显示",
"show_less": "折叠",
+ "show_live_equation_previews_while_typing": "键入时显示实时公式预览",
"show_local_file_contents": "显示本地文件内容",
"show_more": "显示更多",
"show_outline": "显示文件大纲",
+ "show_version_history": "显示版本历史记录",
"show_x_more_projects": "再显示 __x__ 个项目",
"showing_1_result": "显示 1 个结果",
"showing_1_result_of_total": "显示 1 个结果(共计 __total__ )",
@@ -1707,6 +2000,8 @@
"showing_x_results_of_total": "显示 __x__ 个结果(共计__total__ )",
"sign_up": "注册",
"sign_up_for_free": "免费注册",
+ "sign_up_for_free_account": "注册免费帐户并接收定期更新",
+ "simple_search_mode": "简易搜索",
"single_sign_on_sso": "单点登录 (SSO)",
"site_description": "一个简洁的在线 LaTeX 编辑器。无需安装,实时共享,版本控制,数百免费模板……",
"site_wide_option_available": "提供站点范围的选项",
@@ -1727,7 +2022,10 @@
"sorry_it_looks_like_that_didnt_work_this_time": "抱歉!这次似乎没有成功。请重试。",
"sorry_something_went_wrong_opening_the_document_please_try_again": "很抱歉,尝试在Overleaf打开此内容时发生意外错误。请再试一次。",
"sorry_there_are_no_experiments": "抱歉,Overleaf Labs 目前没有正在进行任何实验。",
+ "sorry_there_was_an_issue_adding_x_users_to_your_subscription": "抱歉,向您的订阅添加__count__个用户时出现问题。请<0>联系我们的支持团队0>寻求帮助。",
+ "sorry_there_was_an_issue_upgrading_your_subscription": "抱歉,升级您的订阅时出现问题。请<0>联系我们的支持团队0>寻求帮助。",
"sorry_this_account_has_been_suspended": "抱歉,该账户已被暂停。",
+ "sorry_you_can_only_change_to_group_from_trial_via_support": "抱歉,您只能通过联系支持人员在免费试用期间更改为团体计划。",
"sorry_your_table_cant_be_displayed_at_the_moment": "抱歉,您的表格暂时无法显示。",
"sorry_your_token_expired": "抱歉,您的令牌已过期",
"sort_by": "排序方式",
@@ -1735,6 +2033,9 @@
"sort_projects": "排序项目",
"source": "源代码",
"spell_check": "拼写检查",
+ "spellcheck": "拼写检查",
+ "spellcheck_language": "拼写检查语言",
+ "split_view": "拆分视图",
"sso": "单点登录(SSO)",
"sso_account_already_linked": "帐户已链接到另一个__appName__用户",
"sso_active": "SSO 激活",
@@ -1792,6 +2093,7 @@
"start_by_fixing_the_first_error_in_your_doc": "首先修复文档中的第一个错误,以避免以后出现问题。",
"start_free_trial": "开始免费试用",
"start_free_trial_without_exclamation": "开始免费试用",
+ "start_the_conversation_by_saying_hello_or_sharing_an_update": "通过打招呼或分享最新动态来开始对话",
"start_typing_find_your_company": " 开始键入以查找您的公司",
"start_typing_find_your_organization": "开始键入以查找您的组织",
"start_typing_find_your_university": "开始键入以查找您的大学",
@@ -1807,6 +2109,7 @@
"stretch_width_to_text": "拉伸宽度适应文本",
"student": "学生",
"student_disclaimer": "教育折扣适用于中学和高等教育机构(学校和大学)的所有学生。 我们可能会与您联系以确认您是否有资格享受折扣。",
+ "student_verification_required": "需要学生验证",
"students": "学生",
"subject": "主题",
"subject_area": "主题区",
@@ -1815,6 +2118,7 @@
"submit_title": "提交",
"subscribe": "提交",
"subscribe_to_find_the_symbols_you_need_faster": "订阅以更快地找到您需要的符号",
+ "subscribe_to_plan": "订阅 __planName__",
"subscription": "订购",
"subscription_admins_cannot_be_deleted": "订阅时不能删除您的帐户。请取消订阅并重试。如果您一直看到此消息,请与我们联系。",
"subscription_canceled": "订阅已取消",
@@ -1828,14 +2132,23 @@
"suggested_fix_for_error_in_path": "针对 __path__ 中的错误建议修复",
"suggestion": "建议",
"suggestion_applied": "应用建议的修改",
+ "suggests_code_completions_while_typing": "键入时开启自动补全建议",
"support": "支持",
+ "support_for_your_browser_is_ending_soon": "对您的浏览器的支持即将结束",
+ "supports_up_to_x_licenses": "最多支持 <0>__count__ 个许可证0>",
"sure_you_want_to_cancel_plan_change": "是否确实要撤销计划的套餐更改?您将继续订阅<0>__planName__0>。",
"sure_you_want_to_change_plan": "您确定想要改变套餐为 <0>__planName__0>?",
"sure_you_want_to_delete": "您确定要永久删除以下文件吗?",
"sure_you_want_to_leave_group": "您确定要退出该群吗?",
"sv": "瑞典语",
+ "switch_back_to_monthly_pay_20_more": "切换回按月付费(增加 20%)",
+ "switch_compile_mode_for_faster_draft_compilation": "切换编译模式以加快草稿编译速度",
"switch_to_editor": "切换到编辑器",
+ "switch_to_new_editor": "切换到新编辑器",
+ "switch_to_old_editor": "切换到旧编辑器",
"switch_to_pdf": "切换到 PDF",
+ "switch_to_standard_plan": "切换到标准订阅计划",
+ "symbol": "符号",
"symbol_palette": "数学符号面板",
"symbol_palette_highlighted": "<0>符号0> 面板",
"symbol_palette_info_new": "单击按钮即可将数学符号插入到您的文档中。",
@@ -1848,6 +2161,8 @@
"syntax_validation": "代码检查",
"tab_connecting": "与编辑器连接中",
"tab_no_longer_connected": "该选项卡与编辑器已断开连接",
+ "table": "表格",
+ "table_generator": "表格生成器",
"tag_color": "标签颜色",
"tag_name_cannot_exceed_characters": "标签名称不能超过 __maxLength__ 个字符",
"tag_name_is_already_used": "标签“__tagName__”已存在",
@@ -1867,7 +2182,9 @@
"template_top_pick_by_overleaf": "该模板是由 Overleaf 工作人员精心挑选的高质量模版",
"templates": "模板",
"templates_admin_source_project": "管理员:源项目",
+ "templates_lowercase": "模版",
"templates_page_title": "模板 - 期刊、简历、演示文稿、报告等",
+ "temporarily_hides_the_preview": "暂时隐藏预览",
"ten_collaborators_per_project": "每个项目 10 位协作者",
"ten_per_project": "每个项目 10 个",
"terminated": "编译取消",
@@ -1882,17 +2199,28 @@
"thank_you_for_being_part_of_our_beta_program": "感谢您参与我们的测试版计划,您可以<0>尽早使用新功能0>并帮助我们更好地了解您的需求",
"thank_you_for_your_feedback": "感谢您的反馈意见!",
"thanks": "谢谢",
+ "thanks_for_being_part_of_this_labs_experiment_your_feedback_will_help_us_make_the_new_editor_the_best_yet": "感谢您参与本次实验室实验。您的反馈和支持将帮助我们打造迄今为止最出色的新编辑器!部分功能仍在开发中,如果您需要某些尚未使用的功能,可以切换回旧版编辑器。",
"thanks_for_confirming_your_email_address": "感谢您确认邮件地址",
"thanks_for_getting_in_touch": "感谢您联系我们。我们的团队将尽快通过电子邮件回复您。",
"thanks_for_subscribing": "感谢订购!",
+ "thanks_for_subscribing_to_plan_with_add_on": "感谢您订阅带有 __addOnName__ 插件的 __planName__ 方案。正是像您这样的用户的支持,才使得 __appName__ 得以不断发展和完善。",
+ "thanks_for_subscribing_to_the_add_on": "感谢您订阅 __addOnName__ 插件。正是像您这样的用户的支持,才使得 __appName__ 得以不断发展和完善。",
"thanks_for_subscribing_you_help_sl": "感谢您订阅 __planName__ 计划。 正是像您这样的人的支持才使得 __appName__ 能够继续成长和改进。",
"thanks_settings_updated": "谢谢,您的设置已更新",
+ "the_add_on_will_remain_active_until": "该插件将一直有效,直到当前计费期结束。",
+ "the_code_editor_color_scheme": "代码编辑器配色方案",
+ "the_document_environment_contains_no_content": "文档环境未包含任何内容。如果为空,请添加一些内容并重新编译。",
"the_file_supplied_is_of_an_unsupported_type ": "在Overleaf打开此内容的链接指向错误的文件类型。有效的文件类型是.tex文档和.zip文件。如果某个网站的链接经常出现这种情况,请向他们报告。",
"the_following_files_already_exist_in_this_project": "该项目中已存在以下文件:",
"the_following_files_and_folders_already_exist_in_this_project": "此项目中已存在以下文件和文件夹:",
"the_following_folder_already_exists_in_this_project": "该项目中已存在以下文件夹:",
"the_following_folder_already_exists_in_this_project_plural": "该项目中已存在以下文件夹:",
+ "the_latex_engine_used_for_compiling": "用于编译的 LaTeX 引擎",
+ "the_new_overleaf_editor": "新的 Overleaf 编辑器",
+ "the_next_payment_will_be_collected_on": "下一笔付款将于__date__ 收取。",
"the_original_text_has_changed": "原文本已发生改变,因此此建议无法应用",
+ "the_overleaf_color_scheme": "__appName__ 配色方案",
+ "the_primary_file_for_compiling_your_project": "编译项目的主要文件",
"the_project_that_contains_this_file_is_not_shared_with_you": "包含此文件的项目未与您共享",
"the_requested_conversion_job_was_not_found": "在Overleaf打开此内容的链接指定了找不到的转换作业。作业可能已过期,需要重新运行。如果某个网站的链接经常出现这种情况,请向他们报告。",
"the_requested_publisher_was_not_found": "在Overleaf打开此内容的链接指定了找不到的发布者。如果某个网站的链接经常出现这种情况,请向他们报告。",
@@ -1900,12 +2228,14 @@
"the_supplied_parameters_were_invalid": "在Overleaf打开此内容的链接包含一些无效参数。如果某个网站的链接经常出现这种情况,请向他们报告。",
"the_supplied_uri_is_invalid": "在Overleaf打开此内容的链接包含无效的URI。如果某个网站的链接经常出现这种情况,请向他们报告。",
"the_target_folder_could_not_be_found": "找不到目标文件夹。",
+ "the_version_of_tex_live_used_for_compiling": "用于编译的 TeX Live 版本",
"the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "您在此处选择的宽度基于文档中文本的宽度。 或者,您可以直接在 LaTeX 代码中自定义图像大小。",
"their_projects_will_be_transferred_to_another_user": "他们的项目将全部转移给您选择的另一个用户",
"theme": "主题",
"then_x_price_per_month": "接着每月__price__",
"then_x_price_per_year": "接着每年__price__",
"there_are_lots_of_options_to_edit_and_customize_your_figures": "有很多选项可用于编辑和自定义图形,例如在图形周围环绕文本、旋转图像或在单个图形中包含多个图像。 您需要编辑 LaTeX 代码才能执行此操作。 <0>了解具体方法0>",
+ "there_is_an_unrecoverable_latex_error": "存在无法恢复的 LaTeX 错误。如果下方或原始日志中显示 LaTeX 错误,请尝试修复它们并重新编译。",
"there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "恢复项目时出现问题。请稍后重试。如果问题仍然存在,请联系我们。",
"there_was_an_error_opening_your_content": "创建项目时出错",
"thesis": "论文",
@@ -1917,18 +2247,25 @@
"this_experiment_isnt_accepting_new_participants": "此实验不接受新参与者。",
"this_field_is_required": "此字段必填",
"this_grants_access_to_features_2": "这将授予您访问 <0>__appName__0> <0>__featureType__0> 功能的权限。",
+ "this_is_a_labs_experiment_for_the_new_overleaf_editor_some_features_are_still_in_progress": "这是新版 Overleaf 编辑器的实验室实验。部分功能仍在开发中,如果您需要某些尚未使用的功能,可以随时切换回旧版编辑器。",
+ "this_is_a_new_feature": "这是一个新功能",
+ "this_is_the_file_that_references_pulled_from_your_reference_manager_will_be_added_to": "这是将从参考文献管理器中提取的参考文献添加到的文件。",
"this_is_your_template": "这是从你的项目提取的模版",
- "this_project_already_has_maximum_editors": "此项目的编辑者人数已达到所有者方案允许的最大数量。这意味着您可以查看但无法编辑该项目。",
+ "this_project_already_has_maximum_collaborators": "此项目的协作者人数已达到所有者方案允许的上限。这意味着您可以查看该项目,但无法编辑或审核。",
+ "this_project_contains_a_file_called_output": "该项目包含一个名为 output.pdf 的文件。如果该文件存在,请重命名并重新编译。",
+ "this_project_exceeded_collaborator_limit": "此项目超出了您方案的协作者限制。所有其他用户现在都只有查看权限。",
"this_project_exceeded_compile_timeout_limit_on_free_plan": "该项目超出了我们免费计划的编译超时限制。",
- "this_project_exceeded_editor_limit": "此项目超出了您的方案的编辑者限制。所有协作者现在都只有查看权限。",
"this_project_has_more_than_max_collabs": "此项目的协作者数量超出了项目所有者的 Overleaf 计划允许的最大数量。这意味着您可能会失去 __linkSharingDate__ 的编辑权限。",
"this_project_is_public": "此项目是公共的,可以被任何人通过URL编辑",
"this_project_is_public_read_only": "该项目是公开的,任何人都可以通过该URL查看,但是不能编辑。",
+ "this_project_need_more_time_to_compile": "看起来这个项目可能需要比我们的免费计划允许的更多时间来编译。",
"this_project_will_appear_in_your_dropbox_folder_at": "此项目将显示在您的Dropbox的目录 ",
"this_tool_helps_you_insert_figures": "该工具可帮助您将图片插入项目中,而无需编写 LaTeX 代码。 以下信息详细介绍了该工具中的选项以及如何进一步自定义您的图片。",
"this_tool_helps_you_insert_simple_tables_into_your_project_without_writing_latex_code_give_feedback": "该工具可帮助您将简单的表格插入项目中,而无需编写 LaTeX 代码。 该工具是新工具,因此请<0>向我们提供反馈0>并留意即将推出的其他功能。",
+ "this_total_reflects_the_amount_due_until": "此总额反映从今天到__date__ (即您现有计划的结算期结束)为止的应付金额。",
"this_was_helpful": "很有帮助",
"this_wasnt_helpful": "没有帮助",
+ "this_will_remove_primary_email": "请注意,此操作还会从帐户中移除电子邮件地址 __email__ ,因为它是未经确认的电子邮件地址。如果您想保留它,请先确认。",
"three_free_collab": "3个免费的合作者",
"timedout": "超时",
"tip": "提示",
@@ -1939,14 +2276,18 @@
"to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account": "要确认电子邮件地址,您必须使用请求新的辅助电子邮件的 Overleaf 帐户 来登录 。",
"to_confirm_transfer_enter_email_address": "要接受邀请,请输入与您的帐户关联的电子邮件地址。",
"to_confirm_unlink_all_users_enter_email": "要确认您要取消所有用户的链接,请输入您的电子邮件地址:",
+ "to_continue_using_upgrade_or_change_your_browser": "要继续顺利使用 __appName__,您需要升级或更改为<0>受支持的浏览器0>。",
+ "to_delete_your_writefull_account": "要删除您的 Writefull 帐户,或检查您是否有帐户,请联系 __email__ 。",
"to_fix_this_you_can": "要解决此问题,您可以:",
"to_fix_this_you_can_ask_the_github_repository_owner": "要解决此问题,您可以要求 GitHub 存储库所有者 (<0>__repoOwnerEmail__0>) 续订其 __appName__ 订阅并重新连接项目。",
"to_insert_or_move_a_caption_make_sure_tabular_is_directly_within_table": "要插入或移动标题,请确保 \\begin{tabular} 直接位于table环境中",
"to_keep_edit_access": "要保留编辑权限,请要求项目所有者升级其计划或减少具有编辑权限的人数。",
"to_many_login_requests_2_mins": "您的账户尝试登录次数过多。请等待2分钟后再试",
"to_modify_your_subscription_go_to": "如需修改您的订阅,请到",
+ "to_pull_results_directly_from_your_reference_manager_enable_one_of_the_available_reference_manager_integrations": "要直接从参考文献管理器中提取结果,请<0>启用其中一个可用的参考文献管理器集成0>。",
"to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package": "<0>请注意:0> 要在表格中使用文本换行,请确保在文档序言中包含 <1>array1> 包:",
"toggle_compile_options_menu": "切换编译选项菜单",
+ "toggle_unknown_group": "切换未知组",
"token": "令牌",
"token_access_failure": "无法授予访问权限;联系项目负责人寻求帮助",
"token_limit_reached": "您已达到 10 个令牌的限制。 要生成新的身份验证令牌,请删除现有的身份验证令牌。",
@@ -1964,9 +2305,13 @@
"took_a_while": "这会花一段时间...",
"toolbar_bullet_list": "无序列表",
"toolbar_choose_section_heading_level": "选择章节标题级别",
+ "toolbar_code_visual_editor_switch": "代码和可视化编辑器切换",
"toolbar_decrease_indent": "减少缩进",
+ "toolbar_editor": "编辑器工具",
"toolbar_format_bold": "粗体格式",
"toolbar_format_italic": "斜体格式",
+ "toolbar_generate_math": "生成数学公式",
+ "toolbar_generate_table": "生成表格",
"toolbar_increase_indent": "增加缩进",
"toolbar_insert_citation": "插入引文",
"toolbar_insert_cross_reference": "插入交叉引用",
@@ -1975,7 +2320,11 @@
"toolbar_insert_inline_math": "插入行内数学公式",
"toolbar_insert_link": "插入链接",
"toolbar_insert_math": "插入数学公式",
+ "toolbar_insert_math_and_symbols": "插入数学公式或符号",
+ "toolbar_insert_math_lowercase": "插入数学公式",
+ "toolbar_insert_misc": "插入杂项(链接、引文、交叉引用、图表、表格)",
"toolbar_insert_table": "插入表格",
+ "toolbar_list_indentation": "列表和缩进",
"toolbar_numbered_list": "有序列表",
"toolbar_redo": "重做",
"toolbar_selected_projects": "选择的项目",
@@ -1984,17 +2333,25 @@
"toolbar_selected_projects_restore": "恢复选定的项目",
"toolbar_table_insert_size_table": "插入 __size__ 表格",
"toolbar_table_insert_table_lowercase": "插入表格",
+ "toolbar_text_formatting": "文本格式",
+ "toolbar_text_style": "文本样式",
"toolbar_toggle_symbol_palette": "数学符号面板开关",
"toolbar_undo": "撤销",
+ "toolbar_undo_redo_actions": "撤消/重做操作",
+ "tools": "工具",
"tooltip_hide_filetree": "单击以隐藏文件树",
+ "tooltip_hide_panel": "单击隐藏面板",
"tooltip_hide_pdf": "单击隐藏PDF",
"tooltip_show_filetree": "单击以显示文件树",
+ "tooltip_show_panel": "单击显示面板",
"tooltip_show_pdf": "单击显示PDF",
"top_pick": "首选",
"total": "总计",
+ "total_due_today": "今日应付总额",
"total_per_month": "每月总计",
"total_per_year": "每年合计",
"total_per_year_lowercase": "每年合计",
+ "total_today": "今日总计",
"total_with_subtotal_and_tax": "总计:每年 <0> __total__ 0>(__subtotal__ + __tax__税)",
"total_words": "总字数",
"tr": "土耳其语",
@@ -2011,6 +2368,7 @@
"transfer_management_resolve_following_issues": "如需转移账户管理权,您需要解决以下问题:",
"transfer_this_users_projects": "转移该用户的项目",
"transfer_this_users_projects_description": "该用户的项目将转移给新所有者。",
+ "transferring": "正在转移中",
"trash": "回收站",
"trash_projects": "已删除项目",
"trashed": "被删除",
@@ -2025,9 +2383,11 @@
"try_for_free": "免费试用",
"try_it_for_free": "免费体验",
"try_now": "立刻尝试",
+ "try_papers_for_free": "免费尝试 Papers",
"try_premium_for_free": "免费试用 Premium",
"try_recompile_project_or_troubleshoot": "请尝试从头开始重新编译项目,如果仍然无效,请按照我们的<0>问题排查指南0>进行操作。",
"try_relinking_provider": "您似乎需要重新链接您的 __provider__ 帐户。",
+ "try_the_new_editor": "尝试新的编辑器",
"try_to_compile_despite_errors": "忽略错误编译",
"turn_off": "关闭",
"turn_off_link_sharing": "关闭通过链接分享功能。",
@@ -2043,14 +2403,17 @@
"undelete": "恢复删除",
"undeleting": "取消删除",
"understanding_labels": "了解标签",
+ "undo": "撤消",
"unfold_line": "展开线",
"unique_identifier_attribute": "唯一标识符属性",
"university": "大学",
"university_school": "大学或学校名称",
"unknown": "未知",
+ "unknown_group": "未知组",
"unlimited": "无限制",
"unlimited_collaborators_per_project": "每个项目的合作者数量不受限制",
"unlimited_collabs": "无限制的合作者数",
+ "unlimited_document_history": "无限文档历史记录",
"unlimited_projects": "项目无限制",
"unlink": "取消关联",
"unlink_all_users": "取消所有用户的链接",
@@ -2071,11 +2434,13 @@
"unlink_warning_reference": "警告:如果将账户与此提供者取消关联,您将无法把参考文献导入到项目中。",
"unlinking": "取消链接",
"unmerge_cells": "取消合并单元格",
+ "unpause_subscription": "取消暂停订阅",
"unpublish": "未出版",
"unpublishing": "取消发布",
"unsubscribe": "取消订阅",
"unsubscribed": "订阅被取消",
"unsubscribing": "正在取消订阅",
+ "until_then_you_can_still": "在此之前,您仍然可以:",
"untrash": "恢复",
"update": "更新",
"update_account_info": "更新账户信息",
@@ -2087,10 +2452,16 @@
"upgrade": "升级",
"upgrade_cc_btn": "现在升级,7天后付款",
"upgrade_for_12x_more_compile_time": "升级以获得 12 倍以上的编译时间",
+ "upgrade_my_plan": "升级我的计划",
"upgrade_now": "现在升级",
- "upgrade_to_add_more_editors_and_access_collaboration_features": "升级以添加更多编辑器并访问协作功能,如跟踪更改和完整的项目历史记录。",
+ "upgrade_plan": "升级计划",
+ "upgrade_summary": "升级摘要",
+ "upgrade_to_add_more_collaborators_and_access_collaboration_features": "升级以添加更多合作者并访问协作功能,如跟踪更改和完整的项目历史记录。",
"upgrade_to_get_feature": "升级以获得__feature__,以及:",
+ "upgrade_to_review": "升级以获取评论",
"upgrade_to_track_changes": "升级以记录文档修改历史",
+ "upgrade_to_unlock_more_time": "立即升级即可在我们最快的服务器上解锁 12 倍以上的编译时间。",
+ "upgrade_your_subscription": "升级您的订阅",
"upload": "上传",
"upload_failed": "上传失败",
"upload_file": "上传文件",
@@ -2098,6 +2469,9 @@
"upload_project": "上传项目",
"upload_zipped_project": "上传项目的压缩包",
"url_to_fetch_the_file_from": "获取文件的URL",
+ "us_gov_banner_fedramp": "<0>现已获得 FedRAMP® 授权,可用于 LI-SaaS:0>Overleaf 的 Group Professional 订阅。需要隔离部署吗?我们也提供本地部署解决方案。请联系我们的美国联邦政府团队。",
+ "us_gov_banner_government_purchasing": "<0>获取适用于美国联邦政府的 __appName__。0>使用我们量身定制的采购方案,加快采购流程。请联系我们的政府团队。",
+ "us_gov_banner_small_business_reseller": "<0>轻松为美国联邦政府采购。0>我们与小型企业经销商合作,帮助您购买 Overleaf 组织方案。请联系我们的政府团队。",
"usage_metrics": "使用指标",
"use_a_different_password": "请使用不同的密码",
"use_saml_metadata_to_configure_sso_with_idp": "使用 Overleaf SAML 元数据通过您的身份提供商配置 SSO。",
@@ -2122,13 +2496,19 @@
"using_latex": "使用 LaTeX",
"using_premium_features": "使用高级功能",
"using_the_overleaf_editor": "使用 __appName__ 编辑器",
+ "using_writefull": "使用 Writefull",
"valid": "有效的",
"valid_sso_configuration": "有效的 SSO 配置",
"validation_issue_entry_description": "阻止此项目编译的验证问题",
+ "value_must_be_a_number": "值必须是数字",
+ "value_must_be_a_whole_number": "值必须是整数",
+ "value_must_be_at_least_x": "值必须至少为 __value__",
"vat": "增值税",
"vat_number": "增值税号",
"verify_email_address_before_enabling_managed_users": "在启用托管用户之前,您需要验证您的电子邮件地址。",
+ "view": "查看",
"view_all": "预览所有",
+ "view_billing_details": "查看账单详情",
"view_code": "查看代码",
"view_configuration": "查看配置",
"view_group_members": "查看群组成员",
@@ -2136,6 +2516,7 @@
"view_hub_subtext": "访问和下载订阅统计数据和用户列表",
"view_in_template_gallery": "在模板库查看",
"view_invitation": "查看邀请",
+ "view_invoices": "查看发票",
"view_labs_experiments": "查看实验性的内容",
"view_less": "查看更少",
"view_logs": "查看日志",
@@ -2145,29 +2526,39 @@
"view_more": "查看更多",
"view_only_access": "只读访问",
"view_only_downgraded": "仅可查看。升级可恢复编辑权限。",
+ "view_only_reviewer_downgraded": "仅限查看。升级即可恢复查看权限。",
"view_options": "查看选项",
"view_pdf": "查看 PDF",
"view_source": "查看源代码",
"view_your_invoices": "查看您的账单",
- "viewer": "查看者",
+ "viewer": "查看者(只读)",
+ "viewing": "视图",
"viewing_x": "正在查看<0>__endTime__0>",
"visual_editor": "可视化编辑器",
"visual_editor_is_only_available_for_tex_files": "可视化编辑器仅适用于 TeX 文件",
"want_access_to_overleaf_premium_features_through_your_university": "想要通过您的大学访问__appName__高级功能吗?",
"want_change_to_apply_before_plan_end": "如果您希望在当前计费周期结束前应用此更改,请与我们联系。",
+ "we_are_unable_to_generate_the_pdf_at_this_time": "我们目前无法生成 pdf。",
"we_are_unable_to_opt_you_into_this_experiment": "目前我们无法让您加入此实验,请确保您的组织已允许此功能,或稍后重试。",
"we_cant_confirm_this_email": "我们无法确认此电子邮件",
"we_cant_find_any_sections_or_subsections_in_this_file": "在此文件中找不到任何 sections 或 subsections",
"we_do_not_share_personal_information": "有关我们如何处理您的个人数据的详细信息,请参阅我们的<0>隐私声明0>",
+ "we_got_your_request": "我们已经收到了你的请求",
"we_logged_you_in": "我们已为您登录。",
"we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>我们也可能联系您0> 通过电子邮件进行调查,或询问您是否愿意参与其他用户研究计划",
+ "we_sent_code": "我们已向您发送确认码",
"we_sent_new_code": "我们发送了一个新代码。如果您没有收到,请检查您的垃圾邮件和任何促销邮件等。",
+ "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "我们将根据您当前订阅的剩余月份向您收取额外许可证的费用。",
+ "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "我们将根据您当前订阅的剩余月份向您收取新订阅的费用。",
+ "we_will_use_your_existing_payment_method": "我们将使用您现有的付款方式__paymentMethod__ 。",
"webinars": "在线教程",
"website_status": "网站状态",
"wed_love_you_to_stay": "我们希望你留下来",
"welcome_to_sl": "欢迎使用 __appName__",
+ "well_be_here_when_youre_ready": "当您准备好回归时,我们还在这里!🦆",
"were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "我们正在<0>对项目共享进行一些更改0>。这意味着,作为具有编辑权限的人,项目所有者和其他编辑者将可以看到您的姓名和电子邮件地址。",
"were_performing_maintenance": "我们正在对Overleaf进行维护,您需要等待片刻。很抱歉给您带来不便。编辑器将在 __seconds__ 秒后自动刷新。",
+ "were_redesigning_our_editor_to_make_it_easier_to_use": "我们正在重新设计我们的编辑器,使其更易于使用,并确保其面向未来。请试用并向我们提供反馈,帮助我们改进。(部分功能仍在开发中,您可以随时切换回之前的版本。)",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "我们最近<0>降低了免费计划的编译超时限制0>,这可能会影响这个项目。",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "我们最近<0>降低了免费计划的编译时限0>,这可能会影响这个项目。",
"what_do_you_need": "你需要什么?",
@@ -2176,22 +2567,30 @@
"what_does_this_mean_for_you": "这意味着:",
"what_happens_when_sso_is_enabled": "开启单点登录后会发生什么?",
"what_should_we_call_you": "我们该怎么称呼你?",
+ "whats_new": "有什么新功能?",
+ "whats_next": "接下来?",
"when_you_join_labs": "加入实验室后,您可以选择要参与的实验。完成此操作后,您可以正常使用 Overleaf,但您会看到所有实验室功能都标有此徽章:",
"when_you_tick_the_include_caption_box": "当您勾选“包含标题”框时,图像将带有占位符标题插入到文档中。 要编辑它,您只需选择占位符文本并键入以将其替换为您自己的文本。",
"why_latex": "为何用 LaTeX?",
+ "why_might_this_happen": "为什么会这样?",
+ "why_not_pause_instead": "暂停一下,从上次中断的地方继续",
"wide": "宽松的",
"will_lose_edit_access_on_date": "将于 __date__ 失去编辑权限",
"will_need_to_log_out_from_and_in_with": "您需要从 __email1__ 帐户注销 ,然后使用 __email2__ 登录。",
"with_premium_subscription_you_also_get": "通过Overleaf Premium订阅,您还可以获得",
"word_count": "字数统计",
+ "work_in_vim_or_emacs_emulation_mode": "在 Vim 或 Emacs 模拟编辑器下工作",
"work_offline": "离线工作",
+ "work_offline_pull_to_overleaf": "离线工作,然后拉取到__appName__",
"work_or_university_sso": "工作/高校账户 单点登录",
"work_with_non_overleaf_users": "和非Overleaf用户一起工作",
+ "work_with_other_github_users": "与其他 GitHub 用户合作",
"writefull": "Writefull",
"writefull_loading_error_body": "尝试刷新页面,如果无效,尝试禁用所有的浏览器拓展,以便检查是否他们阻止了 Writefull 的加载。",
"writefull_loading_error_title": "Writefull 加载失败",
"x_changes_in": "__count__ 处变化在",
"x_changes_in_plural": "__count__ 处变化在",
+ "x_libraries_accessed_in_this_project": "本项目中访问的 __provider__ 库",
"x_price_for_first_month": "首月 <0>__price__0>",
"x_price_for_first_year": "首年 <0>__price__0>",
"x_price_for_y_months": "您前 __discountMonths__ 个月的费用:<0>__price__0>",
@@ -2211,26 +2610,38 @@
"you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "您是由 <1>__adminEmail__1> 管理的 <1>__groupName__1> 团队的、<0>__planName__0> 计划的 <1>管理员1>",
"you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you": "您是<1>您 (__adminEmail__1>) 管理的<0>__planName__0>团体订阅<1>__groupName__1>的<1>管理员1>。",
"you_are_currently_logged_in_as": "您当前以 __email__ 身份登录。",
+ "you_are_now_saving_20_percent": "您现在节省 20%",
"you_are_on_a_paid_plan_contact_support_to_find_out_more": "您使用的是 __appName__ 付费计划。 <0>联系支持人员0>以了解更多信息。",
"you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "您作为 <1>__institutionName__1> 的<1>确认成员1>加入了我们的<0>__planName__0>计划",
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "您作为<1>__groupName__1>群组订阅的<1>成员1>加入了我们的<0>__planName__0>计划,该群组订阅由<1>__adminEmail__1>管理",
"you_can_also_choose_to_view_anonymously_or_leave_the_project": "您还可以选择<0>匿名查看0>(您将失去编辑权限)或<1>离开项目1>。",
"you_can_buy_this_plan_but_not_as_a_trial": "您可以购买此计划,但不能试用,因为您最近已经完成试用。",
+ "you_can_leave_the_experiment_from_your_account_settings_at_any_time": "您可以随时从您的<0>帐户设置0>退出实验。",
+ "you_can_manage_your_reference_manager_integrations_from_your_account_settings_page": "您可以从<0>帐户设置页面0>管理参考文献管理集成。",
"you_can_now_enable_sso": "现在,您可以在“组设置”页面上启用SSO。",
"you_can_now_log_in_sso": "您现在可以通过您的机构登录,如果符合条件,您将获得<0>__appName__ 专业功能0>。",
+ "you_can_now_sync_your_papers_library_directly_with_your_overleaf_projects": "您现在可以将您的论文库直接与 Overleaf 项目同步",
"you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "您可以随时在此页面上<0>选择加入和退出0>该计划",
"you_can_request_a_maximum_of_limit_fixes_per_day": "您每天最多可以请求 __limit__ 个修复。请明天再试。",
- "you_can_select_or_invite": "您可以在当前计划中选择或邀请__count__位编辑者,或者升级以获得更多编辑者。",
- "you_can_select_or_invite_plural": "您可以在当前计划中选择或邀请__count__位编辑者,也可以升级以获得更多编辑者。",
+ "you_can_select_or_invite_collaborator": "您可以在当前套餐中选择或邀请__count__位协作者。升级即可添加更多编辑者或审阅者。",
+ "you_can_select_or_invite_collaborator_plural": "您可以在当前订阅中选择或邀请__count__位协作者。升级即可添加更多编辑者或审阅者。",
+ "you_can_still_use_your_premium_features": "在暂停生效之前,您仍然可以使用高级功能。",
"you_cant_add_or_change_password_due_to_sso": "您无法添加或更改密码,因为您的群组或组织使用<0>单点登录 (SSO)0>。",
"you_cant_join_this_group_subscription": "您无法加入此团队订阅",
"you_cant_reset_password_due_to_sso": "您无法重置密码,因为您的群组或组织使用 SSO。 <0>使用单点登录登录0>。",
+ "you_dont_have_any_add_ons_on_your_account": "您的帐户中没有任何附加组件。",
"you_dont_have_any_repositories": "您没有任何仓库",
+ "you_have_0_free_suggestions_left": "您还剩余 0 条建议",
+ "you_have_1_free_suggestion_left": "您还剩 1 条建议",
+ "you_have_1_license_and_your_plan_supports_up_to_y": "您有 1 个许可证并且您的计划最多支持 __groupSize__。",
"you_have_added_x_of_group_size_y": "您已经添加 <0>__addedUsersSize__0> / <1>__groupSize__1> 个可用成员。",
"you_have_been_invited_to_transfer_management_of_your_account": "您已被邀请转移您帐户的管理权。",
"you_have_been_invited_to_transfer_management_of_your_account_to": "您已被邀请将帐户管理转移到__groupName__。",
"you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard": "您已从该项目中删除,将不再有权访问该项目。您将被立即重定向到项目面板。",
+ "you_have_x_licenses_and_your_plan_supports_up_to_y": "您拥有__addedUsersSize__许可证并且您的计划最多支持__groupSize__。",
+ "you_have_x_licenses_on_your_subscription": "您的订阅中有 __groupSize__ 许可证。",
"you_need_to_configure_your_sso_settings": "在启用SSO之前,您需要配置并测试SSO设置",
+ "you_unpaused_your_subscription": "您已取消暂停订阅。",
"you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "您可以随时联系我们分享您的反馈",
"you_will_be_able_to_reassign_subscription": "您可以将他们的订阅成员资格重新分配给组织中的其他人",
"youll_get_best_results_in_visual_but_can_be_used_in_source": "尽管您仍可使用此工具在<1>代码编辑器1>中插入表格,但在<0>可视化编辑器0>中使用此工具将获得最佳结果。 选择所需的行数和列数后,表格将出现在文档中,您可以双击单元格向其中添加内容。",
@@ -2239,9 +2650,14 @@
"your_account_is_managed_by_admin_cant_join_additional_group": "您的__appName__帐户由您当前的组管理员(__admin__)管理。这意味着您不能加入其他组订阅<0>阅读有关托管用户的更多信息0>",
"your_account_is_managed_by_your_group_admin": "您的帐户由您的群组管理员管理。 您无法更改或删除您的电子邮件地址。",
"your_account_is_suspended": "你的账户暂时无法使用",
+ "your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on": "您的附加组件已被取消,并将一直有效,直到您的结算周期于 __nextBillingDate__ 结束",
"your_affiliation_is_confirmed": "您已确认属于<0>__institutionName__0>。",
"your_browser_does_not_support_this_feature": "很抱歉,您的浏览器不支持此功能。请将浏览器更新到最新版本。",
+ "your_changes_will_save": "当我们恢复连接时,您的更改将会保存。",
"your_compile_timed_out": "您的编译超时",
+ "your_current_plan": "您当前的计划",
+ "your_current_plan_gives_you": "通过暂停订阅,您将能够在再次需要时更快地访问高级功能。",
+ "your_current_plan_supports_up_to_x_licenses": "您当前的计划最多支持__users__个许可证。",
"your_current_project_will_revert_to_the_version_from_time": "您当前的项目将恢复到时间戳为 __timestamp__ 的版本",
"your_git_access_info": "当进行 Git 操作时,若系统提示您输入密码,请输入您的 Git 身份验证令牌。",
"your_git_access_info_bullet_1": "您最多可以拥有 10 个令牌。",
@@ -2259,26 +2675,36 @@
"your_plan_is_changing_at_term_end": "在当前计费周期结束时,您的计划将更改为<0>__pendingPlanName__0>。",
"your_plan_is_limited_to_n_editors": "您的计划允许 __count__ 位合作者拥有编辑权限和无限位查看者。",
"your_plan_is_limited_to_n_editors_plural": "您的计划允许 __count__ 位合作者拥有编辑权限和无限数量的查看者。",
+ "your_premium_plan_is_paused": "您的高级计划已<0>暂停0>。",
+ "your_project_exceeded_collaborator_limit": "您的项目超出了协作者限制,访问级别已更改。请为您的协作者选择新的访问级别,或升级以添加更多编辑者或审阅者。",
"your_project_exceeded_compile_timeout_limit_on_free_plan": "你的项目超过了我们免费计划的编译时限。",
- "your_project_exceeded_editor_limit": "您的项目超出了编辑者限制,访问级别已更改。请为您的协作者选择新的访问级别,或升级以添加更多编辑者。",
"your_project_near_compile_timeout_limit": "对于我们的免费计划,你的项目已经达到编译时限。",
+ "your_project_need_more_time_to_compile": "看起来您的项目可能需要比我们的免费计划允许的更多时间来编译。",
"your_projects": "您的项目",
"your_questions_answered": "常见问题",
"your_role": "您的角色",
"your_sessions": "我的会话",
"your_subscription": "您的订阅",
"your_subscription_has_expired": "您的订购已过期",
+ "your_subscription_will_pause_on": "您的 <0>__planName__0> 订阅将于 <0>__pauseDate__0> 暂停。它将于 <0>__reactivationDate__0> 自动取消暂停。您也可以随时自行取消暂停。",
+ "your_subscription_will_pause_on_short": "您的订阅将于 <0>__pauseDate__0> 暂停。",
"youre_a_member_of_overleaf_labs": "您是 Overleaf Labs 的成员。别忘了定期查看您可以报名参加哪些实验。",
"youre_about_to_disable_single_sign_on": "您将禁用所有群成员的单点登录。",
"youre_about_to_enable_single_sign_on": "您即将启用单点登录(SSO)。在执行此操作之前,您应该确保您确信SSO配置是正确的,并且您的所有组成员都具有托管用户帐户。",
"youre_about_to_enable_single_sign_on_sso_only": "您即将启用单点登录 (SSO)。 在执行此操作之前,您应该确保 SSO 配置正确。",
+ "youre_adding_x_licenses_to_your_plan_giving_you_a_total_of_y_licenses": "您正在向您的计划添加 <0>__adding__0> 个许可证,使您的计划总共拥有 <1>__total__1> 个许可证。",
"youre_already_setup_for_sso": "您已完成 SSO 设置",
+ "youre_helping_us_shape_the_future_of_overleaf": "您正在帮助我们创造 Overleaf 的未来",
"youre_joining": "您正在加入",
"youre_on_free_trial_which_ends_on": "您正在享受免费试用,试用期将于<0>__date__0>结束。",
"youre_signed_in_as_logout": "您已使用 <0>__email__0> 登录。 <1>退出。1>",
"youre_signed_up": "您已注册",
- "youve_lost_edit_access": "您已失去编辑连接",
+ "youve_added_more_licenses": "您已添加更多许可证!",
+ "youve_added_x_more_licenses_to_your_subscription_invite_people": "您已向订阅添加了__users__个许可证。<0>邀请其他人0>。",
+ "youve_lost_collaboration_access": "您已失去协作访问权限",
+ "youve_paused_your_subscription": "您的<0>__planName__0>订阅已暂停,直至<0>__reactivationDate__0>,之后将自动取消暂停。您可以随时提前取消暂停。",
"youve_unlinked_all_users": "您已取消所有用户的关联",
+ "youve_upgraded_your_plan": "您已升级您的计划!",
"zh-CN": "中文",
"zip_contents_too_large": "压缩包太大",
"zoom_in": "放大",
@@ -2286,6 +2712,7 @@
"zoom_to": "缩放至",
"zotero": "Zotero",
"zotero_and_mendeley_integrations": "<0>Zotero0> 与 <0>Mendeley0> 集成",
+ "zotero_dynamic_sync_description": "通过 Zotero 集成,您可以将参考文献导入 __appName__。您可以一次性导入所有参考文献,也可以直接从 __appName__ 动态搜索 Zotero 库。",
"zotero_groups_loading_error": "从 Zotero 加载群组时出错",
"zotero_groups_relink": "访问您的Zotero数据时出错。这可能是由于缺乏权限造成的。请重新链接您的帐户,然后重试。",
"zotero_integration": "Zotero 集成",
@@ -2293,5 +2720,7 @@
"zotero_reference_loading_error": "错误,无法加载Zotero的参考文献",
"zotero_reference_loading_error_expired": "Zotero令牌过期,请重新关联您的账户",
"zotero_reference_loading_error_forbidden": "无法加载Zotero的参考文献,请重新关联您的账户后重试",
- "zotero_sync_description": "集成 Zotero 后,您可以将 Zotero 的参考文献导入__appName__项目。"
+ "zotero_sync_description": "集成 Zotero 后,您可以将 Zotero 的参考文献导入__appName__项目。",
+ "zotero_upgrade_prompt_content": "关联您的 Zotero 帐户,即可直接在项目中搜索并添加 Zotero 中的参考文献——它们将自动添加到您的 .bib 文件中。或者,您也可以将它们作为文件导入到您的 __appName__ 项目中。",
+ "zotero_upgrade_prompt_title": "引用自 Zotero"
}
diff --git a/services/web/migrations/20250403133427_create_index_for_script_logs.mjs b/services/web/migrations/20250403133427_create_index_for_script_logs.mjs
new file mode 100644
index 0000000000..0ca677a82a
--- /dev/null
+++ b/services/web/migrations/20250403133427_create_index_for_script_logs.mjs
@@ -0,0 +1,32 @@
+/* eslint-disable no-unused-vars */
+
+import Helpers from './lib/helpers.mjs'
+
+const tags = ['saas']
+
+const indexes = [
+ {
+ key: { canonicalName: 1 },
+ name: 'canonicalName_1',
+ },
+ {
+ key: { username: 1 },
+ name: 'username_1',
+ },
+]
+
+const migrate = async client => {
+ const { db } = client
+ await Helpers.addIndexesToCollection(db.scriptLogs, indexes)
+}
+
+const rollback = async client => {
+ const { db } = client
+ await Helpers.dropIndexesFromCollection(db.scriptLogs, indexes)
+}
+
+export default {
+ tags,
+ migrate,
+ rollback,
+}
diff --git a/services/web/migrations/20250411200550_active_chunk_index_update.mjs b/services/web/migrations/20250411200550_active_chunk_index_update.mjs
new file mode 100644
index 0000000000..23893a4836
--- /dev/null
+++ b/services/web/migrations/20250411200550_active_chunk_index_update.mjs
@@ -0,0 +1,41 @@
+import Helpers from './lib/helpers.mjs'
+
+const tags = ['server-ce', 'server-pro', 'saas']
+
+const oldIndex = {
+ name: 'projectId_1_startVersion_1',
+ key: {
+ projectId: 1,
+ startVersion: 1,
+ },
+ unique: true,
+ partialFilterExpression: { state: 'active' },
+}
+
+const newIndex = {
+ name: 'projectId_1_startVersion_1_v2',
+ key: {
+ projectId: 1,
+ startVersion: 1,
+ },
+ unique: true,
+ partialFilterExpression: { state: { $in: ['active', 'closed'] } },
+}
+
+const migrate = async client => {
+ const { db } = client
+ await Helpers.addIndexesToCollection(db.projectHistoryChunks, [newIndex])
+ await Helpers.dropIndexesFromCollection(db.projectHistoryChunks, [oldIndex])
+}
+
+const rollback = async client => {
+ const { db } = client
+ await Helpers.addIndexesToCollection(db.projectHistoryChunks, [oldIndex])
+ await Helpers.dropIndexesFromCollection(db.projectHistoryChunks, [newIndex])
+}
+
+export default {
+ tags,
+ migrate,
+ rollback,
+}
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 d64c2c413e..213fff7f3f 100644
--- a/services/web/modules/user-activate/app/views/user/register.pug
+++ b/services/web/modules/user-activate/app/views/user/register.pug
@@ -3,10 +3,6 @@ extends ../../../../../app/views/layout-marketing
block entrypointVar
- entrypoint = 'modules/user-activate/pages/user-activate-page'
-
-block vars
- - bootstrap5PageStatus = 'disabled'
-
block content
.content.content-alt#main-content
.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 0fc8211af6..7008781631 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
@@ -1,5 +1,9 @@
+import { useState } from 'react'
import PropTypes from 'prop-types'
-import { postJSON } from '../../../../../frontend/js/infrastructure/fetch-json'
+import { postJSON } from '@/infrastructure/fetch-json'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLForm from '@/features/ui/components/ol/ol-form'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
function RegisterForm({
setRegistrationSuccess,
@@ -7,6 +11,7 @@ function RegisterForm({
setRegisterError,
setFailedEmails,
}) {
+ const [isLoading, setIsLoading] = useState(false)
function handleRegister(event) {
event.preventDefault()
const formData = new FormData(event.target)
@@ -22,6 +27,7 @@ function RegisterForm({
async function registerGivenUsers(emails) {
const registeredEmails = []
const failingEmails = []
+ setIsLoading(true)
for (const email of emails) {
try {
const result = await registerUser(email)
@@ -30,6 +36,7 @@ function RegisterForm({
failingEmails.push(email)
}
}
+ setIsLoading(false)
if (registeredEmails.length > 0) setRegistrationSuccess(true)
if (failingEmails.length > 0) {
setRegisterError(true)
@@ -45,10 +52,10 @@ function RegisterForm({
}
return (
-
-
-
-
+
+
-
- Register
-
+
+ Register
+
-
+
)
}
diff --git a/services/web/modules/user-activate/frontend/js/components/user-activate-register.jsx b/services/web/modules/user-activate/frontend/js/components/user-activate-register.jsx
index d3c66c8f59..0b33270179 100644
--- a/services/web/modules/user-activate/frontend/js/components/user-activate-register.jsx
+++ b/services/web/modules/user-activate/frontend/js/components/user-activate-register.jsx
@@ -1,6 +1,10 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import RegisterForm from './register-form'
+import OLRow from '@/features/ui/components/ol/ol-row'
+import OLCol from '@/features/ui/components/ol/ol-col'
+import OLCard from '@/features/ui/components/ol/ol-card'
+
function UserActivateRegister() {
const [emails, setEmails] = useState([])
const [failedEmails, setFailedEmails] = useState([])
@@ -8,11 +12,11 @@ function UserActivateRegister() {
const [registrationSuccess, setRegistrationSuccess] = useState(false)
return (
-
-
-
+
+
+
-
Register New Users
+ Register New Users
>
) : null}
-
-
-
+
+
+
)
}
diff --git a/services/web/modules/user-activate/frontend/js/pages/user-activate-page.jsx b/services/web/modules/user-activate/frontend/js/pages/user-activate-page.jsx
index 22174de921..c03b5206a7 100644
--- a/services/web/modules/user-activate/frontend/js/pages/user-activate-page.jsx
+++ b/services/web/modules/user-activate/frontend/js/pages/user-activate-page.jsx
@@ -1,9 +1,10 @@
import '@/marketing'
-import ReactDOM from 'react-dom'
+import { createRoot } from 'react-dom/client'
import UserActivateRegister from '../components/user-activate-register'
-ReactDOM.render(
-
,
- document.getElementById('user-activate-register-container')
-)
+const container = document.getElementById('user-activate-register-container')
+if (container) {
+ const root = createRoot(container)
+ root.render(
)
+}
diff --git a/services/web/modules/user-activate/test/frontend/js/components/register-form.test.jsx b/services/web/modules/user-activate/test/frontend/js/components/register-form.test.jsx
index 39c6e98c1b..58833f1faf 100644
--- a/services/web/modules/user-activate/test/frontend/js/components/register-form.test.jsx
+++ b/services/web/modules/user-activate/test/frontend/js/components/register-form.test.jsx
@@ -6,10 +6,10 @@ import RegisterForm from '../../../../frontend/js/components/register-form'
describe('RegisterForm', function () {
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('should render the register form', async function () {
const setRegistrationSuccessStub = sinon.stub()
@@ -57,6 +57,6 @@ describe('RegisterForm', function () {
const registerButton = screen.getByRole('button', { name: /register/i })
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
- expect(registerMock.called()).to.be.true
+ expect(registerMock.callHistory.called()).to.be.true
})
})
diff --git a/services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.jsx b/services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.jsx
index 773170feec..ed928bb7e2 100644
--- a/services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.jsx
+++ b/services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.jsx
@@ -2,13 +2,14 @@ import { expect } from 'chai'
import { render, screen, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import UserActivateRegister from '../../../../frontend/js/components/user-activate-register'
+import { TPDS_SYNCED } from '../../../../../dropbox/test/frontend/components/dropbox-sync-status.test'
describe('UserActivateRegister', function () {
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('should display the error message', async function () {
const email = 'abc@gmail.com'
@@ -16,6 +17,7 @@ describe('UserActivateRegister', function () {
const endPointResponse = {
status: 500,
}
+ fetchMock.get('/user/tpds/queues', TPDS_SYNCED)
const registerMock = fetchMock.post('/admin/register', endPointResponse)
const registerInput = screen.getByLabelText('emails to register')
const registerButton = screen.getByRole('button', { name: /register/i })
@@ -23,7 +25,7 @@ describe('UserActivateRegister', function () {
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
- expect(registerMock.called()).to.be.true
+ expect(registerMock.callHistory.called()).to.be.true
await screen.findByText('Sorry, an error occured', { exact: false })
})
@@ -37,6 +39,7 @@ describe('UserActivateRegister', function () {
setNewPasswordUrl: 'SetNewPasswordURL',
},
}
+ fetchMock.get('/user/tpds/queues', TPDS_SYNCED)
const registerMock = fetchMock.post('/admin/register', endPointResponse)
const registerInput = screen.getByLabelText('emails to register')
const registerButton = screen.getByRole('button', { name: /register/i })
@@ -44,7 +47,7 @@ describe('UserActivateRegister', function () {
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
- expect(registerMock.called()).to.be.true
+ expect(registerMock.callHistory.called()).to.be.true
await screen.findByText(
"We've sent out welcome emails to the registered users."
)
@@ -67,6 +70,7 @@ describe('UserActivateRegister', function () {
setNewPasswordUrl: 'SetNewPasswordURL',
},
}
+ fetchMock.get('/user/tpds/queues', TPDS_SYNCED)
const registerMock = fetchMock.post('/admin/register', (path, req) => {
const body = JSON.parse(req.body)
if (body.email === 'abc@gmail.com') return endPointResponse1
@@ -78,7 +82,7 @@ describe('UserActivateRegister', function () {
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
- expect(registerMock.called()).to.be.true
+ expect(registerMock.callHistory.called()).to.be.true
await screen.findByText('abc@gmail.com')
await screen.findByText('def@gmail.com')
})
@@ -92,6 +96,7 @@ describe('UserActivateRegister', function () {
const endPointResponse2 = {
status: 500,
}
+ fetchMock.get('/user/tpds/queues', TPDS_SYNCED)
const registerMock = fetchMock.post('/admin/register', (path, req) => {
const body = JSON.parse(req.body)
if (body.email === 'abc@') return endPointResponse1
@@ -103,7 +108,7 @@ describe('UserActivateRegister', function () {
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
- expect(registerMock.called()).to.be.true
+ expect(registerMock.callHistory.called()).to.be.true
await screen.findByText('abc@')
await screen.findByText('def@')
})
@@ -121,6 +126,7 @@ describe('UserActivateRegister', function () {
const endPointResponse2 = {
status: 500,
}
+ fetchMock.get('/user/tpds/queues', TPDS_SYNCED)
const registerMock = fetchMock.post('/admin/register', (path, req) => {
const body = JSON.parse(req.body)
if (body.email === 'abc@gmail.com') return endPointResponse1
@@ -133,7 +139,7 @@ describe('UserActivateRegister', function () {
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
- expect(registerMock.called()).to.be.true
+ expect(registerMock.callHistory.called()).to.be.true
await screen.findByText('abc@gmail.com')
await screen.findByText('def@')
})
diff --git a/services/web/package.json b/services/web/package.json
index a2c7a6fe4b..679c226556 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -178,12 +178,12 @@
"yauzl": "^2.10.0"
},
"devDependencies": {
- "@babel/cli": "^7.24.8",
- "@babel/core": "^7.25.2",
- "@babel/preset-env": "^7.25.3",
- "@babel/preset-react": "^7.24.7",
- "@babel/preset-typescript": "^7.24.7",
- "@babel/register": "^7.24.6",
+ "@babel/cli": "^7.27.0",
+ "@babel/core": "^7.26.10",
+ "@babel/preset-env": "^7.26.9",
+ "@babel/preset-react": "^7.26.3",
+ "@babel/preset-typescript": "^7.27.0",
+ "@babel/register": "^7.25.9",
"@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-markdown": "^6.3.2",
@@ -202,7 +202,7 @@
"@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.3.tar.gz",
"@overleaf/ranges-tracker": "*",
"@overleaf/stream-utils": "*",
- "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
+ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.16",
"@pollyjs/adapter-node-http": "^6.0.6",
"@pollyjs/core": "^6.0.6",
"@pollyjs/persister-fs": "^6.0.6",
@@ -210,20 +210,19 @@
"@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161",
"@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b",
"@sentry/browser": "7.46.0",
- "@storybook/addon-a11y": "^8.6.4",
- "@storybook/addon-essentials": "^8.6.4",
- "@storybook/addon-interactions": "^8.6.4",
- "@storybook/addon-links": "^8.6.4",
+ "@storybook/addon-a11y": "^8.6.12",
+ "@storybook/addon-essentials": "^8.6.12",
+ "@storybook/addon-interactions": "^8.6.12",
+ "@storybook/addon-links": "^8.6.12",
"@storybook/addon-styling-webpack": "^1.0.1",
- "@storybook/addon-webpack5-compiler-babel": "^3.0.5",
- "@storybook/cli": "^8.6.4",
- "@storybook/react": "^8.6.4",
- "@storybook/react-webpack5": "^8.6.4",
- "@storybook/theming": "^8.6.4",
+ "@storybook/addon-webpack5-compiler-babel": "^3.0.6",
+ "@storybook/cli": "^8.6.12",
+ "@storybook/react": "^8.6.12",
+ "@storybook/react-webpack5": "^8.6.12",
+ "@storybook/theming": "^8.6.12",
"@testing-library/cypress": "^10.0.1",
- "@testing-library/dom": "^9.3.0",
- "@testing-library/react": "^12.1.5",
- "@testing-library/react-hooks": "^8.0.1",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.4.3",
"@types/bootstrap": "^5.2.10",
"@types/bootstrap-5": "npm:@types/bootstrap@^5.2.10",
@@ -234,13 +233,12 @@
"@types/express": "^4.17.13",
"@types/mocha": "^9.1.0",
"@types/mocha-each": "^2.0.0",
- "@types/react": "^17.0.40",
- "@types/react-bootstrap": "^0.32.36",
- "@types/react-color": "^3.0.6",
- "@types/react-dom": "^17.0.13",
- "@types/react-google-recaptcha": "^2.1.5",
- "@types/react-linkify": "^1.0.0",
- "@types/react-overlays": "^1.1.3",
+ "@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",
+ "@types/react-linkify": "^1.0.4",
"@types/recurly__recurly-js": "^4.22.0",
"@types/sinon-chai": "^3.2.8",
"@types/uuid": "^9.0.8",
@@ -275,7 +273,7 @@
"classnames": "^2.2.6",
"cookie-signature": "^1.2.1",
"copy-webpack-plugin": "^11.0.0",
- "core-js": "^3.38.1",
+ "core-js": "^3.41.0",
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"cypress": "13.13.2",
@@ -284,7 +282,7 @@
"daterangepicker": "2.1.27",
"diff": "^5.1.0",
"dompurify": "^3.2.4",
- "downshift": "^6.1.0",
+ "downshift": "^9.0.9",
"es6-promise": "^4.2.8",
"escodegen": "^2.0.0",
"eslint-config-standard-jsx": "^11.0.0",
@@ -296,7 +294,7 @@
"esmock": "^2.6.7",
"events": "^3.3.0",
"fake-indexeddb": "^6.0.0",
- "fetch-mock": "^9.10.2",
+ "fetch-mock": "^12.5.2",
"formik": "^2.2.9",
"fuse.js": "^3.0.0",
"glob": "^7.1.6",
@@ -321,25 +319,23 @@
"nock": "^13.5.6",
"nvd3": "^1.8.6",
"overleaf-editor-core": "*",
- "pdfjs-dist": "4.10.38",
+ "pdfjs-dist": "5.1.91",
"pirates": "^4.0.1",
"postcss": "^8.4.31",
"postcss-loader": "^7.3.3",
"prop-types": "^15.7.2",
"qrcode": "^1.4.4",
- "react": "^17.0.2",
- "react-bootstrap": "^0.33.1",
+ "react": "^18.3.1",
"react-bootstrap-5": "npm:react-bootstrap@^2.10.5",
"react-chartjs-2": "^5.0.1",
"react-color": "^2.19.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
- "react-dom": "^17.0.2",
- "react-error-boundary": "^2.3.1",
+ "react-dom": "^18.3.1",
+ "react-error-boundary": "^5.0.0",
"react-google-recaptcha": "^3.1.0",
"react-i18next": "^13.3.1",
"react-linkify": "^1.0.0-alpha",
- "react-overlays": "^0.9.3",
"react-refresh": "^0.14.0",
"react-resizable-panels": "^2.1.1",
"resolve-url-loader": "^5.0.0",
@@ -351,7 +347,7 @@
"sinon": "^7.5.0",
"sinon-chai": "^3.7.0",
"sinon-mongoose": "^2.3.0",
- "storybook": "^8.6.4",
+ "storybook": "^8.6.12",
"stylelint-config-standard-scss": "^13.1.0",
"terser-webpack-plugin": "^5.3.9",
"thread-loader": "^4.0.2",
@@ -364,7 +360,7 @@
"webpack": "^5.98.0",
"webpack-assets-manifest": "^5.2.1",
"webpack-cli": "^5.1.4",
- "webpack-dev-server": "^5.2.0",
+ "webpack-dev-server": "^5.2.1",
"webpack-merge": "^5.10.0",
"yup": "^0.32.11"
},
diff --git a/services/web/scripts/add_subscription_members_csv.mjs b/services/web/scripts/add_subscription_members_csv.mjs
new file mode 100644
index 0000000000..4120e16fdb
--- /dev/null
+++ b/services/web/scripts/add_subscription_members_csv.mjs
@@ -0,0 +1,202 @@
+import fs from 'node:fs'
+import minimist from 'minimist'
+import { parse } from 'csv'
+import Stream from 'node:stream/promises'
+import SubscriptionGroupHandler from '../app/src/Features/Subscription/SubscriptionGroupHandler.js'
+import { Subscription } from '../app/src/models/Subscription.js'
+import { InvalidEmailError } from '../app/src/Features/Errors/Errors.js'
+
+function usage() {
+ console.log(
+ 'Usage: node scripts/add_subscription_members_csv.mjs -f
-i -s [options]'
+ )
+ console.log('Required arguments:')
+ console.log(
+ ' -s, --subscriptionId The ID of the subscription to update'
+ )
+ console.log(
+ ' -i, --inviterId The ID of the user sending the invites'
+ )
+ console.log(
+ ' -f, --filename The path to the file to read data from'
+ )
+ console.log('Options:')
+ console.log(
+ ' --commit, -c Whether changes should be committed to the DB invites should be sent/revoked'
+ )
+ console.log(
+ ' --removeMembersNotIncluded -r Remove members that are not in the CSV. Disabled when managed users are enabled for the subscription'
+ )
+ console.log(
+ ' --verbose, -v Prints detailed information about the affected group members'
+ )
+ console.log(' -h, --help Show this help message')
+ process.exit(0)
+}
+
+let {
+ commit,
+ removeMembersNotIncluded,
+ inviterId,
+ subscriptionId,
+ filename,
+ help,
+ verbose,
+} = minimist(process.argv.slice(2), {
+ string: ['filename', 'subscriptionId', 'inviterId'],
+ boolean: ['commit', 'removeMembersNotIncluded', 'help', 'verbose'],
+ alias: {
+ commit: 'c',
+ removeMembersNotIncluded: 'r',
+ filename: 'f',
+ help: 'h',
+ inviterId: 'i',
+ subscriptionId: 's',
+ verbose: 'v',
+ },
+ default: {
+ commit: false,
+ removeMembersNotIncluded: false,
+ help: false,
+ verbose: false,
+ },
+})
+
+const EMAIL_FIELD = 'email'
+
+if (help) {
+ usage()
+ process.exit(0)
+}
+
+if (!subscriptionId || !inviterId || !filename) {
+ usage()
+ process.exit(1)
+}
+
+async function processRows(rows) {
+ const emailList = []
+ for await (const row of rows) {
+ const email = row[EMAIL_FIELD]
+ if (email) {
+ emailList.push(email)
+ }
+ }
+ if (emailList.length === 0) {
+ console.error(`CSV error: 'email' column doesn't exist or it's empty'`)
+ process.exit(1)
+ }
+
+ let previewResult
+
+ try {
+ previewResult =
+ await SubscriptionGroupHandler.promises.updateGroupMembersBulk(
+ inviterId,
+ subscriptionId,
+ emailList,
+ { removeMembersNotIncluded }
+ )
+ } catch (error) {
+ if (error instanceof InvalidEmailError) {
+ console.error(`${filename} contains invalid email addresses:`)
+ console.error(error.info?.invalidEmails.join(','))
+ process.exit(1)
+ } else {
+ throw error
+ }
+ }
+
+ console.log('Result Preview:')
+ logResult(previewResult)
+
+ if (previewResult.newTotalCount > previewResult.membersLimit) {
+ console.warn(
+ 'WARNING: the invite list has reached the membership limit (newTotalCount > membersLimit)'
+ )
+ if (commit) {
+ console.error(`Invites won't be sent and users won't be deleted`)
+ }
+ process.exit(1)
+ }
+
+ if (!commit) {
+ console.log(
+ 'this is a dry-run, use the --commit option to send the invite and make any DB changes'
+ )
+ return
+ }
+
+ console.log(
+ `Sending invites to ${previewResult.emailsToSendInvite.length} email addresses`
+ )
+
+ if (previewResult.membersToRemove > 0) {
+ console.log(
+ `${previewResult.membersToRemove.length} members will be removed from the group`
+ )
+ }
+
+ const commitResult =
+ await SubscriptionGroupHandler.promises.updateGroupMembersBulk(
+ inviterId,
+ subscriptionId,
+ emailList,
+ { removeMembersNotIncluded, commit }
+ )
+
+ console.log('Result:')
+ logResult(commitResult)
+}
+
+function logResult(result) {
+ console.log(
+ JSON.stringify(
+ {
+ ...result,
+ emailsToSendInvite: verbose
+ ? result.emailsToSendInvite
+ : result.emailsToSendInvite.length,
+ membersToRemove: verbose
+ ? result.membersToRemove
+ : result.membersToRemove.length,
+ emailsToRevokeInvite: verbose
+ ? result.emailsToRevokeInvite
+ : result.emailsToRevokeInvite.length,
+ },
+ null,
+ 2
+ )
+ )
+}
+
+async function main() {
+ const subscription = await Subscription.findOne({
+ _id: subscriptionId,
+ }).exec()
+ if (!subscription) {
+ console.error(`subscription with id=${subscriptionId} not found`)
+ process.exit(1)
+ }
+ if (subscription.managedUsersEnabled && removeMembersNotIncluded) {
+ console.warn(
+ `subscription with id=${subscriptionId} has 'managedUsersEnabled=true'` +
+ `'--removeMembersNotIncluded' has been disabled`
+ )
+ removeMembersNotIncluded = false
+ }
+ await Stream.pipeline(
+ fs.createReadStream(filename),
+ parse({
+ columns: true,
+ }),
+ processRows
+ )
+}
+
+main()
+ .then(() => process.exit(0))
+ .catch(error => {
+ console.error(error)
+ process.exit(1)
+ })
diff --git a/services/web/scripts/check_project_files.js b/services/web/scripts/check_project_files.js
index 41fe9ecda7..e827895e66 100644
--- a/services/web/scripts/check_project_files.js
+++ b/services/web/scripts/check_project_files.js
@@ -136,7 +136,8 @@ async function createRecoveryFolder(projectId) {
const recoveryFolder = `recovered-${Date.now()}`
const { folder } = await ProjectEntityMongoUpdateHandler.promises.mkdirp(
new ObjectId(projectId),
- recoveryFolder
+ recoveryFolder,
+ null // unset lastUpdatedBy
)
console.log('Created recovery folder:', folder._id.toString())
return folder
@@ -149,7 +150,8 @@ async function restoreMissingDocs(projectId, folder, missingDocs) {
await ProjectEntityMongoUpdateHandler.promises.addDoc(
new ObjectId(projectId),
folder._id,
- doc
+ doc,
+ null // unset lastUpdatedBy
)
console.log('Restored doc to filetree:', doc._id.toString())
} catch (err) {
diff --git a/services/web/scripts/delete_dangling_file_refs.mjs b/services/web/scripts/delete_dangling_file_refs.mjs
index 262376aa7d..39383bed79 100644
--- a/services/web/scripts/delete_dangling_file_refs.mjs
+++ b/services/web/scripts/delete_dangling_file_refs.mjs
@@ -104,7 +104,8 @@ async function deleteDoc(projectId, docId) {
await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
projectId,
docId,
- 'doc'
+ 'doc',
+ null // unset lastUpdatedBy
)
}
}
@@ -115,7 +116,8 @@ async function deleteFile(projectId, fileId) {
await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
projectId,
fileId,
- 'file'
+ 'file',
+ null // unset lastUpdatedBy
)
}
}
diff --git a/services/web/scripts/example/track_progress.mjs b/services/web/scripts/example/track_progress.mjs
new file mode 100644
index 0000000000..531eb877a6
--- /dev/null
+++ b/services/web/scripts/example/track_progress.mjs
@@ -0,0 +1,31 @@
+// Import the script runner utility (adjust the path as needed)
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+
+const subJobs = 30
+
+/**
+ * Your script's main work goes here.
+ * It must be an async function and accept `trackProgress`.
+ * @param {(message: string) => Promise} trackProgress - Call this to log progress.
+ */
+async function main(trackProgress) {
+ for (let i = 0; i < subJobs; i++) {
+ await new Promise(resolve => setTimeout(() => resolve(), 1000))
+ await trackProgress(`Job in progress ${i + 1}/${subJobs}`)
+ }
+ await trackProgress('Job finished')
+}
+
+// Define any variables your script needs (optional)
+const scriptVariables = {
+ subJobs,
+}
+
+// --- Execute the script using the runner with async/await ---
+try {
+ await scriptRunner(main, scriptVariables)
+ process.exit()
+} catch (error) {
+ console.error(error)
+ process.exit(1)
+}
diff --git a/services/web/scripts/fix_malformed_filetree.mjs b/services/web/scripts/fix_malformed_filetree.mjs
index 2c2971ecfd..2358bed850 100644
--- a/services/web/scripts/fix_malformed_filetree.mjs
+++ b/services/web/scripts/fix_malformed_filetree.mjs
@@ -17,6 +17,7 @@ import fs from 'node:fs'
import logger from '@overleaf/logger'
const { ObjectId } = mongodb
+const lastUpdated = new Date()
const argv = minimist(process.argv.slice(2), {
string: ['logs'],
@@ -157,6 +158,8 @@ async function fixRootFolder(projectId) {
fileRefs: [],
},
],
+ lastUpdated,
+ lastUpdatedBy: null, // unset lastUpdatedBy
},
}
)
@@ -185,6 +188,10 @@ async function removeNulls(projectId, _id) {
[`${path}.docs`]: null,
[`${path}.fileRefs`]: null,
},
+ $set: {
+ lastUpdated,
+ lastUpdatedBy: null, // unset lastUpdatedBy
+ },
}
)
return result.modifiedCount
@@ -196,7 +203,7 @@ async function removeNulls(projectId, _id) {
async function fixArray(projectId, path) {
const result = await db.projects.updateOne(
{ _id: new ObjectId(projectId), [path]: { $not: { $type: 'array' } } },
- { $set: { [path]: [] } }
+ { $set: { [path]: [], lastUpdated, lastUpdatedBy: null } }
)
return result.modifiedCount
}
@@ -207,7 +214,13 @@ async function fixArray(projectId, path) {
async function fixFolderId(projectId, path) {
const result = await db.projects.updateOne(
{ _id: new ObjectId(projectId), [path]: { $exists: false } },
- { $set: { [path]: new ObjectId() } }
+ {
+ $set: {
+ [path]: new ObjectId(),
+ lastUpdated,
+ lastUpdatedBy: null, // unset lastUpdatedBy
+ },
+ }
)
return result.modifiedCount
}
@@ -218,7 +231,13 @@ async function fixFolderId(projectId, path) {
async function removeElementsWithoutIds(projectId, path) {
const result = await db.projects.updateOne(
{ _id: new ObjectId(projectId), [path]: { $type: 'array' } },
- { $pull: { [path]: { _id: null } } }
+ {
+ $pull: { [path]: { _id: null } },
+ $set: {
+ lastUpdated,
+ lastUpdatedBy: null, // unset lastUpdatedBy
+ },
+ }
)
return result.modifiedCount
}
@@ -245,7 +264,13 @@ async function fixName(projectId, _id) {
const pathToName = `${path}.name`
const result = await db.projects.updateOne(
{ _id: new ObjectId(projectId), [pathToName]: { $in: [null, ''] } },
- { $set: { [pathToName]: name } }
+ {
+ $set: {
+ [pathToName]: name,
+ lastUpdated,
+ lastUpdatedBy: null, // unset lastUpdatedBy
+ },
+ }
)
return result.modifiedCount
}
diff --git a/services/web/scripts/fix_oversized_docs.mjs b/services/web/scripts/fix_oversized_docs.mjs
index 07f66a76cf..9f2e250b92 100644
--- a/services/web/scripts/fix_oversized_docs.mjs
+++ b/services/web/scripts/fix_oversized_docs.mjs
@@ -76,7 +76,8 @@ async function processDoc(projectId, docId) {
await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
new ObjectId(projectId),
new ObjectId(docId),
- fileRef
+ fileRef,
+ null // unset lastUpdatedBy
)
await deleteDocFromMongo(projectId, doc)
await deleteDocFromRedis(projectId, docId)
diff --git a/services/web/scripts/lib/README.md b/services/web/scripts/lib/README.md
new file mode 100644
index 0000000000..8dae2db07f
--- /dev/null
+++ b/services/web/scripts/lib/README.md
@@ -0,0 +1,55 @@
+# Script Runner
+
+## Overview
+
+The Script Runner wraps your script's main logic to automatically handle logging, status tracking (success/error), and progress updates. Script execution status can be viewed from "Script Logs" portal page.
+
+## Features
+
+- Automatically logs the start and end of your script.
+- Records the final status ('success' or 'error').
+- Provides a simple function (`trackProgress`) to your script for logging custom progress steps.
+- Captures script parameters and basic environment details.
+
+## Usage
+
+1. **Import `scriptRunner`**.
+2. **Define your script's main logic** as an `async` function that accepts `trackProgress` as its argument (can ignore `trackProgress` if you don't need to track progress).
+3. **Call `scriptRunner`**, passing your function and any variables it needs.
+4. **Check script execution status** by visiting the "Script Logs" portal page using the URL printed in the console output.
+
+**Example:**
+
+```javascript
+// Import the script runner utility (adjust the path as needed)
+import { scriptRunner } from './lib/ScriptRunner.mjs'
+
+const subJobs = 30
+
+/**
+ * Your script's main work goes here.
+ * It must be an async function and accept `trackProgress`.
+ * @param {(message: string) => void} trackProgress - Call this to log progress.
+ */
+async function main(trackProgress) {
+ for (let i = 0; i < subJobs; i++) {
+ await new Promise(resolve => setTimeout(() => resolve(), 1000))
+ await trackProgress(`Job in progress ${i + 1}/${subJobs}`)
+ }
+ await trackProgress('Job finished')
+}
+
+// Define any variables your script needs (optional)
+const scriptVariables = {
+ subJobs,
+}
+
+// --- Execute the script using the runner with async/await ---
+try {
+ await scriptRunner(main, scriptVariables)
+ process.exit()
+} catch (error) {
+ console.error(error)
+ process.exit(1)
+}
+```
diff --git a/services/web/scripts/lib/ScriptRunner.mjs b/services/web/scripts/lib/ScriptRunner.mjs
new file mode 100644
index 0000000000..1708fa9310
--- /dev/null
+++ b/services/web/scripts/lib/ScriptRunner.mjs
@@ -0,0 +1,75 @@
+import { ScriptLog } from '../../app/src/models/ScriptLog.mjs'
+import Settings from '@overleaf/settings'
+
+async function beforeScriptExecution(canonicalName, vars, scriptPath) {
+ let log = new ScriptLog({
+ canonicalName,
+ filePathAtVersion: scriptPath,
+ podName: process.env.OL_POD_NAME,
+ username: process.env.OL_USERNAME,
+ imageVersion: process.env.OL_IMAGE_VERSION,
+ vars,
+ })
+ log = await log.save()
+ console.log(
+ '\n==================================' +
+ '\n✨ Your script is running!' +
+ '\n📊 Track progress at:' +
+ `\n${Settings.adminUrl}/admin/script-log/${log._id}` +
+ '\n==================================\n'
+ )
+ return log._id
+}
+
+async function afterScriptExecution(logId, status) {
+ await ScriptLog.findByIdAndUpdate(logId, { status, endTime: new Date() })
+}
+
+/**
+ * @param {(trackProgress: (progress: string) => Promise) => Promise} main - Main function for the script
+ * @param {Object} vars - Variables to be used in the script
+ * @param {string} canonicalName - The canonical name of the script, default to filename
+ * @param {string} scriptPath - The file path of the script, default to process.argv[1]
+ * @returns {Promise}
+ * @async
+ */
+export async function scriptRunner(
+ main,
+ vars = {},
+ canonicalName = process.argv[1].split('/').pop().split('.')[0],
+ scriptPath = process.argv[1]
+) {
+ const isSaaS = Boolean(Settings.overleaf)
+ if (!isSaaS) {
+ await main(async message => {
+ console.warn(message)
+ })
+ return
+ }
+ const logId = await beforeScriptExecution(canonicalName, vars, scriptPath)
+
+ async function trackProgress(message) {
+ try {
+ console.warn(message)
+ await ScriptLog.findByIdAndUpdate(logId, {
+ $push: {
+ progressLogs: {
+ timestamp: new Date(),
+ message,
+ },
+ },
+ })
+ } catch (error) {
+ console.error('Error tracking progress:', error)
+ }
+ }
+
+ try {
+ await main(trackProgress)
+ } catch (error) {
+ await afterScriptExecution(logId, 'error')
+ throw error
+ }
+
+ await afterScriptExecution(logId, 'success')
+}
diff --git a/services/web/scripts/oauth/register_client.mjs b/services/web/scripts/oauth/register_client.mjs
index 6f14ae1230..a3b798155b 100644
--- a/services/web/scripts/oauth/register_client.mjs
+++ b/services/web/scripts/oauth/register_client.mjs
@@ -16,10 +16,6 @@ async function main() {
console.error('Missing --name option')
process.exit(1)
}
- if (opts.secret == null) {
- console.error('Missing --secret option')
- process.exit(1)
- }
} else {
console.log(`Updating configuration for client: ${application.name}`)
if (opts.mongoId != null) {
@@ -104,7 +100,7 @@ Creates or updates an OAuth client configuration
Options:
--name Descriptive name for the OAuth client (required for creation)
- --secret Client secret (required for creation)
+ --secret Client secret
--scope Accepted scope (can be given more than once)
--grant Accepted grant type (can be given more than once)
--redirect-uri Accepted redirect URI (can be given more than once)
diff --git a/services/web/scripts/recurly/setup_assistant_addon.js b/services/web/scripts/recurly/setup_assistant_addon.js
index 0df44b54c9..bba4cb3e04 100644
--- a/services/web/scripts/recurly/setup_assistant_addon.js
+++ b/services/web/scripts/recurly/setup_assistant_addon.js
@@ -6,7 +6,7 @@ const minimist = require('minimist')
const Settings = require('@overleaf/settings')
const ADD_ON_CODE = 'assistant'
-const ADD_ON_NAME = 'Error Assist'
+const ADD_ON_NAME = 'AI Assist'
const INDIVIDUAL_PLANS = [
'student',
diff --git a/services/web/scripts/remove_emails_with_commas.mjs b/services/web/scripts/remove_emails_with_commas.mjs
new file mode 100644
index 0000000000..29d78b129c
--- /dev/null
+++ b/services/web/scripts/remove_emails_with_commas.mjs
@@ -0,0 +1,124 @@
+// @ts-check
+
+import minimist from 'minimist'
+import fs from 'node:fs/promises'
+import * as csv from 'csv'
+import { promisify } from 'node:util'
+import UserAuditLogHandler from '../app/src/Features/User/UserAuditLogHandler.js'
+import { db } from '../app/src/infrastructure/mongodb.js'
+
+const CSV_FILENAME = '/tmp/emails-with-commas.csv'
+
+/**
+ * @type {(csvString: string) => Promise}
+ */
+const parseAsync = promisify(csv.parse)
+
+function usage() {
+ console.log('Usage: node remove_emails_with_commas.mjs')
+ console.log(`Read emails from ${CSV_FILENAME} and remove them from users.`)
+ console.log('Add support+@overleaf.com instead.')
+ console.log('Options:')
+ console.log(' --commit apply the changes\n')
+ process.exit(0)
+}
+
+const { commit, help } = minimist(process.argv.slice(2), {
+ boolean: ['commit', 'help'],
+ alias: { help: 'h' },
+ default: { commit: false },
+})
+
+async function consumeCsvFileAndUpdate() {
+ console.time('remove_emails_with_commas')
+
+ const csvContent = await fs.readFile(CSV_FILENAME, 'utf8')
+ const rows = await parseAsync(csvContent)
+ const emailsWithComma = rows.map(row => row[0])
+
+ console.log('Total emails in the CSV:', emailsWithComma.length)
+
+ const unexpectedValidEmails = emailsWithComma.filter(
+ str => !str.includes(',')
+ )
+ if (unexpectedValidEmails.length > 0) {
+ throw new Error(
+ 'CSV file contains unexpected valid emails: ' +
+ JSON.stringify(emailsWithComma)
+ )
+ }
+
+ let updatedUsersCount = 0
+ for (const oldEmail of emailsWithComma) {
+ const encodedEmail = oldEmail
+ .replaceAll('_', '_5f')
+ .replaceAll('@', '_40')
+ .replaceAll(',', '_2c')
+ .replaceAll('<', '_60')
+ .replaceAll('>', '_62')
+
+ const newEmail = `support+${encodedEmail}@overleaf.com`
+
+ console.log(oldEmail, '->', newEmail)
+
+ const user = await db.users.findOne({ email: oldEmail })
+
+ if (!user) {
+ console.log('User not found for email:', oldEmail)
+ continue
+ }
+
+ if (commit) {
+ await db.users.updateOne(
+ { _id: user._id },
+ {
+ $set: { email: newEmail },
+ $pull: { emails: { email: oldEmail } },
+ }
+ )
+ await db.users.updateOne(
+ { _id: user._id },
+ {
+ $addToSet: {
+ emails: {
+ email: newEmail,
+ createdAt: Date.now(),
+ reversedHostname: 'moc.faelrevo',
+ },
+ },
+ }
+ )
+
+ await UserAuditLogHandler.promises.addEntry(
+ user._id,
+ 'remove-email',
+ undefined,
+ undefined,
+ {
+ removedEmail: oldEmail,
+ script: true,
+ note: 'remove primary email containing commas',
+ }
+ )
+ updatedUsersCount++
+ }
+ }
+
+ console.log('Updated users:', updatedUsersCount)
+
+ if (!commit) {
+ console.log('Note: this was a dry-run. No changes were made.')
+ }
+ console.log()
+ console.timeEnd('remove_emails_with_commas')
+ console.log()
+}
+
+try {
+ if (help) usage()
+ else await consumeCsvFileAndUpdate()
+ process.exit(0)
+} catch (error) {
+ console.error(error)
+ process.exit(1)
+}
diff --git a/services/web/test/acceptance/src/MalformedFiletreesTests.mjs b/services/web/test/acceptance/src/MalformedFiletreesTests.mjs
index 744781f8c8..16282d592d 100644
--- a/services/web/test/acceptance/src/MalformedFiletreesTests.mjs
+++ b/services/web/test/acceptance/src/MalformedFiletreesTests.mjs
@@ -5,6 +5,10 @@ import logger from '@overleaf/logger'
import { filterOutput } from './helpers/settings.mjs'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
+const lastUpdated = new Date(42)
+const lastUpdatedBy = new ObjectId()
+const lastUpdatedChanged = new Date(1337)
+
async function runScriptFind() {
try {
const result = await promisify(exec)(
@@ -36,7 +40,18 @@ async function runScriptFix(instructions) {
const findProjects = () =>
db.projects
- .find({}, { projection: { rootFolder: 1, _id: 1, version: 1 } })
+ .find(
+ {},
+ {
+ projection: {
+ rootFolder: 1,
+ _id: 1,
+ version: 1,
+ lastUpdated: 1,
+ lastUpdatedBy: 1,
+ },
+ }
+ )
.toArray()
const projectId = new ObjectId()
@@ -75,13 +90,15 @@ const wellFormedProject = {
fileRefs: [wellFormedFileRef('fr00'), wellFormedFileRef('fr01')],
},
],
+ lastUpdated,
+ lastUpdatedBy,
}
const testCases = [
...[{}, { rootFolder: undefined }, { rootFolder: '1234' }].map(
(project, idx) => ({
name: `bad rootFolder ${idx + 1}`,
- project: { _id: projectId, ...project },
+ project: { _id: projectId, ...project, lastUpdated, lastUpdatedBy },
expectFind: [
{
_id: null,
@@ -98,7 +115,7 @@ const testCases = [
{
name: `missing rootFolder`,
- project: { _id: projectId, rootFolder: [] },
+ project: { _id: projectId, rootFolder: [], lastUpdated, lastUpdatedBy },
expectFind: [
{
_id: null,
@@ -123,6 +140,8 @@ const testCases = [
docs: [],
},
],
+ lastUpdated: lastUpdatedChanged,
+ lastUpdatedBy: null,
})
},
},
@@ -132,6 +151,8 @@ const testCases = [
project: {
_id: projectId,
rootFolder: [{ _id: '1234' }],
+ lastUpdated,
+ lastUpdatedBy,
},
expectFind: [
{ reason: 'bad folder id', path: 'rootFolder.0._id' },
@@ -154,6 +175,8 @@ const testCases = [
project: {
_id: projectId,
rootFolder: [{ _id: rootFolderId }],
+ lastUpdated,
+ lastUpdatedBy,
},
expectFind: [
{ reason: 'bad folder name', path: 'rootFolder.0.name' },
@@ -180,6 +203,8 @@ const testCases = [
name: 'rootFolder',
},
],
+ lastUpdated: lastUpdatedChanged,
+ lastUpdatedBy: null,
})
},
},
@@ -197,6 +222,8 @@ const testCases = [
fileRefs: [null, null],
},
],
+ lastUpdated,
+ lastUpdatedBy,
},
expectFind: [
{
@@ -235,6 +262,8 @@ const testCases = [
folders: [],
},
],
+ lastUpdated: lastUpdatedChanged,
+ lastUpdatedBy: null,
})
},
},
@@ -255,6 +284,8 @@ const testCases = [
fileRefs: [{ _id: null, name: 'ref-a' }, { name: 'ref-b' }],
},
],
+ lastUpdated,
+ lastUpdatedBy,
},
expectFind: [
{ reason: 'bad folder id', path: 'rootFolder.0.folders.0._id', _id: 123 },
@@ -291,6 +322,8 @@ const testCases = [
fileRefs: [],
},
],
+ lastUpdated: lastUpdatedChanged,
+ lastUpdatedBy: null,
})
},
},
@@ -314,6 +347,8 @@ const testCases = [
],
},
],
+ lastUpdated,
+ lastUpdatedBy,
},
expectFind: [
{
@@ -386,6 +421,8 @@ const testCases = [
],
},
],
+ lastUpdated: lastUpdatedChanged,
+ lastUpdatedBy: null,
})
},
},
@@ -403,6 +440,8 @@ const testCases = [
],
},
],
+ lastUpdated,
+ lastUpdatedBy,
},
expectFind: [
{ path: 'rootFolder.0.fileRefs.0.hash', _id: strId('fa') },
@@ -442,6 +481,8 @@ const testCases = [
fileRefs: [null, null, { ...wellFormedFileRef('fr02'), name: null }],
},
],
+ lastUpdated,
+ lastUpdatedBy,
},
expectFind: [
{
@@ -508,6 +549,8 @@ const testCases = [
fileRefs: [{ ...wellFormedFileRef('fr02'), name: 'untitled' }],
},
],
+ lastUpdated: lastUpdatedChanged,
+ lastUpdatedBy: null,
})
},
},
@@ -539,6 +582,8 @@ const testCases = [
fileRefs: [],
},
],
+ lastUpdated,
+ lastUpdatedBy,
},
expectFind: [
{
@@ -637,6 +682,8 @@ const testCases = [
fileRefs: [],
},
],
+ lastUpdated: lastUpdatedChanged,
+ lastUpdatedBy: null,
})
},
},
@@ -674,6 +721,9 @@ describe('find_malformed_filetrees and fix_malformed_filetree scripts', function
expect(expectFixStdout).to.be.a('string')
expect(stdout).to.include(expectFixStdout)
const [updatedProject] = await findProjects()
+ if (updatedProject.lastUpdated > lastUpdated) {
+ updatedProject.lastUpdated = lastUpdatedChanged
+ }
expectProject(updatedProject)
})
}
diff --git a/services/web/test/acceptance/src/RemoveEmailsWithCommasScriptTest.mjs b/services/web/test/acceptance/src/RemoveEmailsWithCommasScriptTest.mjs
new file mode 100644
index 0000000000..f50f8f19df
--- /dev/null
+++ b/services/web/test/acceptance/src/RemoveEmailsWithCommasScriptTest.mjs
@@ -0,0 +1,226 @@
+import { promisify } from 'node:util'
+import { exec } from 'node:child_process'
+import { expect } from 'chai'
+import { filterOutput } from './helpers/settings.mjs'
+import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
+import fs from 'node:fs/promises'
+
+const CSV_FILENAME = '/tmp/emails-with-commas.csv'
+
+async function runScript(commit) {
+ const result = await promisify(exec)(
+ ['node', 'scripts/remove_emails_with_commas.mjs', commit && '--commit']
+ .filter(Boolean)
+ .join(' ')
+ )
+ return {
+ ...result,
+ stdout: result.stdout.split('\n').filter(filterOutput),
+ }
+}
+
+function createUser(email, emails) {
+ return {
+ _id: new ObjectId(),
+ email,
+ emails,
+ }
+}
+
+describe('scripts/remove_emails_with_commas', function () {
+ let user, unchangedUser
+
+ beforeEach(async function () {
+ await fs.writeFile(
+ CSV_FILENAME,
+ '"user,email@test.com"\n"user,another@test.com"\n'
+ )
+ })
+
+ afterEach(async function () {
+ try {
+ await fs.unlink(CSV_FILENAME)
+ } catch (err) {
+ // Ignore errors if file doesn't exist
+ }
+ })
+
+ describe('when removing email addresses with commas', function () {
+ beforeEach(async function () {
+ user = createUser('user,email@test.com', [
+ {
+ email: 'user,email@test.com',
+ createdAt: new Date(),
+ reversedHostname: 'moc.tset',
+ },
+ ])
+ await db.users.insertOne(user)
+
+ unchangedUser = createUser('john.doe@example.com', [
+ {
+ email: 'john.doe@example.com',
+ createdAt: new Date(),
+ reversedHostname: 'moc.elpmaxe',
+ },
+ ])
+ await db.users.insertOne(unchangedUser)
+ })
+
+ afterEach(async function () {
+ await db.users.deleteOne({ _id: user._id })
+ })
+
+ it('should replace emails with commas with encoded support emails', async function () {
+ const r = await runScript(true)
+
+ expect(r.stdout).to.include(
+ 'user,email@test.com -> support+user_2cemail_40test.com@overleaf.com'
+ )
+ expect(r.stdout).to.include('Updated users: 1')
+
+ const updatedUser = await db.users.findOne({ _id: user._id })
+ expect(updatedUser.email).to.equal(
+ 'support+user_2cemail_40test.com@overleaf.com'
+ )
+ expect(updatedUser.emails).to.have.length(1)
+ expect(updatedUser.emails[0].email).to.equal(
+ 'support+user_2cemail_40test.com@overleaf.com'
+ )
+ expect(updatedUser.emails[0].reversedHostname).to.equal('moc.faelrevo')
+
+ const unchanged = await db.users.findOne({ _id: unchangedUser._id })
+
+ expect(unchanged.emails).to.have.length(1)
+ expect(unchanged.email).to.equal('john.doe@example.com')
+ expect(unchanged.emails[0].email).to.equal('john.doe@example.com')
+ })
+
+ it('should not modify anything in dry run mode', async function () {
+ const r = await runScript(false)
+
+ expect(r.stdout).to.include(
+ 'user,email@test.com -> support+user_2cemail_40test.com@overleaf.com'
+ )
+ expect(r.stdout).to.include(
+ 'Note: this was a dry-run. No changes were made.'
+ )
+
+ const updatedUser = await db.users.findOne({ _id: user._id })
+ expect(updatedUser.email).to.equal('user,email@test.com')
+ expect(updatedUser.emails).to.have.length(1)
+ expect(updatedUser.emails[0].email).to.equal('user,email@test.com')
+ })
+ })
+
+ describe('when handling multiple email replacements', function () {
+ beforeEach(async function () {
+ user = createUser('user,email@test.com', [
+ {
+ email: 'user,email@test.com',
+ createdAt: new Date(),
+ reversedHostname: 'moc.tset',
+ },
+ {
+ email: 'normal@test.com',
+ createdAt: new Date(),
+ reversedHostname: 'moc.tset',
+ },
+ ])
+ await db.users.insertOne(user)
+ })
+
+ afterEach(async function () {
+ await db.users.deleteOne({ _id: user._id })
+ })
+
+ it('should only replace primary email with comma and keep other emails', async function () {
+ const r = await runScript(true)
+
+ expect(r.stdout).to.include(
+ 'user,email@test.com -> support+user_2cemail_40test.com@overleaf.com'
+ )
+ expect(r.stdout).to.include('Updated users: 1')
+
+ const updatedUser = await db.users.findOne({ _id: user._id })
+ expect(updatedUser.email).to.equal(
+ 'support+user_2cemail_40test.com@overleaf.com'
+ )
+ expect(updatedUser.emails).to.have.length(2)
+ expect(updatedUser.emails[0].email).to.equal('normal@test.com')
+ expect(updatedUser.emails[1].email).to.equal(
+ 'support+user_2cemail_40test.com@overleaf.com'
+ )
+ })
+ })
+
+ describe('when handling special characters in emails', function () {
+ beforeEach(async function () {
+ await fs.writeFile(
+ CSV_FILENAME,
+ '"user,email@test.com"\n","\n"user_special@test.co,"\n'
+ )
+
+ user = createUser('user,email@test.com', [
+ {
+ email: 'user,email@test.com',
+ createdAt: new Date(),
+ reversedHostname: 'moc.tset',
+ },
+ ])
+
+ await db.users.insertOne(user)
+
+ const user2 = createUser('user<>@test.com', [
+ {
+ email: 'user<>@test.com',
+ createdAt: new Date(),
+ reversedHostname: 'moc.tset',
+ },
+ ])
+
+ await db.users.insertOne(user2)
+ })
+
+ afterEach(async function () {
+ await db.users.deleteMany({
+ email: {
+ $in: [
+ 'support+user_2cemail_40test.com@overleaf.com',
+ 'support+user_60_62_40test.com@overleaf.com',
+ ],
+ },
+ })
+ })
+
+ it('should correctly encode various special characters', async function () {
+ const r = await runScript(true)
+
+ expect(r.stdout).to.include(
+ 'user,email@test.com -> support+user_2cemail_40test.com@overleaf.com'
+ )
+ expect(r.stdout).to.include(
+ ', -> support+_2c_60user_40test.com_62@overleaf.com'
+ )
+
+ const updatedUser1 = await db.users.findOne({ _id: user._id })
+ expect(updatedUser1.email).to.equal(
+ 'support+user_2cemail_40test.com@overleaf.com'
+ )
+ })
+ })
+
+ describe('when user does not exist', function () {
+ beforeEach(async function () {
+ await fs.writeFile(CSV_FILENAME, '"nonexistent,email@test.com"\n')
+ })
+
+ it('should handle missing users gracefully', async function () {
+ const r = await runScript(true)
+
+ expect(r.stdout).to.include(
+ 'User not found for email: nonexistent,email@test.com'
+ )
+ expect(r.stdout).to.include('Updated users: 0')
+ })
+ })
+})
diff --git a/services/web/test/acceptance/src/helpers/Subscription.mjs b/services/web/test/acceptance/src/helpers/Subscription.mjs
index ee6f1a7d8a..db5c9c5898 100644
--- a/services/web/test/acceptance/src/helpers/Subscription.mjs
+++ b/services/web/test/acceptance/src/helpers/Subscription.mjs
@@ -25,6 +25,7 @@ class PromisifiedSubscription {
this.ssoConfig = options.ssoConfig
this.groupPolicy = options.groupPolicy
this.addOns = options.addOns
+ this.paymentProvider = options.paymentProvider
}
async ensureExists() {
diff --git a/services/web/test/acceptance/src/helpers/User.mjs b/services/web/test/acceptance/src/helpers/User.mjs
index 5f8ac2903f..361466e259 100644
--- a/services/web/test/acceptance/src/helpers/User.mjs
+++ b/services/web/test/acceptance/src/helpers/User.mjs
@@ -36,6 +36,7 @@ class User {
this.request = request.defaults({
jar: this.jar,
})
+ this.signUpDate = options.signUpDate ?? new Date()
}
getSession(options, callback) {
@@ -425,7 +426,13 @@ class User {
UserModel.findOneAndUpdate(
filter,
- { $set: { hashedPassword, emails: this.emails } },
+ {
+ $set: {
+ hashedPassword,
+ emails: this.emails,
+ signUpDate: this.signUpDate,
+ },
+ },
options
)
.then(user => {
diff --git a/services/web/test/acceptance/src/helpers/request.js b/services/web/test/acceptance/src/helpers/request.js
index 16f8db0700..aa538892e4 100644
--- a/services/web/test/acceptance/src/helpers/request.js
+++ b/services/web/test/acceptance/src/helpers/request.js
@@ -1,5 +1,3 @@
-// TODO: This file was created by bulk-decaffeinate.
-// Sanity-check the conversion and remove this comment.
const BASE_URL = `http://${process.env.HTTP_TEST_HOST || '127.0.0.1'}:23000`
const request = require('request').defaults({
baseUrl: BASE_URL,
@@ -21,4 +19,5 @@ module.exports.promises = {
})
})
},
+ BASE_URL,
}
diff --git a/services/web/test/frontend/bootstrap.js b/services/web/test/frontend/bootstrap.js
index cdc9300138..e98d2c35de 100644
--- a/services/web/test/frontend/bootstrap.js
+++ b/services/web/test/frontend/bootstrap.js
@@ -98,3 +98,9 @@ globalThis.DOMParser = window.DOMParser
// Polyfill for IndexedDB
require('fake-indexeddb/auto')
+
+const fetchMock = require('fetch-mock').default
+
+fetchMock.spyGlobal()
+fetchMock.config.fetch = global.fetch
+fetchMock.config.Response = fetch.Response
diff --git a/services/web/test/frontend/components/pdf-preview/pdf-js-viewer.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-js-viewer.spec.tsx
index 12ba83cb48..0c15f912c9 100644
--- a/services/web/test/frontend/components/pdf-preview/pdf-js-viewer.spec.tsx
+++ b/services/web/test/frontend/components/pdf-preview/pdf-js-viewer.spec.tsx
@@ -1,7 +1,7 @@
import { EditorProviders } from '../../helpers/editor-providers'
import PdfJsViewer from '../../../../frontend/js/features/pdf-preview/components/pdf-js-viewer'
import { mockScope } from './scope'
-import { getContainerEl } from 'cypress/react'
+import { getContainerEl } from 'cypress/react18'
import { unmountComponentAtNode } from 'react-dom'
import { PdfPreviewProvider } from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-provider'
@@ -23,7 +23,7 @@ describe(' ', function () {
@@ -68,7 +68,7 @@ describe(' ', function () {
@@ -88,7 +88,7 @@ describe(' ', function () {
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 4f134006c2..60326d8d3f 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
@@ -18,7 +18,7 @@ describe(' ', function () {
entity: { _id: '123', name: '123 Doc' },
}
- const FileTreePathProvider: FC = ({ children }) => (
+ const FileTreePathProvider: FC = ({ children }) => (
', function () {
)
- const EditorManagerProvider: FC = ({ children }) => {
+ const EditorManagerProvider: FC = ({ children }) => {
const value = {
openDocWithId: cy.spy().as('openDocWithId'),
// @ts-ignore
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 245009de74..db637c1f77 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
@@ -28,18 +28,34 @@ const Layout: FC<{ layout: IdeLayout; view?: IdeView }> = ({
}
describe(' ', function () {
+ let projectId: string
beforeEach(function () {
+ /**
+ * There are time sensitive tests in this test suite. They need to wait for a Promise before resolving a request.
+ *
+ * Using a promise across the test-env (browser) vs stub-env (server) causes additional latency.
+ *
+ * This latency seems to stack up when adding more intercepts for the same path. Using static responses for some of these intercepts does not help.
+ *
+ * All of that seems like a bug in Cypress. For now just work around it by using a unique projectId for each intercept.
+ */
+ projectId = Math.random().toString().slice(2)
+
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set(
'ol-compilesUserContentDomain',
'https://compiles-user.dev-overleaf.com'
)
+ window.metaAttributesCache.set('ol-splitTestVariants', {
+ 'initial-compile-from-clsi-cache': 'enabled',
+ })
+ window.metaAttributesCache.set('ol-projectOwnerHasPremiumOnPageLoad', true)
cy.interceptEvents()
})
it('renders the PDF preview', function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
- cy.interceptCompile('compile')
+ cy.interceptCompile()
const scope = mockScope()
@@ -57,6 +73,216 @@ describe(' ', function () {
cy.findByRole('button', { name: 'Recompile' })
})
+ it('uses the cache when available', function () {
+ cy.interceptCompile({
+ prefix: 'compile',
+ times: 1,
+ cached: true,
+ regular: false,
+ })
+
+ const scope = mockScope()
+
+ cy.mount(
+
+
+
+ )
+
+ // wait for "compile from cache on load" to finish
+ cy.waitForCompile({ pdf: true, cached: true, regular: false })
+
+ cy.contains('Your Paper')
+ })
+
+ it('uses the cache when available then compiles', function () {
+ cy.interceptCompile({
+ prefix: 'compile',
+ times: 1,
+ cached: true,
+ regular: false,
+ })
+
+ const scope = mockScope()
+
+ cy.mount(
+
+
+
+ )
+
+ // wait for "compile from cache on load" to finish
+ cy.waitForCompile({ pdf: true, cached: true, regular: false })
+ cy.contains('Your Paper')
+
+ // Then trigger a new compile
+ cy.interceptCompile({
+ prefix: 'recompile',
+ times: 1,
+ cached: false,
+ outputPDFFixture: 'output-2.pdf',
+ })
+
+ // press the Recompile button => compile
+ cy.findByRole('button', { name: 'Recompile' }).click()
+
+ // wait for compile to finish
+ cy.waitForCompile({ prefix: 'recompile', pdf: true })
+ cy.contains('Modern Authoring Tools for Science')
+ })
+
+ describe('racing compile from cache and regular compile trigger', function () {
+ for (const [timing] of ['before rendering', 'after rendering']) {
+ it(`replaces the compile from cache with a regular compile - ${timing}`, function () {
+ const requestedOnce = new Set()
+ ;['log', 'pdf', 'blg'].forEach(ext => {
+ cy.intercept({ pathname: `/build/*/output.${ext}` }, req => {
+ if (requestedOnce.has(ext)) {
+ throw new Error(
+ `compile from cache triggered extra ${ext} request: ${req.url}`
+ )
+ }
+ requestedOnce.add(ext)
+ req.reply({ fixture: `build/output.${ext},null` })
+ }).as(`compile-${ext}`)
+ })
+ const { promise, resolve } = Promise.withResolvers()
+ cy.interceptCompileFromCacheRequest({
+ promise,
+ times: 1,
+ }).as('cached-compile')
+ cy.interceptCompileRequest().as('compile')
+
+ const scope = mockScope()
+ cy.mount(
+
+
+
+ )
+
+ // press the Recompile button => compile
+ cy.findByRole('button', { name: 'Recompile' }).click()
+
+ if (timing === 'before rendering') {
+ cy.then(() => resolve())
+ cy.wait('@cached-compile')
+ }
+
+ // wait for rendering to finish
+ cy.waitForCompile({ pdf: true, cached: false })
+
+ if (timing === 'after rendering') {
+ cy.then(() => resolve())
+ cy.wait('@cached-compile')
+ }
+
+ cy.contains('Your Paper')
+ cy.then(() => Array.from(requestedOnce).sort().join(',')).should(
+ 'equal',
+ 'blg,log,pdf'
+ )
+ })
+ }
+ })
+
+ describe('clsi-cache project settings validation', function () {
+ const cases = {
+ // Flaky, skip for now
+ 'uses compile from cache when nothing changed': {
+ cached: true,
+ setup: () => {},
+ props: {},
+ },
+ 'ignores the compile from cache when imageName changed': {
+ cached: false,
+ setup: () => {},
+ props: {
+ imageName: 'texlive-full:2025.1',
+ },
+ },
+ 'ignores the compile from cache when compiler changed': {
+ cached: false,
+ setup: () => {},
+ props: {
+ compiler: 'lualatex',
+ },
+ },
+ 'ignores the compile from cache when draft mode changed': {
+ cached: false,
+ setup: () => {
+ cy.window().then(w =>
+ w.localStorage.setItem(`draft:${projectId}`, 'true')
+ )
+ },
+ props: {},
+ },
+ 'ignores the compile from cache when stopOnFirstError mode changed': {
+ cached: false,
+ setup: () => {
+ cy.window().then(w =>
+ w.localStorage.setItem(`stop_on_first_error:${projectId}`, 'true')
+ )
+ },
+ props: {},
+ },
+ 'ignores the compile from cache when rootDoc changed': {
+ cached: false,
+ setup: () => {},
+ props: {
+ rootDocId: 'new-root-doc-id',
+ rootFolder: [
+ {
+ _id: 'root-folder-id',
+ name: 'rootFolder',
+ docs: [
+ {
+ _id: '_root_doc_id',
+ name: 'main.tex',
+ },
+ {
+ _id: 'new-root-doc-id',
+ name: 'new-main.tex',
+ },
+ ],
+ folders: [],
+ fileRefs: [],
+ },
+ ],
+ },
+ },
+ }
+ Object.entries(cases).forEach(([name, { cached, setup, props }]) => {
+ it(name, function () {
+ cy.interceptCompile({
+ cached: true,
+ regular: !cached,
+ })
+
+ const scope = mockScope()
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
+ setup()
+
+ cy.mount(
+
+
+
+ )
+
+ // wait for compile to finish
+ cy.waitForCompile({ pdf: true, cached, regular: !cached })
+ cy.contains('Your Paper')
+ })
+ })
+ })
+
it('runs a compile when the Recompile button is pressed', function () {
cy.interceptCompile()
@@ -479,7 +705,7 @@ describe(' ', function () {
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile({ pdf: true })
- cy.interceptCompile('recompile')
+ cy.interceptCompile({ prefix: 'recompile' })
cy.intercept('DELETE', '/project/*/output*', {
statusCode: 204,
diff --git a/services/web/test/frontend/components/pdf-preview/scope.tsx b/services/web/test/frontend/components/pdf-preview/scope.tsx
index 8e61102108..bb4e4d9d1d 100644
--- a/services/web/test/frontend/components/pdf-preview/scope.tsx
+++ b/services/web/test/frontend/components/pdf-preview/scope.tsx
@@ -10,6 +10,7 @@ export const mockScope = () => ({
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
+ hasBufferedOps: () => false,
},
view: new EditorView({
doc: '\\documentclass{article}',
diff --git a/services/web/test/frontend/components/shared/accessible-modal.spec.tsx b/services/web/test/frontend/components/shared/accessible-modal.spec.tsx
deleted file mode 100644
index 38fb995275..0000000000
--- a/services/web/test/frontend/components/shared/accessible-modal.spec.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Modal } from 'react-bootstrap'
-import AccessibleModal from '../../../../frontend/js/shared/components/accessible-modal'
-
-describe('AccessibleModal', function () {
- it('renders a visible modal', function () {
- const handleHide = cy.stub()
-
- cy.mount(
-
-
- Test
-
-
- Some content
-
- )
-
- cy.findByRole('dialog').should('have.length', 1)
- })
-
- it('does not render a hidden modal', function () {
- const handleHide = cy.stub()
-
- cy.mount(
-
-
- Test
-
-
- Some content
-
- )
-
- cy.findByRole('dialog', { hidden: true }).should('have.length', 0)
- })
-})
diff --git a/services/web/test/frontend/components/shared/select.spec.tsx b/services/web/test/frontend/components/shared/select.spec.tsx
index 17220f5ef0..fc94b76153 100644
--- a/services/web/test/frontend/components/shared/select.spec.tsx
+++ b/services/web/test/frontend/components/shared/select.spec.tsx
@@ -1,5 +1,7 @@
import { useCallback, FormEvent } from 'react'
-import { Button, Form, FormControl } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLForm from '@/features/ui/components/ol/ol-form'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import {
Select,
SelectProps,
@@ -62,17 +64,17 @@ describe(' ', function () {
it('renders default text', function () {
render({ defaultText: 'Choose an item' })
cy.findByTestId('spinner').should('not.exist')
- cy.findByRole('textbox', { name: 'Choose an item' })
+ cy.findByRole('combobox').should('have.value', 'Choose an item')
})
it('renders default item', function () {
render({ defaultItem: testData[2] })
- cy.findByRole('textbox', { name: 'Demo item 3' })
+ cy.findByRole('combobox').should('have.value', 'Demo item 3')
})
it('default item takes precedence over default text', function () {
render({ defaultText: 'Choose an item', defaultItem: testData[2] })
- cy.findByRole('textbox', { name: 'Demo item 3' })
+ cy.findByRole('combobox').should('have.value', 'Demo item 3')
})
it('renders label', function () {
@@ -81,8 +83,8 @@ describe(' ', function () {
label: 'test label',
optionalLabel: false,
})
- cy.findByRole('textbox', { name: 'test label' })
- cy.findByRole('textbox', { name: '(Optional)' }).should('not.exist')
+ cy.findByRole('combobox', { name: 'test label' })
+ cy.findByRole('combobox', { name: '(Optional)' }).should('not.exist')
})
it('renders optional label', function () {
@@ -91,7 +93,7 @@ describe(' ', function () {
label: 'test label',
optionalLabel: true,
})
- cy.findByRole('textbox', { name: 'test label (Optional)' })
+ cy.findByRole('combobox', { name: 'test label (Optional)' })
})
it('renders a spinner while loading when there is a label', function () {
@@ -115,7 +117,7 @@ describe(' ', function () {
describe('items rendering', function () {
it('renders all items', function () {
render({ defaultText: 'Choose an item' })
- cy.findByRole('textbox', { name: 'Choose an item' }).click()
+ cy.findByRole('combobox').click()
cy.findByRole('option', { name: 'Demo item 1' })
cy.findByRole('option', { name: 'Demo item 2' })
@@ -127,7 +129,7 @@ describe(' ', function () {
defaultText: 'Choose an item',
itemToSubtitle: x => String(x?.sub),
})
- cy.findByRole('textbox', { name: 'Choose an item' }).click()
+ cy.findByRole('combobox').click()
cy.findByRole('option', { name: 'Demo item 1 Subtitle 1' })
cy.findByRole('option', { name: 'Demo item 2 Subtitle 2' })
@@ -138,27 +140,27 @@ describe(' ', function () {
describe('item selection', function () {
it('cannot select an item when disabled', function () {
render({ defaultText: 'Choose an item', disabled: true })
- cy.findByRole('textbox', { name: 'Choose an item' }).click({
+ cy.findByRole('combobox').click({
force: true,
})
cy.findByRole('option', { name: 'Demo item 1' }).should('not.exist')
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
cy.findByRole('option', { name: 'Demo item 3' }).should('not.exist')
- cy.findByRole('textbox', { name: 'Choose an item' })
+ cy.findByRole('combobox').should('have.value', 'Choose an item')
})
it('renders only the selected item after selection', function () {
render({ defaultText: 'Choose an item' })
- cy.findByRole('textbox', { name: 'Choose an item' }).click()
+ cy.findByRole('combobox').click()
cy.findByRole('option', { name: 'Demo item 1' })
cy.findByRole('option', { name: 'Demo item 2' })
cy.findByRole('option', { name: 'Demo item 3' }).click()
- cy.findByRole('textbox', { name: 'Choose an item' }).should('not.exist')
+ cy.findByRole('combobox').should('not.have.value', 'Choose an item')
cy.findByRole('option', { name: 'Demo item 1' }).should('not.exist')
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
- cy.findByRole('textbox', { name: 'Demo item 3' })
+ cy.findByRole('combobox').should('have.value', 'Demo item 3')
})
it('invokes callback after selection', function () {
@@ -168,7 +170,7 @@ describe(' ', function () {
defaultText: 'Choose an item',
onSelectedItemChanged: selectionHandler,
})
- cy.findByRole('textbox', { name: 'Choose an item' }).click()
+ cy.findByRole('combobox').click()
cy.findByRole('option', { name: 'Demo item 2' }).click()
cy.get('@selectionHandler').should(
@@ -193,7 +195,7 @@ describe(' ', function () {
const submitHandler = cy.stub().as('submitHandler')
render({ defaultItem: testData[1], onSubmit: submitHandler })
- cy.findByRole('textbox', { name: 'Demo item 2' }).click() // open dropdown
+ cy.findByRole('combobox').click() // open dropdown
cy.findByText('Demo item 3').click() // choose a different item
cy.findByText('submit').click()
@@ -230,7 +232,7 @@ describe(' ', function () {
[]
)
- function handleSubmit(event: FormEvent) {
+ function handleSubmit(event: FormEvent) {
event.preventDefault()
const formData = new FormData(event.target as HTMLFormElement)
// a plain object is more convenient to work later with assertions
@@ -238,10 +240,10 @@ describe(' ', function () {
}
return (
-
-
- submit
-
+
+
+ submit
+
)
}
@@ -260,7 +262,7 @@ describe(' ', function () {
)
- cy.findByRole('textbox', { name: 'Demo item 1' }).click() // open dropdown
+ cy.findByRole('combobox').click() // open dropdown
cy.findByRole('option', { name: 'Demo item 3' }).click() // choose a different item
cy.findByText('submit').click()
@@ -273,11 +275,10 @@ describe('
', function () {
describe('keyboard navigation', function () {
it('can select an item using the keyboard', function () {
render({ defaultText: 'Choose an item' })
- cy.findByRole('textbox', { name: 'Choose an item' }).type(
- '{Enter}{downArrow}{Enter}',
- { force: true }
- )
- cy.findByRole('textbox', { name: 'Demo item 1' }).should('exist')
+ cy.findByRole('combobox').type('{Enter}{downArrow}{Enter}', {
+ force: true,
+ })
+ cy.findByRole('combobox').should('exist')
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
})
})
@@ -288,9 +289,9 @@ describe('
', function () {
defaultText: 'Choose an item',
selectedIcon: true,
})
- cy.findByRole('textbox', { name: 'Choose an item' }).click()
+ cy.findByRole('combobox').click()
cy.findByRole('option', { name: 'Demo item 1' }).click()
- cy.findByRole('textbox', { name: 'Demo item 1' }).click()
+ cy.findByRole('combobox').click()
cy.findByText('check').should('exist')
})
@@ -299,11 +300,11 @@ describe('
', function () {
defaultText: 'Choose an item',
selectedIcon: false,
})
- cy.findByRole('textbox', { name: 'Choose an item' }).click()
+ cy.findByRole('combobox').click()
cy.findByRole('option', { name: 'Demo item 1' }).click()
- cy.findByRole('textbox', { name: 'Demo item 1' }).click()
+ cy.findByRole('combobox').click()
- cy.findByText('check').should('exist')
+ cy.findByText('check').should('not.exist')
})
})
@@ -313,7 +314,7 @@ describe('
', function () {
defaultText: 'Choose an item',
itemToDisabled: x => x?.key === 2,
})
- cy.findByRole('textbox', { name: 'Choose an item' }).click()
+ cy.findByRole('combobox').click()
cy.findByRole('option', { name: 'Demo item 2' }).click({ force: true })
// still showing other list items
cy.findByRole('option', { name: 'Demo item 3' }).should('exist')
@@ -329,7 +330,7 @@ describe('
', function () {
defaultText: 'Choose an item',
selected: testData[1],
})
- cy.findByRole('textbox', { name: 'Demo item 2' }).should('exist')
+ cy.findByRole('combobox').should('have.value', 'Demo item 2')
})
it('should show default text when selected is null', function () {
@@ -337,7 +338,7 @@ describe('
', function () {
selected: null,
defaultText: 'Choose an item',
})
- cy.findByRole('textbox', { name: 'Choose an item' }).should('exist')
+ cy.findByRole('combobox').should('have.value', 'Choose an item')
})
})
})
diff --git a/services/web/test/frontend/components/shared/split-test-badge.spec.tsx b/services/web/test/frontend/components/shared/split-test-badge.spec.tsx
index 80f1d28be3..1f77f2548d 100644
--- a/services/web/test/frontend/components/shared/split-test-badge.spec.tsx
+++ b/services/web/test/frontend/components/shared/split-test-badge.spec.tsx
@@ -29,7 +29,8 @@ describe('split test badge', function () {
cy.findByRole('link', { name: /this is an alpha feature/i })
.should('have.attr', 'href', '/alpha/participate')
- .find('.badge.alpha-badge')
+ .find('.badge')
+ .contains('α')
})
it('does not render the alpha badge when user is not assigned to the variant', function () {
@@ -87,7 +88,8 @@ describe('split test badge', function () {
cy.findByRole('link', { name: /this is a beta feature/i })
.should('have.attr', 'href', '/beta/participate')
- .find('.badge.beta-badge')
+ .find('.badge')
+ .contains('β')
})
it('does not render the beta badge when user is not assigned to the variant', function () {
diff --git a/services/web/test/frontend/components/shared/tooltip.spec.tsx b/services/web/test/frontend/components/shared/tooltip.spec.tsx
index 764f05b891..29b2c75ede 100644
--- a/services/web/test/frontend/components/shared/tooltip.spec.tsx
+++ b/services/web/test/frontend/components/shared/tooltip.spec.tsx
@@ -1,6 +1,6 @@
-import Tooltip from '../../../../frontend/js/shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
-describe('
', function () {
+describe('
', function () {
it('calls the bound handler and blur then hides text on click', function () {
const clickHandler = cy.stub().as('clickHandler')
const blurHandler = cy.stub().as('blurHandler')
@@ -16,11 +16,11 @@ describe('
', function () {
height: '100vh',
}}
>
-
+
{btnText}
-
+
)
diff --git a/services/web/test/frontend/features/chat/components/chat-pane.test.jsx b/services/web/test/frontend/features/chat/components/chat-pane.test.jsx
index 5caf51097e..5a59b9b19f 100644
--- a/services/web/test/frontend/features/chat/components/chat-pane.test.jsx
+++ b/services/web/test/frontend/features/chat/components/chat-pane.test.jsx
@@ -23,10 +23,11 @@ describe(' ', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', user)
window.metaAttributesCache.set('ol-chatEnabled', true)
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
const testMessages = [
@@ -45,7 +46,7 @@ describe(' ', function () {
]
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
cleanUpContext()
stubMathJax()
@@ -73,7 +74,7 @@ describe(' ', function () {
await screen.findByText('Try again')
// bring chat back up
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(/messages/, [])
const reconnectButton = screen.getByRole('button', {
diff --git a/services/web/test/frontend/features/chat/context/chat-context.test.jsx b/services/web/test/frontend/features/chat/context/chat-context.test.jsx
index 76410a65fd..ddb69d3025 100644
--- a/services/web/test/frontend/features/chat/context/chat-context.test.jsx
+++ b/services/web/test/frontend/features/chat/context/chat-context.test.jsx
@@ -1,7 +1,7 @@
// Disable prop type checks for test harnesses
/* eslint-disable react/prop-types */
-import { renderHook, act } from '@testing-library/react-hooks/dom'
+import { renderHook, act, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
@@ -23,13 +23,14 @@ describe('ChatContext', function () {
const uuidValue = '00000000-0000-0000-0000-000000000000'
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
cleanUpContext()
stubMathJax()
window.metaAttributesCache.set('ol-user', user)
window.metaAttributesCache.set('ol-chatEnabled', true)
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
this.stub = sinon.stub(chatClientIdGenerator, 'generate').returns(uuidValue)
})
@@ -43,21 +44,22 @@ describe('ChatContext', function () {
describe('socket connection', function () {
beforeEach(function () {
// Mock GET messages to return no messages
- fetchMock.get('express:/project/:projectId/messages', [])
+ fetchMock.get('express:/project/:projectId/messages', [], {
+ name: 'fetchMessages',
+ })
// Mock POST new message to return 200
fetchMock.post('express:/project/:projectId/messages', 200)
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('subscribes when mounted', function () {
const socket = new SocketIOMock()
renderChatContextHook({ socket })
- // Assert that there is 1 listener
- expect(socket.events['new-chat-message']).to.have.length(1)
+ expect(socket.countEventListeners('new-chat-message')).to.equal(1)
})
it('unsubscribes when unmounted', function () {
@@ -66,20 +68,21 @@ describe('ChatContext', function () {
unmount()
- // Assert that there is 0 listeners
- expect(socket.events['new-chat-message'].length).to.equal(0)
+ expect(socket.countEventListeners('new-chat-message')).to.equal(0)
})
it('adds received messages to the list', async function () {
// Mock socket: we only need to emit events, not mock actual connections
const socket = new SocketIOMock()
- const { result, waitForNextUpdate } = renderChatContextHook({
+ const { result } = renderChatContextHook({
socket,
})
// Wait until initial messages have loaded
result.current.loadInitialMessages()
- await waitForNextUpdate()
+ await waitFor(
+ () => expect(result.current.initialMessagesLoaded).to.be.true
+ )
// No messages shown at first
expect(result.current.messages).to.deep.equal([])
@@ -96,21 +99,22 @@ describe('ChatContext', function () {
},
})
- const message = result.current.messages[0]
- expect(message.id).to.equal('msg_1')
- expect(message.contents).to.deep.equal(['new message'])
+ await waitFor(() => {
+ const message = result.current.messages[0]
+ expect(message.id).to.equal('msg_1')
+ expect(message.contents).to.deep.equal(['new message'])
+ })
})
it('deduplicate messages from preloading', async function () {
// Mock socket: we only need to emit events, not mock actual connections
const socket = new SocketIOMock()
- const { result, waitForNextUpdate } = renderChatContextHook({
+ const { result } = renderChatContextHook({
socket,
})
- fetchMock.get(
- 'express:/project/:projectId/messages',
- [
+ fetchMock.modifyRoute('fetchMessages', {
+ response: () => [
{
id: 'msg_1',
content: 'new message',
@@ -122,8 +126,7 @@ describe('ChatContext', function () {
},
},
],
- { overwriteRoutes: true }
- )
+ })
// Mock message being received from another user
socket.emitToClient('new-chat-message', {
@@ -138,11 +141,13 @@ describe('ChatContext', function () {
})
// Check if received the message ID
- expect(result.current.messages).to.have.length(1)
+ await waitFor(() => expect(result.current.messages).to.have.length(1))
// Wait until initial messages have loaded
result.current.loadInitialMessages()
- await waitForNextUpdate()
+ await waitFor(
+ () => expect(result.current.initialMessagesLoaded).to.be.true
+ )
// Check if there are no message duplication
expect(result.current.messages).to.have.length(1)
@@ -155,13 +160,12 @@ describe('ChatContext', function () {
it('deduplicate messages from websocket', async function () {
// Mock socket: we only need to emit events, not mock actual connections
const socket = new SocketIOMock()
- const { result, waitForNextUpdate } = renderChatContextHook({
+ const { result } = renderChatContextHook({
socket,
})
- fetchMock.get(
- 'express:/project/:projectId/messages',
- [
+ fetchMock.modifyRoute('fetchMessages', {
+ response: [
{
id: 'msg_1',
content: 'new message',
@@ -173,15 +177,13 @@ describe('ChatContext', function () {
},
},
],
- { overwriteRoutes: true }
- )
+ })
// Wait until initial messages have loaded
result.current.loadInitialMessages()
- await waitForNextUpdate()
// Check if received the message ID
- expect(result.current.messages).to.have.length(1)
+ await waitFor(() => expect(result.current.messages).to.have.length(1))
// Mock message being received from another user
socket.emitToClient('new-chat-message', {
@@ -205,13 +207,15 @@ describe('ChatContext', function () {
it("doesn't add received messages from the current user if a message was just sent", async function () {
const socket = new SocketIOMock()
- const { result, waitForNextUpdate } = renderChatContextHook({
+ const { result } = renderChatContextHook({
socket,
})
// Wait until initial messages have loaded
result.current.loadInitialMessages()
- await waitForNextUpdate()
+ await waitFor(
+ () => expect(result.current.initialMessagesLoaded).to.be.true
+ )
// Send a message from the current user
const sentMsg = 'sent message'
@@ -228,7 +232,7 @@ describe('ChatContext', function () {
})
})
- expect(result.current.messages).to.have.length(1)
+ await waitFor(() => expect(result.current.messages).to.have.length(1))
const [message] = result.current.messages
@@ -237,17 +241,21 @@ describe('ChatContext', function () {
it('adds the new message from the current user if another message was received after sending', async function () {
const socket = new SocketIOMock()
- const { result, waitForNextUpdate } = renderChatContextHook({
+ const { result } = renderChatContextHook({
socket,
})
// Wait until initial messages have loaded
result.current.loadInitialMessages()
- await waitForNextUpdate()
+ await waitFor(
+ () => expect(result.current.initialMessagesLoaded).to.be.true
+ )
// Send a message from the current user
const sentMsg = 'sent message from current user'
- result.current.sendMessage(sentMsg)
+ act(() => {
+ result.current.sendMessage(sentMsg)
+ })
const [sentMessageFromCurrentUser] = result.current.messages
expect(sentMessageFromCurrentUser.contents).to.deep.equal([sentMsg])
@@ -304,36 +312,41 @@ describe('ChatContext', function () {
})
it('adds messages to the list', async function () {
- const { result, waitForNextUpdate } = renderChatContextHook({})
+ const { result } = renderChatContextHook({})
result.current.loadInitialMessages()
- await waitForNextUpdate()
-
- expect(result.current.messages[0].contents).to.deep.equal(['a message'])
+ await waitFor(() =>
+ expect(result.current.messages[0].contents).to.deep.equal(['a message'])
+ )
})
it("won't load messages a second time", async function () {
- const { result, waitForNextUpdate } = renderChatContextHook({})
+ const { result } = renderChatContextHook({})
result.current.loadInitialMessages()
- await waitForNextUpdate()
- expect(result.current.initialMessagesLoaded).to.equal(true)
+ await waitFor(() =>
+ expect(result.current.initialMessagesLoaded).to.equal(true)
+ )
// Calling a second time won't do anything
result.current.loadInitialMessages()
- expect(fetchMock.calls()).to.have.lengthOf(1)
+ await waitFor(
+ () => expect(result.current.initialMessagesLoaded).to.be.true
+ )
+
+ expect(
+ fetchMock.callHistory.calls('express:/project/:projectId/messages')
+ ).to.have.lengthOf(1)
})
it('provides an error on failure', async function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/project/:projectId/messages', 500)
- const { result, waitForNextUpdate } = renderChatContextHook({})
+ const { result } = renderChatContextHook({})
result.current.loadInitialMessages()
- await waitForNextUpdate()
-
- expect(result.current.error).to.exist
+ await waitFor(() => expect(result.current.error).to.exist)
expect(result.current.status).to.equal('error')
})
})
@@ -350,14 +363,14 @@ describe('ChatContext', function () {
},
])
- const { result, waitForNextUpdate } = renderChatContextHook({})
+ const { result } = renderChatContextHook({})
result.current.loadMoreMessages()
- await waitForNextUpdate()
-
- expect(result.current.messages[0].contents).to.deep.equal([
- 'first message',
- ])
+ await waitFor(() =>
+ expect(result.current.messages[0].contents).to.deep.equal([
+ 'first message',
+ ])
+ )
// The before query param is not set
expect(getLastFetchMockQueryParam('before')).to.be.null
@@ -371,33 +384,32 @@ describe('ChatContext', function () {
// Resolve a full "page" of messages (50)
createMessages(50, user, new Date('2021-03-04T10:00:00').getTime())
)
- .getOnce(
- 'express:/project/:projectId/messages',
- [
- {
- id: 'msg_51',
- content: 'message from second page',
- user,
- timestamp: new Date('2021-03-04T11:00:00').getTime(),
- },
- ],
- { overwriteRoutes: false }
- )
+ .getOnce('express:/project/:projectId/messages', [
+ {
+ id: 'msg_51',
+ content: 'message from second page',
+ user,
+ timestamp: new Date('2021-03-04T11:00:00').getTime(),
+ },
+ ])
- const { result, waitForNextUpdate } = renderChatContextHook({})
+ const { result } = renderChatContextHook({})
result.current.loadMoreMessages()
- await waitForNextUpdate()
+ await waitFor(() =>
+ expect(result.current.messages[0].contents).to.have.length(50)
+ )
// Call a second time
result.current.loadMoreMessages()
- await waitForNextUpdate()
// The second request is added to the list
// Since both messages from the same user, they are collapsed into the
// same "message"
- expect(result.current.messages[0].contents).to.include(
- 'message from second page'
+ await waitFor(() =>
+ expect(result.current.messages[0].contents).to.include(
+ 'message from second page'
+ )
)
// The before query param for the second request matches the timestamp
@@ -407,24 +419,28 @@ describe('ChatContext', function () {
})
it("won't load more messages if there are no more messages", async function () {
- // Mock a GET request for 49 messages. This is less the the full page size
- // (50 messages), meaning that there are no further messages to be loaded
+ // Mock a GET request for 49 messages. This is less than the full page
+ // size (50 messages), meaning that there are no further messages to be
+ // loaded
fetchMock.getOnce(
'express:/project/:projectId/messages',
createMessages(49, user)
)
- const { result, waitForNextUpdate } = renderChatContextHook({})
+ const { result } = renderChatContextHook({})
result.current.loadMoreMessages()
- await waitForNextUpdate()
-
- expect(result.current.messages[0].contents).to.have.length(49)
+ await waitFor(() =>
+ expect(result.current.messages[0].contents).to.have.length(49)
+ )
result.current.loadMoreMessages()
- expect(result.current.atEnd).to.be.true
- expect(fetchMock.calls()).to.have.lengthOf(1)
+ await waitFor(() => expect(result.current.atEnd).to.be.true)
+
+ expect(
+ fetchMock.callHistory.calls('express:/project/:projectId/messages')
+ ).to.have.lengthOf(1)
})
it('handles socket messages while loading', async function () {
@@ -438,7 +454,7 @@ describe('ChatContext', function () {
)
const socket = new SocketIOMock()
- const { result, waitForNextUpdate } = renderChatContextHook({
+ const { result } = renderChatContextHook({
socket,
})
@@ -467,28 +483,27 @@ describe('ChatContext', function () {
timestamp: Date.now(),
},
])
- await waitForNextUpdate()
- // Although the loaded message was resolved last, it appears first (since
- // requested messages must have come first)
- const messageContents = result.current.messages.map(
- ({ contents }) => contents[0]
- )
- expect(messageContents).to.deep.equal([
- 'loaded message',
- 'socket message',
- ])
+ await waitFor(() => {
+ // Although the loaded message was resolved last, it appears first (since
+ // requested messages must have come first)
+ const messageContents = result.current.messages.map(
+ ({ contents }) => contents[0]
+ )
+ expect(messageContents).to.deep.equal([
+ 'loaded message',
+ 'socket message',
+ ])
+ })
})
it('provides an error on failures', async function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/project/:projectId/messages', 500)
- const { result, waitForNextUpdate } = renderChatContextHook({})
+ const { result } = renderChatContextHook({})
result.current.loadMoreMessages()
- await waitForNextUpdate()
-
- expect(result.current.error).to.exist
+ await waitFor(() => expect(result.current.error).to.exist)
expect(result.current.status).to.equal('error')
})
})
@@ -502,14 +517,16 @@ describe('ChatContext', function () {
.postOnce('express:/project/:projectId/messages', 200)
})
- it('optimistically adds the message to the list', function () {
+ it('optimistically adds the message to the list', async function () {
const { result } = renderChatContextHook({})
result.current.sendMessage('sent message')
- expect(result.current.messages[0].contents).to.deep.equal([
- 'sent message',
- ])
+ await waitFor(() =>
+ expect(result.current.messages[0].contents).to.deep.equal([
+ 'sent message',
+ ])
+ )
})
it('POSTs the message to the backend', function () {
@@ -517,10 +534,11 @@ describe('ChatContext', function () {
result.current.sendMessage('sent message')
- const [, { body }] = fetchMock.lastCall(
- 'express:/project/:projectId/messages',
- 'POST'
- )
+ const {
+ options: { body },
+ } = fetchMock.callHistory
+ .calls('express:/project/:projectId/messages', { method: 'POST' })
+ .at(-1)
expect(JSON.parse(body)).to.deep.include({ content: 'sent message' })
})
@@ -531,23 +549,22 @@ describe('ChatContext', function () {
expect(result.current.messages).to.be.empty
expect(
- fetchMock.called('express:/project/:projectId/messages', {
+ fetchMock.callHistory.called('express:/project/:projectId/messages', {
method: 'post',
})
).to.be.false
})
it('provides an error on failure', async function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock
.get('express:/project/:projectId/messages', [])
.postOnce('express:/project/:projectId/messages', 500)
- const { result, waitForNextUpdate } = renderChatContextHook({})
+ const { result } = renderChatContextHook({})
result.current.sendMessage('sent message')
- await waitForNextUpdate()
+ await waitFor(() => expect(result.current.error).to.exist)
- expect(result.current.error).to.exist
expect(result.current.status).to.equal('error')
})
})
@@ -563,11 +580,13 @@ describe('ChatContext', function () {
const { result } = renderChatContextHook({ socket })
// Receive a new message from the socket
- socket.emitToClient('new-chat-message', {
- id: 'msg_1',
- content: 'new message',
- timestamp: Date.now(),
- user,
+ act(() => {
+ socket.emitToClient('new-chat-message', {
+ id: 'msg_1',
+ content: 'new message',
+ timestamp: Date.now(),
+ user,
+ })
})
expect(result.current.unreadMessageCount).to.equal(1)
@@ -616,7 +635,7 @@ function createMessages(number, user, timestamp = Date.now()) {
* Get query param by key from the last fetchMock response
*/
function getLastFetchMockQueryParam(key) {
- const url = fetchMock.lastUrl()
+ const { url } = fetchMock.callHistory.calls().at(-1)
const { searchParams } = new URL(url, 'https://www.overleaf.com')
return searchParams.get(key)
}
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 4c6d49f752..e22a2c1791 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
@@ -7,11 +7,12 @@ import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe(' ', function () {
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
after(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
const project = {
@@ -78,19 +79,23 @@ describe(' ', function () {
fireEvent.click(submitButton)
expect(submitButton.disabled).to.be.true
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
- const [url, options] = fetchMock.lastCall(
- 'express:/project/:projectId/clone'
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
+ const { url, options } = fetchMock.callHistory
+ .calls('express:/project/:projectId/clone')
+ .at(-1)
+ expect(url).to.equal(
+ 'https://www.test-overleaf.com/project/project-1/clone'
)
- expect(url).to.equal('/project/project-1/clone')
expect(JSON.parse(options.body)).to.deep.equal({
projectName: 'A Cloned Project',
tags: [],
})
- expect(openProject).to.be.calledOnce
+ await waitFor(() => {
+ expect(openProject).to.be.calledOnce
+ })
const errorMessage = screen.queryByText('Sorry, something went wrong')
expect(errorMessage).to.be.null
@@ -129,7 +134,7 @@ describe(' ', function () {
fireEvent.click(button)
- expect(fetchMock.done(matcher)).to.be.true
+ expect(fetchMock.callHistory.done(matcher)).to.be.true
expect(openProject).not.to.be.called
await screen.findByText('Sorry, something went wrong')
@@ -165,9 +170,9 @@ describe(' ', function () {
expect(cancelButton.disabled).to.be.false
fireEvent.click(button)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- expect(fetchMock.done(matcher)).to.be.true
+ expect(fetchMock.callHistory.done(matcher)).to.be.true
expect(openProject).not.to.be.called
await screen.findByText('There was an error!')
diff --git a/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx
index 1a239e8147..aea2592bad 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx
@@ -4,23 +4,18 @@ import sinon from 'sinon'
import { expect } from 'chai'
import ActionsCopyProject from '../../../../../frontend/js/features/editor-left-menu/components/actions-copy-project'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
describe(' ', function () {
- let assignStub
-
beforeEach(function () {
- assignStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
afterEach(function () {
- this.locationStub.restore()
- fetchMock.reset()
+ this.locationWrapperSandbox.restore()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct modal when clicked', async function () {
@@ -53,6 +48,7 @@ describe(' ', function () {
expect(button.textContent).to.equal('Copying…')
})
+ const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.calledOnceWith('/project/new-project')
})
diff --git a/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx
index 5f1b7d790c..d48e172c49 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx
@@ -21,7 +21,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu for non-anonymous users', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.jsx
index 86667b8f17..ec45304035 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.jsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.jsx
@@ -6,7 +6,7 @@ import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct modal when clicked after document is compiled', async function () {
@@ -51,11 +51,15 @@ describe(' ', function () {
// when loading, we don't render the "Word Count" as button yet
expect(screen.queryByRole('button', { name: 'Word Count' })).to.equal(null)
- await waitFor(() => expect(fetchMock.called(compileEndpoint)).to.be.true)
+ await waitFor(
+ () => expect(fetchMock.callHistory.called(compileEndpoint)).to.be.true
+ )
const button = await screen.findByRole('button', { name: 'Word Count' })
button.click()
- await waitFor(() => expect(fetchMock.called(wordcountEndpoint)).to.be.true)
+ await waitFor(
+ () => expect(fetchMock.callHistory.called(wordcountEndpoint)).to.be.true
+ )
})
})
diff --git a/services/web/test/frontend/features/editor-left-menu/components/download-menu.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/download-menu.test.jsx
index 065a9316a1..b6295dfa07 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/download-menu.test.jsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/download-menu.test.jsx
@@ -6,7 +6,7 @@ import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows download links with correct url', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx
index 1a29a5d2e5..9b37107fad 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx
@@ -14,7 +14,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('open contact us modal when clicked', function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx
index 4831939e88..eec6fdce31 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx
@@ -14,7 +14,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu if `showSupport` is `true`', function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/help-show-hotkeys.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/help-show-hotkeys.test.jsx
index 2376e056cd..369fd5bbbd 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/help-show-hotkeys.test.jsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/help-show-hotkeys.test.jsx
@@ -6,7 +6,7 @@ import fetchMock from 'fetch-mock'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('open hotkeys modal when clicked', function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-close-brackets.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-close-brackets.test.tsx
index af0a625ca7..fff0f3ada7 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-close-brackets.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-close-brackets.test.tsx
@@ -7,7 +7,7 @@ import { EditorProviders } from '../../../../helpers/editor-providers'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-complete.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-complete.test.tsx
index 82539b39f6..c0b58d307d 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-complete.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-complete.test.tsx
@@ -7,7 +7,7 @@ import { EditorProviders } from '../../../../helpers/editor-providers'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-compiler.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-compiler.test.tsx
index 8dd848a1f6..3471b3dcf7 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-compiler.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-compiler.test.tsx
@@ -7,7 +7,7 @@ import { EditorProviders } from '../../../../helpers/editor-providers'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx
index f7ade270cb..41b6d67746 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx
@@ -30,7 +30,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
window.metaAttributesCache.set('ol-ExposedSettings', originalSettings)
})
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx
index 2b42838a3f..f7b716e984 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx
@@ -16,7 +16,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-family.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-family.test.tsx
index 2a896982c8..36a4f3838a 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-family.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-family.test.tsx
@@ -7,7 +7,7 @@ import { EditorProviders } from '../../../../helpers/editor-providers'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-size.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-size.test.tsx
index 964e81ac46..b65dabb758 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-size.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-size.test.tsx
@@ -9,7 +9,7 @@ describe(' ', function () {
const sizes = ['10', '11', '12', '13', '14', '16', '18', '20', '22', '24']
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx
index c59a44acef..b164c02f65 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx
@@ -23,7 +23,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-keybindings.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-keybindings.test.tsx
index d924a64c9c..d02f4a0701 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-keybindings.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-keybindings.test.tsx
@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-line-height.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-line-height.test.tsx
index 7bc18eb028..c914a9e295 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-line-height.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-line-height.test.tsx
@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-math-preview.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-math-preview.test.tsx
index 0fced14737..ecd2fbe34a 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-math-preview.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-math-preview.test.tsx
@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-overall-theme.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-overall-theme.test.tsx
index 8a5cc00338..83a46e662d 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-overall-theme.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-overall-theme.test.tsx
@@ -32,7 +32,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-pdf-viewer.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-pdf-viewer.test.tsx
index 388016cbb3..d9b15e6bfa 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-pdf-viewer.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-pdf-viewer.test.tsx
@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-spell-check-language.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-spell-check-language.test.tsx
index ed7e4404ed..1b345f9ef6 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-spell-check-language.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-spell-check-language.test.tsx
@@ -25,7 +25,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-syntax-validation.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-syntax-validation.test.tsx
index 1c38ca039f..3b1034f7b5 100644
--- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-syntax-validation.test.tsx
+++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-syntax-validation.test.tsx
@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
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.jsx
index 19173c69e3..55a5213a09 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.jsx
@@ -1,7 +1,7 @@
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import { expect } from 'chai'
-import { screen } from '@testing-library/react'
+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'
@@ -22,7 +22,7 @@ describe(' ', function () {
afterEach(function () {
openStub.restore()
sendMBSpy.restore()
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
})
it('should mark current layout option as selected', async function () {
@@ -31,13 +31,15 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Layout' }).click()
- expect(
- screen
- .getByRole('menuitem', {
- name: 'Editor & PDF',
- })
- .getAttribute('aria-selected')
- ).to.equal('false')
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole('menuitem', {
+ name: 'Editor & PDF',
+ })
+ .getAttribute('aria-selected')
+ ).to.equal('false')
+ )
expect(
screen
@@ -64,7 +66,7 @@ describe(' ', function () {
).to.equal('false')
})
- it('should not select any option in history view', function () {
+ it('should not select any option in history view', async function () {
// Selected is aria-label, visually we show a checkmark
renderWithEditorContext( , {
ui: { ...defaultUi, view: 'history' },
@@ -72,13 +74,15 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Layout' }).click()
- expect(
- screen
- .getByRole('menuitem', {
- name: 'Editor & PDF',
- })
- .getAttribute('aria-selected')
- ).to.equal('false')
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole('menuitem', {
+ name: 'Editor & PDF',
+ })
+ .getAttribute('aria-selected')
+ ).to.equal('false')
+ )
expect(
screen
@@ -105,7 +109,7 @@ describe(' ', function () {
).to.equal('false')
})
- it('should treat file and editor views the same way', function () {
+ it('should treat file and editor views the same way', async function () {
// Selected is aria-label, visually we show a checkmark
renderWithEditorContext( , {
ui: {
@@ -116,13 +120,15 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Layout' }).click()
- expect(
- screen
- .getByRole('menuitem', {
- name: 'Editor & PDF',
- })
- .getAttribute('aria-selected')
- ).to.equal('false')
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole('menuitem', {
+ name: 'Editor & PDF',
+ })
+ .getAttribute('aria-selected')
+ ).to.equal('false')
+ )
expect(
screen
@@ -149,9 +155,9 @@ describe(' ', function () {
).to.equal('false')
})
- describe('on detach', function () {
+ describe('on detach', async function () {
let originalBroadcastChannel
- beforeEach(function () {
+ beforeEach(async function () {
window.BroadcastChannel = originalBroadcastChannel || true // ensure that window.BroadcastChannel is truthy
renderWithEditorContext( , {
@@ -160,19 +166,21 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Layout' }).click()
- screen
- .getByRole('menuitem', {
- name: 'PDF in separate tab',
- })
- .click()
+ await waitFor(() =>
+ screen
+ .getByRole('menuitem', {
+ name: 'PDF in separate tab',
+ })
+ .click()
+ )
})
afterEach(function () {
window.BroadcastChannel = originalBroadcastChannel
})
- it('should show processing', function () {
- screen.getByText('Layout processing')
+ it('should show processing', async function () {
+ await screen.findByText('Layout processing')
})
it('should record event', function () {
@@ -180,8 +188,8 @@ describe(' ', function () {
})
})
- describe('on layout change / reattach', function () {
- beforeEach(function () {
+ describe('on layout change / reattach', async function () {
+ beforeEach(async function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
renderWithEditorContext( , {
ui: { ...defaultUi, view: 'editor' },
@@ -189,11 +197,13 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Layout' }).click()
- screen
- .getByRole('menuitem', {
- name: 'Editor only (hide PDF)',
- })
- .click()
+ await waitFor(() =>
+ screen
+ .getByRole('menuitem', {
+ name: 'Editor only (hide PDF)',
+ })
+ .click()
+ )
})
it('should not show processing', function () {
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.jsx
index f8441aae56..84b1e680ef 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.jsx
@@ -28,6 +28,7 @@ describe(' ', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-chatEnabled', true)
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
describe('cobranding logo', function () {
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-folder.spec.tsx b/services/web/test/frontend/features/file-tree/components/file-tree-folder.spec.tsx
index 24dc139de7..e371bee72f 100644
--- a/services/web/test/frontend/features/file-tree/components/file-tree-folder.spec.tsx
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-folder.spec.tsx
@@ -1,7 +1,7 @@
import FileTreeFolder from '../../../../../frontend/js/features/file-tree/components/file-tree-folder'
import { EditorProviders } from '../../../helpers/editor-providers'
import { FileTreeProvider } from '../helpers/file-tree-provider'
-import { getContainerEl } from 'cypress/react'
+import { getContainerEl } from 'cypress/react18'
import ReactDom from 'react-dom'
describe(' ', function () {
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx b/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx
index 70dad331a2..e663541049 100644
--- a/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx
@@ -5,7 +5,7 @@ import { FileTreeProvider } from '../helpers/file-tree-provider'
describe(' ', function () {
it('without selected files', function () {
cy.mount(
-
+
diff --git a/services/web/test/frontend/features/file-tree/helpers/file-tree-provider.tsx b/services/web/test/frontend/features/file-tree/helpers/file-tree-provider.tsx
index 714128f946..400b43baaa 100644
--- a/services/web/test/frontend/features/file-tree/helpers/file-tree-provider.tsx
+++ b/services/web/test/frontend/features/file-tree/helpers/file-tree-provider.tsx
@@ -1,9 +1,11 @@
import { ComponentProps, FC, useRef, useState } from 'react'
import FileTreeContext from '@/features/file-tree/components/file-tree-context'
-export const FileTreeProvider: FC<{
- refProviders?: Record
-}> = ({ children, refProviders = {} }) => {
+export const FileTreeProvider: FC<
+ React.PropsWithChildren<{
+ refProviders?: Record
+ }>
+> = ({ children, refProviders = {} }) => {
const [fileTreeContainer, setFileTreeContainer] =
useState(null)
diff --git a/services/web/test/frontend/features/file-view/components/file-view-header.test.jsx b/services/web/test/frontend/features/file-view/components/file-view-header.test.jsx
index 83c8edb7bc..11a61d9905 100644
--- a/services/web/test/frontend/features/file-view/components/file-view-header.test.jsx
+++ b/services/web/test/frontend/features/file-view/components/file-view-header.test.jsx
@@ -39,7 +39,7 @@ describe(' ', function () {
}
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
describe('header text', function () {
diff --git a/services/web/test/frontend/features/file-view/components/file-view-refresh-button.test.jsx b/services/web/test/frontend/features/file-view/components/file-view-refresh-button.test.jsx
index 6e05e4e5d9..da29252be0 100644
--- a/services/web/test/frontend/features/file-view/components/file-view-refresh-button.test.jsx
+++ b/services/web/test/frontend/features/file-view/components/file-view-refresh-button.test.jsx
@@ -23,7 +23,7 @@ describe(' ', function () {
}
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
// eslint-disable-next-line mocha/no-skipped-tests
diff --git a/services/web/test/frontend/features/file-view/components/file-view-text.test.jsx b/services/web/test/frontend/features/file-view/components/file-view-text.test.jsx
index ec19f57ecf..1ca50fbb8c 100644
--- a/services/web/test/frontend/features/file-view/components/file-view-text.test.jsx
+++ b/services/web/test/frontend/features/file-view/components/file-view-text.test.jsx
@@ -19,7 +19,8 @@ describe(' ', function () {
}
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
it('renders a text view', async function () {
diff --git a/services/web/test/frontend/features/file-view/components/file-view.test.jsx b/services/web/test/frontend/features/file-view/components/file-view.test.jsx
index be32d1fa4f..7159f1ceaf 100644
--- a/services/web/test/frontend/features/file-view/components/file-view.test.jsx
+++ b/services/web/test/frontend/features/file-view/components/file-view.test.jsx
@@ -32,7 +32,8 @@ describe(' ', function () {
}
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
describe('for a text file', function () {
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 9041214b20..261d43017f 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
@@ -95,7 +95,7 @@ const createInitialValue = () =>
setProjectSearchIsOpen: cy.stub(),
}) satisfies LayoutContextValue
-const LayoutProvider: FC = ({ children }) => {
+const LayoutProvider: FC = ({ children }) => {
const [value] = useState(createInitialValue)
return (
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 ab5ddb8355..91f2dd9841 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
@@ -1,6 +1,7 @@
import AddSeats, {
MAX_NUMBER_OF_USERS,
} from '@/features/group-management/components/add-seats/add-seats'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
describe(' ', function () {
beforeEach(function () {
@@ -11,9 +12,17 @@ describe(' ', function () {
win.metaAttributesCache.set('ol-subscriptionId', '123')
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( )
+ cy.mount(
+
+
+
+ )
cy.findByRole('button', { name: /buy licenses/i })
cy.findByTestId('add-more-users-group-form')
@@ -68,6 +77,28 @@ describe(' ', function () {
)
})
+ describe('PO number', function () {
+ it('should not render the PO checkbox and PO input if collection method is not manual', function () {
+ cy.window().then(win => {
+ win.metaAttributesCache.set('ol-isCollectionMethodManual', false)
+ })
+ cy.mount(
+
+
+
+ )
+
+ cy.findByLabelText(/i want to add a po number/i).should('not.exist')
+ cy.findByLabelText(/^po number$/i).should('not.exist')
+ })
+
+ it('should check the PO checkbox in order to activate the PO input field', function () {
+ cy.findByLabelText(/^po number$/i).should('not.exist')
+ cy.findByLabelText(/i want to add a po number/i).check()
+ cy.findByLabelText(/^po number$/i)
+ })
+ })
+
describe('"Upgrade my plan" link', function () {
it('shows the link', function () {
cy.findByRole('link', { name: /upgrade my plan/i }).should(
@@ -82,7 +113,11 @@ describe(' ', function () {
win.metaAttributesCache.set('ol-isProfessional', true)
})
- cy.mount( )
+ cy.mount(
+
+
+
+ )
cy.findByRole('link', { name: /upgrade my plan/i }).should('not.exist')
})
@@ -325,16 +360,23 @@ describe(' ', function () {
})
function makeRequest(statusCode: number, adding: string) {
+ const PO_NUMBER = 'PO123456789'
cy.intercept('POST', '/user/subscription/group/add-users/create', {
statusCode,
}).as('addUsersRequest')
cy.get('@input').type(adding)
+ cy.findByLabelText(/i want to add a po number/i).check()
+ cy.findByLabelText(/^po number$/i).type(PO_NUMBER)
cy.get('@addUsersBtn').click()
+
+ const body = {
+ adding: Number(adding),
+ poNumber: PO_NUMBER,
+ }
cy.get('@addUsersRequest')
.its('request.body')
- .should('deep.equal', {
- adding: Number(adding),
- })
+ .should('deep.equal', body)
+ .and('have.keys', Object.keys(body))
cy.findByTestId('add-more-users-group-form').should('not.exist')
}
diff --git a/services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx b/services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx
index b4787e97c0..57eb8a30c0 100644
--- a/services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx
+++ b/services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { ReactElement } from 'react'
+import { FC, PropsWithChildren, ReactElement } from 'react'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import UnlinkUserModal from '@/features/group-management/components/members-table/unlink-user-modal'
@@ -7,11 +7,9 @@ import { GroupMembersProvider } from '@/features/group-management/context/group-
import { expect } from 'chai'
export function renderWithContext(component: ReactElement, props = {}) {
- const GroupMembersProviderWrapper = ({
- children,
- }: {
- children: ReactElement
- }) => {children}
+ const GroupMembersProviderWrapper: FC = ({ children }) => (
+ {children}
+ )
return render(component, { wrapper: GroupMembersProviderWrapper })
}
@@ -31,7 +29,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('displays the modal', async function () {
@@ -58,6 +56,15 @@ describe(' ', function () {
await waitFor(() => expect(defaultProps.onClose).to.have.been.called)
})
+ it('closes the modal on cancelling', async function () {
+ renderWithContext( )
+
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' })
+ fireEvent.click(cancelButton)
+
+ await waitFor(() => expect(defaultProps.onClose).to.have.been.called)
+ })
+
it('handles errors', async function () {
fetchMock.post(`/manage/groups/${groupId}/unlink-user/${userId}`, 500)
diff --git a/services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx b/services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx
index 4eb137c0dc..d9e8de6770 100644
--- a/services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx
+++ b/services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx
@@ -25,7 +25,7 @@ describe(' ', function () {
}).should(
'have.attr',
'href',
- '/user/subscription/recurly/billing-details'
+ '/user/subscription/payment/billing-details'
)
cy.findByRole('link', { name: /get in touch/i }).should(
'have.attr',
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 05021e9cfd..b3a1071015 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
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, FC } from 'react'
import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
import {
@@ -10,13 +10,14 @@ import { HistoryProvider } from '../../../../../frontend/js/features/history/con
import { updates } from '../fixtures/updates'
import { labels } from '../fixtures/labels'
import { formatTime, relativeDate } from '@/features/utils/format-date'
+import { withTestContainerErrorBoundary } from '../../../helpers/error-boundary'
-const mountWithEditorProviders = (
- component: React.ReactNode,
- scope: Record = {},
- props: Record = {}
-) => {
- cy.mount(
+const TestContainerWithoutErrorBoundary: FC<{
+ component: React.ReactNode
+ scope: Record
+ props: Record
+}> = ({ component, scope, props }) => {
+ return (
@@ -27,6 +28,18 @@ const mountWithEditorProviders = (
)
}
+const TestContainer = withTestContainerErrorBoundary(
+ TestContainerWithoutErrorBoundary
+)
+
+const mountWithEditorProviders = (
+ component: React.ReactNode,
+ scope: Record
= {},
+ props: Record = {}
+) => {
+ cy.mount( )
+}
+
describe('change list (Bootstrap 5)', function () {
const scope = {
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
diff --git a/services/web/test/frontend/features/history/components/document-diff-viewer.spec.tsx b/services/web/test/frontend/features/history/components/document-diff-viewer.spec.tsx
index 940626b39c..cfa80381d7 100644
--- a/services/web/test/frontend/features/history/components/document-diff-viewer.spec.tsx
+++ b/services/web/test/frontend/features/history/components/document-diff-viewer.spec.tsx
@@ -59,7 +59,7 @@ const highlights: Highlight[] = [
},
]
-const Container: FC = ({ children }) => (
+const Container: FC = ({ children }) => (
{children}
)
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 18b8b929e8..f60beff7ce 100644
--- a/services/web/test/frontend/features/history/components/toolbar.spec.tsx
+++ b/services/web/test/frontend/features/history/components/toolbar.spec.tsx
@@ -3,6 +3,28 @@ import { HistoryProvider } from '../../../../../frontend/js/features/history/con
import { HistoryContextValue } from '../../../../../frontend/js/features/history/context/types/history-context-value'
import { Diff } from '../../../../../frontend/js/features/history/services/types/doc'
import { EditorProviders } from '../../../helpers/editor-providers'
+import { FC } from 'react'
+import { withTestContainerErrorBoundary } from '../../../helpers/error-boundary'
+
+const TestContainerWithoutErrorBoundary: FC<{
+ scope: Record
+ diff: Diff
+ selection: HistoryContextValue['selection']
+}> = ({ scope, diff, selection }) => {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+const TestContainer = withTestContainerErrorBoundary(
+ TestContainerWithoutErrorBoundary
+)
describe('history toolbar', function () {
const editorProvidersScope = {
@@ -58,13 +80,11 @@ describe('history toolbar', function () {
}
cy.mount(
-
-
-
-
-
-
-
+
)
cy.get('.history-react-toolbar').within(() => {
@@ -108,13 +128,11 @@ describe('history toolbar', function () {
}
cy.mount(
-
-
-
-
-
-
-
+
)
cy.get('.history-react-toolbar').within(() => {
diff --git a/services/web/test/frontend/features/project-list/components/load-more.test.tsx b/services/web/test/frontend/features/project-list/components/load-more.test.tsx
index bc99584212..553fbcc487 100644
--- a/services/web/test/frontend/features/project-list/components/load-more.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/load-more.test.tsx
@@ -11,7 +11,7 @@ import { renderWithProjectListContext } from '../helpers/render-with-context'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders on a project list longer than 40', async function () {
diff --git a/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx
index 93c7a68e3a..1c86b861a3 100644
--- a/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx
@@ -7,7 +7,7 @@ import getMeta from '@/utils/meta'
describe(' ', function () {
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
describe('for every user (affiliated and non-affiliated)', function () {
diff --git a/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx b/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
index f33ad2f345..7b9a070a85 100644
--- a/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
@@ -3,24 +3,17 @@ import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import sinon from 'sinon'
import ModalContentNewProjectForm from '../../../../../../frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form'
-import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
describe(' ', function () {
- let assignStub: sinon.SinonStub
-
beforeEach(function () {
- assignStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: sinon.stub(),
- setHash: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
})
afterEach(function () {
- this.locationStub.restore()
- fetchMock.reset()
+ this.locationWrapperSandbox.restore()
+ fetchMock.removeRoutes().clearHistory()
})
it('submits form', async function () {
@@ -49,12 +42,13 @@ describe(' ', function () {
fireEvent.click(createButton)
- expect(newProjectMock.called()).to.be.true
+ expect(newProjectMock.callHistory.called()).to.be.true
+ const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
sinon.assert.calledOnce(assignStub)
- sinon.assert.calledWith(assignStub, `/project/${projectId}`)
})
+ sinon.assert.calledWith(assignStub, `/project/${projectId}`)
})
it('shows error when project name contains "/"', async function () {
@@ -76,7 +70,7 @@ describe(' ', function () {
})
fireEvent.click(createButton)
- expect(newProjectMock.called()).to.be.true
+ expect(newProjectMock.callHistory.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
@@ -102,7 +96,7 @@ describe(' ', function () {
})
fireEvent.click(createButton)
- expect(newProjectMock.called()).to.be.true
+ expect(newProjectMock.callHistory.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
@@ -141,7 +135,7 @@ describe(' ', function () {
})
fireEvent.click(createButton)
- expect(newProjectMock.called()).to.be.true
+ expect(newProjectMock.callHistory.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
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 22927c2cf1..f91399ff0c 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
@@ -1,5 +1,5 @@
import { expect } from 'chai'
-import sinon, { SinonStub } from 'sinon'
+import sinon from 'sinon'
import {
fireEvent,
render,
@@ -41,7 +41,7 @@ import { Project } from '../../../../../types/project/dashboard/api'
import GroupsAndEnterpriseBanner from '../../../../../frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner'
import GroupSsoSetupSuccess from '../../../../../frontend/js/features/project-list/components/notifications/groups/group-sso-setup-success'
import localStorage from '@/infrastructure/local-storage'
-import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
import {
commonsSubscription,
freeSubscription,
@@ -49,9 +49,10 @@ import {
individualSubscription,
} from '../fixtures/user-subscriptions'
import getMeta from '@/utils/meta'
-import * as bootstrapUtils from '@/features/utils/bootstrap-5'
-const renderWithinProjectListProvider = (Component: React.ComponentType) => {
+const renderWithinProjectListProvider = (
+ Component: React.ComponentType
+) => {
render( , {
wrapper: ({ children }) => (
@@ -69,18 +70,8 @@ describe(' ', function () {
appName: 'Overleaf',
}
- let isBootstrap5Stub: SinonStub
-
- before(function () {
- isBootstrap5Stub = sinon.stub(bootstrapUtils, 'isBootstrap5').returns(true)
- })
-
- after(function () {
- isBootstrap5Stub.restore()
- })
-
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
// at least one project is required to show some notifications
const projects = [{}] as Project[]
@@ -94,7 +85,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
describe('', function () {
@@ -104,7 +95,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('accepts project invite', async function () {
@@ -117,14 +108,14 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
const deleteMock = fetchMock.delete(
`/notifications/${reconfiguredNotification._id}`,
200
)
const acceptMock = fetchMock.post(
- `project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
+ `/project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
200
)
@@ -145,8 +136,9 @@ describe(' ', function () {
screen.getByRole('button', { name: /joining/i })
)
- expect(acceptMock.called()).to.be.true
- screen.getByText(/joined/i)
+ expect(acceptMock.callHistory.called()).to.be.true
+ await screen.findByText(/joined/i)
+
expect(screen.queryByRole('button', { name: /join project/i })).to.be.null
const openProject = screen.getByRole('button', { name: /open project/i })
@@ -157,7 +149,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(deleteMock.called()).to.be.true
+ expect(deleteMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -171,9 +163,9 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.post(
- `project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
+ `/project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
500
)
@@ -190,7 +182,7 @@ describe(' ', function () {
screen.getByRole('button', { name: /joining/i })
)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
screen.getByRole('button', { name: /join project/i })
expect(screen.queryByRole('button', { name: /open project/i })).to.be.null
})
@@ -205,7 +197,7 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
@@ -218,7 +210,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -236,7 +228,7 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
@@ -257,7 +249,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -275,7 +267,7 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
@@ -292,7 +284,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -305,7 +297,7 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
screen.getByRole('alert')
screen.getByText(/file limit/i)
@@ -334,7 +326,7 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
@@ -348,7 +340,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -366,7 +358,7 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
@@ -382,7 +374,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -396,7 +388,7 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
@@ -405,7 +397,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -425,7 +417,7 @@ describe(' ', function () {
])
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.delete(`/notifications/${notificationGroupInvite._id}`, 200)
screen.getByRole('alert')
screen.getByText('inviter@overleaf.com')
@@ -455,7 +447,7 @@ describe(' ', function () {
)
renderWithinProjectListProvider(Common)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.delete(
`/notifications/${notificationGroupInvite._id}`,
200
@@ -476,11 +468,11 @@ describe(' ', function () {
describe('', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows sso available', function () {
@@ -524,7 +516,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -548,7 +540,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -574,7 +566,7 @@ describe(' ', function () {
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -665,12 +657,10 @@ describe(' ', function () {
window.metaAttributesCache.set('ol-user', {
signUpDate: new Date('2024-01-01').toISOString(),
})
- this.clock = sinon.useFakeTimers(new Date('2025-07-01').getTime())
})
afterEach(function () {
- fetchMock.reset()
- this.clock.restore()
+ fetchMock.removeRoutes().clearHistory()
})
function testUnconfirmedNotification(
@@ -681,11 +671,13 @@ describe(' ', function () {
window.metaAttributesCache.set('ol-userEmails', userEmails)
renderWithinProjectListProvider(ConfirmEmail)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.post('/user/emails/resend_confirmation', 200)
const email = userEmails[0].email
- const notificationBody = screen.getByTestId('pro-notification-body')
+ const notificationBody = await screen.findByTestId(
+ 'pro-notification-body'
+ )
if (isPrimary) {
expect(notificationBody.textContent).to.contain(
@@ -701,10 +693,10 @@ describe(' ', function () {
fireEvent.click(resendButton)
await waitForElementToBeRemoved(() =>
- screen.getByRole('button', { name: /resend/i })
+ screen.queryByRole('button', { name: /resend/i })
)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
}
@@ -723,11 +715,11 @@ describe(' ', function () {
window.metaAttributesCache.set('ol-userEmails', [untrustedUserData])
renderWithinProjectListProvider(ConfirmEmail)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.post('/user/emails/resend_confirmation', 200)
const email = untrustedUserData.email
- const notificationBody = screen.getByTestId(
+ const notificationBody = await screen.findByTestId(
'not-trusted-notification-body'
)
expect(notificationBody.textContent).to.contain(
@@ -741,7 +733,7 @@ describe(' ', function () {
screen.getByRole('button', { name: /resend/i })
)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
@@ -749,10 +741,12 @@ describe(' ', function () {
window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
renderWithinProjectListProvider(ConfirmEmail)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
fetchMock.post('/user/emails/resend_confirmation', 500)
- const resendButtons = screen.getAllByRole('button', { name: /resend/i })
+ const resendButtons = await screen.findAllByRole('button', {
+ name: /resend/i,
+ })
const resendButton = resendButtons[0]
fireEvent.click(resendButton)
const notificationBody = screen.getByTestId('pro-notification-body')
@@ -763,7 +757,7 @@ describe(' ', function () {
)
)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
screen.getByText(/something went wrong/i)
})
@@ -775,9 +769,9 @@ describe(' ', function () {
window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
renderWithinProjectListProvider(ConfirmEmail)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- const alert = screen.getByRole('alert')
+ const alert = await screen.findByRole('alert')
const email = unconfirmedCommonsUserData.email
const notificationBody = within(alert).getByTestId('notification-body')
expect(notificationBody.textContent).to.contain(
@@ -796,9 +790,9 @@ describe(' ', function () {
window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
renderWithinProjectListProvider(ConfirmEmail)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- const alert = screen.getByRole('alert')
+ const alert = await screen.findByRole('alert')
const email = unconfirmedCommonsUserData.email
const notificationBody = within(alert).getByTestId(
'pro-notification-body'
@@ -818,23 +812,16 @@ describe(' ', function () {
})
describe(' ', function () {
- let assignStub: sinon.SinonStub
-
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
- assignStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: sinon.stub(),
- setHash: sinon.stub(),
- })
- fetchMock.reset()
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- this.locationStub.restore()
- fetchMock.reset()
+ this.locationWrapperSandbox.restore()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows reconfirm message with SSO disabled', async function () {
@@ -871,12 +858,12 @@ describe(' ', function () {
.be.null
expect(screen.queryByRole('link', { name: /remove it/i })).to.be.null
expect(screen.queryByRole('link', { name: /learn more/i })).to.be.null
- expect(sendReconfirmationMock.called()).to.be.true
+ expect(sendReconfirmationMock.callHistory.called()).to.be.true
fireEvent.click(
screen.getByRole('button', { name: /resend confirmation email/i })
)
await waitForElementToBeRemoved(() => screen.getByText('Sending…'))
- expect(sendReconfirmationMock.calls()).to.have.lengthOf(2)
+ expect(sendReconfirmationMock.callHistory.calls()).to.have.lengthOf(2)
})
it('shows reconfirm message with SSO enabled', async function () {
@@ -889,9 +876,9 @@ describe(' ', function () {
fireEvent.click(
screen.getByRole('button', { name: /confirm affiliation/i })
)
- sinon.assert.calledOnce(assignStub)
+ sinon.assert.calledOnce(this.locationWrapperStub.assign)
sinon.assert.calledWithMatch(
- assignStub,
+ this.locationWrapperStub.assign,
`${exposedSettings.samlInitPath}?university_id=${professionalUserData.affiliation.institution.id}&reconfirm=/project`
)
})
@@ -916,7 +903,7 @@ describe(' ', function () {
describe(' ', function () {
beforeEach(function () {
localStorage.clear()
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
// at least one project is required to show some notifications
const projects = [{}] as Project[]
@@ -935,14 +922,14 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('does not show the banner for users that are in group or are affiliated', async function () {
window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', false)
renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.be.null
})
@@ -952,10 +939,9 @@ describe(' ', function () {
localStorage.setItem('has_dismissed_groups_and_enterprise_banner', true)
renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.not.be
- .null
+ await screen.findByRole('button', { name: 'Contact Sales' })
})
it('shows the banner for users that have dismissed the banner more than 30 days ago', async function () {
@@ -968,10 +954,9 @@ describe(' ', function () {
)
renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.not.be
- .null
+ await screen.findByRole('button', { name: 'Contact Sales' })
})
it('does not show the banner for users that have dismissed the banner within the last 30 days', async function () {
@@ -984,7 +969,7 @@ describe(' ', function () {
)
renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.be.null
})
@@ -992,7 +977,7 @@ describe(' ', function () {
describe('users that are not in group and are not affiliated', function () {
beforeEach(function () {
localStorage.clear()
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
// at least one project is required to show some notifications
const projects = [{}] as Project[]
@@ -1008,7 +993,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
after(function () {
@@ -1022,9 +1007,9 @@ describe(' ', function () {
)
renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- screen.getByText(
+ await screen.findByText(
'Overleaf On-Premises: Does your company want to keep its data within its firewall? Overleaf offers Server Pro, an on-premises solution for companies. Get in touch to learn more.'
)
const link = screen.getByRole('button', { name: 'Contact Sales' })
@@ -1039,9 +1024,9 @@ describe(' ', function () {
)
renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- screen.getByText(
+ await screen.findByText(
'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.'
)
const link = screen.getByRole('button', { name: 'Contact Sales' })
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 3145280f63..4cfc119f0b 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
@@ -19,7 +19,7 @@ import {
archiveableProject,
copyableProject,
} from '../fixtures/projects-data'
-import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
import getMeta from '@/utils/meta'
const {
@@ -37,7 +37,6 @@ describe(' ', function () {
this.timeout('10s')
let sendMBSpy: sinon.SinonSpy
- let assignStub: sinon.SinonStub
beforeEach(async function () {
global.localStorage.clear()
@@ -68,19 +67,14 @@ describe(' ', function () {
window.metaAttributesCache.set('ol-navbar', {
items: [],
})
- assignStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: sinon.stub(),
- setHash: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
})
afterEach(function () {
sendMBSpy.restore()
- fetchMock.reset()
- this.locationStub.restore()
+ fetchMock.removeRoutes().clearHistory()
+ this.locationWrapperSandbox.restore()
})
describe('welcome page', function () {
@@ -88,11 +82,11 @@ describe(' ', function () {
renderWithProjectListContext( , {
projects: [],
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
})
it('the welcome page is displayed', async function () {
- screen.getByRole('heading', { name: 'Welcome to Overleaf' })
+ await screen.findByRole('heading', { name: 'Welcome to Overleaf' })
})
it('the email confirmation alert is not displayed', async function () {
@@ -110,7 +104,7 @@ describe(' ', function () {
projects: fullList,
})
this.unmount = unmount
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
await screen.findByRole('table')
})
@@ -136,6 +130,7 @@ describe(' ', function () {
within(actionsToolbar).getByLabelText('Download')
fireEvent.click(downloadButton)
+ const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.called
})
@@ -172,13 +167,17 @@ describe(' ', function () {
await waitFor(
() =>
expect(
- archiveProjectMock.called(`/project/${project1Id}/archive`)
+ archiveProjectMock.callHistory.called(
+ `/project/${project1Id}/archive`
+ )
).to.be.true
)
await waitFor(
() =>
expect(
- archiveProjectMock.called(`/project/${project2Id}/archive`)
+ archiveProjectMock.callHistory.called(
+ `/project/${project2Id}/archive`
+ )
).to.be.true
)
})
@@ -203,13 +202,19 @@ describe(' ', function () {
await waitFor(
() =>
- expect(trashProjectMock.called(`/project/${project1Id}/trash`)).to
- .be.true
+ expect(
+ trashProjectMock.callHistory.called(
+ `/project/${project1Id}/trash`
+ )
+ ).to.be.true
)
await waitFor(
() =>
- expect(trashProjectMock.called(`/project/${project2Id}/trash`)).to
- .be.true
+ expect(
+ trashProjectMock.callHistory.called(
+ `/project/${project2Id}/trash`
+ )
+ ).to.be.true
)
})
@@ -281,8 +286,8 @@ describe(' ', function () {
})
fireEvent.click(unarchiveButton)
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
await screen.findByText('No projects')
})
@@ -296,8 +301,8 @@ describe(' ', function () {
archivedProjects.length - 1
)
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
expect(screen.queryByText('No projects')).to.be.null
})
@@ -348,8 +353,8 @@ describe(' ', function () {
within(actionsToolbar).getByText('Restore')
fireEvent.click(untrashButton)
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
await screen.findByText('No projects')
})
@@ -361,8 +366,8 @@ describe(' ', function () {
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(trashedList.length - 1)
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
expect(screen.queryByText('No projects')).to.be.null
})
@@ -386,26 +391,28 @@ describe(' ', function () {
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
- const calls = fetchMock.calls().map(([url]) => url)
+ const calls = fetchMock.callHistory.calls().map(({ url }) => url)
trashedList.forEach(project => {
- expect(calls).to.contain(`/project/${project.id}/archive`)
+ expect(calls).to.contain(
+ `https://www.test-overleaf.com/project/${project.id}/archive`
+ )
})
})
it('removes only selected projects from view when leaving', async function () {
// rerender content with different projects
this.unmount()
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
renderWithProjectListContext( , {
projects: leavableList,
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
await screen.findByRole('table')
expect(leavableList.length).to.be.greaterThan(0)
@@ -418,12 +425,15 @@ describe(' ', function () {
{ repeat: leavableList.length }
)
- allCheckboxes = screen.getAllByRole('checkbox')
// + 1 because of select all
- expect(allCheckboxes.length).to.equal(leavableList.length + 1)
+ await waitFor(() =>
+ expect(
+ screen.getAllByRole('checkbox').length
+ ).to.equal(leavableList.length + 1)
+ )
// first one is the select all checkbox
- fireEvent.click(allCheckboxes[0])
+ fireEvent.click(screen.getAllByRole('checkbox')[0])
actionsToolbar = screen.getAllByRole('toolbar')[0]
@@ -446,25 +456,27 @@ describe(' ', function () {
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
- const calls = fetchMock.calls().map(([url]) => url)
+ const calls = fetchMock.callHistory.calls().map(({ url }) => url)
leavableList.forEach(project => {
- expect(calls).to.contain(`/project/${project.id}/leave`)
+ expect(calls).to.contain(
+ `https://www.test-overleaf.com/project/${project.id}/leave`
+ )
})
})
it('removes only selected projects from view when deleting', async function () {
// rerender content with different projects
this.unmount()
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
renderWithProjectListContext( , {
projects: deletableList,
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
await screen.findByRole('table')
expect(deletableList.length).to.be.greaterThan(0)
@@ -477,9 +489,11 @@ describe(' ', function () {
{ repeat: deletableList.length }
)
- allCheckboxes = screen.getAllByRole('checkbox')
- // + 1 because of select all
- expect(allCheckboxes.length).to.equal(deletableList.length + 1)
+ await waitFor(() => {
+ allCheckboxes = screen.getAllByRole('checkbox')
+ // + 1 because of select all
+ expect(allCheckboxes.length).to.equal(deletableList.length + 1)
+ })
// first one is the select all checkbox
fireEvent.click(allCheckboxes[0])
@@ -505,19 +519,21 @@ describe(' ', function () {
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
- const calls = fetchMock.calls().map(([url]) => url)
+ const calls = fetchMock.callHistory.calls().map(({ url }) => url)
deletableList.forEach(project => {
- expect(calls).to.contain(`/project/${project.id}`)
+ expect(calls).to.contain(
+ `https://www.test-overleaf.com/project/${project.id}`
+ )
})
})
it('removes only selected projects from view when deleting and leaving', async function () {
// rerender content with different projects
this.unmount()
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
const deletableAndLeavableList = [...deletableList, ...leavableList]
@@ -525,7 +541,7 @@ describe(' ', function () {
projects: deletableAndLeavableList,
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
await screen.findByRole('table')
expect(deletableList.length).to.be.greaterThan(0)
@@ -547,14 +563,15 @@ describe(' ', function () {
{ repeat: leavableList.length }
)
- allCheckboxes = screen.getAllByRole('checkbox')
// + 1 because of select all
- expect(allCheckboxes.length).to.equal(
- deletableAndLeavableList.length + 1
+ await waitFor(() =>
+ expect(
+ screen.getAllByRole('checkbox').length
+ ).to.equal(deletableAndLeavableList.length + 1)
)
// first one is the select all checkbox
- fireEvent.click(allCheckboxes[0])
+ fireEvent.click(screen.getAllByRole('checkbox')[0])
actionsToolbar = screen.getAllByRole('toolbar')[0]
@@ -573,14 +590,14 @@ describe(' ', function () {
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
- const calls = fetchMock.calls().map(([url]) => url)
+ const calls = fetchMock.callHistory.calls().map(({ url }) => url)
deletableAndLeavableList.forEach(project => {
expect(calls).to.contain.oneOf([
- `/project/${project.id}`,
- `/project/${project.id}/leave`,
+ `https://www.test-overleaf.com/project/${project.id}`,
+ `https://www.test-overleaf.com/project/${project.id}/leave`,
])
})
})
@@ -589,7 +606,7 @@ describe(' ', function () {
describe('tags', function () {
it('does not show archived or trashed project', async function () {
this.unmount()
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
window.metaAttributesCache.set('ol-tags', [
{
_id: this.tagId,
@@ -642,14 +659,16 @@ describe(' ', function () {
)
await waitFor(() => {
expect(
- trashProjectMock.called(
+ trashProjectMock.callHistory.called(
`/project/${projectsData[index].id}/trash`
)
).to.be.true
})
- expect(
- screen.queryAllByText(projectsData[index].name)
- ).to.have.length(0)
+ await waitFor(() => {
+ expect(
+ screen.queryAllByText(projectsData[index].name)
+ ).to.have.length(0)
+ })
screen.getAllByRole('button', {
name: `${this.tagName} (${--visibleProjectsCount})`,
@@ -683,13 +702,15 @@ describe(' ', function () {
status: 204,
})
- await waitFor(() => {
- const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
- fireEvent.click(tagsDropdown)
- })
+ const tagsDropdown =
+ await within(actionsToolbar).findByLabelText('Tags')
+ fireEvent.click(tagsDropdown)
screen.getByText('Add to tag')
- const newTagButton = screen.getByText('Create new tag')
+ const newTagButton = screen.getByRole('menuitem', {
+ name: 'Create new tag',
+ })
+
fireEvent.click(newTagButton)
const modal = screen.getAllByRole('dialog')[0]
@@ -702,18 +723,24 @@ describe(' ', function () {
})
fireEvent.click(createButton)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- expect(fetchMock.called('/tag', { name: this.newTagName })).to.be.true
expect(
- fetchMock.called(`/tag/${this.newTagId}/projects`, {
+ fetchMock.callHistory.called('/tag', { name: this.newTagName })
+ ).to.be.true
+ expect(
+ fetchMock.callHistory.called(`/tag/${this.newTagId}/projects`, {
body: {
projectIds: [projectsData[0].id, projectsData[1].id],
},
})
).to.be.true
- screen.getByRole('button', { name: `${this.newTagName} (2)` })
+ await screen.findByRole(
+ 'button',
+ { name: `${this.newTagName} (2)` },
+ { timeout: 5000 }
+ )
})
it('opens the tags dropdown and remove a tag from selected projects', async function () {
@@ -734,10 +761,10 @@ describe(' ', function () {
)
fireEvent.click(tagButton)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(
- deleteProjectsFromTagMock.called(
+ deleteProjectsFromTagMock.callHistory.called(
`/tag/${this.tagId}/projects/remove`,
{
body: {
@@ -770,14 +797,17 @@ describe(' ', function () {
)
fireEvent.click(tagButton)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(
- addProjectsToTagMock.called(`/tag/${this.tagId}/projects`, {
- body: {
- projectIds: [projectsData[2].id],
- },
- })
+ addProjectsToTagMock.callHistory.called(
+ `/tag/${this.tagId}/projects`,
+ {
+ body: {
+ projectIds: [projectsData[2].id],
+ },
+ }
+ )
).to.be.true
screen.getByRole('button', { name: `${this.tagName} (3)` })
})
@@ -919,14 +949,16 @@ describe(' ', function () {
expect(confirmButton.disabled).to.be.false
fireEvent.click(confirmButton)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(
- renameProjectMock.called(`/project/${projectsData[0].id}/rename`)
+ renameProjectMock.callHistory.called(
+ `/project/${projectsData[0].id}/rename`
+ )
).to.be.true
const table = await screen.findByRole('table')
- within(table).getByText(newProjectName)
+ await within(table).findByText(newProjectName)
expect(within(table).queryByText(oldName)).to.be.null
const allCheckboxesInTable =
@@ -982,10 +1014,12 @@ describe(' ', function () {
) as HTMLElement
fireEvent.click(copyConfirmButton)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(
- cloneProjectMock.called(`/project/${projectsData[1].id}/clone`)
+ cloneProjectMock.callHistory.called(
+ `/project/${projectsData[1].id}/clone`
+ )
).to.be.true
expect(sendMBSpy).to.have.been.calledTwice
@@ -1000,7 +1034,7 @@ describe(' ', function () {
}
)
- screen.getByText(copiedProjectName)
+ await screen.findByText(copiedProjectName)
})
})
})
@@ -1048,6 +1082,8 @@ describe(' ', function () {
}
beforeEach(function () {
+ fetchMock.removeRoutes().clearHistory()
+
allCheckboxes = screen.getAllByRole('checkbox')
// first one is the select all checkbox, just check 2 at first
fireEvent.click(allCheckboxes[1])
@@ -1149,8 +1185,8 @@ describe(' ', function () {
) as HTMLElement
fireEvent.click(copyConfirmButton)
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
expect(sendMBSpy).to.have.been.calledTwice
expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
diff --git a/services/web/test/frontend/features/project-list/components/project-list-title.tsx b/services/web/test/frontend/features/project-list/components/project-list-title.tsx
index e69cea0151..8e34c6ca0e 100644
--- a/services/web/test/frontend/features/project-list/components/project-list-title.tsx
+++ b/services/web/test/frontend/features/project-list/components/project-list-title.tsx
@@ -4,9 +4,6 @@ import { Tag } from '../../../../../app/src/Features/Tags/types'
import ProjectListTitle from '../../../../../frontend/js/features/project-list/components/title/project-list-title'
describe(' ', function () {
- beforeEach(function () {
- window.metaAttributesCache.set('ol-bootstrapVersion', 5)
- })
type TestCase = {
filter: Filter
selectedTag: Tag | undefined
diff --git a/services/web/test/frontend/features/project-list/components/project-search.test.tsx b/services/web/test/frontend/features/project-list/components/project-search.test.tsx
index f8c482740b..06820a38ca 100644
--- a/services/web/test/frontend/features/project-list/components/project-search.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/project-search.test.tsx
@@ -12,11 +12,11 @@ describe('Project list search form', function () {
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
sendMBSpy.restore()
})
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 cfedf93978..8d69c2ddda 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
@@ -14,11 +14,11 @@ describe('Add affiliation widget', function () {
}
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders the component', async function () {
@@ -27,10 +27,10 @@ describe('Add affiliation widget', function () {
renderWithProjectListContext( )
- await fetchMock.flush(true)
- await waitFor(() => expect(fetchMock.called('/api/project')))
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
- screen.getByText(/are you affiliated with an institution/i)
+ await screen.findByText(/are you affiliated with an institution/i)
const addAffiliationLink = screen.getByRole('button', {
name: /add affiliation/i,
})
@@ -43,8 +43,8 @@ describe('Add affiliation widget', function () {
renderWithProjectListContext( )
- await fetchMock.flush(true)
- await waitFor(() => expect(fetchMock.called('/api/project')))
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
validateNonExistence()
})
@@ -57,8 +57,8 @@ describe('Add affiliation widget', function () {
projects: [],
})
- await fetchMock.flush(true)
- await waitFor(() => expect(fetchMock.called('/api/project')))
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
validateNonExistence()
})
@@ -69,8 +69,8 @@ describe('Add affiliation widget', function () {
renderWithProjectListContext( )
- await fetchMock.flush(true)
- await waitFor(() => expect(fetchMock.called('/api/project')))
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
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 1543e2e90d..b8e5768c99 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
@@ -29,16 +29,16 @@ describe(' ', function () {
})
fetchMock.post('express:/tag/:tagId/projects', 200)
fetchMock.post('express:/tag/:tagId/edit', 200)
- fetchMock.delete('express:/tag/:tagId', 200)
+ fetchMock.delete('express:/tag/:tagId', 200, { name: 'delete tag' })
renderWithProjectListContext( )
- await fetchMock.flush(true)
- await waitFor(() => expect(fetchMock.called('/api/project')))
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('displays the tags list', function () {
@@ -63,7 +63,7 @@ describe(' ', function () {
const tag1Button = screen.getByText('Tag 1')
assert.isFalse(tag1Button.closest('li')?.classList.contains('active'))
- await fireEvent.click(tag1Button)
+ fireEvent.click(tag1Button)
assert.isTrue(tag1Button.closest('li')?.classList.contains('active'))
})
@@ -85,7 +85,7 @@ describe(' ', function () {
name: 'New Tag',
})
- await fireEvent.click(newTagButton)
+ fireEvent.click(newTagButton)
})
it('modal is open', async function () {
@@ -97,7 +97,7 @@ describe(' ', function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
- await fireEvent.click(cancelButton)
+ fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
@@ -144,11 +144,15 @@ describe(' ', function () {
const createButton = within(modal).getByRole('button', { name: 'Create' })
expect(createButton.hasAttribute('disabled')).to.be.false
- await fireEvent.click(createButton)
+ fireEvent.click(createButton)
- await waitFor(() => expect(fetchMock.called(`/tag`)).to.be.true)
+ await waitFor(
+ () => expect(fetchMock.callHistory.called(`/tag`)).to.be.true
+ )
- expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
+ await waitFor(
+ () => expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
+ )
screen.getByRole('button', {
name: 'New Tag (0)',
@@ -162,9 +166,9 @@ describe(' ', function () {
const dropdownToggle = within(
tag1Button.closest('li') as HTMLElement
).getByTestId('tag-dropdown-toggle')
- await fireEvent.click(dropdownToggle)
+ fireEvent.click(dropdownToggle)
const editMenuItem = await screen.findByRole('menuitem', { name: 'Edit' })
- await fireEvent.click(editMenuItem)
+ fireEvent.click(editMenuItem)
})
it('modal is open', async function () {
@@ -176,7 +180,7 @@ describe(' ', function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
- await fireEvent.click(cancelButton)
+ fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
@@ -235,11 +239,15 @@ describe(' ', function () {
const saveButton = within(modal).getByRole('button', { name: 'Save' })
expect(saveButton.hasAttribute('disabled')).to.be.false
- await fireEvent.click(saveButton)
+ fireEvent.click(saveButton)
- await waitFor(() => expect(fetchMock.called(`/tag/abc123def456/rename`)))
+ await waitFor(() =>
+ expect(fetchMock.callHistory.called(`/tag/abc123def456/rename`))
+ )
- expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
+ await waitFor(
+ () => expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
+ )
screen.getByRole('button', {
name: 'New Tag Name (1)',
@@ -253,11 +261,11 @@ describe(' ', function () {
const dropdownToggle = within(
tag1Button.closest('li') as HTMLElement
).getByTestId('tag-dropdown-toggle')
- await fireEvent.click(dropdownToggle)
+ fireEvent.click(dropdownToggle)
const deleteMenuItem = await screen.findByRole('menuitem', {
name: 'Delete',
})
- await fireEvent.click(deleteMenuItem)
+ fireEvent.click(deleteMenuItem)
})
it('modal is open', async function () {
@@ -269,18 +277,22 @@ describe(' ', function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
- await fireEvent.click(cancelButton)
+ fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
it('clicking Delete sends a request', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const deleteButton = within(modal).getByRole('button', { name: 'Delete' })
- await fireEvent.click(deleteButton)
+ fireEvent.click(deleteButton)
- await waitFor(() => expect(fetchMock.called(`/tag/bcd234efg567`)))
+ await waitFor(() =>
+ expect(fetchMock.callHistory.called(`/tag/bcd234efg567`))
+ )
- expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
+ await waitFor(
+ () => expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
+ )
expect(
screen.queryByRole('button', {
name: 'Another Tag (2)',
@@ -289,15 +301,17 @@ describe(' ', function () {
})
it('a failed request displays an error message', async function () {
- fetchMock.delete('express:/tag/:tagId', 500, { overwriteRoutes: true })
+ fetchMock.modifyRoute('delete tag', { response: { status: 500 } })
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const deleteButton = within(modal).getByRole('button', { name: 'Delete' })
- await fireEvent.click(deleteButton)
+ fireEvent.click(deleteButton)
- await waitFor(() => expect(fetchMock.called(`/tag/bcd234efg567`)))
+ await waitFor(() =>
+ expect(fetchMock.callHistory.called(`/tag/bcd234efg567`))
+ )
- within(modal).getByText('Sorry, something went wrong')
+ await within(modal).findByText('Sorry, something went wrong')
})
})
})
diff --git a/services/web/test/frontend/features/project-list/components/system-messages.test.tsx b/services/web/test/frontend/features/project-list/components/system-messages.test.tsx
index e825f3c57a..2b98b9218c 100644
--- a/services/web/test/frontend/features/project-list/components/system-messages.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/system-messages.test.tsx
@@ -5,12 +5,12 @@ import SystemMessages from '@/shared/components/system-messages'
describe(' ', function () {
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
localStorage.clear()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
localStorage.clear()
})
@@ -22,9 +22,9 @@ describe(' ', function () {
fetchMock.get(/\/system\/messages/, [data])
render( )
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- screen.getByText(data.content)
+ await screen.findByText(data.content)
expect(screen.queryByRole('button', { name: /close/i })).to.be.null
})
@@ -36,9 +36,9 @@ describe(' ', function () {
fetchMock.get(/\/system\/messages/, [data])
render( )
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
- screen.getByText(data.content)
+ await screen.findByText(data.content)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
@@ -60,7 +60,7 @@ describe(' ', function () {
window.metaAttributesCache.set('ol-currentUrl', currentUrl)
render( )
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
const link = screen.getByRole('link', { name: /click here/i })
expect(link.getAttribute('href')).to.equal(`${data.url}${currentUrl}`)
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx
index 6a4eee370f..216f08a89b 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx
@@ -67,8 +67,11 @@ describe(' ', function () {
await waitFor(
() =>
- expect(archiveProjectMock.called(`/project/${project.id}/archive`)).to
- .be.true
+ expect(
+ archiveProjectMock.callHistory.called(
+ `/project/${project.id}/archive`
+ )
+ ).to.be.true
)
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx
index ba0b3fd3b9..91f37a8a1e 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx
@@ -2,33 +2,26 @@ import { expect } from 'chai'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import sinon from 'sinon'
import { projectsData } from '../../../../fixtures/projects-data'
-import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
import { CompileAndDownloadProjectPDFButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button'
import fetchMock from 'fetch-mock'
import * as eventTracking from '@/infrastructure/event-tracking'
describe(' ', function () {
- let assignStub: sinon.SinonStub
- let locationStub: sinon.SinonStub
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
- assignStub = sinon.stub()
- locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: sinon.stub(),
- setHash: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
render(
)
})
afterEach(function () {
- locationStub.restore()
- fetchMock.reset()
+ this.locationWrapperSandbox.restore()
+ fetchMock.removeRoutes().clearHistory()
sendMBSpy.restore()
})
@@ -57,6 +50,7 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Compiling…' })
})
+ const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.called
})
@@ -85,9 +79,9 @@ describe(' ', function () {
}) as HTMLButtonElement
fireEvent.click(btn)
- await waitFor(() => {
- screen.getByText(`${projectsData[0].name}: PDF unavailable for download`)
- })
- expect(assignStub).to.have.not.been.called
+ await screen.findByText(
+ `${projectsData[0].name}: PDF unavailable for download`
+ )
+ expect(this.locationWrapperStub.assign).to.have.not.been.called
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx
index d50a8cff0a..37c38d13d9 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx
@@ -66,8 +66,11 @@ describe(' ', function () {
await waitFor(
() =>
- expect(copyProjectMock.called(`/project/${copyableProject.id}/clone`))
- .to.be.true
+ expect(
+ copyProjectMock.callHistory.called(
+ `/project/${copyableProject.id}/clone`
+ )
+ ).to.be.true
)
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx
index 15555728c9..1f51d4b39a 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx
@@ -69,7 +69,8 @@ describe(' ', function () {
await waitFor(
() =>
- expect(deleteProjectMock.called(`/project/${project.id}`)).to.be.true
+ expect(deleteProjectMock.callHistory.called(`/project/${project.id}`))
+ .to.be.true
)
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
index 89e215c082..1723acc8ae 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
@@ -3,24 +3,17 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import sinon from 'sinon'
import { DownloadProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button'
import { projectsData } from '../../../../fixtures/projects-data'
-import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
describe(' ', function () {
- let assignStub: sinon.SinonStub
-
beforeEach(function () {
- assignStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: sinon.stub(),
- setHash: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
render( )
})
afterEach(function () {
- this.locationStub.restore()
+ this.locationWrapperSandbox.restore()
})
it('renders tooltip for button', async function () {
@@ -35,6 +28,7 @@ describe(' ', function () {
}) as HTMLButtonElement
fireEvent.click(btn)
+ const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.called
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx
index fcbea5a22c..ef0fc52b73 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx
@@ -75,8 +75,9 @@ describe(' ', function () {
await waitFor(
() =>
- expect(leaveProjectMock.called(`/project/${project.id}/leave`)).to.be
- .true
+ expect(
+ leaveProjectMock.callHistory.called(`/project/${project.id}/leave`)
+ ).to.be.true
)
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/rename-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/rename-project-button.test.tsx
index e5a8e051b7..b383c705ac 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/rename-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/rename-project-button.test.tsx
@@ -63,8 +63,9 @@ describe(' ', function () {
await waitFor(
() =>
- expect(renameProjectMock.called(`/project/${project.id}/rename`)).to.be
- .true
+ expect(
+ renameProjectMock.callHistory.called(`/project/${project.id}/rename`)
+ ).to.be.true
)
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx
index 960d506b2b..7b4dac0b79 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx
@@ -57,8 +57,9 @@ describe(' ', function () {
await waitFor(
() =>
- expect(trashProjectMock.called(`/project/${project.id}/trash`)).to.be
- .true
+ expect(
+ trashProjectMock.callHistory.called(`/project/${project.id}/trash`)
+ ).to.be.true
)
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx
index f4541de3f8..c7f6c342d9 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx
@@ -57,8 +57,11 @@ describe(' ', function () {
await waitFor(
() =>
- expect(unarchiveProjectMock.called(`/project/${project.id}/archive`)).to
- .be.true
+ expect(
+ unarchiveProjectMock.callHistory.called(
+ `/project/${project.id}/archive`
+ )
+ ).to.be.true
)
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx
index d72b7f9344..cde2987f6b 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx
@@ -49,8 +49,9 @@ describe(' ', function () {
await waitFor(
() =>
- expect(untrashProjectMock.called(`/project/${project.id}/trash`)).to.be
- .true
+ expect(
+ untrashProjectMock.callHistory.called(`/project/${project.id}/trash`)
+ ).to.be.true
)
})
})
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 0f12e72265..971b2aaa48 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
@@ -57,12 +57,15 @@ describe(' ', function () {
const removeButton = screen.getByRole('button', {
name: 'Remove tag My Test Tag',
})
- await fireEvent.click(removeButton)
+ fireEvent.click(removeButton)
await waitFor(() =>
expect(
- fetchMock.called(`/tag/789fff789fff/project/${copyableProject.id}`, {
- method: 'DELETE',
- })
+ fetchMock.callHistory.called(
+ `/tag/789fff789fff/project/${copyableProject.id}`,
+ {
+ method: 'DELETE',
+ }
+ )
)
)
expect(screen.queryByText('My Test Tag')).to.not.exist
diff --git a/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx
index e0accb3c06..f1d4a0755d 100644
--- a/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx
@@ -1,4 +1,4 @@
-import { screen, within, fireEvent } from '@testing-library/react'
+import { screen, within, fireEvent, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import ProjectListTable from '../../../../../../frontend/js/features/project-list/components/table/project-list-table'
import { currentProjects } from '../../fixtures/projects-data'
@@ -11,11 +11,11 @@ describe(' ', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-tags', [])
window.metaAttributesCache.set('ol-user_id', userId)
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders the table', function () {
@@ -65,11 +65,15 @@ describe(' ', function () {
this.timeout(10000)
renderWithProjectListContext( )
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() =>
+ expect(screen.getAllByRole('row').length).to.equal(
+ currentProjects.length + 1
+ )
+ )
const rows = screen.getAllByRole('row')
rows.shift() // remove first row since it's the header
- expect(rows.length).to.equal(currentProjects.length)
// Project name cell
currentProjects.forEach(project => {
@@ -138,43 +142,61 @@ describe(' ', function () {
it('selects all projects when header checkbox checked', async function () {
renderWithProjectListContext( )
- await fetchMock.flush(true)
- const checkbox = screen.getByLabelText('Select all projects')
+ await fetchMock.callHistory.flush(true)
+ const checkbox = await screen.findByLabelText('Select all projects')
fireEvent.click(checkbox)
- const allCheckboxes = screen.getAllByRole('checkbox')
- const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
- // + 1 because of select all checkbox
- expect(allCheckboxesChecked.length).to.equal(currentProjects.length + 1)
+
+ await waitFor(() => {
+ const allCheckboxes = screen.queryAllByRole('checkbox')
+ const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+ // + 1 because of select all checkbox
+ expect(allCheckboxesChecked.length).to.equal(currentProjects.length + 1)
+ })
})
it('unselects all projects when select all checkbox uchecked', async function () {
renderWithProjectListContext( )
- await fetchMock.flush(true)
- const checkbox = screen.getByLabelText('Select all projects')
+ await fetchMock.callHistory.flush(true)
+ const checkbox = await screen.findByLabelText('Select all projects')
fireEvent.click(checkbox)
fireEvent.click(checkbox)
- const allCheckboxes = screen.getAllByRole('checkbox')
- const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
- expect(allCheckboxesChecked.length).to.equal(0)
+
+ await waitFor(() => {
+ const allCheckboxes = screen.queryAllByRole('checkbox')
+ const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+ expect(allCheckboxesChecked.length).to.equal(0)
+ })
})
it('unselects select all projects checkbox when one project is unchecked', async function () {
renderWithProjectListContext( )
- await fetchMock.flush(true)
- const checkbox = screen.getByLabelText('Select all projects')
+ await fetchMock.callHistory.flush(true)
+ const checkbox = await screen.findByLabelText('Select all projects')
fireEvent.click(checkbox)
- let allCheckboxes = screen.getAllByRole('checkbox')
- expect(allCheckboxes[1].getAttribute('data-project-id')).to.exist // make sure we are unchecking a project checkbox
- fireEvent.click(allCheckboxes[1])
- allCheckboxes = screen.getAllByRole('checkbox')
- const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
- expect(allCheckboxesChecked.length).to.equal(currentProjects.length - 1)
+
+ await waitFor(() => {
+ expect(
+ screen
+ .getAllByRole('checkbox')[1]
+ .getAttribute('data-project-id')
+ ).to.exist // make sure we are unchecking a project checkbox
+ })
+
+ fireEvent.click(screen.getAllByRole('checkbox')[1])
+
+ await waitFor(() => {
+ const allCheckboxes = screen.getAllByRole('checkbox')
+ const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+ expect(allCheckboxesChecked.length).to.equal(currentProjects.length - 1)
+ })
})
it('only checks the checked project', async function () {
renderWithProjectListContext( )
- await fetchMock.flush(true)
- const checkbox = screen.getByLabelText(`Select ${currentProjects[0].name}`)
+ await fetchMock.callHistory.flush(true)
+ const checkbox = await screen.findByLabelText(
+ `Select ${currentProjects[0].name}`
+ )
fireEvent.click(checkbox)
const allCheckboxes = screen.getAllByRole('checkbox')
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools-rename.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools-rename.test.tsx
index 8903fc3089..cfc9dbfd5f 100644
--- a/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools-rename.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools-rename.test.tsx
@@ -1,11 +1,9 @@
-import { render, screen, within } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
import { expect } from 'chai'
import moment from 'moment/moment'
import fetchMock from 'fetch-mock'
import { Project } from '../../../../../../../types/project/dashboard/api'
import { ProjectListRootInner } from '@/features/project-list/components/project-list-root'
-import * as bootstrapUtils from '@/features/utils/bootstrap-5'
-import sinon, { type SinonStub } from 'sinon'
const users = {
picard: {
@@ -48,15 +46,6 @@ const projects: Project[] = [
]
describe(' ', function () {
- let isBootstrap5Stub: SinonStub
-
- before(function () {
- isBootstrap5Stub = sinon.stub(bootstrapUtils, 'isBootstrap5').returns(true)
- })
-
- after(function () {
- isBootstrap5Stub.restore()
- })
beforeEach(function () {
window.metaAttributesCache.set('ol-user', {})
window.metaAttributesCache.set('ol-prefetchedProjectsBlob', {
@@ -76,27 +65,33 @@ describe(' ', function () {
afterEach(function () {
window.metaAttributesCache.clear()
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
- it('does not show the Rename option for a project owned by a different user', function () {
+ it('does not show the Rename option for a project owned by a different user', async function () {
render( )
screen.getByLabelText('Select Starfleet Report (readAndWrite)').click()
screen.getByRole('button', { name: 'More' }).click()
- expect(
- within(
- screen.getByTestId('project-tools-more-dropdown-menu')
- ).queryByRole('menuitem', { name: 'Rename' })
- ).to.be.null
+ await waitFor(
+ () =>
+ expect(
+ within(
+ screen.getByTestId('project-tools-more-dropdown-menu')
+ ).queryByRole('menuitem', { name: 'Rename' })
+ ).to.be.null
+ )
})
- it('displays the Rename option for a project owned by the current user', function () {
+ it('displays the Rename option for a project owned by the current user', async function () {
render( )
screen.getByLabelText('Select Starfleet Report (owner)').click()
screen.getByRole('button', { name: 'More' }).click()
- within(screen.getByTestId('project-tools-more-dropdown-menu'))
- .getByRole('menuitem', { name: 'Rename' })
- .click()
- within(screen.getByRole('dialog')).getByText('Rename Project')
+ const menu = await screen.findByTestId('project-tools-more-dropdown-menu')
+ const menuItem = within(menu).getByRole('menuitem', {
+ name: 'Rename',
+ })
+ menuItem.click()
+ const dialog = await screen.findByRole('dialog')
+ within(dialog).getByText('Rename Project')
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx
index 607aa32e74..6e9649b137 100644
--- a/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx
@@ -58,7 +58,9 @@ describe(' ', function () {
await waitFor(
() =>
expect(
- renameProjectMock.called(`/project/${currentProjects[0].id}/rename`)
+ renameProjectMock.callHistory.called(
+ `/project/${currentProjects[0].id}/rename`
+ )
).to.be.true
)
})
@@ -88,8 +90,8 @@ describe(' ', function () {
const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
fireEvent.click(submitButton)
- await waitFor(() => expect(postRenameMock.called()).to.be.true)
+ await waitFor(() => expect(postRenameMock.callHistory.called()).to.be.true)
- screen.getByText('Something went wrong. Please try again.')
+ await screen.findByText('Something went wrong. Please try again.')
})
})
diff --git a/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx b/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
index c28dcd0c94..ef807db4c7 100644
--- a/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
+++ b/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
@@ -58,5 +58,5 @@ export function renderWithProjectListContext(
}
export function resetProjectListContextFetch() {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
}
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 8e723cf8d1..112e7d90e7 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
@@ -29,7 +29,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('submits all inputs', async function () {
@@ -50,14 +50,14 @@ describe(' ', function () {
name: 'Update',
})
)
- expect(updateMock.called()).to.be.true
- expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
- {
- email: 'john@watson.co.uk',
- first_name: 'John',
- last_name: 'Watson',
- }
- )
+ expect(updateMock.callHistory.called()).to.be.true
+ expect(
+ JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+ ).to.deep.equal({
+ email: 'john@watson.co.uk',
+ first_name: 'John',
+ last_name: 'Watson',
+ })
})
it('disables button on invalid email', async function () {
@@ -74,7 +74,7 @@ describe(' ', function () {
expect(button.disabled).to.be.true
fireEvent.click(button)
- expect(updateMock.called()).to.be.false
+ expect(updateMock.callHistory.called()).to.be.false
})
it('shows inflight state and success message', async function () {
@@ -156,12 +156,12 @@ describe(' ', function () {
name: 'Update',
})
)
- expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
- {
- first_name: 'Sherlock',
- last_name: 'Holmes',
- }
- )
+ expect(
+ JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+ ).to.deep.equal({
+ first_name: 'Sherlock',
+ last_name: 'Holmes',
+ })
})
it('disables email input', async function () {
@@ -187,12 +187,12 @@ describe(' ', function () {
name: 'Update',
})
)
- expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
- {
- first_name: 'Sherlock',
- last_name: 'Holmes',
- }
- )
+ expect(
+ JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+ ).to.deep.equal({
+ first_name: 'Sherlock',
+ last_name: 'Holmes',
+ })
})
it('disables names input', async function () {
@@ -215,10 +215,10 @@ describe(' ', function () {
name: 'Update',
})
)
- expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
- {
- email: 'sherlock@holmes.co.uk',
- }
- )
+ expect(
+ JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+ ).to.deep.equal({
+ email: 'sherlock@holmes.co.uk',
+ })
})
})
diff --git a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx b/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx
index c7707d9efb..50220152c6 100644
--- a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx
+++ b/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx
@@ -19,7 +19,7 @@ describe(' ', function () {
beforeEach(function () {
clearDomainCache()
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
describe('on initial render', function () {
@@ -69,7 +69,7 @@ describe(' ', function () {
})
it('should not make any request for institution domains', function () {
- expect(fetchMock.called()).to.be.false
+ expect(fetchMock.callHistory.called()).to.be.false
})
it('should submit on Enter if email looks valid', async function () {
@@ -118,10 +118,24 @@ describe(' ', function () {
})
describe('when there is a domain match', function () {
- beforeEach(function () {
+ beforeEach(async function () {
fetchMock.get('express:/institutions/domains', testInstitutionData)
- fireEvent.change(screen.getByTestId('affiliations-email'), {
- target: { value: 'user@d' },
+ const input = await screen.findByTestId('affiliations-email')
+ fireEvent.change(input, { target: { value: 'user@d' } })
+
+ // Wait for the request to complete and the domain cache to pouplate
+ await waitFor(
+ () =>
+ expect(
+ fetchMock.callHistory.called('express:/institutions/domains')
+ ).to.be.true
+ )
+ // Wait for component to process the change and update the shadow input
+ await waitFor(() => {
+ const shadowInput = screen.getByTestId(
+ 'affiliations-email-shadow'
+ ) as HTMLInputElement
+ expect(shadowInput.value).to.equal('user@domain.edu')
})
})
@@ -146,7 +160,7 @@ describe(' ', function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@domain.edu' },
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(
onChangeStub.calledWith(
'user@domain.edu',
@@ -231,7 +245,7 @@ describe(' ', function () {
})
it('should cache the result and skip subsequent requests', async function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
// clear input
fireEvent.change(screen.getByTestId('affiliations-email'), {
@@ -242,7 +256,7 @@ describe(' ', function () {
target: { value: 'user@d' },
})
- expect(fetchMock.called()).to.be.false
+ expect(fetchMock.callHistory.called()).to.be.false
expect(onChangeStub.calledWith('user@d')).to.equal(true)
await waitFor(() => {
const shadowInput = screen.getByTestId(
@@ -258,7 +272,7 @@ describe(' ', function () {
afterEach(function () {
clearDomainCache()
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('should not render the suggestion with blocked domain', async function () {
@@ -269,7 +283,7 @@ describe(' ', function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: `user@${blockedDomain.split('.')[0]}` },
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(screen.queryByText(`user@${blockedDomain}`)).to.be.null
})
@@ -286,7 +300,7 @@ describe(' ', function () {
value: `user@subdomain.${blockedDomain.split('.')[0]}`,
},
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
expect(screen.queryByText(`user@subdomain.${blockedDomain}`)).to.be.null
})
})
@@ -307,7 +321,7 @@ describe(' ', function () {
// make sure the next suggestions are delayed
clearDomainCache()
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', 200, { delay: 1000 })
})
@@ -354,7 +368,7 @@ describe(' ', function () {
})
// subsequent requests fail
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', 500)
})
@@ -373,7 +387,7 @@ describe(' ', function () {
expect(shadowInput.value).to.equal('')
})
- expect(fetchMock.called()).to.be.true // ensures `domainCache` hasn't been hit
+ expect(fetchMock.callHistory.called()).to.be.true // ensures `domainCache` hasn't been hit
})
})
@@ -384,7 +398,7 @@ describe(' ', function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@other' },
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
diff --git a/services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx
index 08928ff23d..990d905ba8 100644
--- a/services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx
+++ b/services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx
@@ -37,7 +37,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
describe('with unaffiliated email data', function () {
@@ -86,9 +86,9 @@ describe(' ', function () {
describe('when the email is not yet linked to the institution', function () {
beforeEach(async function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail, unconfirmedUserData])
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
})
it('prompts the user to link to their institutional account', function () {
@@ -103,9 +103,9 @@ describe(' ', function () {
describe('when the email is already linked to the institution', function () {
beforeEach(async function () {
affiliatedEmail.samlProviderId = '1'
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail, unconfirmedUserData])
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
})
it('prompts the user to login using their institutional account', function () {
diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx
index c1b83b3421..fca5714fe9 100644
--- a/services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx
+++ b/services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx
@@ -37,11 +37,11 @@ describe('email actions - make primary', function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
describe('disabled `make primary` button', function () {
@@ -236,11 +236,11 @@ describe('email actions - delete', function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows loader when deleting and removes the row', async function () {
diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx
index ed3c227083..c556ac83a7 100644
--- a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx
+++ b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx
@@ -56,12 +56,14 @@ const institutionDomainData = [
] as const
function resetFetchMock() {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', [])
}
async function confirmCodeForEmail(email: string) {
- screen.getByText(`Enter the 6-digit confirmation code sent to ${email}.`)
+ await screen.findByText(
+ `Enter the 6-digit confirmation code sent to ${email}.`
+ )
const inputCode = screen.getByLabelText(/6-digit confirmation code/i)
fireEvent.change(inputCode, { target: { value: '123456' } })
const submitCodeBtn = screen.getByRole('button', {
@@ -80,7 +82,7 @@ describe(' ', function () {
hasSamlFeature: true,
samlInitPath: 'saml/init',
})
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
@@ -97,7 +99,7 @@ describe(' ', function () {
it('renders input', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render( )
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
const button = await screen.findByRole('button', {
name: /add another email/i,
@@ -112,7 +114,7 @@ describe(' ', function () {
fetchMock.get(`/institutions/domains?hostname=email.com&limit=1`, 200)
fetchMock.get(`/institutions/domains?hostname=email&limit=1`, 200)
render( )
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
const button = await screen.findByRole('button', {
name: /add another email/i,
@@ -190,7 +192,7 @@ describe(' ', function () {
{ name: /add another email/i }
)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
resetFetchMock()
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
@@ -233,7 +235,7 @@ describe(' ', function () {
{ name: /add another email/i }
)
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
resetFetchMock()
fetchMock
.get('/user/emails?ensureAffiliation=true', [])
@@ -271,8 +273,8 @@ describe(' ', function () {
name: /add another email/i,
})
- await fetchMock.flush(true)
- fetchMock.reset()
+ await fetchMock.callHistory.flush(true)
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', institutionDomainData)
await userEvent.click(button)
@@ -295,7 +297,7 @@ describe(' ', function () {
name: /add another email/i,
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
resetFetchMock()
await userEvent.click(button)
@@ -304,7 +306,7 @@ describe(' ', function () {
await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
- const universityInput = screen.getByRole('textbox', {
+ const universityInput = screen.getByRole('combobox', {
name: /university/i,
})
@@ -321,7 +323,7 @@ describe(' ', function () {
// Select the country from dropdown
await userEvent.type(
- screen.getByRole('textbox', {
+ screen.getByRole('combobox', {
name: /country/i,
}),
country
@@ -330,7 +332,7 @@ describe(' ', function () {
expect(universityInput.disabled).to.be.false
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
resetFetchMock()
// Select the university from dropdown
@@ -339,9 +341,11 @@ describe(' ', function () {
await screen.findByText(userEmailData.affiliation.institution.name)
)
- const roleInput = screen.getByRole('textbox', { name: /role/i })
+ const roleInput = screen.getByRole('combobox', { name: /role/i })
await userEvent.type(roleInput, userEmailData.affiliation.role!)
- const departmentInput = screen.getByRole('textbox', { name: /department/i })
+ const departmentInput = screen.getByRole('combobox', {
+ name: /department/i,
+ })
await userEvent.click(departmentInput)
await userEvent.click(screen.getByText(customDepartment))
@@ -364,9 +368,11 @@ describe(' ', function () {
})
)
- const [[, request]] = fetchMock.calls(/\/user\/emails/)
+ const request = fetchMock.callHistory.calls(/\/user\/emails/).at(0)
- expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
+ expect(
+ JSON.parse(request?.options.body?.toString() || '{}')
+ ).to.deep.include({
email: userEmailData.email,
university: {
id: userEmailData.affiliation?.institution.id,
@@ -393,7 +399,7 @@ describe(' ', function () {
name: /add another email/i,
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
resetFetchMock()
fetchMock.get('/institutions/list?country_code=de', [
@@ -413,7 +419,7 @@ describe(' ', function () {
await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
// select a country
- const countryInput = screen.getByRole('textbox', {
+ const countryInput = screen.getByRole('combobox', {
name: /country/i,
})
await userEvent.click(countryInput)
@@ -421,7 +427,7 @@ describe(' ', function () {
await userEvent.click(await screen.findByText('Germany'))
// match several universities on initial typing
- const universityInput = screen.getByRole('textbox', {
+ const universityInput = screen.getByRole('combobox', {
name: /university/i,
})
await userEvent.click(universityInput)
@@ -446,7 +452,7 @@ describe(' ', function () {
name: /add another email/i,
})
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
resetFetchMock()
await userEvent.click(button)
@@ -455,7 +461,7 @@ describe(' ', function () {
await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
- const universityInput = screen.getByRole('textbox', {
+ const universityInput = screen.getByRole('combobox', {
name: /university/i,
})
@@ -472,7 +478,7 @@ describe(' ', function () {
// Select the country from dropdown
await userEvent.type(
- screen.getByRole('textbox', {
+ screen.getByRole('combobox', {
name: /country/i,
}),
country
@@ -481,15 +487,17 @@ describe(' ', function () {
expect(universityInput.disabled).to.be.false
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
resetFetchMock()
// Enter the university manually
await userEvent.type(universityInput, newUniversity)
- const roleInput = screen.getByRole('textbox', { name: /role/i })
+ const roleInput = screen.getByRole('combobox', { name: /role/i })
await userEvent.type(roleInput, userEmailData.affiliation.role!)
- const departmentInput = screen.getByRole('textbox', { name: /department/i })
+ const departmentInput = screen.getByRole('combobox', {
+ name: /department/i,
+ })
await userEvent.type(departmentInput, userEmailData.affiliation.department!)
const userEmailDataCopy = {
@@ -516,9 +524,11 @@ describe(' ', function () {
await confirmCodeForEmail(userEmailData.email)
- const [[, request]] = fetchMock.calls(/\/user\/emails/)
+ const request = fetchMock.callHistory.calls(/\/user\/emails/).at(0)
- expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
+ expect(
+ JSON.parse(request?.options.body?.toString() || '{}')
+ ).to.deep.include({
email: userEmailData.email,
university: {
name: newUniversity,
@@ -554,8 +564,8 @@ describe(' ', function () {
name: /add another email/i,
})
- await fetchMock.flush(true)
- fetchMock.reset()
+ await fetchMock.callHistory.flush(true)
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(
`/institutions/domains?hostname=${hostnameFirstChar}&limit=1`,
institutionDomainDataCopy
@@ -569,41 +579,41 @@ describe(' ', function () {
)
await userEvent.keyboard('{Tab}')
- await fetchMock.flush(true)
- fetchMock.reset()
+ await fetchMock.callHistory.flush(true)
+ fetchMock.removeRoutes().clearHistory()
expect(
- screen.queryByRole('textbox', {
+ screen.queryByRole('combobox', {
name: /country/i,
})
).to.be.null
expect(
- screen.queryByRole('textbox', {
+ screen.queryByRole('combobox', {
name: /university/i,
})
).to.be.null
- screen.getByRole('textbox', {
+ screen.getByRole('combobox', {
name: /role/i,
})
- screen.getByRole('textbox', {
+ screen.getByRole('combobox', {
name: /department/i,
})
await userEvent.click(screen.getByRole('button', { name: /change/i }))
- screen.getByRole('textbox', {
+ screen.getByRole('combobox', {
name: /country/i,
})
- screen.getByRole('textbox', {
+ screen.getByRole('combobox', {
name: /university/i,
})
expect(
- screen.queryByRole('textbox', {
+ screen.queryByRole('combobox', {
name: /role/i,
})
).to.be.null
expect(
- screen.queryByRole('textbox', {
+ screen.queryByRole('combobox', {
name: /department/i,
})
).to.be.null
@@ -627,8 +637,8 @@ describe(' ', function () {
name: /add another email/i,
})
- await fetchMock.flush(true)
- fetchMock.reset()
+ await fetchMock.callHistory.flush(true)
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(
`/institutions/domains?hostname=${hostnameFirstChar}&limit=1`,
institutionDomainDataCopy
@@ -642,8 +652,8 @@ describe(' ', function () {
)
await userEvent.keyboard('{Tab}')
- await fetchMock.flush(true)
- fetchMock.reset()
+ await fetchMock.callHistory.flush(true)
+ fetchMock.removeRoutes().clearHistory()
screen.getByText(institutionDomainDataCopy[0].university.name)
@@ -664,11 +674,11 @@ describe(' ', function () {
.post('/user/emails/confirm-secondary', 200)
await userEvent.type(
- screen.getByRole('textbox', { name: /role/i }),
+ screen.getByRole('combobox', { name: /role/i }),
userEmailData.affiliation.role!
)
await userEvent.type(
- screen.getByRole('textbox', { name: /department/i }),
+ screen.getByRole('combobox', { name: /department/i }),
userEmailData.affiliation.department!
)
await userEvent.click(
@@ -679,8 +689,8 @@ describe(' ', function () {
await confirmCodeForEmail('user@autocomplete.edu')
- await fetchMock.flush(true)
- fetchMock.reset()
+ await fetchMock.callHistory.flush(true)
+ fetchMock.removeRoutes().clearHistory()
screen.getByText(userEmailDataCopy.affiliation.institution.name, {
exact: false,
diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx
index 4906f28fd9..977b9a2bf6 100644
--- a/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx
+++ b/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx
@@ -79,12 +79,14 @@ describe('user role and institution', function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
- fetchMock.reset()
- fetchMock.get('/user/emails?ensureAffiliation=true', [])
+ fetchMock.removeRoutes().clearHistory()
+ fetchMock.get('/user/emails?ensureAffiliation=true', [], {
+ name: 'get user emails',
+ })
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders affiliation name with add role/department button', function () {
@@ -122,13 +124,11 @@ describe('user role and institution', function () {
it('fetches institution data and replaces departments dropdown on add/change', async function () {
const userEmailData = userData1
- fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData], {
- overwriteRoutes: true,
- })
+ fetchMock.modifyRoute('get user emails', { response: [userEmailData] })
render( )
- await fetchMock.flush(true)
- fetchMock.reset()
+ await fetchMock.callHistory.flush(true)
+ fetchMock.removeRoutes().clearHistory()
const fakeDepartment = 'Fake department'
const institution = userEmailData.affiliation.institution
@@ -139,23 +139,23 @@ describe('user role and institution', function () {
departments: [fakeDepartment],
})
- fireEvent.click(
- screen.getByRole('button', { name: /add role and department/i })
- )
+ const addRoleButton = await screen.findByRole('button', {
+ name: /add role and department/i,
+ })
- await fetchMock.flush(true)
- fetchMock.reset()
+ fireEvent.click(addRoleButton)
- fireEvent.click(screen.getByRole('textbox', { name: /department/i }))
+ await fetchMock.callHistory.flush(true)
+ fetchMock.removeRoutes().clearHistory()
- screen.getByText(fakeDepartment)
+ fireEvent.click(screen.getByRole('combobox', { name: /department/i }))
+
+ await screen.findByText(fakeDepartment)
})
it('adds new role and department', async function () {
fetchMock
- .get('/user/emails?ensureAffiliation=true', [userData1], {
- overwriteRoutes: true,
- })
+ .modifyRoute('get user emails', { response: [userData1] })
.get(/\/institutions\/list/, { departments: [] })
.post('/user/emails/endorse', 200)
render( )
diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx
index e4183e6e3f..451a510855 100644
--- a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx
+++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx
@@ -22,11 +22,11 @@ describe(' ', function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders translated heading', function () {
diff --git a/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx b/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
index ef921ac6b7..d18039a280 100644
--- a/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
+++ b/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
@@ -13,7 +13,7 @@ import ReconfirmationInfo from '../../../../../../frontend/js/features/settings/
import { ssoUserData } from '../../fixtures/test-user-email-data'
import { UserEmailData } from '../../../../../../types/user-email'
import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context'
-import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
import getMeta from '@/utils/meta'
function renderReconfirmationInfo(data: UserEmailData) {
@@ -25,25 +25,18 @@ function renderReconfirmationInfo(data: UserEmailData) {
}
describe(' ', function () {
- let assignStub: sinon.SinonStub
-
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
samlInitPath: '/saml',
})
fetchMock.get('/user/emails?ensureAffiliation=true', [])
- assignStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: sinon.stub(),
- setHash: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
})
afterEach(function () {
- fetchMock.reset()
- this.locationStub.restore()
+ fetchMock.removeRoutes().clearHistory()
+ this.locationWrapperSandbox.restore()
})
describe('reconfirmed via SAML', function () {
@@ -115,9 +108,9 @@ describe(' ', function () {
await waitFor(() => {
expect(confirmButton.disabled).to.be.true
})
- sinon.assert.calledOnce(assignStub)
+ sinon.assert.calledOnce(this.locationWrapperStub.assign)
sinon.assert.calledWithMatch(
- assignStub,
+ this.locationWrapperStub.assign,
'/saml/init?university_id=2&reconfirm=/user/settings'
)
})
@@ -145,13 +138,13 @@ describe(' ', function () {
await waitFor(() => {
expect(confirmButton.disabled).to.be.true
})
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
// the confirmation text should now be displayed
await screen.findByText(/Please check your email inbox to confirm/)
// try the resend button
- fetchMock.resetHistory()
+ fetchMock.clearHistory()
const resendButton = await screen.findByRole('button', {
name: 'Resend confirmation email',
})
@@ -160,7 +153,7 @@ describe(' ', function () {
// commented out as it's already gone by this point
// await screen.findByText(/Sending/)
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
await waitForElementToBeRemoved(() => screen.getByText('Sending…'))
await screen.findByRole('button', {
name: 'Resend confirmation email',
diff --git a/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx
index eba7176e19..475998064d 100644
--- a/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx
+++ b/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx
@@ -12,7 +12,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('disable delete button if form is not valid', function () {
diff --git a/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
index 52c07de20f..4d5c7f6bcb 100644
--- a/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
+++ b/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
@@ -1,10 +1,10 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, screen, render, waitFor } from '@testing-library/react'
-import fetchMock, { FetchMockStatic } from 'fetch-mock'
+import fetchMock, { type FetchMock } from 'fetch-mock'
import LeaveModalForm from '../../../../../../frontend/js/features/settings/components/leave/modal-form'
-import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
import getMeta from '@/utils/meta'
describe(' ', function () {
@@ -14,7 +14,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('validates form', async function () {
@@ -50,26 +50,20 @@ describe(' ', function () {
describe('submits', async function () {
let setInFlight: sinon.SinonStub
let setIsFormValid: sinon.SinonStub
- let deleteMock: FetchMockStatic
- let assignStub: sinon.SinonStub
+ let deleteMock: FetchMock
beforeEach(function () {
setInFlight = sinon.stub()
setIsFormValid = sinon.stub()
deleteMock = fetchMock.post('/user/delete', 200)
- assignStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: sinon.stub(),
- setHash: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
})
afterEach(function () {
- fetchMock.reset()
- this.locationStub.restore()
+ fetchMock.removeRoutes().clearHistory()
+ this.locationWrapperSandbox.restore()
})
it('with valid form', async function () {
@@ -85,7 +79,8 @@ describe(' ', function () {
sinon.assert.calledOnce(setInFlight)
sinon.assert.calledWithMatch(setInFlight, true)
- expect(deleteMock.called()).to.be.true
+ expect(deleteMock.callHistory.called()).to.be.true
+ const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
sinon.assert.calledTwice(setInFlight)
sinon.assert.calledWithMatch(setInFlight, false)
@@ -105,7 +100,7 @@ describe(' ', function () {
fireEvent.submit(screen.getByLabelText('Email'))
- expect(deleteMock.called()).to.be.false
+ expect(deleteMock.callHistory.called()).to.be.false
sinon.assert.notCalled(setInFlight)
})
})
diff --git a/services/web/test/frontend/features/settings/components/leave/modal.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal.test.tsx
index 442afb9319..3e52f01638 100644
--- a/services/web/test/frontend/features/settings/components/leave/modal.test.tsx
+++ b/services/web/test/frontend/features/settings/components/leave/modal.test.tsx
@@ -13,7 +13,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('closes modal on cancel', async function () {
diff --git a/services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx b/services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx
index 2ffa2e5051..b5de65d392 100644
--- a/services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx
+++ b/services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx
@@ -21,7 +21,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('should render before the expiration date', function () {
diff --git a/services/web/test/frontend/features/settings/components/linking-section.test.tsx b/services/web/test/frontend/features/settings/components/linking-section.test.tsx
index 8567769fc4..d134ffeae6 100644
--- a/services/web/test/frontend/features/settings/components/linking-section.test.tsx
+++ b/services/web/test/frontend/features/settings/components/linking-section.test.tsx
@@ -52,7 +52,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows header', async function () {
diff --git a/services/web/test/frontend/features/settings/components/password-section.test.tsx b/services/web/test/frontend/features/settings/components/password-section.test.tsx
index ae6a620f1f..63f151a3df 100644
--- a/services/web/test/frontend/features/settings/components/password-section.test.tsx
+++ b/services/web/test/frontend/features/settings/components/password-section.test.tsx
@@ -17,7 +17,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows password managed externally message', async function () {
@@ -45,14 +45,14 @@ describe(' ', function () {
render( )
submitValidForm()
- expect(updateMock.called()).to.be.true
- expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
- {
- currentPassword: 'foobar',
- newPassword1: 'barbaz',
- newPassword2: 'barbaz',
- }
- )
+ expect(updateMock.callHistory.called()).to.be.true
+ expect(
+ JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+ ).to.deep.equal({
+ currentPassword: 'foobar',
+ newPassword1: 'barbaz',
+ newPassword2: 'barbaz',
+ })
})
it('disables button on invalid form', async function () {
@@ -64,7 +64,7 @@ describe(' ', function () {
name: 'Change',
})
)
- expect(updateMock.called()).to.be.false
+ expect(updateMock.callHistory.called()).to.be.false
})
it('validates inputs', async function () {
diff --git a/services/web/test/frontend/features/settings/components/security-section.test.tsx b/services/web/test/frontend/features/settings/components/security-section.test.tsx
index 48146e82cf..08aa89e9a4 100644
--- a/services/web/test/frontend/features/settings/components/security-section.test.tsx
+++ b/services/web/test/frontend/features/settings/components/security-section.test.tsx
@@ -5,7 +5,7 @@ import fetchMock from 'fetch-mock'
describe(' ', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows Group SSO rows in security section', async function () {
diff --git a/services/web/test/frontend/features/settings/context/sso-context.test.tsx b/services/web/test/frontend/features/settings/context/sso-context.test.tsx
index 2c7cbebd2b..0d5cb591b4 100644
--- a/services/web/test/frontend/features/settings/context/sso-context.test.tsx
+++ b/services/web/test/frontend/features/settings/context/sso-context.test.tsx
@@ -1,5 +1,5 @@
import { expect } from 'chai'
-import { renderHook } from '@testing-library/react-hooks'
+import { renderHook, waitFor } from '@testing-library/react'
import {
SSOProvider,
useSSOContext,
@@ -35,7 +35,7 @@ describe('SSOContext', function () {
google: 'google-id',
})
window.metaAttributesCache.set('ol-oauthProviders', mockOauthProviders)
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('should initialise subscriptions with their linked status', function () {
@@ -60,22 +60,23 @@ describe('SSOContext', function () {
})
it('should unlink an existing subscription', async function () {
- const { result, waitForNextUpdate } = renderSSOContext()
+ const { result } = renderSSOContext()
result.current.unlink('google')
- await waitForNextUpdate()
- expect(result.current.subscriptions.google.linked).to.be.false
+ await waitFor(
+ () => expect(result.current.subscriptions.google.linked).to.be.false
+ )
})
it('when the provider is not linked, should do nothing', function () {
const { result } = renderSSOContext()
result.current.unlink('orcid')
- expect(fetchMock.called()).to.be.false
+ expect(fetchMock.callHistory.called()).to.be.false
})
it('supports unmounting the component while the request is inflight', async function () {
const { result, unmount } = renderSSOContext()
result.current.unlink('google')
- expect(fetchMock.called()).to.be.true
+ expect(fetchMock.callHistory.called()).to.be.true
unmount()
})
})
diff --git a/services/web/test/frontend/features/settings/context/user-email-context.test.tsx b/services/web/test/frontend/features/settings/context/user-email-context.test.tsx
index 7d9544ffe7..3d68202470 100644
--- a/services/web/test/frontend/features/settings/context/user-email-context.test.tsx
+++ b/services/web/test/frontend/features/settings/context/user-email-context.test.tsx
@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { cloneDeep } from 'lodash'
-import { renderHook } from '@testing-library/react-hooks'
+import { renderHook, waitFor } from '@testing-library/react'
import {
EmailContextType,
UserEmailsProvider,
@@ -26,7 +26,7 @@ const renderUserEmailsContext = () =>
describe('UserEmailContext', function () {
beforeEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
describe('context bootstrap', function () {
@@ -49,14 +49,16 @@ describe('UserEmailContext', function () {
it('should load all user emails and update the initialisation state to "success"', async function () {
fetchMock.get(/\/user\/emails/, fakeUsersData)
const { result } = renderUserEmailsContext()
- await fetchMock.flush(true)
- expect(fetchMock.calls()).to.have.lengthOf(1)
- expect(result.current.state.data.byId).to.deep.equal({
- 'bar@overleaf.com': { ...untrustedUserData, ...confirmedUserData },
- 'baz@overleaf.com': unconfirmedUserData,
- 'foo@overleaf.com': professionalUserData,
- 'qux@overleaf.com': unconfirmedCommonsUserData,
- })
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.calls()).to.have.lengthOf(1)
+ await waitFor(() =>
+ expect(result.current.state.data.byId).to.deep.equal({
+ 'bar@overleaf.com': { ...untrustedUserData, ...confirmedUserData },
+ 'baz@overleaf.com': unconfirmedUserData,
+ 'foo@overleaf.com': professionalUserData,
+ 'qux@overleaf.com': unconfirmedCommonsUserData,
+ })
+ )
expect(result.current.state.data.linkedInstitutionIds).to.have.lengthOf(0)
expect(result.current.isInitializing).to.equal(false)
@@ -66,9 +68,9 @@ describe('UserEmailContext', function () {
it('when loading user email fails, it should update the initialisation state to "failed"', async function () {
fetchMock.get(/\/user\/emails/, 500)
const { result } = renderUserEmailsContext()
- await fetchMock.flush()
+ await fetchMock.callHistory.flush()
- expect(result.current.isInitializing).to.equal(false)
+ await waitFor(() => expect(result.current.isInitializing).to.equal(false))
expect(result.current.isInitializingError).to.equal(true)
})
@@ -78,12 +80,16 @@ describe('UserEmailContext', function () {
expect(result.current.state.isLoading).to.equal(true)
})
- it('should be updated with `setLoading`', function () {
+ it('should be updated with `setLoading`', async function () {
const { result } = renderUserEmailsContext()
result.current.setLoading(true)
- expect(result.current.state.isLoading).to.equal(true)
+ await waitFor(() =>
+ expect(result.current.state.isLoading).to.equal(true)
+ )
result.current.setLoading(false)
- expect(result.current.state.isLoading).to.equal(false)
+ await waitFor(() =>
+ expect(result.current.state.isLoading).to.equal(false)
+ )
})
})
})
@@ -94,23 +100,27 @@ describe('UserEmailContext', function () {
fetchMock.get(/\/user\/emails/, fakeUsersData)
const value = renderUserEmailsContext()
result = value.result
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
})
describe('getEmails()', function () {
beforeEach(async function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
- it('should set `isLoading === true`', function () {
- fetchMock.get(/\/user\/emails/, [
- {
- email: 'new@email.com',
- default: true,
- },
- ])
+ it('should set `isLoading === true`', async function () {
+ fetchMock.get(
+ /\/user\/emails/,
+ [
+ {
+ email: 'new@email.com',
+ default: true,
+ },
+ ],
+ { delay: 100 }
+ )
result.current.getEmails()
- expect(result.current.state.isLoading).to.be.true
+ await waitFor(() => expect(result.current.state.isLoading).to.be.true)
})
it('requests a new set of emails', async function () {
@@ -120,10 +130,12 @@ describe('UserEmailContext', function () {
}
fetchMock.get(/\/user\/emails/, [emailData])
result.current.getEmails()
- await fetchMock.flush(true)
- expect(result.current.state.data.byId).to.deep.equal({
- 'new@email.com': emailData,
- })
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() =>
+ expect(result.current.state.data.byId).to.deep.equal({
+ 'new@email.com': emailData,
+ })
+ )
})
it('should populate `linkedInstitutionIds`', async function () {
@@ -133,43 +145,56 @@ describe('UserEmailContext', function () {
{ ...professionalUserData, samlProviderId: 'saml_provider_2' },
])
const { result } = renderUserEmailsContext()
- await fetchMock.flush(true)
- expect(result.current.state.data.linkedInstitutionIds).to.deep.equal([
- 'saml_provider_1',
- 'saml_provider_2',
- ])
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() =>
+ expect(result.current.state.data.linkedInstitutionIds).to.deep.equal([
+ 'saml_provider_1',
+ 'saml_provider_2',
+ ])
+ )
})
})
describe('makePrimary()', function () {
- it('sets an email as `default`', function () {
+ it('sets an email as `default`', async function () {
expect(result.current.state.data.byId['bar@overleaf.com'].default).to.be
.false
result.current.makePrimary('bar@overleaf.com')
- expect(result.current.state.data.byId['bar@overleaf.com'].default).to.be
- .true
+ await waitFor(
+ () =>
+ expect(result.current.state.data.byId['bar@overleaf.com'].default)
+ .to.be.true
+ )
})
- it('sets `default=false` for the current primary email ', function () {
+ it('sets `default=false` for the current primary email ', async function () {
expect(result.current.state.data.byId['foo@overleaf.com'].default).to.be
.true
result.current.makePrimary('bar@overleaf.com')
- expect(result.current.state.data.byId['foo@overleaf.com'].default).to.be
- .false
+ await waitFor(
+ () =>
+ expect(result.current.state.data.byId['foo@overleaf.com'].default)
+ .to.be.false
+ )
})
- it('produces no effect when passing a non-existing email', function () {
+ it('produces no effect when passing a non-existing email', async function () {
const emails = cloneDeep(result.current.state.data.byId)
result.current.makePrimary('non-existing@email.com')
- expect(result.current.state.data.byId).to.deep.equal(emails)
+ await waitFor(() =>
+ expect(result.current.state.data.byId).to.deep.equal(emails)
+ )
})
})
describe('deleteEmail()', function () {
- it('removes data from the deleted email', function () {
+ it('removes data from the deleted email', async function () {
result.current.deleteEmail('bar@overleaf.com')
- expect(result.current.state.data.byId['bar@overleaf.com']).to.be
- .undefined
+ await waitFor(
+ () =>
+ expect(result.current.state.data.byId['bar@overleaf.com']).to.be
+ .undefined
+ )
})
it('produces no effect when passing a non-existing email', function () {
@@ -180,14 +205,20 @@ describe('UserEmailContext', function () {
})
describe('setEmailAffiliationBeingEdited()', function () {
- it('sets an email as currently being edited', function () {
+ it('sets an email as currently being edited', async function () {
result.current.setEmailAffiliationBeingEdited('bar@overleaf.com')
- expect(result.current.state.data.emailAffiliationBeingEdited).to.equal(
- 'bar@overleaf.com'
+ await waitFor(() =>
+ expect(
+ result.current.state.data.emailAffiliationBeingEdited
+ ).to.equal('bar@overleaf.com')
)
result.current.setEmailAffiliationBeingEdited(null)
- expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null
+ await waitFor(
+ () =>
+ expect(result.current.state.data.emailAffiliationBeingEdited).to.be
+ .null
+ )
})
it('produces no effect when passing a non-existing email', function () {
@@ -198,15 +229,17 @@ describe('UserEmailContext', function () {
})
describe('updateAffiliation()', function () {
- it('updates affiliation data for an email', function () {
+ it('updates affiliation data for an email', async function () {
result.current.updateAffiliation(
'foo@overleaf.com',
'new role',
'new department'
)
- expect(
- result.current.state.data.byId['foo@overleaf.com'].affiliation!.role
- ).to.equal('new role')
+ await waitFor(() =>
+ expect(
+ result.current.state.data.byId['foo@overleaf.com'].affiliation!.role
+ ).to.equal('new role')
+ )
expect(
result.current.state.data.byId['foo@overleaf.com'].affiliation!
.department
@@ -257,11 +290,11 @@ describe('UserEmailContext', function () {
const affiliatedEmail2 = cloneDeep(professionalUserData)
affiliatedEmail2.emailHasInstitutionLicence = true
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2])
result.current.getEmails()
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
@@ -281,11 +314,11 @@ describe('UserEmailContext', function () {
const affiliatedEmail2 = cloneDeep(professionalUserData)
affiliatedEmail2.emailHasInstitutionLicence = true
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2])
result.current.getEmails()
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
@@ -303,19 +336,21 @@ describe('UserEmailContext', function () {
affiliatedEmail1.email = 'institution-test@example.com'
affiliatedEmail1.affiliation.pastReconfirmDate = true
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [confirmedUserData, affiliatedEmail1])
result.current.getEmails()
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
result.current.resetLeaversSurveyExpiration(affiliatedEmail1)
- expect(
- localStorage.getItem('showInstitutionalLeaversSurveyUntil')
- ).to.be.greaterThan(Date.now())
+ await waitFor(() =>
+ expect(
+ localStorage.getItem('showInstitutionalLeaversSurveyUntil')
+ ).to.be.greaterThan(Date.now())
+ )
})
it("when the leaver has no institution license, it shouldn't reset the survey expiration date", async function () {
@@ -323,11 +358,11 @@ describe('UserEmailContext', function () {
emailWithInstitutionLicense.email = 'institution-licensed@example.com'
emailWithInstitutionLicense.emailHasInstitutionLicence = false
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense])
result.current.getEmails()
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(emailWithInstitutionLicense.email)
@@ -342,11 +377,11 @@ describe('UserEmailContext', function () {
emailWithInstitutionLicense.email = 'institution-licensed@example.com'
emailWithInstitutionLicense.affiliation.pastReconfirmDate = false
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense])
result.current.getEmails()
- await fetchMock.flush(true)
+ await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(emailWithInstitutionLicense.email)
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 cee1bca5ab..cdeb0adb6f 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
@@ -3,7 +3,6 @@ import sinon from 'sinon'
import { screen, fireEvent, render, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import userEvent from '@testing-library/user-event'
-import * as bootstrapUtils from '@/features/utils/bootstrap-5'
import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
import {
@@ -15,15 +14,15 @@ import {
USER_EMAIL,
USER_ID,
} from '../../../helpers/editor-providers'
-import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
async function changePrivilegeLevel(screen, { current, next }) {
const select = screen.getByDisplayValue(current)
- await fireEvent.click(select)
+ fireEvent.click(select)
const option = screen.getByRole('option', {
name: next,
})
- await fireEvent.click(option)
+ fireEvent.click(option)
}
describe(' ', function () {
@@ -90,24 +89,18 @@ describe(' ', function () {
}
beforeEach(function () {
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: sinon.stub(),
- replace: sinon.stub(),
- reload: sinon.stub(),
- })
- this.isBootstrap5Stub = sinon
- .stub(bootstrapUtils, 'isBootstrap5')
- .returns(true)
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
fetchMock.get('/user/contacts', { contacts })
window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
window.metaAttributesCache.set('ol-showUpgradePrompt', true)
window.metaAttributesCache.set('ol-isReviewerRoleEnabled', true)
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
afterEach(function () {
- this.locationStub.restore()
- this.isBootstrap5Stub.restore()
- fetchMock.restore()
+ this.locationWrapperSandbox.restore()
+ fetchMock.removeRoutes().clearHistory()
cleanUpContext()
})
@@ -204,6 +197,8 @@ describe(' ', function () {
})
it('displays actions for project-owners', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
+
const invites = [
{
_id: 'invited-author',
@@ -309,6 +304,7 @@ describe(' ', 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`, {})
renderWithEditorContext( , {
isRestrictedTokenMember: true,
@@ -354,6 +350,8 @@ describe(' ', function () {
},
]
+ fetchMock.get(`/project/${project._id}/tokens`, {})
+
renderWithEditorContext( , {
scope: {
project: {
@@ -387,6 +385,7 @@ describe(' ', function () {
})
it('resends an invite', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.postOnce(
'express:/project/:projectId/invite/:inviteId/resend',
204
@@ -419,11 +418,12 @@ describe(' ', function () {
await waitFor(() => expect(closeButton.disabled).to.be.true)
- expect(fetchMock.done()).to.be.true
- expect(closeButton.disabled).to.be.false
+ expect(fetchMock.callHistory.done()).to.be.true
+ await waitFor(() => expect(closeButton.disabled).to.be.false)
})
it('revokes an invite', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204)
const invites = [
@@ -452,11 +452,12 @@ describe(' ', function () {
fireEvent.click(revokeButton)
await waitFor(() => expect(closeButton.disabled).to.be.true)
- expect(fetchMock.done()).to.be.true
- expect(closeButton.disabled).to.be.false
+ expect(fetchMock.callHistory.done()).to.be.true
+ await waitFor(() => expect(closeButton.disabled).to.be.false)
})
it('changes member privileges to read + write', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.putOnce('express:/project/:projectId/users/:userId', 204)
const members = [
@@ -489,14 +490,15 @@ describe(' ', function () {
await waitFor(() => expect(closeButton.disabled).to.be.true)
- const { body } = fetchMock.lastOptions()
+ const { body } = fetchMock.callHistory.calls().at(-1).options
expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
- expect(fetchMock.done()).to.be.true
- expect(closeButton.disabled).to.be.false
+ expect(fetchMock.callHistory.done()).to.be.true
+ await waitFor(() => expect(closeButton.disabled).to.be.false)
})
it('removes a member from the project', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204)
const members = [
@@ -530,13 +532,16 @@ describe(' ', function () {
})
fireEvent.click(removeButton)
- const url = fetchMock.lastUrl()
- expect(url).to.equal('/project/test-project/users/member-viewer')
+ const url = fetchMock.callHistory.calls().at(-1).url
+ expect(url).to.equal(
+ 'https://www.test-overleaf.com/project/test-project/users/member-viewer'
+ )
- expect(fetchMock.done()).to.be.true
+ expect(fetchMock.callHistory.done()).to.be.true
})
it('changes member privileges to owner with confirmation', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
const members = [
@@ -578,13 +583,15 @@ describe(' ', function () {
fireEvent.click(confirmButton)
await waitFor(() => expect(confirmButton.disabled).to.be.true)
- const { body } = fetchMock.lastOptions()
+ const { body } = fetchMock.callHistory.calls().at(-1).options
expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
- expect(fetchMock.done()).to.be.true
+ expect(fetchMock.callHistory.done()).to.be.true
})
it('sends invites to input email addresses', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
+
renderWithEditorContext( , {
scope: {
project: {
@@ -598,7 +605,7 @@ describe(' ', function () {
// loading contacts
await waitFor(() => {
- expect(fetchMock.called('express:/user/contacts')).to.be.true
+ expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
})
// displaying a list of matching contacts
@@ -609,26 +616,29 @@ describe(' ', function () {
// sending invitations
- fetchMock.post('express:/project/:projectId/invite', (url, req) => {
- const data = JSON.parse(req.body)
+ fetchMock.post(
+ 'express:/project/:projectId/invite',
+ ({ args: [url, req] }) => {
+ const data = JSON.parse(req.body)
+
+ if (data.email === 'a@b.c') {
+ return {
+ status: 400,
+ body: { errorReason: 'invalid_email' },
+ }
+ }
- if (data.email === 'a@b.c') {
return {
- status: 400,
- body: { errorReason: 'invalid_email' },
+ status: 200,
+ body: {
+ invite: {
+ ...data,
+ _id: data.email,
+ },
+ },
}
}
-
- return {
- status: 200,
- body: {
- invite: {
- ...data,
- _id: data.email,
- },
- },
- }
- })
+ )
fireEvent.paste(inputElement, {
clipboardData: {
@@ -648,22 +658,24 @@ describe(' ', function () {
let calls
await waitFor(
() => {
- calls = fetchMock.calls('express:/project/:projectId/invite')
+ calls = fetchMock.callHistory.calls(
+ 'express:/project/:projectId/invite'
+ )
expect(calls).to.have.length(4)
},
{ timeout: 5000 } // allow time for delay between each request
)
- expect(calls[0][1].body).to.equal(
+ expect(calls[0].args[1].body).to.equal(
JSON.stringify({ email: 'test@example.com', privileges: 'readOnly' })
)
- expect(calls[1][1].body).to.equal(
+ expect(calls[1].args[1].body).to.equal(
JSON.stringify({ email: 'foo@example.com', privileges: 'readOnly' })
)
- expect(calls[2][1].body).to.equal(
+ expect(calls[2].args[1].body).to.equal(
JSON.stringify({ email: 'bar@example.com', privileges: 'readOnly' })
)
- expect(calls[3][1].body).to.equal(
+ expect(calls[3].args[1].body).to.equal(
JSON.stringify({ email: 'a@b.c', privileges: 'readOnly' })
)
@@ -672,6 +684,7 @@ describe(' ', function () {
})
it('displays a message when the collaborator limit is reached', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.post(
'/event/paywall-prompt',
{},
@@ -691,7 +704,7 @@ describe(' ', function () {
},
})
- await screen.findByText('Add more editors')
+ await screen.findByText('Add more collaborators')
const user = userEvent.setup()
await user.click(screen.getByTestId('add-collaborator-select'))
@@ -704,7 +717,7 @@ describe(' ', function () {
expect(viewerOption.classList.contains('disabled')).to.be.false
screen.getByText(
- /Upgrade to add more editors and access collaboration features like track changes and full project history/
+ /Upgrade to add more collaborators and access collaboration features like track changes and full project history/
)
})
@@ -727,7 +740,7 @@ describe(' ', function () {
},
})
- await screen.findByText('Add more editors')
+ await screen.findByText('Add more collaborators')
const user = userEvent.setup()
await user.click(screen.getByTestId('add-collaborator-select'))
@@ -741,11 +754,13 @@ describe(' ', function () {
expect(viewerOption.classList.contains('disabled')).to.be.false
screen.getByText(
- /Upgrade to add more editors and access collaboration features like track changes and full project history/
+ /Upgrade to add more collaborators and access collaboration features like track changes and full project history/
)
})
it('handles server error responses', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
+
renderWithEditorContext( , {
scope: {
project: {
@@ -757,7 +772,7 @@ describe(' ', function () {
// loading contacts
await waitFor(() => {
- expect(fetchMock.called('express:/user/contacts')).to.be.true
+ expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
})
const [inputElement] = await screen.findAllByLabelText('Add people')
@@ -771,19 +786,15 @@ describe(' ', function () {
})
fireEvent.blur(inputElement)
- fetchMock.postOnce(
- 'express:/project/:projectId/invite',
- {
- status: 400,
- body: { errorReason },
- },
- { overwriteRoutes: true }
- )
+ fetchMock.postOnce('express:/project/:projectId/invite', {
+ status: 400,
+ body: { errorReason },
+ })
expect(submitButton.disabled).to.be.false
await userEvent.click(submitButton)
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
}
await respondWithError('cannot_invite_non_user')
@@ -809,6 +820,7 @@ describe(' ', function () {
})
it('handles switching between access levels', async function () {
+ fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.post('express:/project/:projectId/settings/admin', 204)
renderWithEditorContext( , {
@@ -825,7 +837,7 @@ describe(' ', function () {
fireEvent.click(enableButton)
await waitFor(() => expect(enableButton.disabled).to.be.true)
- const { body: tokenBody } = fetchMock.lastOptions()
+ const { body: tokenBody } = fetchMock.callHistory.calls().at(-1).options
expect(JSON.parse(tokenBody)).to.deep.equal({
publicAccessLevel: 'tokenBased',
})
@@ -845,7 +857,7 @@ describe(' ', function () {
fireEvent.click(disableButton)
await waitFor(() => expect(disableButton.disabled).to.be.true)
- const { body: privateBody } = fetchMock.lastOptions()
+ const { body: privateBody } = fetchMock.callHistory.calls().at(-1).options
expect(JSON.parse(privateBody)).to.deep.equal({
publicAccessLevel: 'private',
})
@@ -870,7 +882,7 @@ describe(' ', function () {
// Wait for contacts to load
await waitFor(() => {
- expect(fetchMock.called('express:/user/contacts')).to.be.true
+ expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
})
// Enter a prefix that matches a contact
@@ -924,7 +936,7 @@ describe(' ', function () {
// Wait for contacts to load
await waitFor(() => {
- expect(fetchMock.called('express:/user/contacts')).to.be.true
+ expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
})
// Enter a prefix that matches a contact
@@ -959,7 +971,7 @@ describe(' ', function () {
// Wait for contacts to load
await waitFor(() => {
- expect(fetchMock.called('express:/user/contacts')).to.be.true
+ expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
})
// Enter a prefix that matches a contact
@@ -993,7 +1005,7 @@ describe(' ', function () {
// Wait for contacts to load
await waitFor(() => {
- expect(fetchMock.called('express:/user/contacts')).to.be.true
+ expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
})
// Enter a prefix that matches a contact
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 46f0709614..932482205f 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
@@ -307,7 +307,7 @@ describe('autocomplete', { scrollBehavior: false }, function () {
packageNames: new Set(['foo']),
}
- const MetadataProvider: FC = ({ children }) => {
+ const MetadataProvider: FC = ({ children }) => {
return (
{children}
@@ -376,7 +376,7 @@ describe('autocomplete', { scrollBehavior: false }, function () {
const scope = mockScope()
- const ReferencesProvider: FC = ({ children }) => {
+ const ReferencesProvider: FC = ({ children }) => {
return (
('amsmath'),
}
- const MetadataProvider: FC = ({ children }) => {
+ const MetadataProvider: FC = ({ children }) => {
return (
{children}
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 0986e0ee41..eda6dcec6b 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
@@ -43,7 +43,9 @@ describe(' ', function () {
const scope = mockScope(content)
scope.editor.showVisual = true
- const FileTreePathProvider: FC = ({ children }) => (
+ const FileTreePathProvider: FC = ({
+ children,
+ }) => (
', function () {
})
it('Lists files from project', function () {
- cy.findByRole('textbox', { name: 'Image file' }).click()
+ cy.findByRole('combobox', { name: 'Image file' }).click()
cy.findByRole('listbox')
.children()
.should('have.length', 2)
@@ -137,7 +139,7 @@ describe(' ', function () {
it('Enables insert button when choosing file', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
- cy.findByRole('textbox', { name: 'Image file' }).click()
+ cy.findByRole('combobox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByText('frog.jpg').click()
})
@@ -145,7 +147,7 @@ describe(' ', function () {
})
it('Inserts file when pressing insert button', function () {
- cy.findByRole('textbox', { name: 'Image file' }).click()
+ cy.findByRole('combobox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByText('frog.jpg').click()
})
@@ -166,8 +168,8 @@ describe(' ', function () {
cy.findByRole('menu').within(() => {
cy.findByRole('button', { name: 'From another project' }).click()
})
- cy.findByRole('textbox', { name: 'Project' }).as('project-dropdown')
- cy.findByRole('textbox', { name: 'Image file' }).as('file-dropdown')
+ cy.findByRole('combobox', { name: 'Project' }).as('project-dropdown')
+ cy.findByRole('combobox', { name: 'Image file' }).as('file-dropdown')
})
it('List projects and files in projects', function () {
@@ -203,6 +205,19 @@ describe(' ', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
+ it('Closes project dropdown on pressing Esc key but leaves modal open', function () {
+ cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
+ cy.get('@project-dropdown').click()
+ cy.findByRole('listbox').should('exist')
+ cy.get('@project-dropdown').type('{esc}', { force: true })
+ cy.findByRole('listbox').should('not.exist')
+ cy.findByRole('dialog').should('exist')
+
+ // Check that a subsequent press of the Esc key closes the modal
+ cy.get('@project-dropdown').type('{esc}', { force: true })
+ cy.findByRole('dialog').should('not.exist')
+ })
+
it('Creates linked file when pressing insert', function () {
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
@@ -235,7 +250,7 @@ describe(' ', function () {
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.findByRole('button', { name: 'select from output files' }).click()
- cy.findByRole('textbox', { name: 'Output file' }).click()
+ cy.findByRole('combobox', { name: 'Output file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'output.pdf' }).click()
})
@@ -298,7 +313,7 @@ describe(' ', function () {
cy.findByRole('menu').within(() => {
cy.findByText('From another project').click()
})
- cy.findByRole('textbox', { name: 'Project' }).click()
+ cy.findByRole('combobox', { name: 'Project' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
@@ -322,7 +337,7 @@ describe(' ', function () {
})
expectNoOutputSwitch()
it('should show output file selector', function () {
- cy.findByRole('textbox', { name: 'Output file' }).click()
+ cy.findByRole('combobox', { name: 'Output file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'output.pdf' }).click()
})
@@ -341,7 +356,7 @@ describe(' ', function () {
expectNoOutputSwitch()
it('should show source file selector', function () {
- cy.findByRole('textbox', { name: 'Image file' }).click()
+ cy.findByRole('combobox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'frog.jpg' }).click()
})
@@ -532,7 +547,7 @@ describe(' ', function () {
cy.findByText('Done').click()
cy.get('.cm-content').should(
'have.text',
- '\\begin{figure}\\centering\\end{figure}'
+ '\\begin{figure}\\centering\\includegraphics[width=0.75\\linewidth]{frog.jpg}\\end{figure}'
)
})
@@ -554,7 +569,7 @@ describe(' ', function () {
cy.findByText('Done').click()
cy.get('.cm-content').should(
'have.text',
- '\\begin{figure}\\centering🏷fig:my-label\\end{figure}'
+ '\\begin{figure}\\centering\\includegraphics[width=0.75\\linewidth]{frog.jpg}🏷fig:my-label\\end{figure}'
)
})
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 b391a85222..ea7414ddab 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
@@ -108,7 +108,7 @@ describe(' Table editor', function () {
cy.interceptEvents()
cy.interceptMathJax()
- cy.interceptCompile('compile', Number.MAX_SAFE_INTEGER)
+ cy.interceptCompile({ prefix: 'compile', times: Number.MAX_SAFE_INTEGER })
cy.intercept('/project/*/doc/*/metadata', { body: {} })
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
@@ -178,7 +178,9 @@ cell 3 & cell 4 \\\\
})
it('Adds and removes borders when theme is changed', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{tabular}{c|c}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
@@ -216,7 +218,9 @@ cell 3 & cell 4 \\\\
})
it('Changes the column alignment with dropdown buttons', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{tabular}{cc}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
@@ -269,7 +273,9 @@ cell 3 & cell 4 \\\\
})
it('Removes rows and columns', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{tabular}{|c|c|c|}
\\hline
cell 1 & cell 2 & cell 3 \\\\ \\hline
@@ -304,7 +310,9 @@ cell 3 & cell 4 \\\\
})
it('Removes rows correctly when removing from the left', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{tabular}{|c|c|c|}\\hline
cell 1&cell 2&cell 3 \\\\\\hline
\\end{tabular}
@@ -324,7 +332,9 @@ cell 3 & cell 4 \\\\
})
it('Merges and unmerged cells', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{tabular}{ccc}
cell 1 & cell 2 & cell 3 \\\\
cell 4 & cell 5 & cell 6 \\\\
@@ -333,6 +343,7 @@ cell 3 & cell 4 \\\\
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-cell').first().type('{shift}{rightarrow}')
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
+ cy.get('@toolbar').scrollIntoView()
cy.get('@toolbar').findByLabelText('Merge cells').click()
checkTable([
[{ text: 'cell 1 cell 2', colspan: 2 }, 'cell 3'],
@@ -346,7 +357,9 @@ cell 3 & cell 4 \\\\
})
it('Adds rows and columns', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{tabular}{c}
cell 1
\\end{tabular}
@@ -398,7 +411,9 @@ cell 3 & cell 4 \\\\
})
it('Removes the table on toolbar button click', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{tabular}{c}
cell 1
\\end{tabular}`)
@@ -409,7 +424,9 @@ cell 3 & cell 4 \\\\
})
it('Moves the caption when using dropdown', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{table}
\\caption{Table caption}
\\label{tab:table}
@@ -456,7 +473,9 @@ cell 3 & cell 4 \\\\
})
it('Renders a table with custom column spacing', function () {
+ // Add a blank line above the table to allow room for the table toolbar
mountEditor(`
+
\\begin{tabular}{@{}c@{}l!{}}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
@@ -498,8 +517,10 @@ cell 3 & cell 4 \\\\
describe('Fixed width columns', function () {
it('Can add fixed width columns', function () {
+ // Add a blank line above the table to allow room for the table toolbar
// Check that no column indicators exist
mountEditor(`
+
\\begin{tabular}{cc}
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
@@ -574,9 +595,11 @@ cell 3 & cell 4 \\\\
}
)
- it(`It can justify fixed width cells`, function () {
+ it(`Can justify fixed width cells`, function () {
+ // Add a blank line above the table to allow room for the table toolbar
// Check that no column indicators exist
mountEditor(`
+
\\begin{tabular}{>{\\raggedright\\arraybackslash}p{2cm}c}
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
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 c51ae8b68d..7faa740a67 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
@@ -6,7 +6,7 @@ import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-pat
import { TestContainer } from '../helpers/test-container'
import { PermissionsContext } from '@/features/ide-react/context/permissions-context'
-const FileTreePathProvider: FC = ({ children }) => (
+const FileTreePathProvider: FC = ({ children }) => (
(
)
-const PermissionsProvider: FC = ({ children }) => (
+const PermissionsProvider: FC = ({ children }) => (
toolbar in Rich Text mode', function () {
mountEditor('test')
selectAll()
- clickToolbarButton('More')
+ clickToolbarButton('More editor toolbar items')
clickToolbarButton('Bullet List')
cy.get('.cm-content').should('have.text', ' test')
@@ -134,7 +134,7 @@ describe(' toolbar in Rich Text mode', function () {
mountEditor('test')
selectAll()
- clickToolbarButton('More')
+ clickToolbarButton('More editor toolbar items')
clickToolbarButton('Numbered List')
cy.get('.cm-content').should('have.text', ' test')
@@ -147,7 +147,7 @@ describe(' toolbar in Rich Text mode', function () {
mountEditor('test')
selectAll()
- clickToolbarButton('More')
+ clickToolbarButton('More editor toolbar items')
clickToolbarButton('Numbered List')
// expose the markup
@@ -180,7 +180,7 @@ describe(' toolbar in Rich Text mode', function () {
mountEditor('test')
selectAll()
- clickToolbarButton('More')
+ clickToolbarButton('More editor toolbar items')
clickToolbarButton('Numbered List')
// expose the markup
@@ -205,7 +205,7 @@ describe(' toolbar in Rich Text mode', function () {
mountEditor('test\ntest')
selectAll()
- clickToolbarButton('More')
+ clickToolbarButton('More editor toolbar items')
clickToolbarButton('Numbered List')
// expose the markup
@@ -242,7 +242,7 @@ describe(' toolbar in Rich Text mode', function () {
cy.get('.cm-line').eq(1).click()
- clickToolbarButton('More')
+ clickToolbarButton('More editor toolbar items')
clickToolbarButton('Numbered List')
cy.get('.cm-line').eq(0).type('{upArrow}')
@@ -263,7 +263,7 @@ describe(' toolbar in Rich Text mode', function () {
mountEditor('test\ntest')
selectAll()
- clickToolbarButton('More')
+ clickToolbarButton('More editor toolbar items')
clickToolbarButton('Numbered List')
// expose the markup
@@ -300,7 +300,7 @@ describe(' toolbar in Rich Text mode', function () {
cy.get('.cm-line').eq(0).click()
- clickToolbarButton('More')
+ clickToolbarButton('More editor toolbar items')
clickToolbarButton('Numbered List')
// expose the markup
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 d8c88b46d2..7a9acebf07 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
@@ -21,7 +21,9 @@ describe(' in Visual mode', function () {
const scope = mockScope(content)
scope.editor.showVisual = true
- const FileTreePathProvider: FC = ({ children }) => (
+ const FileTreePathProvider: FC = ({
+ children,
+ }) => (
', { scrollBehavior: false }, function () {
onlineUsersCount: 3,
}
- const OnlineUsersProvider: FC = ({ children }) => {
+ const OnlineUsersProvider: FC = ({ children }) => {
return (
{children}
diff --git a/services/web/test/frontend/features/source-editor/helpers/test-container.tsx b/services/web/test/frontend/features/source-editor/helpers/test-container.tsx
index 8de4dcdc55..d0920df597 100644
--- a/services/web/test/frontend/features/source-editor/helpers/test-container.tsx
+++ b/services/web/test/frontend/features/source-editor/helpers/test-container.tsx
@@ -1,12 +1,18 @@
-import { FC, ComponentProps, Suspense } from 'react'
+import { FC, ComponentProps, PropsWithChildren, Suspense } from 'react'
+import { withTestContainerErrorBoundary } from '../../../helpers/error-boundary'
const style = { width: 785, height: 785 }
-export const TestContainer: FC> = ({
- children,
- ...rest
-}) => (
+const TestContainerWithoutErrorBoundary: FC<
+ PropsWithChildren>
+> = ({ children, ...rest }) => (
{children}
)
+
+// react-error-boundary version 5 requires an error boundary when using
+// useErrorBoundary, which we do in several components
+export const TestContainer = withTestContainerErrorBoundary(
+ TestContainerWithoutErrorBoundary
+)
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
index 830787afc1..c4a2a24f83 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
@@ -15,7 +15,7 @@ import {
groupActiveSubscription,
groupActiveSubscriptionWithPendingLicenseChange,
} from '../../fixtures/subscriptions'
-import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
import { UserId } from '../../../../../../types/user'
import { SplitTestProvider } from '@/shared/context/split-test-context'
@@ -51,7 +51,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders all group subscriptions not managed', function () {
@@ -73,16 +73,9 @@ describe(' ', function () {
})
describe('opens leave group modal when button is clicked', function () {
- let reloadStub: sinon.SinonStub
-
beforeEach(function () {
- reloadStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: sinon.stub(),
- replace: sinon.stub(),
- reload: reloadStub,
- setHash: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
render(
@@ -111,7 +104,7 @@ describe(' ', function () {
})
afterEach(function () {
- this.locationStub.restore()
+ this.locationWrapperSandbox.restore()
})
it('close the modal', function () {
@@ -129,7 +122,8 @@ describe(' ', function () {
fireEvent.click(this.leaveNowButton)
- expect(leaveGroupApiMock.called()).to.be.true
+ expect(leaveGroupApiMock.callHistory.called()).to.be.true
+ const reloadStub = this.locationWrapperStub.reload
await waitFor(() => {
expect(reloadStub).to.have.been.called
})
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx
index c9a28403fd..5c4fa29ed9 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx
@@ -37,7 +37,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders all managed institutions', function () {
@@ -97,8 +97,10 @@ describe(' ', function () {
)
const unsubscribeLink = screen.getByText('Unsubscribe')
- await fireEvent.click(unsubscribeLink)
- await waitFor(() => expect(fetchMock.called(unsubscribeUrl)).to.be.true)
+ fireEvent.click(unsubscribeLink)
+ await waitFor(
+ () => expect(fetchMock.callHistory.called(unsubscribeUrl)).to.be.true
+ )
await waitFor(() => {
expect(screen.getByText('Subscribe')).to.exist
@@ -122,13 +124,14 @@ describe(' ', function () {
)
- const subscribeLink = screen.getByText('Subscribe')
- await fireEvent.click(subscribeLink)
- await waitFor(() => expect(fetchMock.called(subscribeUrl)).to.be.true)
+ const subscribeLink = await screen.findByText('Subscribe')
- await waitFor(() => {
- expect(screen.getByText('Unsubscribe')).to.exist
- })
+ fireEvent.click(subscribeLink)
+ await waitFor(
+ () => expect(fetchMock.callHistory.called(subscribeUrl)).to.be.true
+ )
+
+ await screen.findByText('Unsubscribe')
})
it('renders nothing when there are no institutions', function () {
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/managed-publishers.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/managed-publishers.test.tsx
index 7c696841db..e350e3efd5 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/managed-publishers.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/managed-publishers.test.tsx
@@ -28,7 +28,7 @@ describe(' ', function () {
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders all managed publishers', function () {
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx
index 462db9f81b..421f90275d 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx
@@ -9,12 +9,11 @@ import {
trialSubscription,
} from '../../fixtures/subscriptions'
import { renderActiveSubscription } from '../../helpers/render-active-subscription'
-import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
import { MetaTag } from '@/utils/meta'
const pauseSubscriptionSplitTestMeta: MetaTag[] = [
{ name: 'ol-splitTestVariants', value: { 'pause-subscription': 'enabled' } },
- { name: 'ol-bootstrapVersion', value: 5 },
]
function renderSubscriptionWithPauseSupport(
@@ -46,22 +45,17 @@ function clickSubmitButton() {
describe(' ', function () {
beforeEach(function () {
- reloadStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: sinon.stub(),
- replace: sinon.stub(),
- reload: reloadStub,
- setHash: sinon.stub(),
- toString: sinon
- .stub()
- .returns('https://www.dev-overleaf.com/user/subscription'),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
+ this.locationWrapperStub.toString.returns(
+ 'https://www.dev-overleaf.com/user/subscription'
+ )
this.replaceStateStub = sinon.stub(window.history, 'replaceState')
})
afterEach(function () {
- fetchMock.reset()
- this.locationStub.restore()
+ fetchMock.removeRoutes().clearHistory()
+ this.locationWrapperSandbox.restore()
this.replaceStateStub.restore()
})
@@ -90,7 +84,6 @@ describe(' ', function () {
clickCancelButton()
await screen.findByText('Pause instead, to pick up where you left off')
})
- let reloadStub: sinon.SinonStub
it('renders options for pause duration', async function () {
renderSubscriptionWithPauseSupport()
@@ -136,6 +129,7 @@ describe(' ', function () {
renderSubscriptionWithPauseSupport()
clickCancelButton()
clickSubmitButton()
+ const reloadStub = this.locationWrapperStub.reload
await waitFor(() => {
expect(reloadStub).to.have.been.called
})
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
index 6b40314688..065aeec79f 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
@@ -4,6 +4,7 @@ import {
fireEvent,
waitForElementToBeRemoved,
within,
+ waitFor,
} from '@testing-library/react'
import PersonalSubscription from '../../../../../../frontend/js/features/subscription/components/dashboard/personal-subscription'
import {
@@ -19,7 +20,7 @@ import {
import { reactivateSubscriptionUrl } from '../../../../../../frontend/js/features/subscription/data/subscription-url'
import fetchMock from 'fetch-mock'
import sinon from 'sinon'
-import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
describe(' ', function () {
afterEach(function () {
@@ -48,21 +49,13 @@ describe(' ', function () {
})
describe('subscription states ', function () {
- let reloadStub: sinon.SinonStub
-
beforeEach(function () {
- reloadStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: sinon.stub(),
- replace: sinon.stub(),
- reload: reloadStub,
- setHash: sinon.stub(),
- toString: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
})
afterEach(function () {
- this.locationStub.restore()
+ this.locationWrapperSandbox.restore()
})
it('renders the active dash', function () {
@@ -83,7 +76,7 @@ describe(' ', function () {
'Your subscription has been canceled and will terminate on',
{ exact: false }
)
- screen.getByText(canceledSubscription.recurly!.nextPaymentDueAt, {
+ screen.getByText(canceledSubscription.payment!.nextPaymentDueAt, {
exact: false,
})
@@ -106,18 +99,20 @@ describe(' ', function () {
fetchMock.postOnce(reactivateSubscriptionUrl, 400)
fireEvent.click(reactivateBtn)
expect(reactivateBtn.disabled).to.be.true
- await fetchMock.flush(true)
- expect(reactivateBtn.disabled).to.be.false
- expect(reloadStub).not.to.have.been.called
- fetchMock.reset()
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() => expect(reactivateBtn.disabled).to.be.false)
+ expect(this.locationWrapperStub.reload).not.to.have.been.called
+ fetchMock.removeRoutes().clearHistory()
// 2nd click - success
fetchMock.postOnce(reactivateSubscriptionUrl, 200)
fireEvent.click(reactivateBtn)
- await fetchMock.flush(true)
- expect(reloadStub).to.have.been.calledOnce
+ await fetchMock.callHistory.flush(true)
+ await waitFor(() => {
+ expect(this.locationWrapperStub.reload).to.have.been.calledOnce
+ })
expect(reactivateBtn.disabled).to.be.true
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('renders the expired dash', function () {
@@ -134,7 +129,7 @@ describe(' ', function () {
{},
JSON.parse(JSON.stringify(annualActiveSubscription))
)
- withStateDeleted.recurly.state = undefined
+ withStateDeleted.payment.state = undefined
renderWithSubscriptionDashContext( , {
metaTags: [{ name: 'ol-subscription', value: withStateDeleted }],
})
@@ -194,7 +189,7 @@ describe(' ', function () {
})
})
- it('shows different recurly email address section', async function () {
+ it('shows different payment email address section', async function () {
fetchMock.post('/user/subscription/account/email', 200)
const usersEmail = 'foo@example.com'
renderWithSubscriptionDashContext( , {
@@ -208,7 +203,7 @@ describe(' ', function () {
/your billing email address is currently/i
).textContent
expect(billingText).to.contain(
- `Your billing email address is currently ${annualActiveSubscription.recurly.account.email}.` +
+ `Your billing email address is currently ${annualActiveSubscription.payment.accountEmail}.` +
` If needed you can update your billing address to ${usersEmail}`
)
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 406d6d3710..15dd65b6ba 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
@@ -1,7 +1,7 @@
import { expect } from 'chai'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as eventTracking from '@/infrastructure/event-tracking'
-import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import {
annualActiveSubscription,
groupActiveSubscription,
@@ -11,7 +11,7 @@ import {
trialCollaboratorSubscription,
trialSubscription,
} from '../../../../fixtures/subscriptions'
-import sinon, { type SinonStub } from 'sinon'
+import sinon from 'sinon'
import { cleanUpContext } from '../../../../helpers/render-with-subscription-dash-context'
import { renderActiveSubscription } from '../../../../helpers/render-active-subscription'
import { cloneDeep } from 'lodash'
@@ -21,21 +21,11 @@ import {
extendTrialUrl,
subscriptionUpdateUrl,
} from '@/features/subscription/data/subscription-url'
-import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
import { MetaTag } from '@/utils/meta'
-import * as bootstrapUtils from '@/features/utils/bootstrap-5'
describe(' ', function () {
let sendMBSpy: sinon.SinonSpy
- let isBootstrap5Stub: SinonStub
-
- before(function () {
- isBootstrap5Stub = sinon.stub(bootstrapUtils, 'isBootstrap5').returns(true)
- })
-
- after(function () {
- isBootstrap5Stub.restore()
- })
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
@@ -46,7 +36,7 @@ describe(' ', function () {
sendMBSpy.restore()
})
- function expectedInActiveSubscription(subscription: RecurlySubscription) {
+ function expectedInActiveSubscription(subscription: PaidSubscription) {
// sentence broken up by bolding
screen.getByText('You are currently subscribed to the', { exact: false })
screen.getByText(subscription.plan.name, { exact: false })
@@ -55,11 +45,11 @@ describe(' ', function () {
// sentence broken up by bolding
screen.getByText('The next payment of', { exact: false })
- screen.getByText(subscription.recurly.displayPrice, {
+ screen.getByText(subscription.payment.displayPrice, {
exact: false,
})
screen.getByText('will be collected on', { exact: false })
- const dates = screen.getAllByText(subscription.recurly.nextPaymentDueAt, {
+ const dates = screen.getAllByText(subscription.payment.nextPaymentDueAt, {
exact: false,
})
expect(dates.length).to.equal(2)
@@ -87,9 +77,13 @@ describe(' ', function () {
screen.getByText('You are currently subscribed to the', { exact: false })
await screen.findByRole('heading', { name: 'Change plan' })
- expect(
- screen.getAllByRole('button', { name: 'Change to this plan' }).length > 0
- ).to.be.true
+ await waitFor(
+ () =>
+ expect(
+ screen.getAllByRole('button', { name: 'Change to this plan' })
+ .length > 0
+ ).to.be.true
+ )
})
it('notes when user is changing plan at end of current plan term', function () {
@@ -120,7 +114,7 @@ describe(' ', function () {
JSON.parse(JSON.stringify(annualActiveSubscription))
)
- activePastDueSubscription.recurly.account.has_past_due_invoice._ = 'true'
+ activePastDueSubscription.payment.hasPastDueInvoice = true
renderActiveSubscription(activePastDueSubscription)
@@ -136,14 +130,14 @@ describe(' ', function () {
})
screen.getByText(
- groupActiveSubscriptionWithPendingLicenseChange.recurly
+ groupActiveSubscriptionWithPendingLicenseChange.payment
.pendingAdditionalLicenses!
)
screen.getByText('additional license(s) for a total of', { exact: false })
screen.getByText(
- groupActiveSubscriptionWithPendingLicenseChange.recurly
+ groupActiveSubscriptionWithPendingLicenseChange.payment
.pendingTotalLicenses!
)
@@ -156,10 +150,10 @@ describe(' ', function () {
it('shows the pending license change message when plan change is not pending', function () {
const subscription = Object.assign({}, groupActiveSubscription)
- subscription.recurly.additionalLicenses = 4
- subscription.recurly.totalLicenses =
- subscription.recurly.totalLicenses +
- subscription.recurly.additionalLicenses
+ subscription.payment.additionalLicenses = 4
+ subscription.payment.totalLicenses =
+ subscription.payment.totalLicenses +
+ subscription.payment.additionalLicenses
renderActiveSubscription(subscription)
@@ -167,11 +161,11 @@ describe(' ', function () {
exact: false,
})
- screen.getByText(subscription.recurly.additionalLicenses)
+ screen.getByText(subscription.payment.additionalLicenses)
screen.getByText('additional license(s) for a total of', { exact: false })
- screen.getByText(subscription.recurly.totalLicenses)
+ screen.getByText(subscription.payment.totalLicenses)
})
it('shows when trial ends and first payment collected and when subscription would become inactive if cancelled', function () {
@@ -179,16 +173,18 @@ describe(' ', function () {
screen.getByText('You’re on a free trial which ends on', { exact: false })
const endDate = screen.getAllByText(
- trialSubscription.recurly.trialEndsAtFormatted!
+ trialSubscription.payment.trialEndsAtFormatted!
)
expect(endDate.length).to.equal(3)
})
it('shows current discounts', function () {
const subscriptionWithActiveCoupons = cloneDeep(annualActiveSubscription)
- subscriptionWithActiveCoupons.recurly.activeCoupons = [
+ subscriptionWithActiveCoupons.payment.activeCoupons = [
{
name: 'fake coupon name',
+ code: 'fake-coupon',
+ description: '',
},
]
renderActiveSubscription(subscriptionWithActiveCoupons)
@@ -196,27 +192,19 @@ describe(' ', function () {
/this does not include your current discounts, which will be applied automatically before your next payment/i
)
screen.getByText(
- subscriptionWithActiveCoupons.recurly.activeCoupons[0].name
+ subscriptionWithActiveCoupons.payment.activeCoupons[0].name
)
})
describe('cancel plan', function () {
- const assignStub = sinon.stub()
- const reloadStub = sinon.stub()
-
beforeEach(function () {
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: assignStub,
- replace: sinon.stub(),
- reload: reloadStub,
- setHash: sinon.stub(),
- toString: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
})
afterEach(function () {
- this.locationStub.restore()
- fetchMock.reset()
+ this.locationWrapperSandbox.restore()
+ fetchMock.removeRoutes().clearHistory()
})
function showConfirmCancelUI() {
@@ -233,7 +221,7 @@ describe(' ', function () {
{ exact: false }
)
const dates = screen.getAllByText(
- annualActiveSubscription.recurly.nextPaymentDueAt,
+ annualActiveSubscription.payment.nextPaymentDueAt,
{
exact: false,
}
@@ -252,7 +240,7 @@ describe(' ', function () {
{ exact: false }
)
const dates = screen.getAllByText(
- trialSubscription.recurly.trialEndsAtFormatted!
+ trialSubscription.payment.trialEndsAtFormatted!
)
expect(dates.length).to.equal(3)
const button = screen.getByRole('button', {
@@ -285,6 +273,7 @@ describe(' ', function () {
name: 'Cancel my subscription',
})
fireEvent.click(button)
+ const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.called
})
@@ -414,6 +403,7 @@ describe(' ', function () {
})
fireEvent.click(extendTrialButton)
// page is reloaded on success
+ const reloadStub = this.locationWrapperStub.reload
await waitFor(() => {
expect(reloadStub).to.have.been.called
})
@@ -507,7 +497,10 @@ describe(' ', function () {
const endPointResponse = {
status: 200,
}
- fetchMock.post(subscriptionUpdateUrl, endPointResponse)
+ fetchMock.post(
+ `${subscriptionUpdateUrl}?downgradeToPaidPersonal`,
+ endPointResponse
+ )
renderActiveSubscription(monthlyActiveCollaborator)
showConfirmCancelUI()
const downgradeButton = await screen.findByRole('button', {
@@ -515,6 +508,7 @@ describe(' ', function () {
})
fireEvent.click(downgradeButton)
// page is reloaded on success
+ const reloadStub = this.locationWrapperStub.reload
await waitFor(() => {
expect(reloadStub).to.have.been.called
})
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 6c75d77583..7ffe7760d8 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
@@ -20,26 +20,18 @@ import {
subscriptionUpdateUrl,
} from '../../../../../../../../../frontend/js/features/subscription/data/subscription-url'
import { renderActiveSubscription } from '../../../../../helpers/render-active-subscription'
-import * as useLocationModule from '../../../../../../../../../frontend/js/shared/hooks/use-location'
+import { location } from '@/shared/components/location'
describe(' ', function () {
- let reloadStub: sinon.SinonStub
-
beforeEach(function () {
- reloadStub = sinon.stub()
- this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
- assign: sinon.stub(),
- replace: sinon.stub(),
- reload: reloadStub,
- setHash: sinon.stub(),
- toString: sinon.stub(),
- })
+ this.locationWrapperSandbox = sinon.createSandbox()
+ this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
})
afterEach(function () {
cleanUpContext()
- fetchMock.reset()
- this.locationStub.restore()
+ fetchMock.removeRoutes().clearHistory()
+ this.locationWrapperSandbox.restore()
})
it('renders the individual plans table and group plans UI', async function () {
@@ -210,6 +202,7 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Processing…' })
// page is reloaded on success
+ const reloadStub = this.locationWrapperStub.reload
await waitFor(() => {
expect(reloadStub).to.have.been.called
})
@@ -299,6 +292,7 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Processing…' })
// page is reloaded on success
+ const reloadStub = this.locationWrapperStub.reload
await waitFor(() => {
expect(reloadStub).to.have.been.called
})
@@ -530,7 +524,8 @@ describe(' ', function () {
screen.getByRole('button', { name: 'Processing…' })
- // // page is reloaded on success
+ // page is reloaded on success
+ const reloadStub = this.locationWrapperStub.reload
await waitFor(() => {
expect(reloadStub).to.have.been.called
})
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx
index 54222e7fb3..a3d0a4419d 100644
--- a/services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/group-invite/accepted-invite.test.tsx
@@ -2,38 +2,34 @@ import { render, screen } from '@testing-library/react'
import AcceptedInvite from '../../../../../../frontend/js/features/subscription/components/group-invite/accepted-invite'
import { expect } from 'chai'
-for (const bsVersion of [3, 5])
- describe('accepted group invite' + ` bs${bsVersion}`, function () {
- beforeEach(function () {
- window.metaAttributesCache.set('ol-bootstrapVersion', bsVersion)
- })
- it('renders', async function () {
- window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
- render( )
- await screen.findByText(
- 'You have joined the group subscription managed by example@overleaf.com'
- )
- })
-
- it('links to SSO enrollment page for SSO groups', async function () {
- window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
- window.metaAttributesCache.set('ol-groupSSOActive', true)
- window.metaAttributesCache.set('ol-subscriptionId', 'group123')
- render( )
- const linkBtn = (await screen.findByRole('link', {
- name: 'Done',
- })) as HTMLLinkElement
- expect(linkBtn.href).to.equal(
- 'https://www.test-overleaf.com/subscription/group123/sso_enrollment'
- )
- })
-
- it('links to dash for non-SSO groups', async function () {
- window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
- render( )
- const linkBtn = (await screen.findByRole('link', {
- name: 'Done',
- })) as HTMLLinkElement
- expect(linkBtn.href).to.equal('https://www.test-overleaf.com/project')
- })
+describe('accepted group invite', function () {
+ it('renders', async function () {
+ window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
+ render( )
+ await screen.findByText(
+ 'You have joined the group subscription managed by example@overleaf.com'
+ )
})
+
+ it('links to SSO enrollment page for SSO groups', async function () {
+ window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
+ window.metaAttributesCache.set('ol-groupSSOActive', true)
+ window.metaAttributesCache.set('ol-subscriptionId', 'group123')
+ render( )
+ const linkBtn = (await screen.findByRole('link', {
+ name: 'Done',
+ })) as HTMLLinkElement
+ expect(linkBtn.href).to.equal(
+ 'https://www.test-overleaf.com/subscription/group123/sso_enrollment'
+ )
+ })
+
+ it('links to dash for non-SSO groups', async function () {
+ window.metaAttributesCache.set('ol-inviterName', 'example@overleaf.com')
+ render( )
+ const linkBtn = (await screen.findByRole('link', {
+ name: 'Done',
+ })) as HTMLLinkElement
+ expect(linkBtn.href).to.equal('https://www.test-overleaf.com/project')
+ })
+})
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx
index 1c03302364..cc70eff90d 100644
--- a/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx
@@ -2,80 +2,37 @@ import { render, screen } from '@testing-library/react'
import { expect } from 'chai'
import GroupInvite from '../../../../../../frontend/js/features/subscription/components/group-invite/group-invite'
-for (const bsVersion of [3, 5])
- describe('group invite' + ` bs${bsVersion}`, function () {
- window.metaAttributesCache.set('ol-bootstrapVersion', bsVersion)
- const inviterName = 'example@overleaf.com'
+describe('group invite', function () {
+ const inviterName = 'example@overleaf.com'
+ beforeEach(function () {
+ window.metaAttributesCache.set('ol-inviterName', inviterName)
+ })
+
+ it('renders header', async function () {
+ render( )
+ await screen.findByText(inviterName)
+ screen.getByText(`has invited you to join a group subscription on Overleaf`)
+ expect(screen.queryByText('Email link expired, please request a new one.'))
+ .to.be.null
+ })
+
+ describe('when user has personal subscription', function () {
beforeEach(function () {
- window.metaAttributesCache.set('ol-inviterName', inviterName)
- })
-
- it('renders header', async function () {
- render( )
- await screen.findByText(inviterName)
- screen.getByText(
- `has invited you to join a group subscription on Overleaf`
+ window.metaAttributesCache.set(
+ 'ol-hasIndividualRecurlySubscription',
+ true
)
- expect(
- screen.queryByText('Email link expired, please request a new one.')
- ).to.be.null
})
- describe('when user has personal subscription', function () {
- beforeEach(function () {
- window.metaAttributesCache.set(
- 'ol-hasIndividualRecurlySubscription',
- true
- )
- })
-
- it('renders cancel personal subscription view', async function () {
- render( )
- await screen.findByText(
- 'You already have an individual subscription, would you like us to cancel this first before joining the group licence?'
- )
- })
-
- describe('and in a managed group', function () {
- // note: this should not be possible but managed user view takes priority over all
- beforeEach(function () {
- window.metaAttributesCache.set(
- 'ol-currentManagedUserAdminEmail',
- 'example@overleaf.com'
- )
- window.metaAttributesCache.set('ol-cannot-join-subscription', true)
- })
-
- it('renders managed user cannot join view', async function () {
- render( )
- await screen.findByText('You can’t join this group subscription')
- screen.getByText(
- 'Your Overleaf account is managed by your current group admin (example@overleaf.com). This means you can’t join additional group subscriptions',
- { exact: false }
- )
- screen.getByRole('link', { name: 'Read more about Managed Users.' })
- })
- })
+ it('renders cancel personal subscription view', async function () {
+ render( )
+ await screen.findByText(
+ 'You already have an individual subscription, would you like us to cancel this first before joining the group licence?'
+ )
})
- describe('when user does not have a personal subscription', function () {
- beforeEach(function () {
- window.metaAttributesCache.set(
- 'ol-hasIndividualRecurlySubscription',
- false
- )
- window.metaAttributesCache.set('ol-inviteToken', 'token123')
- })
-
- it('does not render cancel personal subscription view', async function () {
- render( )
- await screen.findByText(
- 'Please click the button below to join the group subscription and enjoy the benefits of an upgraded Overleaf account'
- )
- })
- })
-
- describe('when the user is already a managed user in another group', function () {
+ describe('and in a managed group', function () {
+ // note: this should not be possible but managed user view takes priority over all
beforeEach(function () {
window.metaAttributesCache.set(
'ol-currentManagedUserAdminEmail',
@@ -86,11 +43,7 @@ for (const bsVersion of [3, 5])
it('renders managed user cannot join view', async function () {
render( )
- await screen.findByText(inviterName)
- screen.getByText(
- `has invited you to join a group subscription on Overleaf`
- )
- screen.getByText('You can’t join this group subscription')
+ await screen.findByText('You can’t join this group subscription')
screen.getByText(
'Your Overleaf account is managed by your current group admin (example@overleaf.com). This means you can’t join additional group subscriptions',
{ exact: false }
@@ -98,28 +51,70 @@ for (const bsVersion of [3, 5])
screen.getByRole('link', { name: 'Read more about Managed Users.' })
})
})
+ })
- describe('expired', function () {
- beforeEach(function () {
- window.metaAttributesCache.set('ol-expired', true)
- })
-
- it('shows error notification when expired', async function () {
- render( )
- await screen.findByText('Email link expired, please request a new one.')
- })
+ describe('when user does not have a personal subscription', function () {
+ beforeEach(function () {
+ window.metaAttributesCache.set(
+ 'ol-hasIndividualRecurlySubscription',
+ false
+ )
+ window.metaAttributesCache.set('ol-inviteToken', 'token123')
})
- describe('join view', function () {
- beforeEach(function () {
- window.metaAttributesCache.set('ol-inviteToken', 'token123')
- })
-
- it('shows view to join group', async function () {
- render( )
- await screen.findByText(
- 'Please click the button below to join the group subscription and enjoy the benefits of an upgraded Overleaf account'
- )
- })
+ it('does not render cancel personal subscription view', async function () {
+ render( )
+ await screen.findByText(
+ 'Please click the button below to join the group subscription and enjoy the benefits of an upgraded Overleaf account'
+ )
})
})
+
+ describe('when the user is already a managed user in another group', function () {
+ beforeEach(function () {
+ window.metaAttributesCache.set(
+ 'ol-currentManagedUserAdminEmail',
+ 'example@overleaf.com'
+ )
+ window.metaAttributesCache.set('ol-cannot-join-subscription', true)
+ })
+
+ it('renders managed user cannot join view', async function () {
+ render( )
+ await screen.findByText(inviterName)
+ screen.getByText(
+ `has invited you to join a group subscription on Overleaf`
+ )
+ screen.getByText('You can’t join this group subscription')
+ screen.getByText(
+ 'Your Overleaf account is managed by your current group admin (example@overleaf.com). This means you can’t join additional group subscriptions',
+ { exact: false }
+ )
+ screen.getByRole('link', { name: 'Read more about Managed Users.' })
+ })
+ })
+
+ describe('expired', function () {
+ beforeEach(function () {
+ window.metaAttributesCache.set('ol-expired', true)
+ })
+
+ it('shows error notification when expired', async function () {
+ render( )
+ await screen.findByText('Email link expired, please request a new one.')
+ })
+ })
+
+ describe('join view', function () {
+ beforeEach(function () {
+ window.metaAttributesCache.set('ol-inviteToken', 'token123')
+ })
+
+ it('shows view to join group', async function () {
+ render( )
+ await screen.findByText(
+ 'Please click the button below to join the group subscription and enjoy the benefits of an upgraded Overleaf account'
+ )
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx
index 32a02810cb..b5bf4ec97d 100644
--- a/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx
@@ -7,7 +7,7 @@ import fetchMock from 'fetch-mock'
describe('group invite', function () {
describe('user has a personal subscription', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows option to cancel subscription', async function () {
diff --git a/services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx
index e297d30ac6..4899f29595 100644
--- a/services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx
@@ -10,7 +10,7 @@ describe('join group', function () {
window.metaAttributesCache.set('ol-inviteToken', inviteToken)
})
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
it('shows option to join subscription', async function () {
diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
index 24194ea080..ebd741b240 100644
--- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
+++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
@@ -1,7 +1,7 @@
import {
CustomSubscription,
GroupSubscription,
- RecurlySubscription,
+ PaidSubscription,
} from '../../../../../types/subscription/dashboard/subscription'
import dateformat from 'dateformat'
@@ -15,7 +15,7 @@ const sevenDaysFromTodayFormatted = dateformat(
'dS mmmm yyyy'
)
-export const annualActiveSubscription: RecurlySubscription = {
+export const annualActiveSubscription: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -33,11 +33,10 @@ export const annualActiveSubscription: RecurlySubscription = {
annual: true,
featureDescription: [],
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -45,22 +44,20 @@ export const annualActiveSubscription: RecurlySubscription = {
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
- has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
}
-export const annualActiveSubscriptionEuro: RecurlySubscription = {
+export const annualActiveSubscriptionEuro: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -78,11 +75,10 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = {
annual: true,
featureDescription: [],
},
- recurly: {
- tax: 4296,
+ payment: {
taxRate: 0.24,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -90,22 +86,20 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = {
currency: 'EUR',
state: 'active',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
- has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: false,
displayPrice: '€221.96',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
-export const annualActiveSubscriptionPro: RecurlySubscription = {
+export const annualActiveSubscriptionPro: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -122,11 +116,10 @@ export const annualActiveSubscriptionPro: RecurlySubscription = {
price_in_cents: 4500,
featureDescription: [],
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -134,22 +127,20 @@ export const annualActiveSubscriptionPro: RecurlySubscription = {
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
- has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$42.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
-export const pastDueExpiredSubscription: RecurlySubscription = {
+export const pastDueExpiredSubscription: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -167,11 +158,10 @@ export const pastDueExpiredSubscription: RecurlySubscription = {
annual: true,
featureDescription: [],
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -179,22 +169,20 @@ export const pastDueExpiredSubscription: RecurlySubscription = {
currency: 'USD',
state: 'expired',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
- has_past_due_invoice: { _: 'true', $: { type: 'boolean' } },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: true,
displayPrice: '$199.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
-export const canceledSubscription: RecurlySubscription = {
+export const canceledSubscription: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -212,11 +200,10 @@ export const canceledSubscription: RecurlySubscription = {
annual: true,
featureDescription: [],
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -224,22 +211,20 @@ export const canceledSubscription: RecurlySubscription = {
currency: 'USD',
state: 'canceled',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: { _: 'true', $: { type: 'boolean' } },
- has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
-export const pendingSubscriptionChange: RecurlySubscription = {
+export const pendingSubscriptionChange: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -257,11 +242,10 @@ export const pendingSubscriptionChange: RecurlySubscription = {
annual: true,
featureDescription: [],
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -269,18 +253,16 @@ export const pendingSubscriptionChange: RecurlySubscription = {
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
- has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
pendingPlan: {
planCode: 'professional-annual',
@@ -313,11 +295,10 @@ export const groupActiveSubscription: GroupSubscription = {
membersLimit: 10,
membersLimitAddOn: 'additional-license',
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 10,
nextPaymentDueAt,
@@ -325,18 +306,16 @@ export const groupActiveSubscription: GroupSubscription = {
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
- has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$1290.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
}
@@ -363,11 +342,10 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
membersLimit: 10,
membersLimitAddOn: 'additional-license',
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 11,
totalLicenses: 21,
nextPaymentDueAt,
@@ -375,31 +353,18 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: {
- _: 'false',
- $: {
- type: 'boolean',
- },
- },
- has_past_due_invoice: {
- _: 'false',
- $: {
- type: 'boolean',
- },
- },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$2967.00',
- currentPlanDisplayPrice: '$2709.00',
pendingAdditionalLicenses: 13,
pendingTotalLicenses: 23,
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
pendingPlan: {
planCode: 'group_collaborator_10_enterprise',
@@ -413,7 +378,7 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
},
}
-export const trialSubscription: RecurlySubscription = {
+export const trialSubscription: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -431,11 +396,10 @@ export const trialSubscription: RecurlySubscription = {
featureDescription: [],
hideFromUsers: true,
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt: sevenDaysFromTodayFormatted,
@@ -443,28 +407,16 @@ export const trialSubscription: RecurlySubscription = {
currency: 'USD',
state: 'active',
trialEndsAtFormatted: sevenDaysFromTodayFormatted,
- trial_ends_at: new Date(sevenDaysFromToday).toString(),
+ trialEndsAt: new Date(sevenDaysFromToday).toString(),
activeCoupons: [],
- account: {
- email: 'fake@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: {
- _: 'false',
- $: {
- type: 'boolean',
- },
- },
- has_past_due_invoice: {
- _: 'false',
- $: {
- type: 'boolean',
- },
- },
- },
+ accountEmail: 'fake@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$14.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
}
@@ -489,7 +441,7 @@ export const customSubscription: CustomSubscription = {
customAccount: true,
}
-export const trialCollaboratorSubscription: RecurlySubscription = {
+export const trialCollaboratorSubscription: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -507,11 +459,10 @@ export const trialCollaboratorSubscription: RecurlySubscription = {
featureDescription: [],
hideFromUsers: true,
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt: sevenDaysFromTodayFormatted,
@@ -519,32 +470,20 @@ export const trialCollaboratorSubscription: RecurlySubscription = {
currency: 'USD',
state: 'active',
trialEndsAtFormatted: sevenDaysFromTodayFormatted,
- trial_ends_at: new Date(sevenDaysFromToday).toString(),
+ trialEndsAt: new Date(sevenDaysFromToday).toString(),
activeCoupons: [],
- account: {
- email: 'foo@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: {
- _: 'false',
- $: {
- type: 'boolean',
- },
- },
- has_past_due_invoice: {
- _: 'false',
- $: {
- type: 'boolean',
- },
- },
- },
+ accountEmail: 'foo@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$21.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
-export const monthlyActiveCollaborator: RecurlySubscription = {
+export const monthlyActiveCollaborator: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
@@ -561,11 +500,10 @@ export const monthlyActiveCollaborator: RecurlySubscription = {
price_in_cents: 212300900,
featureDescription: [],
},
- recurly: {
- tax: 0,
+ payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -573,17 +511,15 @@ export const monthlyActiveCollaborator: RecurlySubscription = {
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
- trial_ends_at: null,
+ trialEndsAt: null,
activeCoupons: [],
- account: {
- email: 'foo@example.com',
- created_at: '2024-12-31T09:40:27.000Z',
- has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
- has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
- },
+ accountEmail: 'foo@example.com',
+ hasPastDueInvoice: false,
displayPrice: '$21.00',
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
diff --git a/services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx b/services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx
index 19566a8945..110c6c4f74 100644
--- a/services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx
+++ b/services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx
@@ -1,12 +1,12 @@
import { ActiveSubscription } from '../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
-import { RecurlySubscription } from '../../../../../types/subscription/dashboard/subscription'
+import { PaidSubscription } from '../../../../../types/subscription/dashboard/subscription'
import { groupPlans, plans } from '../fixtures/plans'
import { renderWithSubscriptionDashContext } from './render-with-subscription-dash-context'
import { MetaTag } from '@/utils/meta'
import { CurrencyCode } from '../../../../../types/subscription/currency'
export function renderActiveSubscription(
- subscription: RecurlySubscription,
+ subscription: PaidSubscription,
tags: MetaTag[] = [],
currencyCode?: CurrencyCode
) {
diff --git a/services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx b/services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx
index 38a5d7c021..1246b7271b 100644
--- a/services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx
+++ b/services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx
@@ -90,5 +90,5 @@ export function renderWithSubscriptionDashContext(
export function cleanUpContext() {
// @ts-ignore
delete global.recurly
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
}
diff --git a/services/web/test/frontend/features/word-count-modal/components/word-count-modal.spec.tsx b/services/web/test/frontend/features/word-count-modal/components/word-count-modal.spec.tsx
new file mode 100644
index 0000000000..2ed1dc9448
--- /dev/null
+++ b/services/web/test/frontend/features/word-count-modal/components/word-count-modal.spec.tsx
@@ -0,0 +1,111 @@
+import WordCountModal from '@/features/word-count-modal/components/word-count-modal'
+import { EditorProviders } from '../../../helpers/editor-providers'
+
+describe(' ', function () {
+ beforeEach(function () {
+ cy.interceptCompile()
+ })
+
+ it('renders the translated modal title', function () {
+ cy.intercept('/project/*/wordcount*', {
+ body: { texcount: { messages: 'This is a test' } },
+ })
+
+ cy.mount(
+
+
+
+ )
+
+ cy.findByText('Word Count')
+ cy.findByText(/something went wrong/).should('not.exist')
+ })
+
+ it('renders a loading message when loading', function () {
+ const { promise, resolve } = Promise.withResolvers()
+
+ cy.intercept('/project/*/wordcount*', async req => {
+ await promise
+ req.reply({ texcount: { messages: 'This is a test' } })
+ })
+
+ cy.mount(
+
+
+
+ )
+
+ cy.findByText('Loading…').then(() => {
+ resolve()
+ })
+
+ cy.findByText('This is a test')
+ })
+
+ it('renders an error message and hides loading message on error', function () {
+ cy.intercept('/project/*/wordcount?*', {
+ statusCode: 500,
+ })
+
+ cy.mount(
+
+
+
+ )
+
+ cy.findByText('Sorry, something went wrong')
+
+ cy.findByText('Loading').should('not.exist')
+ })
+
+ it('displays messages', function () {
+ cy.intercept('/project/*/wordcount*', {
+ body: { texcount: { messages: 'This is a test' } },
+ })
+
+ cy.mount(
+
+
+
+ )
+
+ cy.findByText('This is a test')
+ })
+
+ it('displays counts data', function () {
+ cy.intercept('/project/*/wordcount*', {
+ body: {
+ texcount: {
+ textWords: 500,
+ headWords: 100,
+ outside: 200,
+ mathDisplay: 2,
+ mathInline: 3,
+ headers: 4,
+ },
+ },
+ })
+
+ cy.mount(
+
+
+
+ )
+
+ cy.findByText((content, element) => {
+ return /^Total Words\s*:\s*500$/.test(element!.textContent!.trim())
+ })
+
+ cy.findByText((content, element) => {
+ return /^Math Display\s*:\s*2$/.test(element!.textContent!.trim())
+ })
+
+ cy.findByText((content, element) => {
+ return /^Math Inline\s*:\s*3$/.test(element!.textContent!.trim())
+ })
+
+ cy.findByText((content, element) => {
+ return /^Headers\s*:\s*4$/.test(element!.textContent!.trim())
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/word-count-modal/components/word-count-modal.test.jsx b/services/web/test/frontend/features/word-count-modal/components/word-count-modal.test.jsx
deleted file mode 100644
index cc2d37158c..0000000000
--- a/services/web/test/frontend/features/word-count-modal/components/word-count-modal.test.jsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { screen } from '@testing-library/react'
-import { expect } from 'chai'
-import sinon from 'sinon'
-import fetchMock from 'fetch-mock'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import WordCountModal from '../../../../../frontend/js/features/word-count-modal/components/word-count-modal'
-
-describe(' ', function () {
- afterEach(function () {
- fetchMock.reset()
- })
-
- const contextProps = {
- projectId: 'project-1',
- clsiServerId: 'clsi-server-1',
- }
-
- it('renders the translated modal title', async function () {
- fetchMock.get('express:/project/:projectId/wordcount', () => {
- return { status: 200, body: { texcount: { messages: 'This is a test' } } }
- })
-
- const handleHide = sinon.stub()
-
- renderWithEditorContext(
- ,
- contextProps
- )
-
- await screen.findByText('Word Count')
- })
-
- it('renders a loading message when loading', async function () {
- fetchMock.get('express:/project/:projectId/wordcount', () => {
- return { status: 200, body: { texcount: { messages: 'This is a test' } } }
- })
-
- const handleHide = sinon.stub()
-
- renderWithEditorContext(
- ,
- contextProps
- )
-
- await screen.findByText('Loading…')
-
- await screen.findByText('This is a test')
- })
-
- it('renders an error message and hides loading message on error', async function () {
- fetchMock.get('express:/project/:projectId/wordcount', 500)
-
- const handleHide = sinon.stub()
-
- renderWithEditorContext(
- ,
- contextProps
- )
-
- await screen.findByText('Sorry, something went wrong')
-
- expect(screen.queryByText(/Loading/)).to.not.exist
- })
-
- it('displays messages', async function () {
- fetchMock.get('express:/project/:projectId/wordcount', () => {
- return {
- status: 200,
- body: {
- texcount: {
- messages: 'This is a test',
- },
- },
- }
- })
-
- const handleHide = sinon.stub()
-
- renderWithEditorContext(
- ,
- contextProps
- )
-
- await screen.findByText('This is a test')
- })
-
- it('displays counts data', async function () {
- fetchMock.get('express:/project/:projectId/wordcount', () => {
- return {
- status: 200,
- body: {
- texcount: {
- textWords: 100,
- mathDisplay: 200,
- mathInline: 300,
- headers: 400,
- },
- },
- }
- })
-
- const handleHide = sinon.stub()
-
- renderWithEditorContext(
- ,
- contextProps
- )
-
- await screen.findByText((content, element) =>
- element.textContent.trim().match(/^Total Words\s*:\s*100$/)
- )
- await screen.findByText((content, element) =>
- element.textContent.trim().match(/^Math Display\s*:\s*200$/)
- )
- await screen.findByText((content, element) =>
- element.textContent.trim().match(/^Math Inline\s*:\s*300$/)
- )
- await screen.findByText((content, element) =>
- element.textContent.trim().match(/^Headers\s*:\s*400$/)
- )
- })
-})
diff --git a/services/web/test/frontend/helpers/bootstrap-3.ts b/services/web/test/frontend/helpers/bootstrap-3.ts
deleted file mode 100644
index c651ed9900..0000000000
--- a/services/web/test/frontend/helpers/bootstrap-3.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import '../../../frontend/stylesheets/main-style.less'
-
-beforeEach(function () {
- window.metaAttributesCache.set('ol-bootstrapVersion', 3)
-})
diff --git a/services/web/test/frontend/helpers/editor-providers.jsx b/services/web/test/frontend/helpers/editor-providers.jsx
index cd21b92456..a6bc9c32c6 100644
--- a/services/web/test/frontend/helpers/editor-providers.jsx
+++ b/services/web/test/frontend/helpers/editor-providers.jsx
@@ -44,6 +44,8 @@ export function EditorProviders({
email: 'owner@example.com',
},
rootDocId = '_root_doc_id',
+ imageName = 'texlive-full:2024.1',
+ compiler = 'pdflatex',
socket = new SocketIOMock(),
isRestrictedTokenMember = false,
clsiServerId = '1234',
@@ -58,7 +60,12 @@ export function EditorProviders({
{
_id: 'root-folder-id',
name: 'rootFolder',
- docs: [],
+ docs: [
+ {
+ _id: '_root_doc_id',
+ name: 'main.tex',
+ },
+ ],
folders: [],
fileRefs: [],
},
@@ -99,6 +106,10 @@ export function EditorProviders({
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
+ hasBufferedOps: () => false,
+ on: () => {},
+ off: () => {},
+ leaveAndCleanUpPromise: async () => {},
},
},
project: {
@@ -108,6 +119,8 @@ export function EditorProviders({
features: projectFeatures,
rootDoc_id: rootDocId,
rootFolder,
+ imageName,
+ compiler,
},
ui,
$watch: (path, callback) => {
diff --git a/services/web/test/frontend/helpers/error-boundary.tsx b/services/web/test/frontend/helpers/error-boundary.tsx
new file mode 100644
index 0000000000..3fe6101d2e
--- /dev/null
+++ b/services/web/test/frontend/helpers/error-boundary.tsx
@@ -0,0 +1,12 @@
+import { ComponentType, FC } from 'react'
+import withErrorBoundary from '@/infrastructure/error-boundary'
+
+const FallbackComponent: FC = () => {
+ return <>An error occurred within the test container>
+}
+
+export const withTestContainerErrorBoundary = function (
+ Component: ComponentType
+) {
+ return withErrorBoundary(Component, FallbackComponent)
+}
diff --git a/services/web/test/frontend/helpers/reset-meta.ts b/services/web/test/frontend/helpers/reset-meta.ts
index f5b00b332b..f5a979828a 100644
--- a/services/web/test/frontend/helpers/reset-meta.ts
+++ b/services/web/test/frontend/helpers/reset-meta.ts
@@ -2,7 +2,6 @@ export function resetMeta() {
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-projectHistoryBlobsEnabled', true)
window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' })
- window.metaAttributesCache.set('ol-bootstrapVersion', 5)
window.metaAttributesCache.set('ol-ExposedSettings', {
appName: 'Overleaf',
maxEntitiesPerProject: 10,
diff --git a/services/web/test/frontend/infrastructure/fetch-json.test.js b/services/web/test/frontend/infrastructure/fetch-json.test.js
index 66e626758c..b5c095e29d 100644
--- a/services/web/test/frontend/infrastructure/fetch-json.test.js
+++ b/services/web/test/frontend/infrastructure/fetch-json.test.js
@@ -1,6 +1,5 @@
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
-import { Response } from 'node-fetch'
import {
deleteJSON,
FetchError,
@@ -12,11 +11,11 @@ import {
describe('fetchJSON', function () {
before(function () {
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
})
const headers = {
@@ -138,17 +137,7 @@ describe('fetchJSON', function () {
})
it('handles 5xx responses without a status message', async function () {
- // It's hard to make a Response object with statusText=null,
- // so we need to do some monkey-work to make it happen
- const response = new Response('weird scary error', {
- ok: false,
- status: 599,
- })
- Object.defineProperty(response, 'statusText', {
- get: () => null,
- set: () => {},
- })
- fetchMock.get('/test', response)
+ fetchMock.get('/test', { status: 599 })
return expect(getJSON('/test'))
.to.eventually.be.rejectedWith('Unexpected Error: 599')
diff --git a/services/web/test/frontend/infrastructure/project-snapshot.test.ts b/services/web/test/frontend/infrastructure/project-snapshot.test.ts
index 1e9fa6792b..313436a3f5 100644
--- a/services/web/test/frontend/infrastructure/project-snapshot.test.ts
+++ b/services/web/test/frontend/infrastructure/project-snapshot.test.ts
@@ -139,7 +139,7 @@ describe('ProjectSnapshot', function () {
mockLatestChunk()
mockBlobs(['main.tex', 'hello.txt'])
await snapshot.refresh()
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
}
describe('after initialization', function () {
@@ -176,7 +176,7 @@ describe('ProjectSnapshot', function () {
mockChanges()
mockBlobs(['goodbye.txt'])
await snapshot.refresh()
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
}
describe('after refresh', function () {
@@ -184,7 +184,7 @@ describe('ProjectSnapshot', function () {
beforeEach(refreshSnapshot)
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
describe('getDocPaths()', function () {
@@ -214,7 +214,7 @@ describe('ProjectSnapshot', function () {
describe('concurrency', function () {
afterEach(function () {
- fetchMock.reset()
+ fetchMock.removeRoutes().clearHistory()
})
specify('two concurrent inits', async function () {
@@ -226,9 +226,9 @@ describe('ProjectSnapshot', function () {
await Promise.all([snapshot.refresh(), snapshot.refresh()])
// The first request initializes, the second request loads changes
- expect(fetchMock.calls('flush')).to.have.length(2)
- expect(fetchMock.calls('latest-chunk')).to.have.length(1)
- expect(fetchMock.calls('changes-1')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
+ expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
})
specify('three concurrent inits', async function () {
@@ -245,9 +245,9 @@ describe('ProjectSnapshot', function () {
// The first request initializes, the second and third are combined and
// load changes
- expect(fetchMock.calls('flush')).to.have.length(2)
- expect(fetchMock.calls('latest-chunk')).to.have.length(1)
- expect(fetchMock.calls('changes-1')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
+ expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
})
specify('two concurrent inits - first fails', async function () {
@@ -262,9 +262,9 @@ describe('ProjectSnapshot', function () {
// The first init fails, but the second succeeds
expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
- expect(fetchMock.calls('flush')).to.have.length(2)
- expect(fetchMock.calls('latest-chunk')).to.have.length(1)
- expect(fetchMock.calls('changes-1')).to.have.length(0)
+ expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
+ expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-1')).to.have.length(0)
})
specify('three concurrent inits - second fails', async function () {
@@ -285,10 +285,10 @@ describe('ProjectSnapshot', function () {
// The first init succeeds, the two queued requests fail, the last request
// succeeds
expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
- expect(fetchMock.calls('flush')).to.have.length(3)
- expect(fetchMock.calls('latest-chunk')).to.have.length(1)
- expect(fetchMock.calls('changes-1')).to.have.length(1)
- expect(fetchMock.calls('changes-2')).to.have.length(0)
+ expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
+ expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
})
specify('two concurrent load changes', async function () {
@@ -304,10 +304,10 @@ describe('ProjectSnapshot', function () {
await Promise.all([snapshot.refresh(), snapshot.refresh()])
// One init, two load changes
- expect(fetchMock.calls('flush')).to.have.length(3)
- expect(fetchMock.calls('latest-chunk')).to.have.length(1)
- expect(fetchMock.calls('changes-1')).to.have.length(1)
- expect(fetchMock.calls('changes-2')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
+ expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
})
specify('three concurrent load changes', async function () {
@@ -327,10 +327,10 @@ describe('ProjectSnapshot', function () {
])
// One init, two load changes (the two last are queued and combined)
- expect(fetchMock.calls('flush')).to.have.length(3)
- expect(fetchMock.calls('latest-chunk')).to.have.length(1)
- expect(fetchMock.calls('changes-1')).to.have.length(1)
- expect(fetchMock.calls('changes-2')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
+ expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
})
specify('two concurrent load changes - first fails', async function () {
@@ -350,10 +350,10 @@ describe('ProjectSnapshot', function () {
// One init, one load changes fails, the second succeeds
expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
- expect(fetchMock.calls('flush')).to.have.length(3)
- expect(fetchMock.calls('latest-chunk')).to.have.length(1)
- expect(fetchMock.calls('changes-1')).to.have.length(1)
- expect(fetchMock.calls('changes-2')).to.have.length(0)
+ expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
+ expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
})
specify('three concurrent load changes - second fails', async function () {
@@ -378,10 +378,10 @@ describe('ProjectSnapshot', function () {
// One init, one load changes succeeds, the second and third are combined
// and fail, the last request succeeds
expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
- expect(fetchMock.calls('flush')).to.have.length(4)
- expect(fetchMock.calls('latest-chunk')).to.have.length(1)
- expect(fetchMock.calls('changes-1')).to.have.length(1)
- expect(fetchMock.calls('changes-2')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('flush')).to.have.length(4)
+ expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+ expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
})
})
})
diff --git a/services/web/test/frontend/shared/hooks/use-abort-controller.test.tsx b/services/web/test/frontend/shared/hooks/use-abort-controller.test.tsx
index fd91fa6faa..3b18913117 100644
--- a/services/web/test/frontend/shared/hooks/use-abort-controller.test.tsx
+++ b/services/web/test/frontend/shared/hooks/use-abort-controller.test.tsx
@@ -13,7 +13,7 @@ describe('useAbortController', function () {
}
beforeEach(function () {
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
status = {
loading: false,
@@ -23,7 +23,7 @@ describe('useAbortController', function () {
})
after(function () {
- fetchMock.restore()
+ fetchMock.removeRoutes().clearHistory()
})
function AbortableRequest({ url }: { url: string }) {
@@ -80,8 +80,8 @@ describe('useAbortController', function () {
unmount()
- await fetchMock.flush(true)
- expect(fetchMock.done()).to.be.true
+ await fetchMock.callHistory.flush(true)
+ expect(fetchMock.callHistory.done()).to.be.true
// wait for Promises to be resolved
await new Promise(resolve => setTimeout(resolve, 0))
diff --git a/services/web/test/frontend/shared/hooks/use-async.test.ts b/services/web/test/frontend/shared/hooks/use-async.test.ts
index ee0d033372..1700e890c6 100644
--- a/services/web/test/frontend/shared/hooks/use-async.test.ts
+++ b/services/web/test/frontend/shared/hooks/use-async.test.ts
@@ -1,4 +1,4 @@
-import { renderHook, act } from '@testing-library/react-hooks'
+import { renderHook, act } from '@testing-library/react'
import { expect } from 'chai'
import sinon from 'sinon'
import useAsync from '../../../../frontend/js/shared/hooks/use-async'
diff --git a/services/web/test/frontend/shared/hooks/use-callback-handlers.test.js b/services/web/test/frontend/shared/hooks/use-callback-handlers.test.js
index f1ba354cf3..58cb0a4753 100644
--- a/services/web/test/frontend/shared/hooks/use-callback-handlers.test.js
+++ b/services/web/test/frontend/shared/hooks/use-callback-handlers.test.js
@@ -1,5 +1,5 @@
import sinon from 'sinon'
-import { renderHook } from '@testing-library/react-hooks'
+import { renderHook } from '@testing-library/react'
import useCallbackHandlers from '../../../../frontend/js/shared/hooks/use-callback-handlers'
describe('useCallbackHandlers', function () {
diff --git a/services/web/test/frontend/shared/utils/currency.test.js b/services/web/test/frontend/shared/utils/currency.test.js
new file mode 100644
index 0000000000..757b56ad2a
--- /dev/null
+++ b/services/web/test/frontend/shared/utils/currency.test.js
@@ -0,0 +1,316 @@
+import { expect } from 'chai'
+import { formatCurrency } from '../../../../frontend/js/shared/utils/currency'
+
+/*
+ Users can select any language we support, regardless of the country where they are located.
+ Which mean that any combination of "supported language"-"supported currency" can be displayed
+ on the user's screen.
+
+ Users located in the USA visiting https://fr.overleaf.com/user/subscription/plans
+ should see amounts in USD (because of their IP address),
+ but with French text, number formatting and currency formats (because of language choice).
+ (e.g. 1 000,00 $)
+
+ Users located in the France visiting https://www.overleaf.com/user/subscription/plans
+ should see amounts in EUR (because of their IP address),
+ but with English text, number formatting and currency formats (because of language choice).
+ (e.g. €1,000.00)
+ */
+
+describe('formatCurrency', function () {
+ describe('en', function () {
+ const format = currency => priceInCents =>
+ formatCurrency(priceInCents, currency)
+
+ describe('USD', function () {
+ const formatUSD = format('USD')
+
+ it('should format basic amounts', function () {
+ expect(formatUSD(0)).to.equal('$0.00')
+ expect(formatUSD(12.34)).to.equal('$12.34')
+ expect(formatUSD(123)).to.equal('$123.00')
+ })
+
+ it('should format thousand separators', function () {
+ expect(formatUSD(1_000)).to.equal('$1,000.00')
+ expect(formatUSD(98_765_432.1)).to.equal('$98,765,432.10')
+ })
+
+ it('should format negative amounts', function () {
+ expect(formatUSD(-0.01)).to.equal('-$0.01')
+ expect(formatUSD(-12.34)).to.equal('-$12.34')
+ expect(formatUSD(-123)).to.equal('-$123.00')
+ })
+ })
+
+ describe('EUR', function () {
+ const formatEUR = format('EUR')
+
+ it('should format basic amounts', function () {
+ expect(formatEUR(0)).to.equal('€0.00')
+ expect(formatEUR(12.34)).to.equal('€12.34')
+ expect(formatEUR(123)).to.equal('€123.00')
+ })
+
+ it('should format thousand separators', function () {
+ expect(formatEUR(1_000)).to.equal('€1,000.00')
+ expect(formatEUR(98_765_432.1)).to.equal('€98,765,432.10')
+ })
+
+ it('should format negative amounts', function () {
+ expect(formatEUR(-0.01)).to.equal('-€0.01')
+ expect(formatEUR(-12.34)).to.equal('-€12.34')
+ expect(formatEUR(-123)).to.equal('-€123.00')
+ })
+ })
+
+ describe('HUF', function () {
+ const formatHUF = format('HUF')
+
+ it('should format basic amounts', function () {
+ expect(formatHUF(0)).to.equal('Ft 0.00')
+ expect(formatHUF(12.34)).to.equal('Ft 12.34')
+ expect(formatHUF(123)).to.equal('Ft 123.00')
+ })
+
+ it('should format thousand separators', function () {
+ expect(formatHUF(1_000)).to.equal('Ft 1,000.00')
+ expect(formatHUF(98_765_432.1)).to.equal('Ft 98,765,432.10')
+ })
+
+ it('should format negative amounts', function () {
+ expect(formatHUF(-0.01)).to.equal('-Ft 0.01')
+ expect(formatHUF(-12.34)).to.equal('-Ft 12.34')
+ expect(formatHUF(-123)).to.equal('-Ft 123.00')
+ })
+ })
+
+ describe('CLP', function () {
+ const formatCLP = format('CLP')
+
+ it('should format basic amounts', function () {
+ expect(formatCLP(0)).to.equal('$0')
+ expect(formatCLP(12.34)).to.equal('$12')
+ expect(formatCLP(123)).to.equal('$123')
+ expect(formatCLP(1234)).to.equal('$1,234')
+ })
+
+ it('should format thousand separators', function () {
+ expect(formatCLP(1_000)).to.equal('$1,000')
+ expect(formatCLP(98_765_432.1)).to.equal('$98,765,432')
+ })
+
+ it('should format negative amounts', function () {
+ expect(formatCLP(-1)).to.equal('-$1')
+ expect(formatCLP(-12.34)).to.equal('-$12')
+ expect(formatCLP(-1234)).to.equal('-$1,234')
+ })
+ })
+
+ describe('all currencies', function () {
+ it('should format 1 "minimal atomic units"', function () {
+ const amount = 1
+
+ // "no cents currencies"
+ expect(format('CLP')(amount)).to.equal('$1')
+ expect(format('JPY')(amount)).to.equal('¥1')
+ expect(format('KRW')(amount)).to.equal('₩1')
+ expect(format('VND')(amount)).to.equal('₫1')
+
+ // other currencies
+ expect(format('AUD')(amount)).to.equal('$1.00')
+ expect(format('BRL')(amount)).to.equal('R$1.00')
+ expect(format('CAD')(amount)).to.equal('$1.00')
+ expect(format('CHF')(amount)).to.equal('CHF 1.00')
+ expect(format('CNY')(amount)).to.equal('¥1.00')
+ expect(format('COP')(amount)).to.equal('$1.00')
+ expect(format('DKK')(amount)).to.equal('kr 1.00')
+ expect(format('EUR')(amount)).to.equal('€1.00')
+ expect(format('GBP')(amount)).to.equal('£1.00')
+ expect(format('HUF')(amount)).to.equal('Ft 1.00')
+ expect(format('IDR')(amount)).to.equal('Rp 1.00')
+ expect(format('INR')(amount)).to.equal('₹1.00')
+ expect(format('MXN')(amount)).to.equal('$1.00')
+ expect(format('MYR')(amount)).to.equal('RM 1.00')
+ expect(format('NOK')(amount)).to.equal('kr 1.00')
+ expect(format('NZD')(amount)).to.equal('$1.00')
+ expect(format('PEN')(amount)).to.equal('PEN 1.00')
+ expect(format('PHP')(amount)).to.equal('₱1.00')
+ expect(format('SEK')(amount)).to.equal('kr 1.00')
+ expect(format('SGD')(amount)).to.equal('$1.00')
+ expect(format('THB')(amount)).to.equal('฿1.00')
+ expect(format('USD')(amount)).to.equal('$1.00')
+ })
+
+ it('should format 1_234_567.897_654 "minimal atomic units"', function () {
+ const amount = 1_234_567.897_654
+
+ // "no cents currencies"
+ expect(format('CLP')(amount)).to.equal('$1,234,568')
+ expect(format('JPY')(amount)).to.equal('¥1,234,568')
+ expect(format('KRW')(amount)).to.equal('₩1,234,568')
+ expect(format('VND')(amount)).to.equal('₫1,234,568')
+
+ // other currencies
+ expect(format('AUD')(amount)).to.equal('$1,234,567.90')
+ expect(format('BRL')(amount)).to.equal('R$1,234,567.90')
+ expect(format('CAD')(amount)).to.equal('$1,234,567.90')
+ expect(format('CHF')(amount)).to.equal('CHF 1,234,567.90')
+ expect(format('CNY')(amount)).to.equal('¥1,234,567.90')
+ expect(format('COP')(amount)).to.equal('$1,234,567.90')
+ expect(format('DKK')(amount)).to.equal('kr 1,234,567.90')
+ expect(format('EUR')(amount)).to.equal('€1,234,567.90')
+ expect(format('GBP')(amount)).to.equal('£1,234,567.90')
+ expect(format('HUF')(amount)).to.equal('Ft 1,234,567.90')
+ expect(format('IDR')(amount)).to.equal('Rp 1,234,567.90')
+ expect(format('INR')(amount)).to.equal('₹1,234,567.90')
+ expect(format('MXN')(amount)).to.equal('$1,234,567.90')
+ expect(format('MYR')(amount)).to.equal('RM 1,234,567.90')
+ expect(format('NOK')(amount)).to.equal('kr 1,234,567.90')
+ expect(format('NZD')(amount)).to.equal('$1,234,567.90')
+ expect(format('PEN')(amount)).to.equal('PEN 1,234,567.90')
+ expect(format('PHP')(amount)).to.equal('₱1,234,567.90')
+ expect(format('SEK')(amount)).to.equal('kr 1,234,567.90')
+ expect(format('SGD')(amount)).to.equal('$1,234,567.90')
+ expect(format('THB')(amount)).to.equal('฿1,234,567.90')
+ expect(format('USD')(amount)).to.equal('$1,234,567.90')
+ })
+ })
+ })
+
+ describe('fr', function () {
+ const format = currency => priceInCents =>
+ formatCurrency(priceInCents, currency, 'fr')
+
+ describe('USD', function () {
+ const formatUSD = format('USD')
+
+ it('should format basic amounts', function () {
+ expect(formatUSD(0)).to.equal('0,00 $')
+ expect(formatUSD(12.34)).to.equal('12,34 $')
+ expect(formatUSD(123)).to.equal('123,00 $')
+ })
+
+ it('should format thousand separators', function () {
+ expect(formatUSD(1_000)).to.equal('1 000,00 $')
+ expect(formatUSD(98_765_432.1)).to.equal('98 765 432,10 $')
+ })
+
+ it('should format negative amounts', function () {
+ expect(formatUSD(-0.01)).to.equal('-0,01 $')
+ expect(formatUSD(-12.34)).to.equal('-12,34 $')
+ expect(formatUSD(-123)).to.equal('-123,00 $')
+ })
+ })
+
+ describe('EUR', function () {
+ const formatEUR = format('EUR')
+
+ it('should format basic amounts', function () {
+ expect(formatEUR(0)).to.equal('0,00 €')
+ expect(formatEUR(12.34)).to.equal('12,34 €')
+ expect(formatEUR(123)).to.equal('123,00 €')
+ })
+
+ it('should format thousand separators', function () {
+ expect(formatEUR(1_000)).to.equal('1 000,00 €')
+ expect(formatEUR(98_765_432.1)).to.equal('98 765 432,10 €')
+ })
+
+ it('should format negative amounts', function () {
+ expect(formatEUR(-0.01)).to.equal('-0,01 €')
+ expect(formatEUR(-12.34)).to.equal('-12,34 €')
+ expect(formatEUR(-123)).to.equal('-123,00 €')
+ })
+ })
+
+ describe('HUF', function () {
+ const formatHUF = format('HUF')
+
+ it('should format basic amounts', function () {
+ expect(formatHUF(0)).to.equal('0,00 Ft')
+ expect(formatHUF(12.34)).to.equal('12,34 Ft')
+ expect(formatHUF(123)).to.equal('123,00 Ft')
+ })
+
+ it('should format thousand separators', function () {
+ expect(formatHUF(1_000)).to.equal('1 000,00 Ft')
+ expect(formatHUF(98_765_432.1)).to.equal('98 765 432,10 Ft')
+ })
+
+ it('should format negative amounts', function () {
+ expect(formatHUF(-0.01)).to.equal('-0,01 Ft')
+ expect(formatHUF(-12.34)).to.equal('-12,34 Ft')
+ expect(formatHUF(-123)).to.equal('-123,00 Ft')
+ })
+ })
+
+ describe('CLP', function () {
+ const formatCLP = format('CLP')
+
+ it('should format basic amounts', function () {
+ expect(formatCLP(0)).to.equal('0 $')
+ expect(formatCLP(12.34)).to.equal('12 $')
+ expect(formatCLP(123)).to.equal('123 $')
+ expect(formatCLP(1234)).to.equal('1 234 $')
+ })
+
+ it('should format thousand separators', function () {
+ expect(formatCLP(100_000)).to.equal('100 000 $')
+ expect(formatCLP(9_876_543_210)).to.equal('9 876 543 210 $')
+ })
+
+ it('should format negative amounts', function () {
+ expect(formatCLP(-1)).to.equal('-1 $')
+ expect(formatCLP(-12.34)).to.equal('-12 $')
+ expect(formatCLP(-1234)).to.equal('-1 234 $')
+ })
+ })
+
+ describe('all currencies', function () {
+ it('should format 1 "minimal atomic units"', function () {
+ const amount = 1
+
+ // "no cents currencies"
+ expect(format('CLP')(amount)).to.equal('1 $')
+ expect(format('JPY')(amount)).to.equal('1 ¥')
+ expect(format('KRW')(amount)).to.equal('1 ₩')
+ expect(format('VND')(amount)).to.equal('1 ₫')
+
+ // other currencies
+ expect(format('AUD')(amount)).to.equal('1,00 $')
+ expect(format('BRL')(amount)).to.equal('1,00 R$')
+ expect(format('CAD')(amount)).to.equal('1,00 $')
+ expect(format('CHF')(amount)).to.equal('1,00 CHF')
+ expect(format('CNY')(amount)).to.equal('1,00 ¥')
+ expect(format('COP')(amount)).to.equal('1,00 $')
+
+ expect(format('EUR')(amount)).to.equal('1,00 €')
+ expect(format('GBP')(amount)).to.equal('1,00 £')
+ expect(format('USD')(amount)).to.equal('1,00 $')
+ })
+
+ it('should format 1_234_567.897_654 "minimal atomic units"', function () {
+ const amount = 1_234_567.897_654
+
+ // "no cents currencies"
+ expect(format('CLP')(amount)).to.equal('1 234 568 $')
+ expect(format('JPY')(amount)).to.equal('1 234 568 ¥')
+ expect(format('KRW')(amount)).to.equal('1 234 568 ₩')
+ expect(format('VND')(amount)).to.equal('1 234 568 ₫')
+
+ // other currencies
+ expect(format('AUD')(amount)).to.equal('1 234 567,90 $')
+ expect(format('BRL')(amount)).to.equal('1 234 567,90 R$')
+ expect(format('CAD')(amount)).to.equal('1 234 567,90 $')
+ expect(format('CHF')(amount)).to.equal('1 234 567,90 CHF')
+ expect(format('CNY')(amount)).to.equal('1 234 567,90 ¥')
+ expect(format('COP')(amount)).to.equal('1 234 567,90 $')
+
+ expect(format('EUR')(amount)).to.equal('1 234 567,90 €')
+ expect(format('GBP')(amount)).to.equal('1 234 567,90 £')
+ expect(format('USD')(amount)).to.equal('1 234 567,90 $')
+ })
+ })
+ })
+})
diff --git a/services/web/test/frontend/utils/abortsignal-polyfill.spec.ts b/services/web/test/frontend/utils/abortsignal-polyfill.spec.ts
new file mode 100644
index 0000000000..2aa06f123f
--- /dev/null
+++ b/services/web/test/frontend/utils/abortsignal-polyfill.spec.ts
@@ -0,0 +1,43 @@
+describe('AbortSignal polyfills', function () {
+ before(function () {
+ // @ts-expect-error deleting a required method
+ delete AbortSignal.any
+ // @ts-expect-error deleting a required method
+ delete AbortSignal.timeout
+ // this polyfill provides the required methods
+ cy.wrap(import('@/utils/abortsignal-polyfill'))
+ })
+
+ describe('AbortSignal.any', function () {
+ it('aborts the new signal immediately if one of the signals is aborted already', function () {
+ const controller1 = new AbortController()
+ const controller2 = new AbortController()
+
+ controller1.abort()
+ const signal = AbortSignal.any([controller1.signal, controller2.signal])
+
+ cy.wrap(signal.aborted).should('be.true')
+ })
+
+ it('aborts the new signal asynchronously if one of the signals is aborted later', function () {
+ const controller1 = new AbortController()
+ const controller2 = new AbortController()
+
+ const signal = AbortSignal.any([controller1.signal, controller2.signal])
+ controller1.abort()
+
+ cy.wrap(signal.aborted).should('be.true')
+ })
+ })
+
+ describe('AbortSignal.timeout', function () {
+ it('aborts the signal after the timeout', function () {
+ cy.clock().then(clock => {
+ const signal = AbortSignal.timeout(1000)
+ cy.wrap(signal.aborted).should('be.false')
+ clock.tick(1000)
+ cy.wrap(signal.aborted).should('be.true')
+ })
+ })
+ })
+})
diff --git a/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js b/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js
index 33ff719e32..16514fbe63 100644
--- a/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js
+++ b/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js
@@ -206,7 +206,7 @@ describe('AuthorizationMiddleware', function () {
expectForbidden()
})
- describe('for a regular user', function (done) {
+ describe('for a regular user', function () {
setupPermission('isRestrictedUserForProject', false)
invokeMiddleware('blockRestrictedUserFromProject')
expectNext()
diff --git a/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js b/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js
index 08491cc511..47d96406f9 100644
--- a/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js
+++ b/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js
@@ -59,47 +59,34 @@ describe('BrandVariationsHandler', function () {
})
describe('getBrandVariationById', function () {
- it('should call the callback with an error when the branding variation id is not provided', function (done) {
- return this.BrandVariationsHandler.getBrandVariationById(
- null,
- (err, brandVariationDetails) => {
- expect(err).to.be.instanceof(Error)
- return done()
- }
- )
+ it('should reject with an error when the branding variation id is not provided', async function () {
+ await expect(
+ this.BrandVariationsHandler.promises.getBrandVariationById(null)
+ ).to.be.rejected
})
- it('should call the callback with an error when the request errors', function (done) {
+ it('should reject with an error when the request errors', async function () {
this.V1Api.request.callsArgWith(1, new Error())
- return this.BrandVariationsHandler.getBrandVariationById(
- '12',
- (err, brandVariationDetails) => {
- expect(err).to.be.instanceof(Error)
- return done()
- }
- )
+ await expect(
+ this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ ).to.be.rejected
})
- it('should call the callback with branding details when request succeeds', function (done) {
+ it('should return branding details when request succeeds', async function () {
this.V1Api.request.callsArgWith(
1,
null,
{ statusCode: 200 },
this.mockedBrandVariationDetails
)
- return this.BrandVariationsHandler.getBrandVariationById(
- '12',
- (err, brandVariationDetails) => {
- expect(err).to.not.exist
- expect(brandVariationDetails).to.deep.equal(
- this.mockedBrandVariationDetails
- )
- return done()
- }
+ const brandVariationDetails =
+ await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ expect(brandVariationDetails).to.deep.equal(
+ this.mockedBrandVariationDetails
)
})
- it('should transform relative URLs in v1 absolute ones', function (done) {
+ it('should transform relative URLs in v1 absolute ones', async function () {
this.mockedBrandVariationDetails.logo_url = '/journal-logo.png'
this.V1Api.request.callsArgWith(
1,
@@ -107,20 +94,16 @@ describe('BrandVariationsHandler', function () {
{ statusCode: 200 },
this.mockedBrandVariationDetails
)
- return this.BrandVariationsHandler.getBrandVariationById(
- '12',
- (err, brandVariationDetails) => {
- expect(
- brandVariationDetails.logo_url.startsWith(
- this.settings.apis.v1.publicUrl
- )
- ).to.be.true
- return done()
- }
- )
+ const brandVariationDetails =
+ await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ expect(
+ brandVariationDetails.logo_url.startsWith(
+ this.settings.apis.v1.publicUrl
+ )
+ ).to.be.true
})
- it("should sanitize 'submit_button_html'", function (done) {
+ it("should sanitize 'submit_button_html'", async function () {
this.mockedBrandVariationDetails.submit_button_html =
'AGU Journal '
this.V1Api.request.callsArgWith(
@@ -129,14 +112,10 @@ describe('BrandVariationsHandler', function () {
{ statusCode: 200 },
this.mockedBrandVariationDetails
)
- return this.BrandVariationsHandler.getBrandVariationById(
- '12',
- (err, brandVariationDetails) => {
- expect(brandVariationDetails.submit_button_html).to.equal(
- 'AGU Journal hello'
- )
- return done()
- }
+ const brandVariationDetails =
+ await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ expect(brandVariationDetails.submit_button_html).to.equal(
+ 'AGU Journal hello'
)
})
})
diff --git a/services/web/test/unit/src/Chat/ChatManagerTests.js b/services/web/test/unit/src/Chat/ChatManagerTests.js
index 76d9b79c4b..bdd3042513 100644
--- a/services/web/test/unit/src/Chat/ChatManagerTests.js
+++ b/services/web/test/unit/src/Chat/ChatManagerTests.js
@@ -47,8 +47,8 @@ describe('ChatManager', function () {
}))
})
- it('should inject a user object into messaged and resolved data', function (done) {
- return this.ChatManager.injectUserInfoIntoThreads(
+ it('should inject a user object into messaged and resolved data', async function () {
+ const threads = await this.ChatManager.promises.injectUserInfoIntoThreads(
{
thread1: {
resolved: true,
@@ -72,64 +72,56 @@ describe('ChatManager', function () {
},
],
},
- },
- (error, threads) => {
- expect(error).to.be.null
- expect(threads).to.deep.equal({
- thread1: {
- resolved: true,
- resolved_by_user_id: 'user_id_1',
- resolved_by_user: { formatted: 'user_1' },
- messages: [
- {
- user_id: 'user_id_1',
- user: { formatted: 'user_1' },
- content: 'foo',
- },
- {
- user_id: 'user_id_2',
- user: { formatted: 'user_2' },
- content: 'bar',
- },
- ],
- },
- thread2: {
- messages: [
- {
- user_id: 'user_id_1',
- user: { formatted: 'user_1' },
- content: 'baz',
- },
- ],
- },
- })
- return done()
}
)
+
+ expect(threads).to.deep.equal({
+ thread1: {
+ resolved: true,
+ resolved_by_user_id: 'user_id_1',
+ resolved_by_user: { formatted: 'user_1' },
+ messages: [
+ {
+ user_id: 'user_id_1',
+ user: { formatted: 'user_1' },
+ content: 'foo',
+ },
+ {
+ user_id: 'user_id_2',
+ user: { formatted: 'user_2' },
+ content: 'bar',
+ },
+ ],
+ },
+ thread2: {
+ messages: [
+ {
+ user_id: 'user_id_1',
+ user: { formatted: 'user_1' },
+ content: 'baz',
+ },
+ ],
+ },
+ })
})
- it('should only need to look up each user once', function (done) {
- return this.ChatManager.injectUserInfoIntoThreads(
- [
- {
- messages: [
- {
- user_id: 'user_id_1',
- content: 'foo',
- },
- {
- user_id: 'user_id_1',
- content: 'bar',
- },
- ],
- },
- ],
- (error, threads) => {
- expect(error).to.be.null
- this.UserInfoManager.getPersonalInfo.calledOnce.should.equal(true)
- return done()
- }
- )
+ it('should only need to look up each user once', async function () {
+ await this.ChatManager.promises.injectUserInfoIntoThreads([
+ {
+ messages: [
+ {
+ user_id: 'user_id_1',
+ content: 'foo',
+ },
+ {
+ user_id: 'user_id_1',
+ content: 'bar',
+ },
+ ],
+ },
+ ])
+
+ this.UserInfoManager.getPersonalInfo.calledOnce.should.equal(true)
})
})
})
diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs
index 53f49d0eb2..3e7d4c3daa 100644
--- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs
+++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs
@@ -117,6 +117,7 @@ describe('CollaboratorsInviteController', function () {
this.SplitTestHandler = {
promises: {
+ getAssignment: sinon.stub().resolves({ variant: 'default' }),
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
},
}
diff --git a/services/web/test/unit/src/Compile/ClsiFormatCheckerTests.js b/services/web/test/unit/src/Compile/ClsiFormatCheckerTests.js
index 347d9ab422..aa19732b9e 100644
--- a/services/web/test/unit/src/Compile/ClsiFormatCheckerTests.js
+++ b/services/web/test/unit/src/Compile/ClsiFormatCheckerTests.js
@@ -47,62 +47,53 @@ describe('ClsiFormatChecker', function () {
])
})
- it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', function (done) {
+ it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', async function () {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.callsArgWith(1, null)
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.callsArgWith(1)
- return this.ClsiFormatChecker.checkRecoursesForProblems(
- this.resources,
- (err, problems) => {
- this.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(
- true
- )
- this.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
- true
- )
- return done()
- }
+ const problems =
+ await this.ClsiFormatChecker.promises.checkRecoursesForProblems(
+ this.resources
+ )
+ this.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(true)
+ this.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
+ true
)
})
- it('should remove undefined errors', function (done) {
+ it('should remove undefined errors', async function () {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.callsArgWith(1, null, [])
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.callsArgWith(1, null, {})
- return this.ClsiFormatChecker.checkRecoursesForProblems(
- this.resources,
- (err, problems) => {
- expect(problems).to.not.exist
- expect(problems).to.not.exist
- return done()
- }
- )
+ const problems =
+ await this.ClsiFormatChecker.promises.checkRecoursesForProblems(
+ this.resources
+ )
+ expect(problems).to.not.exist
})
- it('should keep populated arrays', function (done) {
+ it('should keep populated arrays', async function () {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.callsArgWith(1, null, [{ path: 'somewhere/main.tex' }])
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.callsArgWith(1, null, {})
- return this.ClsiFormatChecker.checkRecoursesForProblems(
- this.resources,
- (err, problems) => {
- problems.conflictedPaths[0].path.should.equal('somewhere/main.tex')
- expect(problems.sizeCheck).to.not.exist
- return done()
- }
- )
+ const problems =
+ await this.ClsiFormatChecker.promises.checkRecoursesForProblems(
+ this.resources
+ )
+ problems.conflictedPaths[0].path.should.equal('somewhere/main.tex')
+ expect(problems.sizeCheck).to.not.exist
})
- it('should keep populated object', function (done) {
+ it('should keep populated object', async function () {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.callsArgWith(1, null, [])
@@ -112,15 +103,13 @@ describe('ClsiFormatChecker', function () {
resources: [{ 'a.tex': 'a.tex' }, { 'b.tex': 'b.tex' }],
totalSize: 1000000,
})
- return this.ClsiFormatChecker.checkRecoursesForProblems(
- this.resources,
- (err, problems) => {
- problems.sizeCheck.resources.length.should.equal(2)
- problems.sizeCheck.totalSize.should.equal(1000000)
- expect(problems.conflictedPaths).to.not.exist
- return done()
- }
- )
+ const problems =
+ await this.ClsiFormatChecker.promises.checkRecoursesForProblems(
+ this.resources
+ )
+ problems.sizeCheck.resources.length.should.equal(2)
+ problems.sizeCheck.totalSize.should.equal(1000000)
+ expect(problems.conflictedPaths).to.not.exist
})
describe('_checkForConflictingPaths', function () {
@@ -136,56 +125,50 @@ describe('ClsiFormatChecker', function () {
})
})
- it('should flag up when a nested file has folder with same subpath as file elsewhere', function (done) {
+ it('should flag up when a nested file has folder with same subpath as file elsewhere', async function () {
this.resources.push({
path: 'stuff/image',
url: 'http://somwhere.com',
})
- return this.ClsiFormatChecker._checkForConflictingPaths(
- this.resources,
- (err, conflictPathErrors) => {
- conflictPathErrors.length.should.equal(1)
- conflictPathErrors[0].path.should.equal('stuff/image')
- return done()
- }
- )
+ const conflictPathErrors =
+ await this.ClsiFormatChecker.promises._checkForConflictingPaths(
+ this.resources
+ )
+ conflictPathErrors.length.should.equal(1)
+ conflictPathErrors[0].path.should.equal('stuff/image')
})
- it('should flag up when a root level file has folder with same subpath as file elsewhere', function (done) {
+ it('should flag up when a root level file has folder with same subpath as file elsewhere', async function () {
this.resources.push({
path: 'stuff',
content: 'other stuff',
})
- return this.ClsiFormatChecker._checkForConflictingPaths(
- this.resources,
- (err, conflictPathErrors) => {
- conflictPathErrors.length.should.equal(1)
- conflictPathErrors[0].path.should.equal('stuff')
- return done()
- }
- )
+ const conflictPathErrors =
+ await this.ClsiFormatChecker.promises._checkForConflictingPaths(
+ this.resources
+ )
+ conflictPathErrors.length.should.equal(1)
+ conflictPathErrors[0].path.should.equal('stuff')
})
- it('should not flag up when the file is a substring of a path', function (done) {
+ it('should not flag up when the file is a substring of a path', async function () {
this.resources.push({
path: 'stuf',
content: 'other stuff',
})
- return this.ClsiFormatChecker._checkForConflictingPaths(
- this.resources,
- (err, conflictPathErrors) => {
- conflictPathErrors.length.should.equal(0)
- return done()
- }
- )
+ const conflictPathErrors =
+ await this.ClsiFormatChecker.promises._checkForConflictingPaths(
+ this.resources
+ )
+ conflictPathErrors.length.should.equal(0)
})
})
describe('_checkDocsAreUnderSizeLimit', function () {
- it('should error when there is more than 5mb of data', function (done) {
+ it('should error when there is more than 5mb of data', async function () {
this.resources.push({
path: 'massive.tex',
content: 'hello world'.repeat(833333), // over 5mb limit
@@ -198,19 +181,17 @@ describe('ClsiFormatChecker', function () {
})
}
- return this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
- this.resources,
- (err, sizeError) => {
- sizeError.totalSize.should.equal(16 + 833333 * 11) // 16 is for earlier resources
- sizeError.resources.length.should.equal(10)
- sizeError.resources[0].path.should.equal('massive.tex')
- sizeError.resources[0].size.should.equal(833333 * 11)
- return done()
- }
- )
+ const sizeError =
+ await this.ClsiFormatChecker.promises._checkDocsAreUnderSizeLimit(
+ this.resources
+ )
+ sizeError.totalSize.should.equal(16 + 833333 * 11) // 16 is for earlier resources
+ sizeError.resources.length.should.equal(10)
+ sizeError.resources[0].path.should.equal('massive.tex')
+ sizeError.resources[0].size.should.equal(833333 * 11)
})
- it('should return nothing when project is correct size', function (done) {
+ it('should return nothing when project is correct size', async function () {
this.resources.push({
path: 'massive.tex',
content: 'x'.repeat(2 * 1000 * 1000),
@@ -223,13 +204,11 @@ describe('ClsiFormatChecker', function () {
})
}
- return this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
- this.resources,
- (err, sizeError) => {
- expect(sizeError).to.not.exist
- return done()
- }
- )
+ const sizeError =
+ await this.ClsiFormatChecker.promises._checkDocsAreUnderSizeLimit(
+ this.resources
+ )
+ expect(sizeError).to.not.exist
})
})
})
diff --git a/services/web/test/unit/src/Compile/ClsiManagerTests.js b/services/web/test/unit/src/Compile/ClsiManagerTests.js
index ff50ac16bb..b9b7d9af37 100644
--- a/services/web/test/unit/src/Compile/ClsiManagerTests.js
+++ b/services/web/test/unit/src/Compile/ClsiManagerTests.js
@@ -13,6 +13,8 @@ const GLOBAL_BLOB_HASH = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
describe('ClsiManager', function () {
beforeEach(function () {
+ tk.freeze(Date.now())
+
this.user_id = 'user-id'
this.project = {
_id: 'project-id',
@@ -144,6 +146,9 @@ describe('ClsiManager', function () {
enablePdfCaching: true,
clsiCookie: { key: 'clsiserver' },
}
+ this.ClsiCacheHandler = {
+ clearCache: sinon.stub().resolves(),
+ }
this.Features = {
hasFeature: sinon.stub().withArgs('project-history-blobs').returns(true),
}
@@ -172,13 +177,13 @@ describe('ClsiManager', function () {
this.DocumentUpdaterHandler,
'./ClsiCookieManager': () => this.ClsiCookieManager,
'./ClsiStateManager': this.ClsiStateManager,
+ './ClsiCacheHandler': this.ClsiCacheHandler,
'@overleaf/fetch-utils': this.FetchUtils,
'./ClsiFormatChecker': this.ClsiFormatChecker,
'@overleaf/metrics': this.Metrics,
'../History/HistoryManager': this.HistoryManager,
},
})
- tk.freeze(Date.now())
})
after(function () {
@@ -390,6 +395,8 @@ describe('ClsiManager', function () {
incrementalCompilesEnabled: true,
compileBackendClass: 'e2',
compileGroup: 'priority',
+ compileFromClsiCache: true,
+ populateClsiCache: true,
enablePdfCaching: true,
pdfCachingMinChunkSize: 1337,
}
@@ -448,6 +455,8 @@ describe('ClsiManager', function () {
syncType: 'incremental',
syncState: '01234567890abcdef',
compileGroup: 'priority',
+ compileFromClsiCache: true,
+ populateClsiCache: true,
enablePdfCaching: true,
pdfCachingMinChunkSize: 1337,
metricsMethod: 'priority',
@@ -945,6 +954,12 @@ describe('ClsiManager', function () {
)
})
+ it('should clear the output.tar.gz files in clsi-cache', function () {
+ this.ClsiCacheHandler.clearCache
+ .calledWith(this.project._id, this.user_id)
+ .should.equal(true)
+ })
+
it('should clear the project state from the docupdater', function () {
this.DocumentUpdaterHandler.promises.clearProjectState
.calledWith(this.project._id)
diff --git a/services/web/test/unit/src/Compile/CompileControllerTests.js b/services/web/test/unit/src/Compile/CompileControllerTests.js
index 0e9c33f4c3..aefa197a17 100644
--- a/services/web/test/unit/src/Compile/CompileControllerTests.js
+++ b/services/web/test/unit/src/Compile/CompileControllerTests.js
@@ -244,9 +244,12 @@ describe('CompileController', function () {
this.user_id,
{
isAutoCompile: false,
+ compileFromClsiCache: false,
+ populateClsiCache: false,
enablePdfCaching: false,
fileLineErrors: false,
stopOnFirstError: false,
+ editorId: undefined,
}
)
})
@@ -284,9 +287,12 @@ describe('CompileController', function () {
this.user_id,
{
isAutoCompile: true,
+ compileFromClsiCache: false,
+ populateClsiCache: false,
enablePdfCaching: false,
fileLineErrors: false,
stopOnFirstError: false,
+ editorId: undefined,
}
)
})
@@ -305,10 +311,37 @@ describe('CompileController', function () {
this.user_id,
{
isAutoCompile: false,
+ compileFromClsiCache: false,
+ populateClsiCache: false,
enablePdfCaching: false,
draft: true,
fileLineErrors: false,
stopOnFirstError: false,
+ editorId: undefined,
+ }
+ )
+ })
+ })
+
+ describe('with an editor id', function () {
+ beforeEach(function (done) {
+ this.res.callback = done
+ this.req.body = { editorId: 'the-editor-id' }
+ this.CompileController.compile(this.req, this.res, this.next)
+ })
+
+ it('should pass the editor id to the compiler', function () {
+ this.CompileManager.compile.should.have.been.calledWith(
+ this.projectId,
+ this.user_id,
+ {
+ isAutoCompile: false,
+ compileFromClsiCache: false,
+ populateClsiCache: false,
+ enablePdfCaching: false,
+ fileLineErrors: false,
+ stopOnFirstError: false,
+ editorId: 'the-editor-id',
}
)
})
@@ -542,14 +575,16 @@ describe('CompileController', function () {
})
})
describe('proxySyncCode', function () {
- let file, line, column, imageName
+ let file, line, column, imageName, editorId, buildId
beforeEach(function (done) {
this.req.params = { Project_id: this.projectId }
file = 'main.tex'
line = String(Date.now())
column = String(Date.now() + 1)
- this.req.query = { file, line, column }
+ editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
+ buildId = '195b4a3f9e7-03e5be430a9e7796'
+ this.req.query = { file, line, column, editorId, buildId }
imageName = 'foo/bar:tag-0'
this.ProjectGetter.getProject = sinon.stub().yields(null, { imageName })
@@ -566,7 +601,15 @@ describe('CompileController', function () {
this.projectId,
'sync-to-code',
`/project/${this.projectId}/user/${this.user_id}/sync/code`,
- { file, line, column, imageName },
+ {
+ file,
+ line,
+ column,
+ imageName,
+ editorId,
+ buildId,
+ compileFromClsiCache: false,
+ },
this.req,
this.res,
this.next
@@ -575,14 +618,16 @@ describe('CompileController', function () {
})
describe('proxySyncPdf', function () {
- let page, h, v, imageName
+ let page, h, v, imageName, editorId, buildId
beforeEach(function (done) {
this.req.params = { Project_id: this.projectId }
page = String(Date.now())
h = String(Math.random())
v = String(Math.random())
- this.req.query = { page, h, v }
+ editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
+ buildId = '195b4a3f9e7-03e5be430a9e7796'
+ this.req.query = { page, h, v, editorId, buildId }
imageName = 'foo/bar:tag-1'
this.ProjectGetter.getProject = sinon.stub().yields(null, { imageName })
@@ -599,7 +644,15 @@ describe('CompileController', function () {
this.projectId,
'sync-to-pdf',
`/project/${this.projectId}/user/${this.user_id}/sync/pdf`,
- { page, h, v, imageName },
+ {
+ page,
+ h,
+ v,
+ imageName,
+ editorId,
+ buildId,
+ compileFromClsiCache: false,
+ },
this.req,
this.res,
this.next
diff --git a/services/web/test/unit/src/Compile/CompileManagerTests.js b/services/web/test/unit/src/Compile/CompileManagerTests.js
index 41b27cbd69..908e2fe803 100644
--- a/services/web/test/unit/src/Compile/CompileManagerTests.js
+++ b/services/web/test/unit/src/Compile/CompileManagerTests.js
@@ -139,7 +139,7 @@ describe('CompileManager', function () {
})
describe('when the project has been recently compiled', function () {
- it('should return', function (done) {
+ it('should return', async function () {
this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
isAutoCompile,
compileGroup
@@ -147,32 +147,27 @@ describe('CompileManager', function () {
this.CompileManager._checkIfRecentlyCompiled = sinon
.stub()
.resolves(true)
- this.CompileManager.promises
- .compile(this.project_id, this.user_id, {})
- .then(({ status }) => {
- status.should.equal('too-recently-compiled')
- done()
- })
- .catch(error => {
- // Catch any errors and fail the test
- true.should.equal(false)
- done(error)
- })
+ const { status } = await this.CompileManager.promises.compile(
+ this.project_id,
+ this.user_id,
+ {}
+ )
+ status.should.equal('too-recently-compiled')
})
})
describe('should check the rate limit', function () {
- it('should return', function (done) {
+ it('should return', async function () {
this.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon
.stub()
.resolves(false)
- this.CompileManager.promises
- .compile(this.project_id, this.user_id, {})
- .then(({ status }) => {
- expect(status).to.equal('autocompile-backoff')
- done()
- })
- .catch(err => done(err))
+ const { status } = await this.CompileManager.promises.compile(
+ this.project_id,
+ this.user_id,
+ {}
+ )
+
+ expect(status).to.equal('autocompile-backoff')
})
})
})
@@ -250,15 +245,12 @@ describe('CompileManager', function () {
beforeEach(function () {
this.features.compileGroup = 'priority'
})
- it('should return the default class', function (done) {
- this.CompileManager.getProjectCompileLimits(
- this.project_id,
- (err, { compileBackendClass }) => {
- if (err) return done(err)
- expect(compileBackendClass).to.equal('c2d')
- done()
- }
- )
+ it('should return the default class', async function () {
+ const { compileBackendClass } =
+ await this.CompileManager.promises.getProjectCompileLimits(
+ this.project_id
+ )
+ expect(compileBackendClass).to.equal('c2d')
})
})
})
diff --git a/services/web/test/unit/src/Cooldown/CooldownManagerTests.js b/services/web/test/unit/src/Cooldown/CooldownManagerTests.js
index ba4245ee55..7efefe1b04 100644
--- a/services/web/test/unit/src/Cooldown/CooldownManagerTests.js
+++ b/services/web/test/unit/src/Cooldown/CooldownManagerTests.js
@@ -1,15 +1,3 @@
-/* eslint-disable
- n/handle-callback-err,
- max-len,
- no-return-assign,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
@@ -23,158 +11,143 @@ describe('CooldownManager', function () {
this.projectId = 'abcdefg'
this.rclient = { set: sinon.stub(), get: sinon.stub() }
this.RedisWrapper = { client: () => this.rclient }
- return (this.CooldownManager = SandboxedModule.require(modulePath, {
+ this.CooldownManager = SandboxedModule.require(modulePath, {
requires: {
'../../infrastructure/RedisWrapper': this.RedisWrapper,
},
- }))
+ })
})
describe('_buildKey', function () {
it('should build a properly formatted redis key', function () {
- return expect(this.CooldownManager._buildKey('ABC')).to.equal(
- 'Cooldown:{ABC}'
- )
+ expect(this.CooldownManager._buildKey('ABC')).to.equal('Cooldown:{ABC}')
})
})
describe('isProjectOnCooldown', function () {
- beforeEach(function () {
- return (this.call = cb => {
- return this.CooldownManager.isProjectOnCooldown(this.projectId, cb)
- })
- })
-
describe('when project is on cooldown', function () {
beforeEach(function () {
- return (this.rclient.get = sinon.stub().callsArgWith(1, null, '1'))
+ this.rclient.get = sinon.stub().callsArgWith(1, null, '1')
})
- it('should fetch key from redis', function (done) {
- return this.call((err, result) => {
- this.rclient.get.callCount.should.equal(1)
- this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
- return done()
- })
+ it('should fetch key from redis', async function () {
+ await this.CooldownManager.promises.isProjectOnCooldown(this.projectId)
+ this.rclient.get.callCount.should.equal(1)
+ this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
})
- it('should not produce an error', function (done) {
- return this.call((err, result) => {
- expect(err).to.equal(null)
- return done()
- })
- })
-
- it('should produce a true result', function (done) {
- return this.call((err, result) => {
- expect(result).to.equal(true)
- return done()
- })
+ it('should produce a true result', async function () {
+ const result = await this.CooldownManager.promises.isProjectOnCooldown(
+ this.projectId
+ )
+ expect(result).to.equal(true)
})
})
describe('when project is not on cooldown', function () {
beforeEach(function () {
- return (this.rclient.get = sinon.stub().callsArgWith(1, null, null))
+ this.rclient.get = sinon.stub().callsArgWith(1, null, null)
})
- it('should fetch key from redis', function (done) {
- return this.call((err, result) => {
- this.rclient.get.callCount.should.equal(1)
- this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
- return done()
- })
+ it('should fetch key from redis', async function () {
+ await this.CooldownManager.promises.isProjectOnCooldown(this.projectId)
+ this.rclient.get.callCount.should.equal(1)
+ this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
})
- it('should not produce an error', function (done) {
- return this.call((err, result) => {
- expect(err).to.equal(null)
- return done()
- })
- })
-
- it('should produce a false result', function (done) {
- return this.call((err, result) => {
- expect(result).to.equal(false)
- return done()
- })
+ it('should produce a false result', async function () {
+ const result = await this.CooldownManager.promises.isProjectOnCooldown(
+ this.projectId
+ )
+ expect(result).to.equal(false)
})
})
describe('when rclient.get produces an error', function () {
beforeEach(function () {
- return (this.rclient.get = sinon
- .stub()
- .callsArgWith(1, new Error('woops')))
+ this.rclient.get = sinon.stub().callsArgWith(1, new Error('woops'))
})
- it('should fetch key from redis', function (done) {
- return this.call((err, result) => {
- this.rclient.get.callCount.should.equal(1)
- this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
- return done()
- })
+ it('should fetch key from redis', async function () {
+ try {
+ await this.CooldownManager.promises.isProjectOnCooldown(
+ this.projectId
+ )
+ } catch {
+ // ignore errors - expected
+ }
+ this.rclient.get.callCount.should.equal(1)
+ this.rclient.get.calledWith('Cooldown:{abcdefg}').should.equal(true)
})
- it('should produce an error', function (done) {
- return this.call((err, result) => {
- expect(err).to.not.equal(null)
- expect(err).to.be.instanceof(Error)
- return done()
- })
+ it('should produce an error', async function () {
+ let error
+
+ try {
+ await this.CooldownManager.promises.isProjectOnCooldown(
+ this.projectId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
})
describe('putProjectOnCooldown', function () {
- beforeEach(function () {
- return (this.call = cb => {
- return this.CooldownManager.putProjectOnCooldown(this.projectId, cb)
- })
- })
-
describe('when rclient.set does not produce an error', function () {
beforeEach(function () {
- return (this.rclient.set = sinon.stub().callsArgWith(4, null))
+ this.rclient.set = sinon.stub().callsArgWith(4, null)
})
- it('should set a key in redis', function (done) {
- return this.call(err => {
- this.rclient.set.callCount.should.equal(1)
- this.rclient.set.calledWith('Cooldown:{abcdefg}').should.equal(true)
- return done()
- })
+ it('should set a key in redis', async function () {
+ await this.CooldownManager.promises.putProjectOnCooldown(this.projectId)
+ this.rclient.set.callCount.should.equal(1)
+ this.rclient.set.calledWith('Cooldown:{abcdefg}').should.equal(true)
})
- it('should not produce an error', function (done) {
- return this.call(err => {
- expect(err).to.equal(null)
- return done()
- })
+ it('should not produce an error', async function () {
+ let error
+ try {
+ await this.CooldownManager.promises.putProjectOnCooldown(
+ this.projectId
+ )
+ } catch (err) {
+ error = err
+ }
+ expect(error).not.to.exist
})
})
describe('when rclient.set produces an error', function () {
beforeEach(function () {
- return (this.rclient.set = sinon
- .stub()
- .callsArgWith(4, new Error('woops')))
+ this.rclient.set = sinon.stub().callsArgWith(4, new Error('woops'))
})
- it('should set a key in redis', function (done) {
- return this.call(err => {
- this.rclient.set.callCount.should.equal(1)
- this.rclient.set.calledWith('Cooldown:{abcdefg}').should.equal(true)
- return done()
- })
+ it('should set a key in redis', async function () {
+ try {
+ await this.CooldownManager.promises.putProjectOnCooldown(
+ this.projectId
+ )
+ } catch {
+ // ignore errors - expected
+ }
+ this.rclient.set.callCount.should.equal(1)
+ this.rclient.set.calledWith('Cooldown:{abcdefg}').should.equal(true)
})
- it('produce an error', function (done) {
- return this.call(err => {
- expect(err).to.not.equal(null)
- expect(err).to.be.instanceof(Error)
- return done()
- })
+ it('produce an error', async function () {
+ let error
+ try {
+ await this.CooldownManager.promises.putProjectOnCooldown(
+ this.projectId
+ )
+ } catch (err) {
+ error = err
+ }
+ expect(error).to.be.instanceOf(Error)
})
})
})
diff --git a/services/web/test/unit/src/Docstore/DocstoreManagerTests.js b/services/web/test/unit/src/Docstore/DocstoreManagerTests.js
index 2995f90baf..2dbe6b6424 100644
--- a/services/web/test/unit/src/Docstore/DocstoreManagerTests.js
+++ b/services/web/test/unit/src/Docstore/DocstoreManagerTests.js
@@ -1,6 +1,7 @@
const sinon = require('sinon')
const modulePath = '../../../../app/src/Features/Docstore/DocstoreManager'
const SandboxedModule = require('sandboxed-module')
+const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const tk = require('timekeeper')
@@ -26,7 +27,6 @@ describe('DocstoreManager', function () {
this.project_id = 'project-id-123'
this.doc_id = 'doc-id-123'
- this.callback = sinon.stub()
})
describe('deleteDoc', function () {
@@ -39,16 +39,15 @@ describe('DocstoreManager', function () {
tk.reset()
})
- beforeEach(function () {
+ beforeEach(async function () {
this.request.patch = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, '')
- this.DocstoreManager.deleteDoc(
+ await this.DocstoreManager.promises.deleteDoc(
this.project_id,
this.doc_id,
'wombat.tex',
- new Date(),
- this.callback
+ new Date()
)
})
@@ -61,10 +60,6 @@ describe('DocstoreManager', function () {
})
.should.equal(true)
})
-
- it('should call the callback without an error', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('with a failed response code', function () {
@@ -72,28 +67,27 @@ describe('DocstoreManager', function () {
this.request.patch = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
- this.DocstoreManager.deleteDoc(
- this.project_id,
- this.doc_id,
- 'main.tex',
- new Date(),
- this.callback
- )
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.deleteDoc(
+ this.project_id,
+ this.doc_id,
+ 'main.tex',
+ new Date()
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
+ )
})
})
@@ -102,28 +96,26 @@ describe('DocstoreManager', function () {
this.request.patch = sinon
.stub()
.callsArgWith(1, null, { statusCode: 404 }, '')
- this.DocstoreManager.deleteDoc(
- this.project_id,
- this.doc_id,
- 'main.tex',
- new Date(),
- this.callback
- )
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Errors.NotFoundError)
- .and(
- sinon.match.has(
- 'message',
- 'tried to delete doc not in docstore'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+ try {
+ await this.DocstoreManager.promises.deleteDoc(
+ this.project_id,
+ this.doc_id,
+ 'main.tex',
+ new Date()
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'tried to delete doc not in docstore'
+ )
})
})
})
@@ -137,8 +129,8 @@ describe('DocstoreManager', function () {
this.modified = true
})
- describe('with a successful response code', function () {
- beforeEach(function () {
+ describe('with a successful response code', async function () {
+ beforeEach(async function () {
this.request.post = sinon
.stub()
.callsArgWith(
@@ -147,13 +139,12 @@ describe('DocstoreManager', function () {
{ statusCode: 204 },
{ modified: this.modified, rev: this.rev }
)
- this.DocstoreManager.updateDoc(
+ this.updateDocResponse = await this.DocstoreManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.lines,
this.version,
- this.ranges,
- this.callback
+ this.ranges
)
})
@@ -171,10 +162,12 @@ describe('DocstoreManager', function () {
.should.equal(true)
})
- it('should call the callback with the modified status and revision', function () {
- this.callback
- .calledWith(null, this.modified, this.rev)
- .should.equal(true)
+ it('should return the modified status and revision', function () {
+ expect(this.updateDocResponse).to.haveOwnProperty(
+ 'modified',
+ this.modified
+ )
+ expect(this.updateDocResponse).to.haveOwnProperty('rev', this.rev)
})
})
@@ -183,29 +176,28 @@ describe('DocstoreManager', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
- this.DocstoreManager.updateDoc(
- this.project_id,
- this.doc_id,
- this.lines,
- this.version,
- this.ranges,
- this.callback
- )
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.updateDoc(
+ this.project_id,
+ this.doc_id,
+ this.lines,
+ this.version,
+ this.ranges
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
+ )
})
})
})
@@ -221,11 +213,14 @@ describe('DocstoreManager', function () {
})
describe('with a successful response code', function () {
- beforeEach(function () {
+ beforeEach(async function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, this.doc)
- this.DocstoreManager.getDoc(this.project_id, this.doc_id, this.callback)
+ this.getDocResponse = await this.DocstoreManager.promises.getDoc(
+ this.project_id,
+ this.doc_id
+ )
})
it('should get the doc from the docstore api', function () {
@@ -236,10 +231,13 @@ describe('DocstoreManager', function () {
})
})
- it('should call the callback with the lines, version and rev', function () {
- this.callback
- .calledWith(null, this.lines, this.rev, this.version, this.ranges)
- .should.equal(true)
+ it('should resolve with the lines, version and rev', function () {
+ expect(this.getDocResponse).to.eql({
+ lines: this.lines,
+ rev: this.rev,
+ version: this.version,
+ ranges: this.ranges,
+ })
})
})
@@ -248,35 +246,37 @@ describe('DocstoreManager', function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
- this.DocstoreManager.getDoc(this.project_id, this.doc_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.getDoc(
+ this.project_id,
+ this.doc_id
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
+ )
})
})
describe('with include_deleted=true', function () {
- beforeEach(function () {
+ beforeEach(async function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, this.doc)
- this.DocstoreManager.getDoc(
+ this.getDocResponse = await this.DocstoreManager.promises.getDoc(
this.project_id,
this.doc_id,
- { include_deleted: true },
- this.callback
+ { include_deleted: true }
)
})
@@ -289,23 +289,27 @@ describe('DocstoreManager', function () {
})
})
- it('should call the callback with the lines, version and rev', function () {
- this.callback
- .calledWith(null, this.lines, this.rev, this.version, this.ranges)
- .should.equal(true)
+ it('should resolve with the lines, version and rev', function () {
+ expect(this.getDocResponse).to.eql({
+ lines: this.lines,
+ rev: this.rev,
+ version: this.version,
+ ranges: this.ranges,
+ })
})
})
describe('with peek=true', function () {
- beforeEach(function () {
+ beforeEach(async function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, this.doc)
- this.DocstoreManager.getDoc(
+ await this.DocstoreManager.promises.getDoc(
this.project_id,
this.doc_id,
- { peek: true },
- this.callback
+ {
+ peek: true,
+ }
)
})
@@ -323,24 +327,30 @@ describe('DocstoreManager', function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 404 }, '')
- this.DocstoreManager.getDoc(this.project_id, this.doc_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Errors.NotFoundError)
- .and(sinon.match.has('message', 'doc not found in docstore'))
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.getDoc(
+ this.project_id,
+ this.doc_id
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.NotFoundError)
+ expect(error).to.have.property('message', 'doc not found in docstore')
})
})
})
describe('getAllDocs', function () {
describe('with a successful response code', function () {
- beforeEach(function () {
+ let getAllDocsResult
+ beforeEach(async function () {
this.request.get = sinon
.stub()
.callsArgWith(
@@ -349,7 +359,9 @@ describe('DocstoreManager', function () {
{ statusCode: 204 },
(this.docs = [{ _id: 'mock-doc-id' }])
)
- this.DocstoreManager.getAllDocs(this.project_id, this.callback)
+ getAllDocsResult = await this.DocstoreManager.promises.getAllDocs(
+ this.project_id
+ )
})
it('should get all the project docs in the docstore api', function () {
@@ -362,8 +374,8 @@ describe('DocstoreManager', function () {
.should.equal(true)
})
- it('should call the callback with the docs', function () {
- this.callback.calledWith(null, this.docs).should.equal(true)
+ it('should return the docs', function () {
+ expect(getAllDocsResult).to.eql(this.docs)
})
})
@@ -372,35 +384,36 @@ describe('DocstoreManager', function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
- this.DocstoreManager.getAllDocs(this.project_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
- )
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.getAllDocs(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
+ )
})
})
})
describe('getAllDeletedDocs', function () {
describe('with a successful response code', function () {
- beforeEach(function (done) {
- this.callback.callsFake(done)
+ let getAllDeletedDocsResponse
+ beforeEach(async function () {
this.docs = [{ _id: 'mock-doc-id', name: 'foo.tex' }]
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 200 }, this.docs)
- this.DocstoreManager.getAllDeletedDocs(this.project_id, this.callback)
+ getAllDeletedDocsResponse =
+ await this.DocstoreManager.promises.getAllDeletedDocs(this.project_id)
})
it('should get all the project docs in the docstore api', function () {
@@ -411,48 +424,52 @@ describe('DocstoreManager', function () {
})
})
- it('should call the callback with the docs', function () {
- this.callback.should.have.been.calledWith(null, this.docs)
+ it('should resolve with the docs', function () {
+ expect(getAllDeletedDocsResponse).to.eql(this.docs)
})
})
describe('with an error', function () {
- beforeEach(function (done) {
- this.callback.callsFake(() => done())
+ beforeEach(async function () {
this.request.get = sinon
.stub()
.callsArgWith(1, new Error('connect failed'))
- this.DocstoreManager.getAllDocs(this.project_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback.should.have.been.calledWith(
- sinon.match
- .instanceOf(Error)
- .and(sinon.match.has('message', 'connect failed'))
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.getAllDocs(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property('message', 'connect failed')
})
})
describe('with a failed response code', function () {
- beforeEach(function (done) {
- this.callback.callsFake(() => done())
+ beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 })
- this.DocstoreManager.getAllDocs(this.project_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback.should.have.been.calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.getAllDocs(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
)
})
})
@@ -460,7 +477,8 @@ describe('DocstoreManager', function () {
describe('getAllRanges', function () {
describe('with a successful response code', function () {
- beforeEach(function () {
+ let getAllRangesResult
+ beforeEach(async function () {
this.request.get = sinon
.stub()
.callsArgWith(
@@ -469,7 +487,9 @@ describe('DocstoreManager', function () {
{ statusCode: 204 },
(this.docs = [{ _id: 'mock-doc-id', ranges: 'mock-ranges' }])
)
- this.DocstoreManager.getAllRanges(this.project_id, this.callback)
+ getAllRangesResult = await this.DocstoreManager.promises.getAllRanges(
+ this.project_id
+ )
})
it('should get all the project doc ranges in the docstore api', function () {
@@ -482,8 +502,8 @@ describe('DocstoreManager', function () {
.should.equal(true)
})
- it('should call the callback with the docs', function () {
- this.callback.calledWith(null, this.docs).should.equal(true)
+ it('should return the docs', async function () {
+ expect(getAllRangesResult).to.eql(this.docs)
})
})
@@ -492,22 +512,22 @@ describe('DocstoreManager', function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
- this.DocstoreManager.getAllRanges(this.project_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
- )
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.getAllRanges(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
+ )
})
})
})
@@ -518,11 +538,12 @@ describe('DocstoreManager', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 })
- this.DocstoreManager.archiveProject(this.project_id, this.callback)
})
- it('should call the callback', function () {
- this.callback.called.should.equal(true)
+ it('should resolve', async function () {
+ await expect(
+ this.DocstoreManager.promises.archiveProject(this.project_id)
+ ).to.eventually.be.fulfilled
})
})
@@ -531,22 +552,22 @@ describe('DocstoreManager', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 })
- this.DocstoreManager.archiveProject(this.project_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
- )
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.archiveProject(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
+ )
})
})
})
@@ -557,11 +578,12 @@ describe('DocstoreManager', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 })
- this.DocstoreManager.unarchiveProject(this.project_id, this.callback)
})
- it('should call the callback', function () {
- this.callback.called.should.equal(true)
+ it('should resolve', async function () {
+ await expect(
+ this.DocstoreManager.promises.unarchiveProject(this.project_id)
+ ).to.eventually.be.fulfilled
})
})
@@ -570,22 +592,22 @@ describe('DocstoreManager', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 })
- this.DocstoreManager.unarchiveProject(this.project_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
- )
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.unarchiveProject(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
+ )
})
})
})
@@ -596,11 +618,12 @@ describe('DocstoreManager', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 })
- this.DocstoreManager.destroyProject(this.project_id, this.callback)
})
- it('should call the callback', function () {
- this.callback.called.should.equal(true)
+ it('should resolve', async function () {
+ await expect(
+ this.DocstoreManager.promises.destroyProject(this.project_id)
+ ).to.eventually.be.fulfilled
})
})
@@ -609,22 +632,22 @@ describe('DocstoreManager', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 })
- this.DocstoreManager.destroyProject(this.project_id, this.callback)
})
- it('should call the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'docstore api responded with non-success code: 500'
- )
- )
- )
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.DocstoreManager.promises.destroyProject(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'docstore api responded with non-success code: 500'
+ )
})
})
})
diff --git a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js
index 2d5a116337..fba5dc87d4 100644
--- a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js
+++ b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js
@@ -1,6 +1,7 @@
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const path = require('path')
+const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const modulePath = path.join(
__dirname,
@@ -32,7 +33,6 @@ describe('DocumentUpdaterHandler', function () {
}
this.source = 'dropbox'
- this.callback = sinon.stub()
this.handler = SandboxedModule.require(modulePath, {
requires: {
request: {
@@ -68,9 +68,9 @@ describe('DocumentUpdaterHandler', function () {
describe('flushProjectToMongo', function () {
describe('successfully', function () {
- beforeEach(function () {
+ beforeEach(async function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
- this.handler.flushProjectToMongo(this.project_id, this.callback)
+ await this.handler.promises.flushProjectToMongo(this.project_id)
})
it('should flush the document from the document updater', function () {
@@ -81,10 +81,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback with no error', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -95,46 +91,50 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.flushProjectToMongo(this.project_id, this.callback)
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.flushProjectToMongo(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.flushProjectToMongo(this.project_id, this.callback)
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
- )
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.flushProjectToMongo(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
describe('flushProjectToMongoAndDelete', function () {
describe('successfully', function () {
- beforeEach(function () {
+ beforeEach(async function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
- this.handler.flushProjectToMongoAndDelete(
- this.project_id,
- this.callback
+ await this.handler.promises.flushProjectToMongoAndDelete(
+ this.project_id
)
})
@@ -146,10 +146,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback with no error', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -160,41 +156,44 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.flushProjectToMongoAndDelete(
- this.project_id,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.flushProjectToMongoAndDelete(
+ this.project_id
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.flushProjectToMongoAndDelete(
- this.project_id,
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.flushProjectToMongoAndDelete(
+ this.project_id
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -203,14 +202,13 @@ describe('DocumentUpdaterHandler', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
- this.handler.flushDocToMongo(
- this.project_id,
- this.doc_id,
- this.callback
- )
})
- it('should flush the document from the document updater', function () {
+ it('should flush the document from the document updater', async function () {
+ await this.handler.promises.flushDocToMongo(
+ this.project_id,
+ this.doc_id
+ )
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/flush`,
@@ -218,10 +216,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback with no error', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -232,43 +226,46 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.flushDocToMongo(
- this.project_id,
- this.doc_id,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.flushDocToMongo(
+ this.project_id,
+ this.doc_id
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.flushDocToMongo(
- this.project_id,
- this.doc_id,
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.flushDocToMongo(
+ this.project_id,
+ this.doc_id
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -277,10 +274,10 @@ describe('DocumentUpdaterHandler', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
- this.handler.deleteDoc(this.project_id, this.doc_id, this.callback)
})
- it('should delete the document from the document updater', function () {
+ it('should delete the document from the document updater', async function () {
+ await this.handler.promises.deleteDoc(this.project_id, this.doc_id)
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`,
@@ -288,10 +285,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback with no error', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -302,35 +295,40 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.deleteDoc(this.project_id, this.doc_id, this.callback)
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.deleteDoc(this.project_id, this.doc_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.deleteDoc(this.project_id, this.doc_id, this.callback)
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
- )
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.deleteDoc(this.project_id, this.doc_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
@@ -339,12 +337,11 @@ describe('DocumentUpdaterHandler', function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
})
- it('when option is true, should send a `ignore_flush_errors=true` URL query to document-updater', function () {
- this.handler.deleteDoc(
+ it('when option is true, should send a `ignore_flush_errors=true` URL query to document-updater', async function () {
+ await this.handler.promises.deleteDoc(
this.project_id,
this.doc_id,
- true,
- this.callback
+ true
)
this.request
.calledWithMatch({
@@ -354,12 +351,11 @@ describe('DocumentUpdaterHandler', function () {
.should.equal(true)
})
- it("when option is false, shouldn't send any URL query to document-updater", function () {
- this.handler.deleteDoc(
+ it("when option is false, shouldn't send any URL query to document-updater", async function () {
+ await this.handler.promises.deleteDoc(
this.project_id,
this.doc_id,
- false,
- this.callback
+ false
)
this.request
.calledWithMatch({
@@ -375,17 +371,16 @@ describe('DocumentUpdaterHandler', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
- this.handler.setDocument(
+ })
+
+ it('should set the document in the document updater', async function () {
+ await this.handler.promises.setDocument(
this.project_id,
this.doc_id,
this.user_id,
this.lines,
- this.source,
- this.callback
+ this.source
)
- })
-
- it('should set the document in the document updater', function () {
this.request
.calledWith({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`,
@@ -399,10 +394,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback with no error', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -413,49 +404,52 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.setDocument(
- this.project_id,
- this.doc_id,
- this.user_id,
- this.lines,
- this.source,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.setDocument(
+ this.project_id,
+ this.doc_id,
+ this.user_id,
+ this.lines,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.setDocument(
- this.project_id,
- this.doc_id,
- this.user_id,
- this.lines,
- this.source,
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.setDocument(
+ this.project_id,
+ this.doc_id,
+ this.user_id,
+ this.lines,
+ this.source
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -468,15 +462,14 @@ describe('DocumentUpdaterHandler', function () {
}
this.body = this.comment
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
- this.handler.getComment(
- this.project_id,
- this.doc_id,
- this.comment.id,
- this.callback
- )
})
- it('should get the comment from the document updater', function () {
+ it('should get the comment from the document updater', async function () {
+ await this.handler.promises.getComment(
+ this.project_id,
+ this.doc_id,
+ this.comment.id
+ )
const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/comment/${this.comment.id}`
this.request
.calledWith({
@@ -487,16 +480,13 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback with the comment', function () {
- this.callback.calledWithExactly(null, this.comment).should.equal(true)
- })
})
})
describe('getDocument', function () {
describe('successfully', function () {
- beforeEach(function () {
+ let getDocumentResponse
+ beforeEach(async function () {
this.body = {
lines: this.lines,
version: this.version,
@@ -505,11 +495,10 @@ describe('DocumentUpdaterHandler', function () {
}
this.fromVersion = 2
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
- this.handler.getDocument(
+ getDocumentResponse = await this.handler.promises.getDocument(
this.project_id,
this.doc_id,
- this.fromVersion,
- this.callback
+ this.fromVersion
)
})
@@ -525,9 +514,12 @@ describe('DocumentUpdaterHandler', function () {
})
it('should call the callback with the lines and version', function () {
- this.callback
- .calledWith(null, this.lines, this.version, this.ranges, this.ops)
- .should.equal(true)
+ expect(getDocumentResponse).to.eql({
+ lines: this.lines,
+ version: this.version,
+ ranges: this.ranges,
+ ops: this.ops,
+ })
})
})
@@ -539,45 +531,48 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.getDocument(
- this.project_id,
- this.doc_id,
- this.fromVersion,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.getDocument(
+ this.project_id,
+ this.doc_id,
+ this.fromVersion
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.getDocument(
- this.project_id,
- this.doc_id,
- this.fromVersion,
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.getDocument(
+ this.project_id,
+ this.doc_id,
+ this.fromVersion
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -588,7 +583,8 @@ describe('DocumentUpdaterHandler', function () {
})
describe('successfully', function () {
- beforeEach(function () {
+ let getProjectDocsIfMatchResponse
+ beforeEach(async function () {
this.doc0 = {
_id: this.doc_id,
lines: this.lines,
@@ -599,11 +595,11 @@ describe('DocumentUpdaterHandler', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 200 }, this.body)
- this.handler.getProjectDocsIfMatch(
- this.project_id,
- this.project_state_hash,
- this.callback
- )
+ getProjectDocsIfMatchResponse =
+ await this.handler.promises.getProjectDocsIfMatch(
+ this.project_id,
+ this.project_state_hash
+ )
})
it('should get the documents from the document updater', function () {
@@ -612,7 +608,7 @@ describe('DocumentUpdaterHandler', function () {
})
it('should call the callback with the documents', function () {
- this.callback.calledWithExactly(null, this.docs).should.equal(true)
+ expect(getProjectDocsIfMatchResponse).to.eql(this.docs)
})
})
@@ -621,17 +617,21 @@ describe('DocumentUpdaterHandler', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, new Error('something went wrong'), null, null)
- this.handler.getProjectDocsIfMatch(
- this.project_id,
- this.project_state_hash,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.getProjectDocsIfMatch(
+ this.project_id,
+ this.project_state_hash
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
@@ -640,15 +640,14 @@ describe('DocumentUpdaterHandler', function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 409 }, 'Conflict')
- this.handler.getProjectDocsIfMatch(
- this.project_id,
- this.project_state_hash,
- this.callback
- )
})
- it('should return the callback with no documents', function () {
- this.callback.alwaysCalledWithExactly().should.equal(true)
+ it('should return no documents', async function () {
+ const response = await this.handler.promises.getProjectDocsIfMatch(
+ this.project_id,
+ this.project_state_hash
+ )
+ expect(response).to.be.undefined
})
})
})
@@ -657,10 +656,11 @@ describe('DocumentUpdaterHandler', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 })
- this.handler.clearProjectState(this.project_id, this.callback)
})
- it('should clear the project state from the document updater', function () {
+ it('should clear the project state from the document updater', async function () {
+ await this.handler.promises.clearProjectState(this.project_id)
+
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/clearState`,
@@ -668,10 +668,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -682,35 +678,40 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.clearProjectState(this.project_id, this.callback)
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.clearProjectState(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns an error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, null)
- this.handler.clearProjectState(this.project_id, this.callback)
})
- it('should return the callback with no documents', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
- )
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.clearProjectState(this.project_id)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -723,15 +724,14 @@ describe('DocumentUpdaterHandler', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
- this.handler.acceptChanges(
- this.project_id,
- this.doc_id,
- [this.change_id],
- this.callback
- )
})
- it('should accept the change in the document updater', function () {
+ it('should accept the change in the document updater', async function () {
+ await this.handler.promises.acceptChanges(
+ this.project_id,
+ this.doc_id,
+ [this.change_id]
+ )
this.request
.calledWith({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/change/accept`,
@@ -743,10 +743,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -757,45 +753,48 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.acceptChanges(
- this.project_id,
- this.doc_id,
- [this.change_id],
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.acceptChanges(
+ this.project_id,
+ this.doc_id,
+ [this.change_id]
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.acceptChanges(
- this.project_id,
- this.doc_id,
- [this.change_id],
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.acceptChanges(
+ this.project_id,
+ this.doc_id,
+ [this.change_id]
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -808,16 +807,15 @@ describe('DocumentUpdaterHandler', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
- this.handler.deleteThread(
+ })
+
+ it('should delete the thread in the document updater', async function () {
+ await this.handler.promises.deleteThread(
this.project_id,
this.doc_id,
this.thread_id,
- this.user_id,
- this.callback
+ this.user_id
)
- })
-
- it('should delete the thread in the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/comment/${this.thread_id}`,
@@ -825,10 +823,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -839,47 +833,50 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.deleteThread(
- this.project_id,
- this.doc_id,
- this.thread_id,
- this.user_id,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.deleteThread(
+ this.project_id,
+ this.doc_id,
+ this.thread_id,
+ this.user_id
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.deleteThread(
- this.project_id,
- this.doc_id,
- this.thread_id,
- this.user_id,
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.deleteThread(
+ this.project_id,
+ this.doc_id,
+ this.thread_id,
+ this.user_id
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -892,16 +889,15 @@ describe('DocumentUpdaterHandler', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
- this.handler.resolveThread(
+ })
+
+ it('should resolve the thread in the document updater', async function () {
+ await this.handler.promises.resolveThread(
this.project_id,
this.doc_id,
this.thread_id,
- this.user_id,
- this.callback
+ this.user_id
)
- })
-
- it('should resolve the thread in the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/comment/${this.thread_id}/resolve`,
@@ -909,10 +905,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -923,47 +915,50 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.resolveThread(
- this.project_id,
- this.doc_id,
- this.thread_id,
- this.user_id,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.resolveThread(
+ this.project_id,
+ this.doc_id,
+ this.thread_id,
+ this.user_id
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.resolveThread(
- this.project_id,
- this.doc_id,
- this.thread_id,
- this.user_id,
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.resolveThread(
+ this.project_id,
+ this.doc_id,
+ this.thread_id,
+ this.user_id
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -976,16 +971,15 @@ describe('DocumentUpdaterHandler', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
- this.handler.reopenThread(
+ })
+
+ it('should reopen the thread in the document updater', async function () {
+ await this.handler.promises.reopenThread(
this.project_id,
this.doc_id,
this.thread_id,
- this.user_id,
- this.callback
+ this.user_id
)
- })
-
- it('should reopen the thread in the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/comment/${this.thread_id}/reopen`,
@@ -993,10 +987,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -1007,47 +997,50 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.reopenThread(
- this.project_id,
- this.doc_id,
- this.thread_id,
- this.user_id,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.reopenThread(
+ this.project_id,
+ this.doc_id,
+ this.thread_id,
+ this.user_id
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.reopenThread(
- this.project_id,
- this.doc_id,
- this.thread_id,
- this.user_id,
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.reopenThread(
+ this.project_id,
+ this.doc_id,
+ this.thread_id,
+ this.user_id
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
@@ -1061,23 +1054,18 @@ describe('DocumentUpdaterHandler', function () {
describe('with project history disabled', function () {
beforeEach(function () {
this.settings.apis.project_history.sendProjectStructureOps = false
- this.handler.updateProjectStructure(
+ })
+
+ it('does not make a web request', async function () {
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
{},
- this.source,
- this.callback
+ this.source
)
- })
-
- it('does not make a web request', function () {
this.request.called.should.equal(false)
})
-
- it('calls the callback', function () {
- this.callback.called.should.equal(true)
- })
})
describe('with project history enabled', function () {
@@ -1088,7 +1076,7 @@ describe('DocumentUpdaterHandler', function () {
})
describe('when an entity has changed name', function () {
- it('should send the structure update to the document updater', function (done) {
+ it('should send the structure update to the document updater', async function () {
this.docIdA = new ObjectId()
this.docIdB = new ObjectId()
this.changes = {
@@ -1119,35 +1107,33 @@ describe('DocumentUpdaterHandler', function () {
},
]
- this.handler.updateProjectStructure(
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
- this.source,
- () => {
- this.request
- .calledWith({
- url: this.url,
- method: 'POST',
- json: {
- updates,
- userId: this.user_id,
- version: this.version,
- projectHistoryId: this.projectHistoryId,
- source: this.source,
- },
- timeout: 30 * 1000,
- })
- .should.equal(true)
- done()
- }
+ this.source
)
+
+ this.request
+ .calledWith({
+ url: this.url,
+ method: 'POST',
+ json: {
+ updates,
+ userId: this.user_id,
+ version: this.version,
+ projectHistoryId: this.projectHistoryId,
+ source: this.source,
+ },
+ timeout: 30 * 1000,
+ })
+ .should.equal(true)
})
})
describe('when a doc has been added', function () {
- it('should send the structure update to the document updater', function (done) {
+ it('should send the structure update to the document updater', async function () {
this.docId = new ObjectId()
this.changes = {
newDocs: [
@@ -1171,33 +1157,31 @@ describe('DocumentUpdaterHandler', function () {
},
]
- this.handler.updateProjectStructure(
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
- this.source,
- () => {
- this.request.should.have.been.calledWith({
- url: this.url,
- method: 'POST',
- json: {
- updates,
- userId: this.user_id,
- version: this.version,
- projectHistoryId: this.projectHistoryId,
- source: this.source,
- },
- timeout: 30 * 1000,
- })
- done()
- }
+ this.source
)
+
+ this.request.should.have.been.calledWith({
+ url: this.url,
+ method: 'POST',
+ json: {
+ updates,
+ userId: this.user_id,
+ version: this.version,
+ projectHistoryId: this.projectHistoryId,
+ source: this.source,
+ },
+ timeout: 30 * 1000,
+ })
})
})
describe('when a file has been added', function () {
- it('should send the structure update to the document updater', function (done) {
+ it('should send the structure update to the document updater', async function () {
this.fileId = new ObjectId()
this.changes = {
newFiles: [
@@ -1225,33 +1209,31 @@ describe('DocumentUpdaterHandler', function () {
},
]
- this.handler.updateProjectStructure(
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
- this.source,
- () => {
- this.request.should.have.been.calledWith({
- url: this.url,
- method: 'POST',
- json: {
- updates,
- userId: this.user_id,
- version: this.version,
- projectHistoryId: this.projectHistoryId,
- source: this.source,
- },
- timeout: 30 * 1000,
- })
- done()
- }
+ this.source
)
+
+ this.request.should.have.been.calledWith({
+ url: this.url,
+ method: 'POST',
+ json: {
+ updates,
+ userId: this.user_id,
+ version: this.version,
+ projectHistoryId: this.projectHistoryId,
+ source: this.source,
+ },
+ timeout: 30 * 1000,
+ })
})
})
describe('when an entity has been deleted', function () {
- it('should end the structure update to the document updater', function (done) {
+ it('should end the structure update to the document updater', async function () {
this.docId = new ObjectId()
this.changes = {
oldDocs: [
@@ -1269,33 +1251,31 @@ describe('DocumentUpdaterHandler', function () {
},
]
- this.handler.updateProjectStructure(
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
- this.source,
- () => {
- this.request.should.have.been.calledWith({
- url: this.url,
- method: 'POST',
- json: {
- updates,
- userId: this.user_id,
- version: this.version,
- projectHistoryId: this.projectHistoryId,
- source: this.source,
- },
- timeout: 30 * 1000,
- })
- done()
- }
+ this.source
)
+
+ this.request.should.have.been.calledWith({
+ url: this.url,
+ method: 'POST',
+ json: {
+ updates,
+ userId: this.user_id,
+ version: this.version,
+ projectHistoryId: this.projectHistoryId,
+ source: this.source,
+ },
+ timeout: 30 * 1000,
+ })
})
})
describe('when a file is converted to a doc', function () {
- it('should send the delete first', function (done) {
+ it('should send the delete first', async function () {
this.docId = new ObjectId()
this.fileId = new ObjectId()
this.changes = {
@@ -1337,33 +1317,31 @@ describe('DocumentUpdaterHandler', function () {
},
]
- this.handler.updateProjectStructure(
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
- this.source,
- () => {
- this.request.should.have.been.calledWith({
- url: this.url,
- method: 'POST',
- json: {
- updates,
- userId: this.user_id,
- version: this.version,
- projectHistoryId: this.projectHistoryId,
- source: this.source,
- },
- timeout: 30 * 1000,
- })
- done()
- }
+ this.source
)
+
+ this.request.should.have.been.calledWith({
+ url: this.url,
+ method: 'POST',
+ json: {
+ updates,
+ userId: this.user_id,
+ version: this.version,
+ projectHistoryId: this.projectHistoryId,
+ source: this.source,
+ },
+ timeout: 30 * 1000,
+ })
})
})
describe('when the project version is missing', function () {
- it('should call the callback with an error', function () {
+ it('should call the callback with an error', async function () {
this.docId = new ObjectId()
this.changes = {
oldDocs: [
@@ -1371,20 +1349,23 @@ describe('DocumentUpdaterHandler', function () {
],
}
- this.handler.updateProjectStructure(
- this.project_id,
- this.projectHistoryId,
- this.user_id,
- this.changes,
- this.source,
- this.callback
- )
+ let error
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
- const firstCallArgs = this.callback.args[0]
- firstCallArgs[0].message.should.equal(
+ try {
+ await this.handler.promises.updateProjectStructure(
+ this.project_id,
+ this.projectHistoryId,
+ this.user_id,
+ this.changes,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
'did not receive project version in changes'
)
})
@@ -1424,7 +1405,7 @@ describe('DocumentUpdaterHandler', function () {
}
})
- it('should forward ranges', function (done) {
+ it('should forward ranges', async function () {
const updates = [
{
type: 'add-doc',
@@ -1440,31 +1421,29 @@ describe('DocumentUpdaterHandler', function () {
},
]
- this.handler.updateProjectStructure(
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
- this.source,
- () => {
- this.request.should.have.been.calledWith({
- url: this.url,
- method: 'POST',
- json: {
- updates,
- userId: this.user_id,
- version: this.version,
- projectHistoryId: this.projectHistoryId,
- source: this.source,
- },
- timeout: 30 * 1000,
- })
- done()
- }
+ this.source
)
+
+ this.request.should.have.been.calledWith({
+ url: this.url,
+ method: 'POST',
+ json: {
+ updates,
+ userId: this.user_id,
+ version: this.version,
+ projectHistoryId: this.projectHistoryId,
+ source: this.source,
+ },
+ timeout: 30 * 1000,
+ })
})
- it('should include flag when history ranges support is enabled', function (done) {
+ it('should include flag when history ranges support is enabled', async function () {
this.ProjectGetter.getProjectWithoutLock
.withArgs(this.project_id)
.yields(null, {
@@ -1487,28 +1466,26 @@ describe('DocumentUpdaterHandler', function () {
},
]
- this.handler.updateProjectStructure(
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
- this.source,
- () => {
- this.request.should.have.been.calledWith({
- url: this.url,
- method: 'POST',
- json: {
- updates,
- userId: this.user_id,
- version: this.version,
- projectHistoryId: this.projectHistoryId,
- source: this.source,
- },
- timeout: 30 * 1000,
- })
- done()
- }
+ this.source
)
+
+ this.request.should.have.been.calledWith({
+ url: this.url,
+ method: 'POST',
+ json: {
+ updates,
+ userId: this.user_id,
+ version: this.version,
+ projectHistoryId: this.projectHistoryId,
+ source: this.source,
+ },
+ timeout: 30 * 1000,
+ })
})
})
@@ -1516,7 +1493,7 @@ describe('DocumentUpdaterHandler', function () {
beforeEach(function () {
this.settings.disableFilestore = true
})
- it('should add files without URL and with createdBlob', function (done) {
+ it('should add files without URL and with createdBlob', async function () {
this.fileId = new ObjectId()
this.changes = {
newFiles: [
@@ -1544,30 +1521,28 @@ describe('DocumentUpdaterHandler', function () {
},
]
- this.handler.updateProjectStructure(
+ await this.handler.promises.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
- this.source,
- () => {
- this.request.should.have.been.calledWith({
- url: this.url,
- method: 'POST',
- json: {
- updates,
- userId: this.user_id,
- version: this.version,
- projectHistoryId: this.projectHistoryId,
- source: this.source,
- },
- timeout: 30 * 1000,
- })
- done()
- }
+ this.source
)
+
+ this.request.should.have.been.calledWith({
+ url: this.url,
+ method: 'POST',
+ json: {
+ updates,
+ userId: this.user_id,
+ version: this.version,
+ projectHistoryId: this.projectHistoryId,
+ source: this.source,
+ },
+ timeout: 30 * 1000,
+ })
})
- it('should flag files without hash', function (done) {
+ it('should flag files without hash', async function () {
this.fileId = new ObjectId()
this.changes = {
newFiles: [
@@ -1580,25 +1555,26 @@ describe('DocumentUpdaterHandler', function () {
newProject: { version: this.version },
}
- this.handler.updateProjectStructure(
- this.project_id,
- this.projectHistoryId,
- this.user_id,
- this.changes,
- this.source,
- err => {
- err.should.match(/found file with missing hash/)
- this.request.should.not.have.been.called
- done()
- }
- )
+ let error
+ try {
+ await this.handler.promises.updateProjectStructure(
+ this.project_id,
+ this.projectHistoryId,
+ this.user_id,
+ this.changes,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+ expect(error).to.exist
})
})
})
})
describe('resyncProjectHistory', function () {
- it('should add docs', function (done) {
+ it('should add docs', async function () {
const docId1 = new ObjectId()
const docId2 = new ObjectId()
const docs = [
@@ -1609,31 +1585,28 @@ describe('DocumentUpdaterHandler', function () {
this.request.yields(null, { statusCode: 200 })
const projectId = new ObjectId()
const projectHistoryId = 99
- this.handler.resyncProjectHistory(
+ await this.handler.promises.resyncProjectHistory(
projectId,
projectHistoryId,
docs,
files,
- {},
- () => {
- this.request.should.have.been.calledWith({
- url: `${this.settings.apis.documentupdater.url}/project/${projectId}/history/resync`,
- method: 'POST',
- json: {
- docs: [
- { doc: docId1, path: 'main.tex' },
- { doc: docId2, path: 'references.bib' },
- ],
- files: [],
- projectHistoryId,
- },
- timeout: 6 * 60 * 1000,
- })
- done()
- }
+ {}
)
+ this.request.should.have.been.calledWith({
+ url: `${this.settings.apis.documentupdater.url}/project/${projectId}/history/resync`,
+ method: 'POST',
+ json: {
+ docs: [
+ { doc: docId1, path: 'main.tex' },
+ { doc: docId2, path: 'references.bib' },
+ ],
+ files: [],
+ projectHistoryId,
+ },
+ timeout: 6 * 60 * 1000,
+ })
})
- it('should add files', function (done) {
+ it('should add files', async function () {
const fileId1 = new ObjectId()
const fileId2 = new ObjectId()
const fileId3 = new ObjectId()
@@ -1673,66 +1646,63 @@ describe('DocumentUpdaterHandler', function () {
this.request.yields(null, { statusCode: 200 })
const projectId = new ObjectId()
const projectHistoryId = 99
- this.handler.resyncProjectHistory(
+ await this.handler.promises.resyncProjectHistory(
projectId,
projectHistoryId,
docs,
files,
- {},
- () => {
- this.request.should.have.been.calledWith({
- url: `${this.settings.apis.documentupdater.url}/project/${projectId}/history/resync`,
- method: 'POST',
- json: {
- docs: [],
- files: [
- {
- file: fileId1,
- _hash: '42',
- path: '1.png',
- url: `http://filestore/project/${projectId}/file/${fileId1}`,
- createdBlob: false,
- metadata: undefined,
- },
- {
- file: fileId2,
- _hash: '1337',
- path: '1.bib',
- url: `http://filestore/project/${projectId}/file/${fileId2}`,
- createdBlob: false,
- metadata: {
- importedAt: fileCreated2,
- provider: 'references-provider',
- },
- },
- {
- file: fileId3,
- _hash: '21',
- path: 'bar.txt',
- url: `http://filestore/project/${projectId}/file/${fileId3}`,
- createdBlob: false,
- metadata: {
- importedAt: fileCreated3,
- provider: 'project_output_file',
- source_project_id: otherProjectId,
- source_output_file_path: 'foo/bar.txt',
- // build_id and clsiServerId are omitted
- },
- },
- ],
- projectHistoryId,
- },
- timeout: 6 * 60 * 1000,
- })
- done()
- }
+ {}
)
+ this.request.should.have.been.calledWith({
+ url: `${this.settings.apis.documentupdater.url}/project/${projectId}/history/resync`,
+ method: 'POST',
+ json: {
+ docs: [],
+ files: [
+ {
+ file: fileId1,
+ _hash: '42',
+ path: '1.png',
+ url: `http://filestore/project/${projectId}/file/${fileId1}`,
+ createdBlob: false,
+ metadata: undefined,
+ },
+ {
+ file: fileId2,
+ _hash: '1337',
+ path: '1.bib',
+ url: `http://filestore/project/${projectId}/file/${fileId2}`,
+ createdBlob: false,
+ metadata: {
+ importedAt: fileCreated2,
+ provider: 'references-provider',
+ },
+ },
+ {
+ file: fileId3,
+ _hash: '21',
+ path: 'bar.txt',
+ url: `http://filestore/project/${projectId}/file/${fileId3}`,
+ createdBlob: false,
+ metadata: {
+ importedAt: fileCreated3,
+ provider: 'project_output_file',
+ source_project_id: otherProjectId,
+ source_output_file_path: 'foo/bar.txt',
+ // build_id and clsiServerId are omitted
+ },
+ },
+ ],
+ projectHistoryId,
+ },
+ timeout: 6 * 60 * 1000,
+ })
})
describe('with filestore disabled', function () {
beforeEach(function () {
this.settings.disableFilestore = true
})
- it('should add files without URL', function (done) {
+ it('should add files without URL', async function () {
const fileId1 = new ObjectId()
const fileId2 = new ObjectId()
const fileId3 = new ObjectId()
@@ -1772,62 +1742,59 @@ describe('DocumentUpdaterHandler', function () {
this.request.yields(null, { statusCode: 200 })
const projectId = new ObjectId()
const projectHistoryId = 99
- this.handler.resyncProjectHistory(
+ await this.handler.promises.resyncProjectHistory(
projectId,
projectHistoryId,
docs,
files,
- {},
- () => {
- this.request.should.have.been.calledWith({
- url: `${this.settings.apis.documentupdater.url}/project/${projectId}/history/resync`,
- method: 'POST',
- json: {
- docs: [],
- files: [
- {
- file: fileId1,
- _hash: '42',
- path: '1.png',
- url: undefined,
- createdBlob: true,
- metadata: undefined,
- },
- {
- file: fileId2,
- _hash: '1337',
- path: '1.bib',
- url: undefined,
- createdBlob: true,
- metadata: {
- importedAt: fileCreated2,
- provider: 'references-provider',
- },
- },
- {
- file: fileId3,
- _hash: '21',
- path: 'bar.txt',
- url: undefined,
- createdBlob: true,
- metadata: {
- importedAt: fileCreated3,
- provider: 'project_output_file',
- source_project_id: otherProjectId,
- source_output_file_path: 'foo/bar.txt',
- // build_id and clsiServerId are omitted
- },
- },
- ],
- projectHistoryId,
- },
- timeout: 6 * 60 * 1000,
- })
- done()
- }
+ {}
)
+ this.request.should.have.been.calledWith({
+ url: `${this.settings.apis.documentupdater.url}/project/${projectId}/history/resync`,
+ method: 'POST',
+ json: {
+ docs: [],
+ files: [
+ {
+ file: fileId1,
+ _hash: '42',
+ path: '1.png',
+ url: undefined,
+ createdBlob: true,
+ metadata: undefined,
+ },
+ {
+ file: fileId2,
+ _hash: '1337',
+ path: '1.bib',
+ url: undefined,
+ createdBlob: true,
+ metadata: {
+ importedAt: fileCreated2,
+ provider: 'references-provider',
+ },
+ },
+ {
+ file: fileId3,
+ _hash: '21',
+ path: 'bar.txt',
+ url: undefined,
+ createdBlob: true,
+ metadata: {
+ importedAt: fileCreated3,
+ provider: 'project_output_file',
+ source_project_id: otherProjectId,
+ source_output_file_path: 'foo/bar.txt',
+ // build_id and clsiServerId are omitted
+ },
+ },
+ ],
+ projectHistoryId,
+ },
+ timeout: 6 * 60 * 1000,
+ })
})
- it('should flag files with missing hashes', function (done) {
+ it('should flag files with missing hashes', async function () {
const fileId1 = new ObjectId()
const fileId2 = new ObjectId()
const fileId3 = new ObjectId()
@@ -1866,18 +1833,15 @@ describe('DocumentUpdaterHandler', function () {
this.request.yields(null, { statusCode: 200 })
const projectId = new ObjectId()
const projectHistoryId = 99
- this.handler.resyncProjectHistory(
- projectId,
- projectHistoryId,
- docs,
- files,
- {},
- err => {
- err.should.match(/found file with missing hash/)
- this.request.should.not.have.been.called
- done()
- }
- )
+ await expect(
+ this.handler.promises.resyncProjectHistory(
+ projectId,
+ projectHistoryId,
+ docs,
+ files,
+ {}
+ )
+ ).to.be.rejected
})
})
})
@@ -1889,17 +1853,16 @@ describe('DocumentUpdaterHandler', function () {
rev: 1,
}
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
- this.handler.appendToDocument(
+ })
+
+ it('should append to the document in the document updater', async function () {
+ await this.handler.promises.appendToDocument(
this.project_id,
this.doc_id,
this.user_id,
this.lines,
- this.source,
- this.callback
+ this.source
)
- })
-
- it('should append to the document in the document updater', function () {
this.request
.calledWith({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/append`,
@@ -1913,10 +1876,6 @@ describe('DocumentUpdaterHandler', function () {
})
.should.equal(true)
})
-
- it('should call the callback with no error', function () {
- this.callback.calledWith(null).should.equal(true)
- })
})
describe('when the document updater API returns an error', function () {
@@ -1927,49 +1886,52 @@ describe('DocumentUpdaterHandler', function () {
null,
null
)
- this.handler.appendToDocument(
- this.project_id,
- this.doc_id,
- this.user_id,
- this.lines,
- this.source,
- this.callback
- )
})
- it('should return an error to the callback', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Error))
- .should.equal(true)
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.appendToDocument(
+ this.project_id,
+ this.doc_id,
+ this.user_id,
+ this.lines,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
- this.handler.appendToDocument(
- this.project_id,
- this.doc_id,
- this.user_id,
- this.lines,
- this.source,
- this.callback
- )
})
- it('should return the callback with an error', function () {
- this.callback
- .calledWith(
- sinon.match
- .instanceOf(Error)
- .and(
- sinon.match.has(
- 'message',
- 'document updater returned a failure status code: 500'
- )
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await this.handler.promises.appendToDocument(
+ this.project_id,
+ this.doc_id,
+ this.user_id,
+ this.lines,
+ this.source
)
- .should.equal(true)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'document updater returned a failure status code: 500'
+ )
})
})
})
diff --git a/services/web/test/unit/src/Editor/EditorControllerTests.js b/services/web/test/unit/src/Editor/EditorControllerTests.js
index 798f2f9499..2dd11bdf0f 100644
--- a/services/web/test/unit/src/Editor/EditorControllerTests.js
+++ b/services/web/test/unit/src/Editor/EditorControllerTests.js
@@ -518,7 +518,12 @@ describe('EditorController', function () {
it('should add the folder using the project entity handler', function () {
return this.ProjectEntityUpdateHandler.addFolder
- .calledWith(this.project_id, this.folder_id, this.folderName)
+ .calledWith(
+ this.project_id,
+ this.folder_id,
+ this.folderName,
+ this.user_id
+ )
.should.equal(true)
})
@@ -540,6 +545,7 @@ describe('EditorController', function () {
(this.folderA = { _id: 2, parentFolder_id: 1 }),
(this.folderB = { _id: 3, parentFolder_id: 2 }),
]
+ this.userId = new ObjectId().toString()
this.EditorController._notifyProjectUsersOfNewFolders = sinon
.stub()
.yields()
@@ -549,13 +555,14 @@ describe('EditorController', function () {
return this.EditorController.mkdirp(
this.project_id,
this.path,
+ this.userId,
this.callback
)
})
it('should create the folder using the project entity handler', function () {
return this.ProjectEntityUpdateHandler.mkdirp
- .calledWith(this.project_id, this.path)
+ .calledWith(this.project_id, this.path, this.userId)
.should.equal(true)
})
diff --git a/services/web/test/unit/src/FileStore/FileStoreHandlerTests.js b/services/web/test/unit/src/FileStore/FileStoreHandlerTests.js
index bf9fe6c900..3a66f275a7 100644
--- a/services/web/test/unit/src/FileStore/FileStoreHandlerTests.js
+++ b/services/web/test/unit/src/FileStore/FileStoreHandlerTests.js
@@ -95,7 +95,7 @@ describe('FileStoreHandler', function () {
this.request.returns(this.writeStream)
})
- it('should get the project details', function (done) {
+ it('should get the project details', async function () {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
@@ -104,20 +104,17 @@ describe('FileStoreHandler', function () {
}
},
})
- this.handler.uploadFileFromDisk(
+ await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- () => {
- this.ProjectDetailsHandler.getDetails
- .calledWith(this.projectId)
- .should.equal(true)
- done()
- }
+ this.fsPath
)
+ this.ProjectDetailsHandler.getDetails
+ .calledWith(this.projectId)
+ .should.equal(true)
})
- it('should compute the file hash', function (done) {
+ it('should compute the file hash', async function () {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
@@ -126,21 +123,18 @@ describe('FileStoreHandler', function () {
}
},
})
- this.handler.uploadFileFromDisk(
+ await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- () => {
- this.FileHashManager.computeHash
- .calledWith(this.fsPath)
- .should.equal(true)
- done()
- }
+ this.fsPath
)
+ this.FileHashManager.computeHash
+ .calledWith(this.fsPath)
+ .should.equal(true)
})
describe('when project-history-blobs feature is enabled', function () {
- it('should upload the file to the history store as a blob', function (done) {
+ it('should upload the file to the history store as a blob', async function () {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
@@ -150,26 +144,23 @@ describe('FileStoreHandler', function () {
},
})
this.Features.hasFeature.withArgs('project-history-blobs').returns(true)
- this.handler.uploadFileFromDisk(
+ await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- () => {
- this.HistoryManager.uploadBlobFromDisk
- .calledWith(
- this.historyId,
- this.hashValue,
- this.fileSize,
- this.fsPath
- )
- .should.equal(true)
- done()
- }
+ this.fsPath
)
+ this.HistoryManager.uploadBlobFromDisk
+ .calledWith(
+ this.historyId,
+ this.hashValue,
+ this.fileSize,
+ this.fsPath
+ )
+ .should.equal(true)
})
})
describe('when project-history-blobs feature is disabled', function () {
- it('should not upload the file to the history store as a blob', function (done) {
+ it('should not upload the file to the history store as a blob', async function () {
this.fs.createReadStream.returns({
pipe() {},
on(type, cb) {
@@ -178,15 +169,12 @@ describe('FileStoreHandler', function () {
}
},
})
- this.handler.uploadFileFromDisk(
+ await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- () => {
- this.HistoryManager.uploadBlobFromDisk.called.should.equal(false)
- done()
- }
+ this.fsPath
)
+ this.HistoryManager.uploadBlobFromDisk.called.should.equal(false)
})
})
@@ -214,71 +202,63 @@ describe('FileStoreHandler', function () {
)
})
- it('should pipe the read stream to request', function (done) {
+ it('should pipe the read stream to request', function () {
this.request.returns(this.writeStream)
- this.fs.createReadStream.returns({
- on(type, cb) {
- if (type === 'open') {
- cb()
- }
- },
- pipe: o => {
- this.writeStream.should.equal(o)
- done()
- },
+ return new Promise((resolve, reject) => {
+ this.fs.createReadStream.returns({
+ on(type, cb) {
+ if (type === 'open') {
+ cb()
+ }
+ },
+ pipe: o => {
+ this.writeStream.should.equal(o)
+ resolve()
+ },
+ })
+ this.handler.promises
+ .uploadFileFromDisk(this.projectId, this.fileArgs, this.fsPath)
+ .catch(reject)
})
- this.handler.uploadFileFromDisk(
- this.projectId,
- this.fileArgs,
- this.fsPath,
- () => {}
- )
})
- it('should pass the correct options to request', function (done) {
+ it('should pass the correct options to request', async function () {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
this.fs.createReadStream.returns({
- pipe() {},
- on(type, cb) {
+ pipe: sinon.stub(),
+ on: sinon.stub((type, cb) => {
if (type === 'open') {
cb()
}
- },
+ }),
})
- this.handler.uploadFileFromDisk(
+ await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- () => {
- this.request.args[0][0].method.should.equal('post')
- this.request.args[0][0].uri.should.equal(fileUrl)
- done()
- }
+ this.fsPath
)
+ this.request.args[0][0].method.should.equal('post')
+ this.request.args[0][0].uri.should.equal(fileUrl)
})
- it('should callback with the url and fileRef', function (done) {
+ it('should resolve with the url and fileRef', async function () {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
this.fs.createReadStream.returns({
- pipe() {},
- on(type, cb) {
+ pipe: sinon.stub(),
+ on: sinon.stub((type, cb) => {
if (type === 'open') {
cb()
}
- },
+ }),
})
- this.handler.uploadFileFromDisk(
+ const { url, fileRef } = await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- (err, url, fileRef) => {
- expect(err).to.not.exist
- expect(url).to.equal(fileUrl)
- expect(fileRef._id).to.equal(this.fileId)
- expect(fileRef.hash).to.equal(this.hashValue)
- done()
- }
+ this.fsPath
)
+ expect(url).to.equal(fileUrl)
+ expect(fileRef._id).to.equal(this.fileId)
+ expect(fileRef.hash).to.equal(this.hashValue)
})
describe('when upload to filestore fails', function () {
beforeEach(function () {
@@ -289,28 +269,32 @@ describe('FileStoreHandler', function () {
}
})
- it('should callback with an error', function (done) {
+ it('should reject with an error', async function () {
this.fs.createReadStream.callCount = 0
this.fs.createReadStream.returns({
- pipe() {},
- on(type, cb) {
+ pipe: sinon.stub(),
+ on: sinon.stub((type, cb) => {
if (type === 'open') {
cb()
}
- },
+ }),
})
- this.handler.uploadFileFromDisk(
- this.projectId,
- this.fileArgs,
- this.fsPath,
- err => {
- expect(err).to.exist
- expect(err).to.be.instanceof(Error)
- expect(this.fs.createReadStream.callCount).to.equal(
- this.handler.RETRY_ATTEMPTS
- )
- done()
- }
+ let error
+
+ try {
+ await this.handler.promises.uploadFileFromDisk(
+ this.projectId,
+ this.fileArgs,
+ this.fsPath
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+
+ expect(this.fs.createReadStream.callCount).to.equal(
+ this.handler.RETRY_ATTEMPTS
)
})
})
@@ -319,49 +303,40 @@ describe('FileStoreHandler', function () {
beforeEach(function () {
this.Features.hasFeature.withArgs('filestore').returns(false)
})
- it('should not open file handle', function (done) {
- this.handler.uploadFileFromDisk(
+ it('should not open file handle', async function () {
+ await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- () => {
- expect(this.fs.createReadStream).to.not.have.been.called
- done()
- }
+ this.fsPath
)
+ expect(this.fs.createReadStream).to.not.have.been.called
})
- it('should not talk to filestore', function (done) {
- this.handler.uploadFileFromDisk(
+ it('should not talk to filestore', async function () {
+ await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- () => {
- expect(this.request).to.not.have.been.called
- done()
- }
+ this.fsPath
)
+
+ expect(this.request).to.not.have.been.called
})
- it('should callback with the url and fileRef', function (done) {
+ it('should resolve with the url and fileRef', async function () {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
- this.handler.uploadFileFromDisk(
+ const { url, fileRef } = await this.handler.promises.uploadFileFromDisk(
this.projectId,
this.fileArgs,
- this.fsPath,
- (err, url, fileRef) => {
- expect(err).to.not.exist
- expect(url).to.equal(fileUrl)
- expect(fileRef._id).to.equal(this.fileId)
- expect(fileRef.hash).to.equal(this.hashValue)
- done()
- }
+ this.fsPath
)
+ expect(url).to.equal(fileUrl)
+ expect(fileRef._id).to.equal(this.fileId)
+ expect(fileRef.hash).to.equal(this.hashValue)
})
})
describe('symlink', function () {
- it('should not read file if it is symlink', function (done) {
+ it('should not read file if it is symlink', async function () {
this.fs.lstat = sinon.stub().callsArgWith(1, null, {
isFile() {
return false
@@ -371,28 +346,40 @@ describe('FileStoreHandler', function () {
},
})
- this.handler.uploadFileFromDisk(
- this.projectId,
- this.fileArgs,
- this.fsPath,
- () => {
- this.fs.createReadStream.called.should.equal(false)
- done()
- }
- )
+ let error
+
+ try {
+ await this.handler.promises.uploadFileFromDisk(
+ this.projectId,
+ this.fileArgs,
+ this.fsPath
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+
+ this.fs.createReadStream.called.should.equal(false)
})
- it('should not read file stat returns nothing', function (done) {
+ it('should not read file stat returns nothing', async function () {
this.fs.lstat = sinon.stub().callsArgWith(1, null, null)
- this.handler.uploadFileFromDisk(
- this.projectId,
- this.fileArgs,
- this.fsPath,
- () => {
- this.fs.createReadStream.called.should.equal(false)
- done()
- }
- )
+ let error
+
+ try {
+ await this.handler.promises.uploadFileFromDisk(
+ this.projectId,
+ this.fileArgs,
+ this.fsPath
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+
+ this.fs.createReadStream.called.should.equal(false)
})
})
})
@@ -410,13 +397,18 @@ describe('FileStoreHandler', function () {
})
})
- it('should return the error if there is one', function (done) {
- const error = 'my error'
- this.request.callsArgWith(1, error)
- this.handler.deleteFile(this.projectId, this.fileId, err => {
- assert.equal(err, error)
- done()
- })
+ it('should reject with the error if there is one', async function () {
+ const expectedError = 'my error'
+ this.request.callsArgWith(1, expectedError)
+ let error
+
+ try {
+ await this.handler.promises.deleteFile(this.projectId, this.fileId)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.equal(expectedError)
})
})
@@ -425,40 +417,43 @@ describe('FileStoreHandler', function () {
beforeEach(function () {
this.Features.hasFeature.withArgs('filestore').returns(true)
})
- it('should send a delete request to filestore api', function (done) {
+ it('should send a delete request to filestore api', async function () {
const projectUrl = this.getProjectUrl(this.projectId)
this.request.callsArgWith(1, null)
- this.handler.deleteProject(this.projectId, err => {
- assert.equal(err, undefined)
- this.request.args[0][0].method.should.equal('delete')
- this.request.args[0][0].uri.should.equal(projectUrl)
- done()
- })
+ await this.handler.promises.deleteProject(this.projectId)
+ this.request.args[0][0].method.should.equal('delete')
+ this.request.args[0][0].uri.should.equal(projectUrl)
})
- it('should wrap the error if there is one', function (done) {
- const error = new Error('my error')
- this.request.callsArgWith(1, error)
- this.handler.deleteProject(this.projectId, err => {
- expect(OError.getFullStack(err)).to.match(
- /something went wrong deleting a project in filestore/
- )
- expect(OError.getFullStack(err)).to.match(/my error/)
- done()
- })
+ it('should wrap the error if there is one', async function () {
+ const expectedError = new Error('my error')
+ this.request.callsArgWith(1, expectedError)
+ const promise = this.handler.promises.deleteProject(this.projectId)
+ let error
+
+ try {
+ await promise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+
+ expect(OError.getFullStack(error)).to.match(
+ /something went wrong deleting a project in filestore/
+ )
+ expect(OError.getFullStack(error)).to.match(/my error/)
})
})
describe('when filestore is disabled', function () {
beforeEach(function () {
this.Features.hasFeature.withArgs('filestore').returns(false)
})
- it('should not send a delete request to filestore api', function (done) {
- this.handler.deleteProject(this.projectId, err => {
- assert.equal(err, undefined)
- this.request.called.should.equal(false)
- done()
- })
+ it('should not send a delete request to filestore api', async function () {
+ await this.handler.promises.deleteProject(this.projectId)
+
+ this.request.called.should.equal(false)
})
})
})
@@ -469,18 +464,37 @@ describe('FileStoreHandler', function () {
this.Features.hasFeature.withArgs('filestore').returns(false)
})
- it('should callback with a NotFoundError', function (done) {
- this.handler.getFileStream(this.projectId, this.fileId, {}, err => {
- expect(err).to.be.instanceof(Errors.NotFoundError)
- done()
- })
+ it('should callback with a NotFoundError', async function () {
+ let error
+
+ try {
+ await this.handler.promises.getFileStream(
+ this.projectId,
+ this.fileId,
+ {}
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.NotFoundError)
})
- it('should not call request', function (done) {
- this.handler.getFileStream(this.projectId, this.fileId, {}, () => {
- this.request.called.should.equal(false)
- done()
- })
+ it('should not call request', async function () {
+ let error
+
+ try {
+ await this.handler.promises.getFileStream(
+ this.projectId,
+ this.fileId,
+ {}
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+ this.request.called.should.equal(false)
})
})
describe('when filestore is enabled', function () {
@@ -490,53 +504,36 @@ describe('FileStoreHandler', function () {
this.Features.hasFeature.withArgs('filestore').returns(true)
})
- it('should get the stream with the correct params', function (done) {
+ it('should get the stream with the correct params', async function () {
const fileUrl = this.getFileUrl(this.projectId, this.fileId)
- this.handler.getFileStream(
+ await this.handler.promises.getFileStream(
this.projectId,
this.fileId,
- this.query,
- (err, stream) => {
- if (err) {
- return done(err)
- }
- this.request.args[0][0].method.should.equal('get')
- this.request.args[0][0].uri.should.equal(
- fileUrl + '?from=getFileStream'
- )
- done()
- }
+ this.query
+ )
+ this.request.args[0][0].method.should.equal('get')
+ this.request.args[0][0].uri.should.equal(
+ fileUrl + '?from=getFileStream'
)
})
- it('should get stream from request', function (done) {
- this.handler.getFileStream(
+ it('should get stream from request', async function () {
+ const stream = await this.handler.promises.getFileStream(
this.projectId,
this.fileId,
- this.query,
- (err, stream) => {
- if (err) {
- return done(err)
- }
- stream.should.equal(this.readStream)
- done()
- }
+ this.query
)
+
+ stream.should.equal(this.readStream)
})
- it('should add an error handler', function (done) {
- this.handler.getFileStream(
+ it('should add an error handler', async function () {
+ const stream = await this.handler.promises.getFileStream(
this.projectId,
this.fileId,
- this.query,
- (err, stream) => {
- if (err) {
- return done(err)
- }
- stream.on.calledWith('error').should.equal(true)
- done()
- }
+ this.query
)
+ stream.on.calledWith('error').should.equal(true)
})
describe('when range is specified in query', function () {
@@ -544,22 +541,17 @@ describe('FileStoreHandler', function () {
this.query = { range: '0-10' }
})
- it('should add a range header', function (done) {
- this.handler.getFileStream(
+ it('should add a range header', async function () {
+ await this.handler.promises.getFileStream(
this.projectId,
this.fileId,
- this.query,
- (err, stream) => {
- if (err) {
- return done(err)
- }
- this.request.callCount.should.equal(1)
- const { headers } = this.request.firstCall.args[0]
- expect(headers).to.have.keys('range')
- expect(headers.range).to.equal('bytes=0-10')
- done()
- }
+ this.query
)
+
+ this.request.callCount.should.equal(1)
+ const { headers } = this.request.firstCall.args[0]
+ expect(headers).to.have.keys('range')
+ expect(headers.range).to.equal('bytes=0-10')
})
describe('when range is invalid', function () {
@@ -568,21 +560,15 @@ describe('FileStoreHandler', function () {
this.query = { range: `${r}` }
})
- it(`should not add a range header for '${r}'`, function (done) {
- this.handler.getFileStream(
+ it(`should not add a range header for '${r}'`, async function () {
+ await this.handler.promises.getFileStream(
this.projectId,
this.fileId,
- this.query,
- (err, stream) => {
- if (err) {
- return done(err)
- }
- this.request.callCount.should.equal(1)
- const { headers } = this.request.firstCall.args[0]
- expect(headers).to.not.have.keys('range')
- done()
- }
+ this.query
)
+ this.request.callCount.should.equal(1)
+ const { headers } = this.request.firstCall.args[0]
+ expect(headers).to.not.have.keys('range')
})
})
})
@@ -591,7 +577,7 @@ describe('FileStoreHandler', function () {
})
describe('getFileSize', function () {
- it('returns the file size reported by filestore', function (done) {
+ it('returns the file size reported by filestore', async function () {
const expectedFileSize = 32432
const fileUrl =
this.getFileUrl(this.projectId, this.fileId) + '?from=getFileSize'
@@ -605,40 +591,54 @@ describe('FileStoreHandler', function () {
},
})
- this.handler.getFileSize(this.projectId, this.fileId, (err, fileSize) => {
- if (err) {
- return done(err)
- }
- expect(fileSize).to.equal(expectedFileSize)
- done()
- })
+ const fileSize = await this.handler.promises.getFileSize(
+ this.projectId,
+ this.fileId
+ )
+ expect(fileSize).to.equal(expectedFileSize)
})
- it('throws a NotFoundError on a 404 from filestore', function (done) {
+ it('throws a NotFoundError on a 404 from filestore', async function () {
this.request.head.yields(null, { statusCode: 404 })
- this.handler.getFileSize(this.projectId, this.fileId, err => {
- expect(err).to.be.instanceof(Errors.NotFoundError)
- done()
- })
+ let error
+
+ try {
+ await this.handler.promises.getFileSize(this.projectId, this.fileId)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.NotFoundError)
})
- it('throws an error on a non-200 from filestore', function (done) {
+ it('throws an error on a non-200 from filestore', async function () {
this.request.head.yields(null, { statusCode: 500 })
- this.handler.getFileSize(this.projectId, this.fileId, err => {
- expect(err).to.be.instanceof(Error)
- done()
- })
+ let error
+
+ try {
+ await this.handler.promises.getFileSize(this.projectId, this.fileId)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
- it('rethrows errors from filestore', function (done) {
- this.request.head.yields(new Error())
+ it('rethrows errors from filestore', async function () {
+ const expectedError = new Error('from filestore')
+ this.request.head.yields(expectedError)
- this.handler.getFileSize(this.projectId, this.fileId, err => {
- expect(err).to.be.instanceof(Error)
- done()
- })
+ let error
+
+ try {
+ await this.handler.promises.getFileSize(this.projectId, this.fileId)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.equal(expectedError)
})
})
@@ -648,74 +648,75 @@ describe('FileStoreHandler', function () {
this.newFileId = 'new file id'
})
- it('should post json', function (done) {
+ it('should post json', async function () {
const newFileUrl = this.getFileUrl(this.newProjectId, this.newFileId)
this.request.callsArgWith(1, null, { statusCode: 200 })
- this.handler.copyFile(
+ await this.handler.promises.copyFile(
this.projectId,
this.fileId,
this.newProjectId,
- this.newFileId,
- () => {
- this.request.args[0][0].method.should.equal('put')
- this.request.args[0][0].uri.should.equal(newFileUrl)
- this.request.args[0][0].json.source.project_id.should.equal(
- this.projectId
- )
- this.request.args[0][0].json.source.file_id.should.equal(this.fileId)
- done()
- }
+ this.newFileId
)
+ this.request.args[0][0].method.should.equal('put')
+ this.request.args[0][0].uri.should.equal(newFileUrl)
+ this.request.args[0][0].json.source.project_id.should.equal(
+ this.projectId
+ )
+ this.request.args[0][0].json.source.file_id.should.equal(this.fileId)
})
- it('returns the url', function (done) {
+ it('returns the url', async function () {
const expectedUrl = this.getFileUrl(this.newProjectId, this.newFileId)
this.request.callsArgWith(1, null, { statusCode: 200 })
- this.handler.copyFile(
+ const url = await this.handler.promises.copyFile(
this.projectId,
this.fileId,
this.newProjectId,
- this.newFileId,
- (err, url) => {
- if (err) {
- return done(err)
- }
- url.should.equal(expectedUrl)
- done()
- }
+ this.newFileId
)
+
+ url.should.equal(expectedUrl)
})
- it('should return the err', function (done) {
- const error = new Error('error')
- this.request.callsArgWith(1, error)
- this.handler.copyFile(
- this.projectId,
- this.fileId,
- this.newProjectId,
- this.newFileId,
- err => {
- err.should.equal(error)
- done()
- }
- )
+ it('should return the err', async function () {
+ const expectedError = new Error('error')
+ this.request.callsArgWith(1, expectedError)
+ let error
+
+ try {
+ await this.handler.promises.copyFile(
+ this.projectId,
+ this.fileId,
+ this.newProjectId,
+ this.newFileId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.equal(expectedError)
})
- it('should return an error for a non-success statusCode', function (done) {
+ it('should return an error for a non-success statusCode', async function () {
this.request.callsArgWith(1, null, { statusCode: 500 })
- this.handler.copyFile(
- this.projectId,
- this.fileId,
- this.newProjectId,
- this.newFileId,
- err => {
- err.should.be.an('error')
- err.message.should.equal(
- 'non-ok response from filestore for copyFile: 500'
- )
- done()
- }
+ let error
+
+ try {
+ await this.handler.promises.copyFile(
+ this.projectId,
+ this.fileId,
+ this.newProjectId,
+ this.newFileId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ 'non-ok response from filestore for copyFile: 500'
)
})
})
diff --git a/services/web/test/unit/src/History/HistoryControllerTests.js b/services/web/test/unit/src/History/HistoryControllerTests.js
index 8f37e6d258..f575859073 100644
--- a/services/web/test/unit/src/History/HistoryControllerTests.js
+++ b/services/web/test/unit/src/History/HistoryControllerTests.js
@@ -1,16 +1,6 @@
-/* eslint-disable
- max-len,
- no-return-assign,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
const sinon = require('sinon')
const { expect } = require('chai')
+const { RequestFailedError } = require('@overleaf/fetch-utils')
const Errors = require('../../../../app/src/Features/Errors/Errors')
@@ -22,25 +12,59 @@ describe('HistoryController', function () {
beforeEach(function () {
this.callback = sinon.stub()
this.user_id = 'user-id-123'
+ this.project_id = 'mock-project-id'
+ this.stream = sinon.stub()
+ this.fetchResponse = {
+ headers: {
+ get: sinon.stub(),
+ },
+ }
+ this.next = sinon.stub()
+
this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.user_id),
}
+
this.Stream = {
- pipeline: sinon.stub(),
+ pipeline: sinon.stub().resolves(),
}
+
+ this.HistoryManager = {
+ promises: {
+ injectUserDetails: sinon.stub(),
+ },
+ }
+
+ this.ProjectEntityUpdateHandler = {
+ promises: {
+ resyncProjectHistory: sinon.stub().resolves(),
+ },
+ }
+
+ this.fetchJson = sinon.stub()
+ this.fetchStream = sinon.stub().resolves(this.stream)
+ this.fetchStreamWithResponse = sinon
+ .stub()
+ .resolves({ stream: this.stream, response: this.fetchResponse })
+ this.fetchNothing = sinon.stub().resolves()
+
this.HistoryController = SandboxedModule.require(modulePath, {
requires: {
- request: (this.request = sinon.stub()),
+ 'stream/promises': this.Stream,
'@overleaf/settings': (this.settings = {}),
- '@overleaf/fetch-utils': {},
+ '@overleaf/fetch-utils': {
+ fetchJson: this.fetchJson,
+ fetchStream: this.fetchStream,
+ fetchStreamWithResponse: this.fetchStreamWithResponse,
+ fetchNothing: this.fetchNothing,
+ },
'@overleaf/Metrics': {},
'../../infrastructure/mongodb': { ObjectId },
- stream: this.Stream,
'../Authentication/SessionManager': this.SessionManager,
- './HistoryManager': (this.HistoryManager = {}),
+ './HistoryManager': this.HistoryManager,
'../Project/ProjectDetailsHandler': (this.ProjectDetailsHandler = {}),
'../Project/ProjectEntityUpdateHandler':
- (this.ProjectEntityUpdateHandler = {}),
+ this.ProjectEntityUpdateHandler,
'../User/UserGetter': (this.UserGetter = {}),
'../Project/ProjectGetter': (this.ProjectGetter = {}),
'./RestoreManager': (this.RestoreManager = {}),
@@ -50,172 +74,125 @@ describe('HistoryController', function () {
.returns(true)),
},
})
- return (this.settings.apis = {
+ this.settings.apis = {
project_history: {
url: 'http://project_history.example.com',
},
- })
+ }
})
describe('proxyToHistoryApi', function () {
- beforeEach(function () {
- this.req = { url: '/mock/url', method: 'POST' }
- this.res = 'mock-res'
- this.next = sinon.stub()
- this.proxy = sinon.stub()
- this.request.returns(this.proxy)
+ beforeEach(async function () {
+ this.req = { url: '/mock/url', method: 'POST', session: sinon.stub() }
+ this.res = {
+ set: sinon.stub(),
+ }
+ this.contentType = 'application/json'
+ this.contentLength = 212
+ this.fetchResponse.headers.get
+ .withArgs('Content-Type')
+ .returns(this.contentType)
+ this.fetchResponse.headers.get
+ .withArgs('Content-Length')
+ .returns(this.contentLength)
+ await this.HistoryController.proxyToHistoryApi(
+ this.req,
+ this.res,
+ this.next
+ )
})
- describe('for a project with the project history flag', function () {
- beforeEach(function () {
- this.req.useProjectHistory = true
- return this.HistoryController.proxyToHistoryApi(
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should get the user id', function () {
- return this.SessionManager.getLoggedInUserId
- .calledWith(this.req.session)
- .should.equal(true)
- })
-
- it('should call the project history api', function () {
- return this.request
- .calledWith({
- url: `${this.settings.apis.project_history.url}${this.req.url}`,
- method: this.req.method,
- headers: {
- 'X-User-Id': this.user_id,
- },
- })
- .should.equal(true)
- })
-
- it('should pipe the response to the client', function () {
- expect(this.Stream.pipeline).to.have.been.calledWith(
- this.proxy,
- this.res
- )
- })
+ it('should get the user id', function () {
+ this.SessionManager.getLoggedInUserId.should.have.been.calledWith(
+ this.req.session
+ )
})
- describe('for a project without the project history flag', function () {
- beforeEach(function () {
- this.req.useProjectHistory = false
- return this.HistoryController.proxyToHistoryApi(
- this.req,
- this.res,
- this.next
- )
- })
+ it('should call the project history api', function () {
+ this.fetchStreamWithResponse.should.have.been.calledWith(
+ `${this.settings.apis.project_history.url}${this.req.url}`,
+ {
+ method: this.req.method,
+ headers: {
+ 'X-User-Id': this.user_id,
+ },
+ }
+ )
+ })
- it('should get the user id', function () {
- return this.SessionManager.getLoggedInUserId
- .calledWith(this.req.session)
- .should.equal(true)
- })
+ it('should pipe the response to the client', function () {
+ expect(this.Stream.pipeline).to.have.been.calledWith(
+ this.stream,
+ this.res
+ )
+ })
- it('should pipe the response to the client', function () {
- expect(this.Stream.pipeline).to.have.been.calledWith(
- this.proxy,
- this.res
- )
- })
+ it('should propagate the appropriate headers', function () {
+ expect(this.res.set).to.have.been.calledWith(
+ 'Content-Type',
+ this.contentType
+ )
+ expect(this.res.set).to.have.been.calledWith(
+ 'Content-Length',
+ this.contentLength
+ )
})
})
describe('proxyToHistoryApiAndInjectUserDetails', function () {
- beforeEach(function () {
+ beforeEach(async function () {
this.req = { url: '/mock/url', method: 'POST' }
this.res = { json: sinon.stub() }
- this.next = sinon.stub()
- this.request.yields(null, { statusCode: 200 }, (this.data = 'mock-data'))
- return (this.HistoryManager.injectUserDetails = sinon
- .stub()
- .yields(null, (this.data_with_users = 'mock-injected-data')))
+ this.data = 'mock-data'
+ this.dataWithUsers = 'mock-injected-data'
+ this.fetchJson.resolves(this.data)
+ this.HistoryManager.promises.injectUserDetails.resolves(
+ this.dataWithUsers
+ )
+ await this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
+ this.req,
+ this.res,
+ this.next
+ )
})
- describe('for a project with the project history flag', function () {
- beforeEach(function () {
- this.req.useProjectHistory = true
- return this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should get the user id', function () {
- return this.SessionManager.getLoggedInUserId
- .calledWith(this.req.session)
- .should.equal(true)
- })
-
- it('should call the project history api', function () {
- return this.request
- .calledWith({
- url: `${this.settings.apis.project_history.url}${this.req.url}`,
- method: this.req.method,
- json: true,
- headers: {
- 'X-User-Id': this.user_id,
- },
- })
- .should.equal(true)
- })
-
- it('should inject the user data', function () {
- return this.HistoryManager.injectUserDetails
- .calledWith(this.data)
- .should.equal(true)
- })
-
- it('should return the data with users to the client', function () {
- return this.res.json.calledWith(this.data_with_users).should.equal(true)
- })
+ it('should get the user id', function () {
+ this.SessionManager.getLoggedInUserId.should.have.been.calledWith(
+ this.req.session
+ )
})
- describe('for a project without the project history flag', function () {
- beforeEach(function () {
- this.req.useProjectHistory = false
- return this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
- this.req,
- this.res,
- this.next
- )
- })
+ it('should call the project history api', function () {
+ this.fetchJson.should.have.been.calledWith(
+ `${this.settings.apis.project_history.url}${this.req.url}`,
+ {
+ method: this.req.method,
+ headers: {
+ 'X-User-Id': this.user_id,
+ },
+ }
+ )
+ })
- it('should get the user id', function () {
- return this.SessionManager.getLoggedInUserId
- .calledWith(this.req.session)
- .should.equal(true)
- })
+ it('should inject the user data', function () {
+ this.HistoryManager.promises.injectUserDetails.should.have.been.calledWith(
+ this.data
+ )
+ })
- it('should inject the user data', function () {
- return this.HistoryManager.injectUserDetails
- .calledWith(this.data)
- .should.equal(true)
- })
-
- it('should return the data with users to the client', function () {
- return this.res.json.calledWith(this.data_with_users).should.equal(true)
- })
+ it('should return the data with users to the client', function () {
+ this.res.json.should.have.been.calledWith(this.dataWithUsers)
})
})
describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () {
- beforeEach(function () {
- this.req = { url: '/mock/url', method: 'POST', useProjectHistory: true }
+ beforeEach(async function () {
+ this.url = '/mock/url'
+ this.req = { url: this.url, method: 'POST' }
this.res = { json: sinon.stub() }
- this.next = sinon.stub()
- this.request.yields(null, { statusCode: 500 }, (this.data = 'mock-data'))
- this.HistoryManager.injectUserDetails = sinon
- .stub()
- .yields(null, (this.data_with_users = 'mock-injected-data'))
- return this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
+ this.err = new RequestFailedError(this.url, {}, { status: 500 })
+ this.fetchJson.rejects(this.err)
+ await this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
this.req,
this.res,
this.next
@@ -223,30 +200,30 @@ describe('HistoryController', function () {
})
it('should not inject the user data', function () {
- return this.HistoryManager.injectUserDetails
- .calledWith(this.data)
- .should.equal(false)
+ this.HistoryManager.promises.injectUserDetails.should.not.have.been.called
})
it('should not return the data with users to the client', function () {
- return this.res.json.calledWith(this.data_with_users).should.equal(false)
+ this.res.json.should.not.have.been.called
+ })
+
+ it('should throw an error', function () {
+ this.next.should.have.been.calledWith(this.err)
})
})
describe('resyncProjectHistory', function () {
describe('for a project without project-history enabled', function () {
- beforeEach(function () {
- this.project_id = 'mock-project-id'
+ beforeEach(async function () {
this.req = { params: { Project_id: this.project_id }, body: {} }
this.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
- this.next = sinon.stub()
this.error = new Errors.ProjectHistoryDisabledError()
- this.ProjectEntityUpdateHandler.resyncProjectHistory = sinon
- .stub()
- .yields(this.error)
+ this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects(
+ this.error
+ )
- return this.HistoryController.resyncProjectHistory(
+ await this.HistoryController.resyncProjectHistory(
this.req,
this.res,
this.next
@@ -254,22 +231,16 @@ describe('HistoryController', function () {
})
it('response with a 404', function () {
- return this.res.sendStatus.calledWith(404).should.equal(true)
+ this.res.sendStatus.should.have.been.calledWith(404)
})
})
describe('for a project with project-history enabled', function () {
- beforeEach(function () {
- this.project_id = 'mock-project-id'
+ beforeEach(async function () {
this.req = { params: { Project_id: this.project_id }, body: {} }
this.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
- this.next = sinon.stub()
- this.ProjectEntityUpdateHandler.resyncProjectHistory = sinon
- .stub()
- .yields()
-
- return this.HistoryController.resyncProjectHistory(
+ await this.HistoryController.resyncProjectHistory(
this.req,
this.res,
this.next
@@ -281,13 +252,13 @@ describe('HistoryController', function () {
})
it('resyncs the project', function () {
- return this.ProjectEntityUpdateHandler.resyncProjectHistory
- .calledWith(this.project_id)
- .should.equal(true)
+ this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith(
+ this.project_id
+ )
})
it('responds with a 204', function () {
- return this.res.sendStatus.calledWith(204).should.equal(true)
+ this.res.sendStatus.should.have.been.calledWith(204)
})
})
})
diff --git a/services/web/test/unit/src/History/RestoreManagerTests.js b/services/web/test/unit/src/History/RestoreManagerTests.js
index d4bb9fc9ff..2474425bfb 100644
--- a/services/web/test/unit/src/History/RestoreManagerTests.js
+++ b/services/web/test/unit/src/History/RestoreManagerTests.js
@@ -81,7 +81,7 @@ describe('RestoreManager', function () {
it('should find the root folder', function () {
this.RestoreManager.promises._findOrCreateFolder
- .calledWith(this.project_id, '')
+ .calledWith(this.project_id, '', this.user_id)
.should.equal(true)
})
@@ -116,7 +116,7 @@ describe('RestoreManager', function () {
it('should find the folder', function () {
this.RestoreManager.promises._findOrCreateFolder
- .calledWith(this.project_id, 'foo')
+ .calledWith(this.project_id, 'foo', this.user_id)
.should.equal(true)
})
@@ -143,13 +143,14 @@ describe('RestoreManager', function () {
})
this.result = await this.RestoreManager.promises._findOrCreateFolder(
this.project_id,
- 'folder/name'
+ 'folder/name',
+ this.user_id
)
})
it('should look up or create the folder', function () {
this.EditorController.promises.mkdirp
- .calledWith(this.project_id, 'folder/name')
+ .calledWith(this.project_id, 'folder/name', this.user_id)
.should.equal(true)
})
diff --git a/services/web/test/unit/src/Institutions/InstitutionsAPITests.js b/services/web/test/unit/src/Institutions/InstitutionsAPITests.js
index eb98115ec2..f3458f51f7 100644
--- a/services/web/test/unit/src/Institutions/InstitutionsAPITests.js
+++ b/services/web/test/unit/src/Institutions/InstitutionsAPITests.js
@@ -45,48 +45,43 @@ describe('InstitutionsAPI', function () {
})
describe('getInstitutionAffiliations', function () {
- it('get affiliations', function (done) {
+ it('get affiliations', async function () {
this.institutionId = 123
const responseBody = ['123abc', '456def']
this.request.yields(null, { statusCode: 200 }, responseBody)
- this.InstitutionsAPI.getInstitutionAffiliations(
- this.institutionId,
- (err, body) => {
- expect(err).not.to.exist
- this.request.calledOnce.should.equal(true)
- const requestOptions = this.request.lastCall.args[0]
- const expectedUrl = `v1.url/api/v2/institutions/${this.institutionId}/affiliations`
- requestOptions.url.should.equal(expectedUrl)
- requestOptions.method.should.equal('GET')
- requestOptions.maxAttempts.should.exist
- requestOptions.maxAttempts.should.not.equal(0)
- requestOptions.retryDelay.should.exist
- expect(requestOptions.body).not.to.exist
- body.should.equal(responseBody)
- done()
- }
- )
+ const body =
+ await this.InstitutionsAPI.promises.getInstitutionAffiliations(
+ this.institutionId
+ )
+
+ this.request.calledOnce.should.equal(true)
+ const requestOptions = this.request.lastCall.args[0]
+ const expectedUrl = `v1.url/api/v2/institutions/${this.institutionId}/affiliations`
+ requestOptions.url.should.equal(expectedUrl)
+ requestOptions.method.should.equal('GET')
+ requestOptions.maxAttempts.should.exist
+ requestOptions.maxAttempts.should.not.equal(0)
+ requestOptions.retryDelay.should.exist
+ expect(requestOptions.body).not.to.exist
+ body.should.equal(responseBody)
})
- it('handle empty response', function (done) {
+ it('handle empty response', async function () {
this.settings.apis.v1.url = ''
- this.InstitutionsAPI.getInstitutionAffiliations(
- this.institutionId,
- (err, body) => {
- expect(err).not.to.exist
- expect(body).to.be.a('Array')
- body.length.should.equal(0)
- done()
- }
- )
+ const body =
+ await this.InstitutionsAPI.promises.getInstitutionAffiliations(
+ this.institutionId
+ )
+ expect(body).to.be.a('Array')
+ body.length.should.equal(0)
})
})
describe('getLicencesForAnalytics', function () {
const lag = 'daily'
const queryDate = '2017-01-07:00:00.000Z'
- it('should send the request to v1', function (done) {
+ it('should send the request to v1', async function () {
const v1Result = {
lag: 'daily',
date: queryDate,
@@ -96,100 +91,91 @@ describe('InstitutionsAPI', function () {
},
}
this.request.callsArgWith(1, null, { statusCode: 201 }, v1Result)
- this.InstitutionsAPI.getLicencesForAnalytics(
+ await this.InstitutionsAPI.promises.getLicencesForAnalytics(
lag,
- queryDate,
- (error, result) => {
- expect(error).not.to.exist
- const requestOptions = this.request.lastCall.args[0]
- expect(requestOptions.body.query_date).to.equal(queryDate)
- expect(requestOptions.body.lag).to.equal(lag)
- requestOptions.method.should.equal('GET')
- done()
- }
+ queryDate
)
+ const requestOptions = this.request.lastCall.args[0]
+ expect(requestOptions.body.query_date).to.equal(queryDate)
+ expect(requestOptions.body.lag).to.equal(lag)
+ requestOptions.method.should.equal('GET')
})
- it('should handle errors', function (done) {
+ it('should handle errors', async function () {
this.request.callsArgWith(1, null, { statusCode: 500 })
- this.InstitutionsAPI.getLicencesForAnalytics(
- lag,
- queryDate,
- (error, result) => {
- expect(error).to.be.instanceof(Errors.V1ConnectionError)
- done()
- }
- )
+ let error
+
+ try {
+ await this.InstitutionsAPI.promises.getLicencesForAnalytics(
+ lag,
+ queryDate
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.V1ConnectionError)
})
})
describe('getUserAffiliations', function () {
- it('get affiliations', function (done) {
+ it('get affiliations', async function () {
const responseBody = [{ foo: 'bar' }]
this.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
- this.InstitutionsAPI.getUserAffiliations(
- this.stubbedUser._id,
- (err, body) => {
- expect(err).not.to.exist
- this.request.calledOnce.should.equal(true)
- const requestOptions = this.request.lastCall.args[0]
- const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
- requestOptions.url.should.equal(expectedUrl)
- requestOptions.method.should.equal('GET')
- requestOptions.maxAttempts.should.equal(3)
- expect(requestOptions.body).not.to.exist
- body.should.equal(responseBody)
- done()
- }
+ const body = await this.InstitutionsAPI.promises.getUserAffiliations(
+ this.stubbedUser._id
)
+ this.request.calledOnce.should.equal(true)
+ const requestOptions = this.request.lastCall.args[0]
+ const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
+ requestOptions.url.should.equal(expectedUrl)
+ requestOptions.method.should.equal('GET')
+ requestOptions.maxAttempts.should.equal(3)
+ expect(requestOptions.body).not.to.exist
+ body.should.equal(responseBody)
})
- it('handle error', function (done) {
+ it('handle error', async function () {
const body = { errors: 'affiliation error message' }
this.request.callsArgWith(1, null, { statusCode: 503 }, body)
- this.InstitutionsAPI.getUserAffiliations(this.stubbedUser._id, err => {
- expect(err).to.be.instanceof(Errors.V1ConnectionError)
- done()
- })
+ let error
+
+ try {
+ await this.InstitutionsAPI.promises.getUserAffiliations(
+ this.stubbedUser._id
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.V1ConnectionError)
})
- it('handle empty response', function (done) {
+ it('handle empty response', async function () {
this.settings.apis.v1.url = ''
- this.InstitutionsAPI.getUserAffiliations(
- this.stubbedUser._id,
- (err, body) => {
- expect(err).not.to.exist
- expect(body).to.be.a('Array')
- body.length.should.equal(0)
- done()
- }
+ const body = await this.InstitutionsAPI.promises.getUserAffiliations(
+ this.stubbedUser._id
)
+ expect(body).to.be.a('Array')
+ body.length.should.equal(0)
})
})
describe('getUsersNeedingReconfirmationsLapsedProcessed', function () {
- it('get the list of users', function (done) {
+ it('get the list of users', async function () {
this.fetchJson.resolves({ statusCode: 200 })
- this.InstitutionsAPI.getUsersNeedingReconfirmationsLapsedProcessed(
- error => {
- expect(error).not.to.exist
- this.fetchJson.calledOnce.should.equal(true)
- const requestOptions = this.fetchJson.lastCall.args[1]
- const expectedUrl = `v1.url/api/v2/institutions/need_reconfirmation_lapsed_processed`
- this.fetchJson.lastCall.args[0].should.equal(expectedUrl)
- requestOptions.method.should.equal('GET')
- done()
- }
- )
+ await this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
+ this.fetchJson.calledOnce.should.equal(true)
+ const requestOptions = this.fetchJson.lastCall.args[1]
+ const expectedUrl = `v1.url/api/v2/institutions/need_reconfirmation_lapsed_processed`
+ this.fetchJson.lastCall.args[0].should.equal(expectedUrl)
+ requestOptions.method.should.equal('GET')
})
- it('handle error', function (done) {
+ it('handle error', async function () {
this.fetchJson.throws({ info: { statusCode: 500 } })
- this.InstitutionsAPI.getUsersNeedingReconfirmationsLapsedProcessed(
- error => {
- expect(error).to.exist
- done()
- }
- )
+ await expect(
+ this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
+ ).to.be.rejected
})
})
@@ -198,7 +184,7 @@ describe('InstitutionsAPI', function () {
this.fetchNothing.resolves({ status: 201 })
})
- it('add affiliation', function (done) {
+ it('add affiliation', async function () {
const affiliationOptions = {
university: { id: 1 },
department: 'Math',
@@ -206,95 +192,104 @@ describe('InstitutionsAPI', function () {
confirmedAt: new Date(),
entitlement: true,
}
- this.InstitutionsAPI.addAffiliation(
+ await this.InstitutionsAPI.promises.addAffiliation(
this.stubbedUser._id,
this.newEmail,
- affiliationOptions,
- err => {
- expect(err).not.to.exist
- this.fetchNothing.calledOnce.should.equal(true)
- const requestOptions = this.fetchNothing.lastCall.args[1]
- const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
- expect(this.fetchNothing.lastCall.args[0]).to.equal(expectedUrl)
- requestOptions.method.should.equal('POST')
-
- const { json } = requestOptions
- Object.keys(json).length.should.equal(7)
- expect(json).to.deep.equal(
- Object.assign(
- { email: this.newEmail, rejectIfBlocklisted: undefined },
- affiliationOptions
- )
- )
- this.markAsReadIpMatcher.calledOnce.should.equal(true)
- done()
- }
+ affiliationOptions
)
+ this.fetchNothing.calledOnce.should.equal(true)
+ const requestOptions = this.fetchNothing.lastCall.args[1]
+ const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
+ expect(this.fetchNothing.lastCall.args[0]).to.equal(expectedUrl)
+ requestOptions.method.should.equal('POST')
+
+ const { json } = requestOptions
+ Object.keys(json).length.should.equal(7)
+ expect(json).to.deep.equal(
+ Object.assign(
+ { email: this.newEmail, rejectIfBlocklisted: undefined },
+ affiliationOptions
+ )
+ )
+ this.markAsReadIpMatcher.calledOnce.should.equal(true)
})
- it('handles 422 error', function (done) {
+ it('handles 422 error', async function () {
const messageFromApi = 'affiliation error message'
const body = JSON.stringify({ errors: messageFromApi })
this.fetchNothing.throws({ response: { status: 422 }, body })
- this.InstitutionsAPI.addAffiliation(
- this.stubbedUser._id,
- this.newEmail,
- {},
- err => {
- expect(err).to.exist
- expect(err).to.be.instanceOf(Errors.InvalidInstitutionalEmailError)
- err.message.should.have.string(422)
- err.message.should.have.string(messageFromApi)
- done()
- }
- )
+ let error
+
+ try {
+ await this.InstitutionsAPI.promises.addAffiliation(
+ this.stubbedUser._id,
+ this.newEmail,
+ {}
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidInstitutionalEmailError)
+ expect(error).to.have.property('message', `422: ${messageFromApi}`)
})
- it('handles 500 error', function (done) {
+ it('handles 500 error', async function () {
const body = { errors: 'affiliation error message' }
this.fetchNothing.throws({ response: { status: 500 }, body })
- this.InstitutionsAPI.addAffiliation(
- this.stubbedUser._id,
- this.newEmail,
- {},
- err => {
- expect(err).to.be.instanceOf(Errors.V1ConnectionError)
- expect(err.message).to.equal('error getting affiliations from v1')
- expect(err.info).to.deep.equal({ status: 500, body })
- done()
- }
- )
+ let error
+
+ try {
+ await this.InstitutionsAPI.promises.addAffiliation(
+ this.stubbedUser._id,
+ this.newEmail,
+ {}
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.V1ConnectionError)
+ expect(error.message).to.equal('error getting affiliations from v1')
+ expect(error.info).to.eql({
+ status: 500,
+ body: { errors: 'affiliation error message' },
+ })
})
- it('uses default error message when no error body in response', function (done) {
+ it('uses default error message when no error body in response', async function () {
this.fetchNothing.throws({ response: { status: 429 } })
- this.InstitutionsAPI.addAffiliation(
- this.stubbedUser._id,
- this.newEmail,
- {},
- err => {
- expect(err).to.exist
- expect(err.message).to.equal("Couldn't create affiliation: 429")
- done()
- }
+ let error
+
+ try {
+ await this.InstitutionsAPI.promises.addAffiliation(
+ this.stubbedUser._id,
+ this.newEmail,
+ {}
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
+ expect(error).to.have.property(
+ 'message',
+ "Couldn't create affiliation: 429"
)
})
- it('does not try to mark IP matcher notifications as read if no university passed', function (done) {
+ it('does not try to mark IP matcher notifications as read if no university passed', async function () {
const affiliationOptions = {
confirmedAt: new Date(),
}
- this.InstitutionsAPI.addAffiliation(
+ await this.InstitutionsAPI.promises.addAffiliation(
this.stubbedUser._id,
this.newEmail,
- affiliationOptions,
- err => {
- expect(err).not.to.exist
- expect(this.markAsReadIpMatcher.callCount).to.equal(0)
- done()
- }
+ affiliationOptions
)
+
+ expect(this.markAsReadIpMatcher.callCount).to.equal(0)
})
})
@@ -303,58 +298,64 @@ describe('InstitutionsAPI', function () {
this.fetchNothing.throws({ response: { status: 404 } })
})
- it('remove affiliation', function (done) {
- this.InstitutionsAPI.removeAffiliation(
+ it('remove affiliation', async function () {
+ await this.InstitutionsAPI.promises.removeAffiliation(
this.stubbedUser._id,
- this.newEmail,
- err => {
- expect(err).not.to.exist
- this.fetchNothing.calledOnce.should.equal(true)
- const requestOptions = this.fetchNothing.lastCall.args[1]
- const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/remove`
- this.fetchNothing.lastCall.args[0].should.equal(expectedUrl)
- requestOptions.method.should.equal('POST')
- expect(requestOptions.json).to.deep.equal({ email: this.newEmail })
- done()
- }
+ this.newEmail
)
+ this.fetchNothing.calledOnce.should.equal(true)
+ const requestOptions = this.fetchNothing.lastCall.args[1]
+ const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/remove`
+ this.fetchNothing.lastCall.args[0].should.equal(expectedUrl)
+ requestOptions.method.should.equal('POST')
+ expect(requestOptions.json).to.deep.equal({ email: this.newEmail })
})
- it('handle error', function (done) {
+ it('handle error', async function () {
this.fetchNothing.throws({ response: { status: 500 } })
- this.InstitutionsAPI.removeAffiliation(
- this.stubbedUser._id,
- this.newEmail,
- err => {
- expect(err).to.exist
- err.message.should.exist
- done()
- }
- )
+ let error
+
+ try {
+ await this.InstitutionsAPI.promises.removeAffiliation(
+ this.stubbedUser._id,
+ this.newEmail
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+ expect(error).to.have.property('message')
})
})
describe('deleteAffiliations', function () {
- it('delete affiliations', function (done) {
+ it('delete affiliations', async function () {
this.request.callsArgWith(1, null, { statusCode: 200 })
- this.InstitutionsAPI.deleteAffiliations(this.stubbedUser._id, err => {
- expect(err).not.to.exist
- this.request.calledOnce.should.equal(true)
- const requestOptions = this.request.lastCall.args[0]
- const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
- requestOptions.url.should.equal(expectedUrl)
- requestOptions.method.should.equal('DELETE')
- done()
- })
+ await this.InstitutionsAPI.promises.deleteAffiliations(
+ this.stubbedUser._id
+ )
+ this.request.calledOnce.should.equal(true)
+ const requestOptions = this.request.lastCall.args[0]
+ const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
+ requestOptions.url.should.equal(expectedUrl)
+ requestOptions.method.should.equal('DELETE')
})
- it('handle error', function (done) {
+ it('handle error', async function () {
const body = { errors: 'affiliation error message' }
this.request.callsArgWith(1, null, { statusCode: 518 }, body)
- this.InstitutionsAPI.deleteAffiliations(this.stubbedUser._id, err => {
- expect(err).to.be.instanceof(Errors.V1ConnectionError)
- done()
- })
+ let error
+
+ try {
+ await this.InstitutionsAPI.promises.deleteAffiliations(
+ this.stubbedUser._id
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.V1ConnectionError)
})
})
@@ -363,61 +364,57 @@ describe('InstitutionsAPI', function () {
this.request.callsArgWith(1, null, { statusCode: 204 })
})
- it('endorse affiliation', function (done) {
- this.InstitutionsAPI.endorseAffiliation(
+ it('endorse affiliation', async function () {
+ await this.InstitutionsAPI.promises.endorseAffiliation(
this.stubbedUser._id,
this.newEmail,
'Student',
- 'Physics',
- err => {
- expect(err).not.to.exist
- this.request.calledOnce.should.equal(true)
- const requestOptions = this.request.lastCall.args[0]
- const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/endorse`
- requestOptions.url.should.equal(expectedUrl)
- requestOptions.method.should.equal('POST')
-
- const { body } = requestOptions
- Object.keys(body).length.should.equal(3)
- body.email.should.equal(this.newEmail)
- body.role.should.equal('Student')
- body.department.should.equal('Physics')
- done()
- }
+ 'Physics'
)
+ this.request.calledOnce.should.equal(true)
+ const requestOptions = this.request.lastCall.args[0]
+ const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/endorse`
+ requestOptions.url.should.equal(expectedUrl)
+ requestOptions.method.should.equal('POST')
+
+ const { body } = requestOptions
+ Object.keys(body).length.should.equal(3)
+ body.email.should.equal(this.newEmail)
+ body.role.should.equal('Student')
+ body.department.should.equal('Physics')
})
})
describe('sendUsersWithReconfirmationsLapsedProcessed', function () {
const users = ['abc123', 'def456']
- it('sends the list of users', function (done) {
+ it('sends the list of users', async function () {
this.request.callsArgWith(1, null, { statusCode: 200 })
- this.InstitutionsAPI.sendUsersWithReconfirmationsLapsedProcessed(
- users,
- error => {
- expect(error).not.to.exist
- this.request.calledOnce.should.equal(true)
- const requestOptions = this.request.lastCall.args[0]
- const expectedUrl =
- 'v1.url/api/v2/institutions/reconfirmation_lapsed_processed'
- requestOptions.url.should.equal(expectedUrl)
- requestOptions.method.should.equal('POST')
- expect(requestOptions.body).to.deep.equal({ users })
- done()
- }
+ await this.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
+ users
)
+ this.request.calledOnce.should.equal(true)
+ const requestOptions = this.request.lastCall.args[0]
+ const expectedUrl =
+ 'v1.url/api/v2/institutions/reconfirmation_lapsed_processed'
+ requestOptions.url.should.equal(expectedUrl)
+ requestOptions.method.should.equal('POST')
+ expect(requestOptions.body).to.deep.equal({ users })
})
- it('handle error', function (done) {
+ it('handle error', async function () {
this.request.callsArgWith(1, null, { statusCode: 500 })
- this.InstitutionsAPI.sendUsersWithReconfirmationsLapsedProcessed(
- users,
- error => {
- expect(error).to.exist
- done()
- }
- )
+ let error
+
+ try {
+ await this.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
+ users
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
})
})
})
diff --git a/services/web/test/unit/src/Institutions/InstitutionsFeaturesTests.js b/services/web/test/unit/src/Institutions/InstitutionsFeaturesTests.js
index 69def3076f..6f4a1e9715 100644
--- a/services/web/test/unit/src/Institutions/InstitutionsFeaturesTests.js
+++ b/services/web/test/unit/src/Institutions/InstitutionsFeaturesTests.js
@@ -1,17 +1,4 @@
-/* eslint-disable
- max-len,
- no-return-assign,
- no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
const SandboxedModule = require('sandboxed-module')
-const assert = require('assert')
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = require('path').join(
@@ -41,45 +28,39 @@ describe('InstitutionsFeatures', function () {
})
describe('hasLicence', function () {
- it('should handle error', function (done) {
+ it('should handle error', async function () {
this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
- return this.InstitutionsFeatures.hasLicence(
- this.userId,
- (error, hasLicence) => {
- expect(error).to.exist
- return done()
- }
- )
+ let error
+
+ try {
+ await this.InstitutionsFeatures.promises.hasLicence(this.userId)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
})
- it('should return false if user has no paid affiliations', function (done) {
+ it('should return false if user has no paid affiliations', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithoutLicense
)
- return this.InstitutionsFeatures.hasLicence(
- this.userId,
- (error, hasLicence) => {
- expect(error).to.not.exist
- expect(hasLicence).to.be.false
- return done()
- }
+ const hasLicence = await this.InstitutionsFeatures.promises.hasLicence(
+ this.userId
)
+ expect(hasLicence).to.be.false
})
- it('should return true if user has confirmed paid affiliation', function (done) {
+ it('should return true if user has confirmed paid affiliation', async function () {
const emailData = [
{ emailHasInstitutionLicence: true },
{ emailHasInstitutionLicence: false },
]
this.UserGetter.promises.getUserFullEmails.resolves(emailData)
- return this.InstitutionsFeatures.hasLicence(
- this.userId,
- (error, hasLicence) => {
- expect(error).to.not.exist
- expect(hasLicence).to.be.true
- return done()
- }
+ const hasLicence = await this.InstitutionsFeatures.promises.hasLicence(
+ this.userId
)
+ expect(hasLicence).to.be.true
})
})
@@ -91,84 +72,62 @@ describe('InstitutionsFeatures', function () {
.returns(this.testFeatures)
})
- it('should handle error', function (done) {
+ it('should handle error', async function () {
this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
- return this.InstitutionsFeatures.getInstitutionsFeatures(
- this.userId,
- (error, features) => {
- expect(error).to.exist
- return done()
- }
- )
+ await expect(
+ this.InstitutionsFeatures.promises.getInstitutionsFeatures(this.userId)
+ ).to.be.rejected
})
- it('should return no feaures if user has no plan code', function (done) {
+ it('should return no feaures if user has no plan code', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithoutLicense
)
- return this.InstitutionsFeatures.getInstitutionsFeatures(
- this.userId,
- (error, features) => {
- expect(error).to.not.exist
- expect(features).to.deep.equal({})
- return done()
- }
- )
+ const features =
+ await this.InstitutionsFeatures.promises.getInstitutionsFeatures(
+ this.userId
+ )
+ expect(features).to.deep.equal({})
})
- it('should return feaures if user has affiliations plan code', function (done) {
+ it('should return feaures if user has affiliations plan code', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithLicense
)
- return this.InstitutionsFeatures.getInstitutionsFeatures(
- this.userId,
- (error, features) => {
- expect(error).to.not.exist
- expect(features).to.deep.equal(this.testFeatures.features)
- return done()
- }
- )
+ const features =
+ await this.InstitutionsFeatures.promises.getInstitutionsFeatures(
+ this.userId
+ )
+ expect(features).to.deep.equal(this.testFeatures.features)
})
})
describe('getInstitutionsPlan', function () {
- it('should handle error', function (done) {
+ it('should handle error', async function () {
this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
- return this.InstitutionsFeatures.getInstitutionsPlan(
- this.userId,
- error => {
- expect(error).to.exist
- return done()
- }
- )
+ await expect(
+ this.InstitutionsFeatures.promises.getInstitutionsPlan(this.userId)
+ ).to.be.rejected
})
- it('should return no plan if user has no licence', function (done) {
+ it('should return no plan if user has no licence', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithoutLicense
)
- return this.InstitutionsFeatures.getInstitutionsPlan(
- this.userId,
- (error, plan) => {
- expect(error).to.not.exist
- expect(plan).to.equal(null)
- return done()
- }
+ const plan = await this.InstitutionsFeatures.promises.getInstitutionsPlan(
+ this.userId
)
+ expect(plan).to.equal(null)
})
- it('should return plan if user has licence', function (done) {
+ it('should return plan if user has licence', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.emailDataWithLicense
)
- return this.InstitutionsFeatures.getInstitutionsPlan(
- this.userId,
- (error, plan) => {
- expect(error).to.not.exist
- expect(plan).to.equal(this.institutionPlanCode)
- return done()
- }
+ const plan = await this.InstitutionsFeatures.promises.getInstitutionsPlan(
+ this.userId
)
+ expect(plan).to.equal(this.institutionPlanCode)
})
})
})
diff --git a/services/web/test/unit/src/Notifications/NotificationsBuilderTests.js b/services/web/test/unit/src/Notifications/NotificationsBuilderTests.js
index 234cf7547e..b668fffd2a 100644
--- a/services/web/test/unit/src/Notifications/NotificationsBuilderTests.js
+++ b/services/web/test/unit/src/Notifications/NotificationsBuilderTests.js
@@ -22,22 +22,19 @@ describe('NotificationsBuilder', function () {
})
})
- describe('dropboxUnlinkedDueToLapsedReconfirmation', function (done) {
- it('should create the notification', function (done) {
- this.controller
+ describe('dropboxUnlinkedDueToLapsedReconfirmation', function () {
+ it('should create the notification', async function () {
+ await this.controller.promises
.dropboxUnlinkedDueToLapsedReconfirmation(userId)
- .create(error => {
- expect(error).to.not.exist
- expect(this.handler.createNotification).to.have.been.calledWith(
- userId,
- 'drobox-unlinked-due-to-lapsed-reconfirmation',
- 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
- {},
- null,
- true
- )
- done()
- })
+ .create()
+ expect(this.handler.createNotification).to.have.been.calledWith(
+ userId,
+ 'drobox-unlinked-due-to-lapsed-reconfirmation',
+ 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
+ {},
+ null,
+ true
+ )
})
describe('NotificationsHandler error', function () {
let anError
@@ -45,19 +42,23 @@ describe('NotificationsBuilder', function () {
anError = new Error('oops')
this.handler.createNotification.yields(anError)
})
- it('should return errors from NotificationsHandler', function (done) {
- this.controller
- .dropboxUnlinkedDueToLapsedReconfirmation(userId)
- .create(error => {
- expect(error).to.exist
- expect(error).to.deep.equal(anError)
- done()
- })
+ it('should return errors from NotificationsHandler', async function () {
+ let error
+
+ try {
+ await this.controller.promises
+ .dropboxUnlinkedDueToLapsedReconfirmation(userId)
+ .create()
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.equal(anError)
})
})
})
- describe('groupInvitation', function (done) {
+ describe('groupInvitation', function () {
const subscriptionId = '123123bcabca'
beforeEach(function () {
this.invite = {
@@ -67,29 +68,26 @@ describe('NotificationsBuilder', function () {
}
})
- it('should create the notification', function (done) {
- this.controller
+ it('should create the notification', async function () {
+ await this.controller.promises
.groupInvitation(
userId,
subscriptionId,
this.invite.managedUsersEnabled
)
- .create(this.invite, error => {
- expect(error).to.not.exist
- expect(this.handler.createNotification).to.have.been.calledWith(
- userId,
- `groupInvitation-${subscriptionId}-${userId}`,
- 'notification_group_invitation',
- {
- token: this.invite.token,
- inviterName: this.invite.inviterName,
- managedUsersEnabled: this.invite.managedUsersEnabled,
- },
- null,
- true
- )
- done()
- })
+ .create(this.invite)
+ expect(this.handler.createNotification).to.have.been.calledWith(
+ userId,
+ `groupInvitation-${subscriptionId}-${userId}`,
+ 'notification_group_invitation',
+ {
+ token: this.invite.token,
+ inviterName: this.invite.inviterName,
+ managedUsersEnabled: this.invite.managedUsersEnabled,
+ },
+ null,
+ true
+ )
})
})
@@ -107,27 +105,25 @@ describe('NotificationsBuilder', function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
})
- it('should call v1 and create affiliation notifications', function (done) {
+ it('should call v1 and create affiliation notifications', async function () {
const ip = '192.168.0.1'
- this.controller.ipMatcherAffiliation(userId).create(ip, callback => {
- this.request.calledOnce.should.equal(true)
- const expectedOpts = {
- institutionId: this.body.id,
- university_name: this.body.name,
- content: this.body.enrolment_ad_html,
- ssoEnabled: false,
- portalPath: undefined,
- }
- this.handler.createNotification
- .calledWith(
- userId,
- `ip-matched-affiliation-${this.body.id}`,
- 'notification_ip_matched_affiliation',
- expectedOpts
- )
- .should.equal(true)
- done()
- })
+ await this.controller.promises.ipMatcherAffiliation(userId).create(ip)
+ this.request.calledOnce.should.equal(true)
+ const expectedOpts = {
+ institutionId: this.body.id,
+ university_name: this.body.name,
+ content: this.body.enrolment_ad_html,
+ ssoEnabled: false,
+ portalPath: undefined,
+ }
+ this.handler.createNotification
+ .calledWith(
+ userId,
+ `ip-matched-affiliation-${this.body.id}`,
+ 'notification_ip_matched_affiliation',
+ expectedOpts
+ )
+ .should.equal(true)
})
})
describe('without portal and without SSO', function () {
@@ -143,27 +139,25 @@ describe('NotificationsBuilder', function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
})
- it('should call v1 and create affiliation notifications', function (done) {
+ it('should call v1 and create affiliation notifications', async function () {
const ip = '192.168.0.1'
- this.controller.ipMatcherAffiliation(userId).create(ip, callback => {
- this.request.calledOnce.should.equal(true)
- const expectedOpts = {
- institutionId: this.body.id,
- university_name: this.body.name,
- content: this.body.enrolment_ad_html,
- ssoEnabled: true,
- portalPath: '/edu/stanford',
- }
- this.handler.createNotification
- .calledWith(
- userId,
- `ip-matched-affiliation-${this.body.id}`,
- 'notification_ip_matched_affiliation',
- expectedOpts
- )
- .should.equal(true)
- done()
- })
+ await this.controller.promises.ipMatcherAffiliation(userId).create(ip)
+ this.request.calledOnce.should.equal(true)
+ const expectedOpts = {
+ institutionId: this.body.id,
+ university_name: this.body.name,
+ content: this.body.enrolment_ad_html,
+ ssoEnabled: true,
+ portalPath: '/edu/stanford',
+ }
+ this.handler.createNotification
+ .calledWith(
+ userId,
+ `ip-matched-affiliation-${this.body.id}`,
+ 'notification_ip_matched_affiliation',
+ expectedOpts
+ )
+ .should.equal(true)
})
})
})
diff --git a/services/web/test/unit/src/Notifications/NotificationsHandlerTests.js b/services/web/test/unit/src/Notifications/NotificationsHandlerTests.js
index 5eb431f522..0ed0001d65 100644
--- a/services/web/test/unit/src/Notifications/NotificationsHandlerTests.js
+++ b/services/web/test/unit/src/Notifications/NotificationsHandlerTests.js
@@ -1,16 +1,3 @@
-/* eslint-disable
- n/handle-callback-err,
- max-len,
- no-return-assign,
- no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
const SandboxedModule = require('sandboxed-module')
const { assert } = require('chai')
const sinon = require('sinon')
@@ -18,7 +5,6 @@ const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Notifications/NotificationsHandler.js'
)
-const _ = require('lodash')
describe('NotificationsHandler', function () {
const userId = '123nd3ijdks'
@@ -27,18 +13,18 @@ describe('NotificationsHandler', function () {
beforeEach(function () {
this.request = sinon.stub().callsArgWith(1)
- return (this.handler = SandboxedModule.require(modulePath, {
+ this.handler = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': {
apis: { notifications: { url: notificationUrl } },
},
request: this.request,
},
- }))
+ })
})
describe('getUserNotifications', function () {
- it('should get unread notifications', function (done) {
+ it('should get unread notifications', async function () {
const stubbedNotifications = [{ _id: notificationId, user_id: userId }]
this.request.callsArgWith(
1,
@@ -46,51 +32,42 @@ describe('NotificationsHandler', function () {
{ statusCode: 200 },
stubbedNotifications
)
- return this.handler.getUserNotifications(
- userId,
- (err, unreadNotifications) => {
- stubbedNotifications.should.deep.equal(unreadNotifications)
- const getOpts = {
- uri: `${notificationUrl}/user/${userId}`,
- json: true,
- timeout: 1000,
- method: 'GET',
- }
- this.request.calledWith(getOpts).should.equal(true)
- return done()
- }
- )
+ const unreadNotifications =
+ await this.handler.promises.getUserNotifications(userId)
+ stubbedNotifications.should.deep.equal(unreadNotifications)
+ const getOpts = {
+ uri: `${notificationUrl}/user/${userId}`,
+ json: true,
+ timeout: 1000,
+ method: 'GET',
+ }
+ this.request.calledWith(getOpts).should.equal(true)
})
- it('should return empty arrays if there are no notifications', function () {
+ it('should return empty arrays if there are no notifications', async function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, null)
- return this.handler.getUserNotifications(
- userId,
- (err, unreadNotifications) => {
- return unreadNotifications.length.should.equal(0)
- }
- )
+ const unreadNotifications =
+ await this.handler.promises.getUserNotifications(userId)
+ unreadNotifications.length.should.equal(0)
})
})
describe('markAsRead', function () {
beforeEach(function () {
- return (this.key = 'some key here')
+ this.key = 'some key here'
})
- it('should send a delete request when a delete has been received to mark a notification', function (done) {
- return this.handler.markAsReadWithKey(userId, this.key, () => {
- const opts = {
- uri: `${notificationUrl}/user/${userId}`,
- json: {
- key: this.key,
- },
- timeout: 1000,
- method: 'DELETE',
- }
- this.request.calledWith(opts).should.equal(true)
- return done()
- })
+ it('should send a delete request when a delete has been received to mark a notification', async function () {
+ await this.handler.promises.markAsReadWithKey(userId, this.key)
+ const opts = {
+ uri: `${notificationUrl}/user/${userId}`,
+ json: {
+ key: this.key,
+ },
+ timeout: 1000,
+ method: 'DELETE',
+ }
+ this.request.calledWith(opts).should.equal(true)
})
})
@@ -99,30 +76,27 @@ describe('NotificationsHandler', function () {
this.key = 'some key here'
this.messageOpts = { value: 12344 }
this.templateKey = 'renderThisHtml'
- return (this.expiry = null)
+ this.expiry = null
})
- it('should post the message over', function (done) {
- return this.handler.createNotification(
+ it('should post the message over', async function () {
+ await this.handler.promises.createNotification(
userId,
this.key,
this.templateKey,
this.messageOpts,
- this.expiry,
- () => {
- const args = this.request.args[0][0]
- args.uri.should.equal(`${notificationUrl}/user/${userId}`)
- args.timeout.should.equal(1000)
- const expectedJson = {
- key: this.key,
- templateKey: this.templateKey,
- messageOpts: this.messageOpts,
- forceCreate: true,
- }
- assert.deepEqual(args.json, expectedJson)
- return done()
- }
+ this.expiry
)
+ const args = this.request.args[0][0]
+ args.uri.should.equal(`${notificationUrl}/user/${userId}`)
+ args.timeout.should.equal(1000)
+ const expectedJson = {
+ key: this.key,
+ templateKey: this.templateKey,
+ messageOpts: this.messageOpts,
+ forceCreate: true,
+ }
+ assert.deepEqual(args.json, expectedJson)
})
describe('when expiry date is supplied', function () {
@@ -130,50 +104,46 @@ describe('NotificationsHandler', function () {
this.key = 'some key here'
this.messageOpts = { value: 12344 }
this.templateKey = 'renderThisHtml'
- return (this.expiry = new Date())
+ this.expiry = new Date()
})
- it('should post the message over with expiry field', function (done) {
- return this.handler.createNotification(
+ it('should post the message over with expiry field', async function () {
+ await this.handler.promises.createNotification(
userId,
this.key,
this.templateKey,
this.messageOpts,
- this.expiry,
- () => {
- const args = this.request.args[0][0]
- args.uri.should.equal(`${notificationUrl}/user/${userId}`)
- args.timeout.should.equal(1000)
- const expectedJson = {
- key: this.key,
- templateKey: this.templateKey,
- messageOpts: this.messageOpts,
- expires: this.expiry,
- forceCreate: true,
- }
- assert.deepEqual(args.json, expectedJson)
- return done()
- }
+ this.expiry
)
+
+ const args = this.request.args[0][0]
+ args.uri.should.equal(`${notificationUrl}/user/${userId}`)
+ args.timeout.should.equal(1000)
+ const expectedJson = {
+ key: this.key,
+ templateKey: this.templateKey,
+ messageOpts: this.messageOpts,
+ expires: this.expiry,
+ forceCreate: true,
+ }
+ assert.deepEqual(args.json, expectedJson)
})
})
})
describe('markAsReadByKeyOnly', function () {
beforeEach(function () {
- return (this.key = 'some key here')
+ this.key = 'some key here'
})
- it('should send a delete request when a delete has been received to mark a notification', function (done) {
- return this.handler.markAsReadByKeyOnly(this.key, () => {
- const opts = {
- uri: `${notificationUrl}/key/${this.key}`,
- timeout: 1000,
- method: 'DELETE',
- }
- this.request.calledWith(opts).should.equal(true)
- return done()
- })
+ it('should send a delete request when a delete has been received to mark a notification', async function () {
+ await this.handler.promises.markAsReadByKeyOnly(this.key)
+ const opts = {
+ uri: `${notificationUrl}/key/${this.key}`,
+ timeout: 1000,
+ method: 'DELETE',
+ }
+ this.request.calledWith(opts).should.equal(true)
})
})
})
diff --git a/services/web/test/unit/src/Project/ProjectDuplicatorTests.js b/services/web/test/unit/src/Project/ProjectDuplicatorTests.js
index 1a49171163..8a2006a907 100644
--- a/services/web/test/unit/src/Project/ProjectDuplicatorTests.js
+++ b/services/web/test/unit/src/Project/ProjectDuplicatorTests.js
@@ -245,6 +245,9 @@ describe('ProjectDuplicator', function () {
'../Tags/TagsHandler': this.TagsHandler,
'../History/HistoryManager': this.HistoryManager,
'../../infrastructure/Features': this.Features,
+ '../Compile/ClsiCacheManager': {
+ prepareClsiCache: sinon.stub().rejects(new Error('ignore this')),
+ },
},
})
})
diff --git a/services/web/test/unit/src/Project/ProjectEntityHandlerTests.js b/services/web/test/unit/src/Project/ProjectEntityHandlerTests.js
index f3cc6e0c06..8221ae6c7e 100644
--- a/services/web/test/unit/src/Project/ProjectEntityHandlerTests.js
+++ b/services/web/test/unit/src/Project/ProjectEntityHandlerTests.js
@@ -430,14 +430,15 @@ describe('ProjectEntityHandler', function () {
})
})
- it('should call the callback with the lines, version and rev', function (done) {
- this.ProjectEntityHandler.getDoc(projectId, docId, doc => {
- this.DocstoreManager.promises.getDoc
- .calledWith(projectId, docId)
- .should.equal(true)
- expect(doc).to.exist
- done()
- })
+ it('should call the callback with the lines, version and rev', async function () {
+ const doc = await this.ProjectEntityHandler.promises.getDoc(
+ projectId,
+ docId
+ )
+ this.DocstoreManager.promises.getDoc
+ .calledWith(projectId, docId)
+ .should.equal(true)
+ expect(doc).to.exist
})
})
diff --git a/services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js b/services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js
index 53b459ff05..b1b29c5145 100644
--- a/services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js
+++ b/services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js
@@ -12,6 +12,7 @@ const MODULE_PATH =
describe('ProjectEntityMongoUpdateHandler', function () {
beforeEach(function () {
+ tk.freeze(new Date())
this.doc = {
_id: new ObjectId(),
name: 'test-doc.txt',
@@ -209,19 +210,13 @@ describe('ProjectEntityMongoUpdateHandler', function () {
afterEach(function () {
this.DeletedFileMock.restore()
this.ProjectMock.restore()
- })
-
- beforeEach(function () {
- tk.freeze(Date.now())
- })
-
- afterEach(function () {
tk.reset()
})
describe('addDoc', function () {
beforeEach(async function () {
const doc = { _id: new ObjectId(), name: 'other.txt' }
+ const userId = new ObjectId().toString()
this.ProjectMock.expects('findOneAndUpdate')
.withArgs(
{
@@ -231,6 +226,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
{
$push: { 'rootFolder.0.folders.0.docs': doc },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -238,7 +234,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.result = await this.subject.promises.addDoc(
this.project._id,
this.folder._id,
- doc
+ doc,
+ userId
)
})
@@ -260,7 +257,9 @@ describe('ProjectEntityMongoUpdateHandler', function () {
})
describe('addFile', function () {
+ let userId
beforeEach(function () {
+ userId = new ObjectId().toString()
this.newFile = { _id: new ObjectId(), name: 'picture.jpg' }
this.ProjectMock.expects('findOneAndUpdate')
.withArgs(
@@ -271,6 +270,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
{
$push: { 'rootFolder.0.folders.0.fileRefs': this.newFile },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -282,7 +282,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.result = await this.subject.promises.addFile(
this.project._id,
this.folder._id,
- this.newFile
+ this.newFile,
+ userId
)
})
@@ -318,7 +319,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.subject.promises.addFile(
this.project._id,
this.folder._id,
- this.newFile
+ this.newFile,
+ userId
)
).to.be.rejected
})
@@ -327,6 +329,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('addFolder', function () {
beforeEach(async function () {
+ const userId = new ObjectId().toString()
const folderName = 'New folder'
this.FolderModel.withArgs({ name: folderName }).returns({
_id: new ObjectId(),
@@ -345,6 +348,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
}),
},
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -352,7 +356,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
await this.subject.promises.addFolder(
this.project._id,
this.folder._id,
- folderName
+ folderName,
+ userId
)
})
@@ -393,6 +398,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
'rootFolder.0.fileRefs.0.created': sinon.match.date,
'rootFolder.0.fileRefs.0.linkedFileData': newFile.linkedFileData,
'rootFolder.0.fileRefs.0.hash': newFile.hash,
+ lastUpdated: new Date(),
+ lastUpdatedBy: 'userId',
},
$inc: {
version: 1,
@@ -408,7 +415,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
await this.subject.promises.replaceFileWithNew(
this.project._id,
this.file._id,
- newFile
+ newFile,
+ 'userId'
)
})
@@ -460,6 +468,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('when the path is a new folder at the top level', function () {
beforeEach(async function () {
+ const userId = new ObjectId().toString()
this.newFolder = { _id: new ObjectId(), name: 'new-folder' }
this.FolderModel.returns(this.newFolder)
this.exactCaseMatch = false
@@ -469,6 +478,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
{
$push: { 'rootFolder.0.folders': this.newFolder },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -476,6 +486,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.result = await this.subject.promises.mkdirp(
this.project._id,
'/new-folder/',
+ userId,
{ exactCaseMatch: this.exactCaseMatch }
)
})
@@ -504,6 +515,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('adding a subfolder', function () {
beforeEach(async function () {
+ const userId = new ObjectId().toString()
this.newFolder = { _id: new ObjectId(), name: 'new-folder' }
this.FolderModel.returns(this.newFolder)
this.ProjectMock.expects('findOneAndUpdate')
@@ -519,13 +531,15 @@ describe('ProjectEntityMongoUpdateHandler', function () {
}),
},
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
.resolves(this.project)
this.result = await this.subject.promises.mkdirp(
this.project._id,
- '/test-folder/new-folder'
+ '/test-folder/new-folder',
+ userId
)
})
@@ -547,7 +561,9 @@ describe('ProjectEntityMongoUpdateHandler', function () {
})
describe('when mutliple folders are missing', async function () {
+ let userId
beforeEach(function () {
+ userId = new ObjectId().toString()
this.folder1 = { _id: new ObjectId(), name: 'folder1' }
this.folder1Path = {
fileSystem: '/test-folder/folder1',
@@ -593,6 +609,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
}),
},
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -610,6 +627,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
}),
},
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -629,7 +647,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
beforeEach(async function () {
this.result = await this.subject.promises.mkdirp(
this.project._id,
- path
+ path,
+ userId
)
})
@@ -661,6 +680,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('moveEntity', function () {
describe('moving a doc into a different folder', function () {
beforeEach(async function () {
+ const userId = new ObjectId().toString()
this.pathAfterMove = {
fileSystem: '/somewhere/else.txt',
}
@@ -685,6 +705,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
{
$push: { 'rootFolder.0.folders.0.docs': this.doc },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -695,6 +716,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
{
$pull: { 'rootFolder.0.docs': { _id: this.doc._id } },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -703,7 +725,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.project._id,
this.doc._id,
this.folder._id,
- 'doc'
+ 'doc',
+ userId
)
})
@@ -770,12 +793,14 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('deleteEntity', function () {
beforeEach(async function () {
+ const userId = new ObjectId().toString()
this.ProjectMock.expects('findOneAndUpdate')
.withArgs(
{ _id: this.project._id },
{
$pull: { 'rootFolder.0.docs': { _id: this.doc._id } },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -783,7 +808,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
await this.subject.promises.deleteEntity(
this.project._id,
this.doc._id,
- 'doc'
+ 'doc',
+ userId
)
})
@@ -795,6 +821,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('renameEntity', function () {
describe('happy path', function () {
beforeEach(async function () {
+ const userId = new ObjectId().toString()
this.newName = 'new.tex'
this.oldDocs = ['old-doc']
this.oldFiles = ['old-file']
@@ -812,7 +839,11 @@ describe('ProjectEntityMongoUpdateHandler', function () {
.withArgs(
{ _id: this.project._id, 'rootFolder.0.docs.0': { $exists: true } },
{
- $set: { 'rootFolder.0.docs.0.name': this.newName },
+ $set: {
+ 'rootFolder.0.docs.0.name': this.newName,
+ lastUpdated: new Date(),
+ lastUpdatedBy: userId,
+ },
$inc: { version: 1 },
}
)
@@ -822,7 +853,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.project._id,
this.doc._id,
'doc',
- this.newName
+ this.newName,
+ userId
)
})
@@ -864,7 +896,9 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('_putElement', function () {
describe('updating the project', function () {
describe('when the parent folder is given', function () {
+ let userId
beforeEach(function () {
+ userId = new ObjectId().toString()
this.newFile = { _id: new ObjectId(), name: 'new file.png' }
this.ProjectMock.expects('findOneAndUpdate')
.withArgs(
@@ -875,6 +909,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
{
$push: { 'rootFolder.0.folders.0.fileRefs': this.newFile },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -886,7 +921,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.project,
this.folder._id,
this.newFile,
- 'files'
+ 'files',
+ userId
)
this.ProjectMock.verify()
})
@@ -896,7 +932,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.project,
this.folder._id,
this.newFile,
- 'file'
+ 'file',
+ userId
)
this.ProjectMock.verify()
})
@@ -998,6 +1035,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('when the parent folder is not given', function () {
it('should default to root folder insert', async function () {
+ const userId = new ObjectId().toString()
this.newFile = { _id: new ObjectId(), name: 'new file.png' }
this.ProjectMock.expects('findOneAndUpdate')
.withArgs(
@@ -1005,6 +1043,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
{
$push: { 'rootFolder.0.fileRefs': this.newFile },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
}
)
.chain('exec')
@@ -1013,7 +1052,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
this.project,
this.rootFolder._id,
this.newFile,
- 'file'
+ 'file',
+ userId
)
})
})
@@ -1098,6 +1138,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('replaceDocWithFile', function () {
it('should simultaneously remove the doc and add the file', async function () {
+ const userId = new ObjectId().toString()
this.ProjectMock.expects('findOneAndUpdate')
.withArgs(
{ _id: this.project._id, 'rootFolder.0': { $exists: true } },
@@ -1105,6 +1146,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
$pull: { 'rootFolder.0.docs': { _id: this.doc._id } },
$push: { 'rootFolder.0.fileRefs': this.file },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
},
{ new: true }
)
@@ -1113,7 +1155,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
await this.subject.promises.replaceDocWithFile(
this.project._id,
this.doc._id,
- this.file
+ this.file,
+ userId
)
this.ProjectMock.verify()
})
@@ -1121,6 +1164,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
describe('replaceFileWithDoc', function () {
it('should simultaneously remove the file and add the doc', async function () {
+ const userId = new ObjectId().toString()
this.ProjectMock.expects('findOneAndUpdate')
.withArgs(
{ _id: this.project._id, 'rootFolder.0': { $exists: true } },
@@ -1128,6 +1172,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
$pull: { 'rootFolder.0.fileRefs': { _id: this.file._id } },
$push: { 'rootFolder.0.docs': this.doc },
$inc: { version: 1 },
+ $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
},
{ new: true }
)
@@ -1136,7 +1181,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
await this.subject.promises.replaceFileWithDoc(
this.project._id,
this.file._id,
- this.doc
+ this.doc,
+ userId
)
this.ProjectMock.verify()
})
diff --git a/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js b/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js
index de4a67be93..6cfe01e206 100644
--- a/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js
+++ b/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js
@@ -100,7 +100,7 @@ describe('ProjectEntityUpdateHandler', function () {
withTimeout: sinon.stub().returns(this.LockManager),
}
this.ProjectModel = {
- updateOne: sinon.stub(),
+ updateOne: sinon.stub().returns({ exec: sinon.stub().resolves() }),
}
this.ProjectGetter = {
promises: {
@@ -225,26 +225,20 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('when the doc has been modified', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.DocstoreManager.promises.updateDoc.resolves({
modified: true,
rev: (this.rev = 5),
})
- const callback = (...args) => {
- this.callback(...args)
- done()
- }
-
- this.ProjectEntityUpdateHandler.updateDocLines(
+ await this.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
this.docLines,
this.version,
this.ranges,
this.lastUpdatedAt,
- this.lastUpdatedBy,
- callback
+ this.lastUpdatedBy
)
})
@@ -288,7 +282,7 @@ describe('ProjectEntityUpdateHandler', function () {
)
})
- it('should send the doc the to the TPDS', function () {
+ it('should send the doc to the TPDS', function () {
this.TpdsUpdateSender.promises.addDoc.should.have.been.calledWith({
projectId,
projectName: this.project.name,
@@ -298,33 +292,23 @@ describe('ProjectEntityUpdateHandler', function () {
folderId: this.parentFolder._id,
})
})
-
- it('should call the callback', function () {
- this.callback.called.should.equal(true)
- })
})
describe('when the doc has not been modified', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.DocstoreManager.promises.updateDoc.resolves({
modified: false,
rev: (this.rev = 5),
})
- const callback = () => {
- this.callback()
- done()
- }
-
- this.ProjectEntityUpdateHandler.updateDocLines(
+ await this.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
this.docLines,
this.version,
this.ranges,
this.lastUpdatedAt,
- this.lastUpdatedBy,
- callback
+ this.lastUpdatedBy
)
})
@@ -335,33 +319,25 @@ describe('ProjectEntityUpdateHandler', function () {
it('should not send the doc the to the TPDS', function () {
this.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
-
- it('should call the callback', function () {
- this.callback.called.should.equal(true)
- })
})
describe('when the doc has been deleted', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectGetter.promises.getProject.resolves(this.project)
this.ProjectLocator.promises.findElement.rejects(
new Errors.NotFoundError()
)
this.DocstoreManager.promises.isDocDeleted.resolves(true)
- this.DocstoreManager.promises.updateDoc.resolves()
+ this.DocstoreManager.promises.updateDoc.resolves({})
- this.ProjectEntityUpdateHandler.updateDocLines(
+ await this.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
this.docLines,
this.version,
this.ranges,
this.lastUpdatedAt,
- this.lastUpdatedBy,
- (...args) => {
- this.callback(...args)
- done()
- }
+ this.lastUpdatedBy
)
})
@@ -384,14 +360,10 @@ describe('ProjectEntityUpdateHandler', function () {
it('should not send the doc the to the TPDS', function () {
this.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
-
- it('should call the callback', function () {
- this.callback.called.should.equal(true)
- })
})
describe('when projects and docs collection are de-synced', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectGetter.promises.getProject.resolves(this.project)
// The doc is not in the file-tree, but also not marked as deleted.
@@ -401,21 +373,16 @@ describe('ProjectEntityUpdateHandler', function () {
)
this.DocstoreManager.promises.isDocDeleted.resolves(false)
- this.DocstoreManager.promises.updateDoc.resolves()
- const callback = (...args) => {
- this.callback(...args)
- done()
- }
+ this.DocstoreManager.promises.updateDoc.resolves({})
- this.ProjectEntityUpdateHandler.updateDocLines(
+ await this.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
this.docLines,
this.version,
this.ranges,
this.lastUpdatedAt,
- this.lastUpdatedBy,
- callback
+ this.lastUpdatedBy
)
})
@@ -438,14 +405,11 @@ describe('ProjectEntityUpdateHandler', function () {
it('should not send the doc the to the TPDS', function () {
this.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
-
- it('should call the callback', function () {
- this.callback.called.should.equal(true)
- })
})
describe('when the doc is not related to the project', function () {
- beforeEach(function (done) {
+ let updateDocLinesPromise
+ beforeEach(function () {
this.ProjectGetter.promises.getProject.resolves(this.project)
this.ProjectLocator.promises.findElement.rejects(
new Errors.NotFoundError()
@@ -453,65 +417,84 @@ describe('ProjectEntityUpdateHandler', function () {
this.DocstoreManager.promises.isDocDeleted.rejects(
new Errors.NotFoundError()
)
- const callback = (...args) => {
- this.callback(...args)
- done()
+
+ updateDocLinesPromise =
+ this.ProjectEntityUpdateHandler.promises.updateDocLines(
+ projectId,
+ docId,
+ this.docLines,
+ this.version,
+ this.ranges,
+ this.lastUpdatedAt,
+ this.lastUpdatedBy
+ )
+ })
+
+ it('should return a not found error', async function () {
+ let error
+
+ try {
+ await updateDocLinesPromise
+ } catch (err) {
+ error = err
}
- this.ProjectEntityUpdateHandler.updateDocLines(
- projectId,
- docId,
- this.docLines,
- this.version,
- this.ranges,
- this.lastUpdatedAt,
- this.lastUpdatedBy,
- callback
- )
+ expect(error).to.be.instanceOf(Errors.NotFoundError)
})
- it('should return a not found error', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
- .should.equal(true)
- })
+ it('should not update the doc', async function () {
+ let error
- it('should not update the doc', function () {
+ try {
+ await updateDocLinesPromise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
this.DocstoreManager.promises.updateDoc.called.should.equal(false)
})
- it('should not send the doc the to the TPDS', function () {
+ it('should not send the doc the to the TPDS', async function () {
+ let error
+
+ try {
+ await updateDocLinesPromise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
this.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
})
describe('when the project is not found', function () {
- beforeEach(function (done) {
+ let error
+ beforeEach(async function () {
this.ProjectGetter.promises.getProject.rejects(
new Errors.NotFoundError()
)
- this.ProjectEntityUpdateHandler.updateDocLines(
- projectId,
- docId,
- this.docLines,
- this.version,
- this.ranges,
- this.lastUpdatedAt,
- this.lastUpdatedBy,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ try {
+ await this.ProjectEntityUpdateHandler.promises.updateDocLines(
+ projectId,
+ docId,
+ this.docLines,
+ this.version,
+ this.ranges,
+ this.lastUpdatedAt,
+ this.lastUpdatedBy
+ )
+ } catch (err) {
+ error = err
+ }
})
- it('should return a not found error', function () {
- this.callback
- .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
- .should.equal(true)
+ it('should return a not found error', async function () {
+ expect(error).to.be.instanceOf(Errors.NotFoundError)
})
- it('should not update the doc', function () {
+ it('should not update the doc', async function () {
this.DocstoreManager.promises.updateDoc.called.should.equal(false)
})
@@ -526,38 +509,42 @@ describe('ProjectEntityUpdateHandler', function () {
this.rootDocId = 'root-doc-id-123123'
})
- it('should call Project.updateOne when the doc exists and has a valid extension', function (done) {
+ it('should call Project.updateOne when the doc exists and has a valid extension', async function () {
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId.resolves(
`/main.tex`
)
- this.ProjectEntityUpdateHandler.setRootDoc(
+ await this.ProjectEntityUpdateHandler.promises.setRootDoc(
projectId,
- this.rootDocId,
- () => {
- this.ProjectModel.updateOne
- .calledWith({ _id: projectId }, { rootDoc_id: this.rootDocId })
- .should.equal(true)
- done()
- }
+ this.rootDocId
)
+
+ this.ProjectModel.updateOne
+ .calledWith({ _id: projectId }, { rootDoc_id: this.rootDocId })
+ .should.equal(true)
})
- it("should not call Project.updateOne when the doc doesn't exist", function (done) {
+ it("should not call Project.updateOne when the doc doesn't exist", async function () {
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId.rejects(
Errors.NotFoundError
)
- this.ProjectEntityUpdateHandler.setRootDoc(
- projectId,
- this.rootDocId,
- () => {
- this.ProjectModel.updateOne
- .calledWith({ _id: projectId }, { rootDoc_id: this.rootDocId })
- .should.equal(false)
- done()
- }
- )
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.setRootDoc(
+ projectId,
+ this.rootDocId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+
+ this.ProjectModel.updateOne
+ .calledWith({ _id: projectId }, { rootDoc_id: this.rootDocId })
+ .should.equal(false)
})
it('should call the callback with an UnsupportedFileTypeError when the doc has an unaccepted file extension', function () {
@@ -576,19 +563,17 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('unsetRootDoc', function () {
- it('should call Project.updateOne', function (done) {
- this.ProjectEntityUpdateHandler.unsetRootDoc(projectId, () => {
- this.ProjectModel.updateOne
- .calledWith({ _id: projectId }, { $unset: { rootDoc_id: true } })
- .should.equal(true)
- done()
- })
+ it('should call Project.updateOne', async function () {
+ await this.ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
+ this.ProjectModel.updateOne
+ .calledWith({ _id: projectId }, { $unset: { rootDoc_id: true } })
+ .should.equal(true)
})
})
describe('addDoc', function () {
describe('adding a doc', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.path = '/path/to/doc'
this.rev = 5
@@ -607,17 +592,13 @@ describe('ProjectEntityUpdateHandler', function () {
result: { path: { fileSystem: this.path } },
project: this.project,
})
- this.ProjectEntityUpdateHandler.addDoc(
+ await this.ProjectEntityUpdateHandler.promises.addDoc(
projectId,
docId,
this.docName,
this.docLines,
userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
+ this.source
)
})
@@ -650,34 +631,36 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('adding a doc with an invalid name', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.path = '/path/to/doc'
this.newDoc = { _id: docId }
- this.ProjectEntityUpdateHandler.addDoc(
- projectId,
- folderId,
- `*${this.docName}`,
- this.docLines,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.addDoc(
+ projectId,
+ folderId,
+ `*${this.docName}`,
+ this.docLines,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('addFile', function () {
describe('adding a file', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.path = '/path/to/file'
this.newFile = {
@@ -697,18 +680,14 @@ describe('ProjectEntityUpdateHandler', function () {
result: { path: { fileSystem: this.path } },
project: this.project,
})
- this.ProjectEntityUpdateHandler.addFile(
+ await this.ProjectEntityUpdateHandler.promises.addFile(
projectId,
folderId,
this.fileName,
this.fileSystemPath,
this.linkedFileData,
userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
+ this.source
)
})
@@ -747,13 +726,6 @@ describe('ProjectEntityUpdateHandler', function () {
.should.equal(true)
})
- it('should mark the project as updated', function () {
- const args = this.ProjectUpdater.promises.markAsUpdated.args[0]
- args[0].should.equal(projectId)
- args[1].should.exist
- args[2].should.equal(userId)
- })
-
it('sends the change in project structure to the doc updater', function () {
const newFiles = [
{
@@ -777,7 +749,7 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('adding a file with an invalid name', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.path = '/path/to/file'
this.newFile = {
@@ -792,54 +764,59 @@ describe('ProjectEntityUpdateHandler', function () {
result: { path: { fileSystem: this.path } },
project: this.project,
})
- this.ProjectEntityUpdateHandler.addFile(
- projectId,
- folderId,
- `*${this.fileName}`,
- this.fileSystemPath,
- this.linkedFileData,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.addFile(
+ projectId,
+ folderId,
+ `*${this.fileName}`,
+ this.fileSystemPath,
+ this.linkedFileData,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('upsertDoc', function () {
describe('upserting into an invalid folder', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.ProjectLocator.promises.findElement.resolves({ element: null })
- this.ProjectEntityUpdateHandler.upsertDoc(
- projectId,
- folderId,
- this.docName,
- this.docLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Error)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.upsertDoc(
+ projectId,
+ folderId,
+ this.docName,
+ this.docLines,
+ this.source,
+ userId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('updating an existing doc', function () {
- beforeEach(function (done) {
+ let upsertDocResponse
+ beforeEach(async function () {
this.existingDoc = { _id: docId, name: this.docName }
this.existingFile = {
_id: fileId,
@@ -856,18 +833,15 @@ describe('ProjectEntityUpdateHandler', function () {
})
this.DocumentUpdaterHandler.promises.setDocument.resolves()
- this.ProjectEntityUpdateHandler.upsertDoc(
- projectId,
- folderId,
- this.docName,
- this.docLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ upsertDocResponse =
+ await this.ProjectEntityUpdateHandler.promises.upsertDoc(
+ projectId,
+ folderId,
+ this.docName,
+ this.docLines,
+ this.source,
+ userId
+ )
})
it('tries to find the folder', function () {
@@ -893,12 +867,15 @@ describe('ProjectEntityUpdateHandler', function () {
})
it('returns the doc', function () {
- this.callback.calledWith(null, this.existingDoc, false)
+ expect(upsertDocResponse.isNew).to.equal(false)
+ expect(upsertDocResponse.doc).to.eql(this.existingDoc)
})
})
describe('creating a new doc', function () {
- beforeEach(function (done) {
+ let upsertDocResponse
+
+ beforeEach(async function () {
this.folder = { _id: folderId, docs: [], fileRefs: [] }
this.newDoc = { _id: docId }
this.ProjectLocator.promises.findElement.resolves({
@@ -908,18 +885,15 @@ describe('ProjectEntityUpdateHandler', function () {
withoutLock: sinon.stub().resolves({ doc: this.newDoc }),
}
- this.ProjectEntityUpdateHandler.upsertDoc(
- projectId,
- folderId,
- this.docName,
- this.docLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ upsertDocResponse =
+ await this.ProjectEntityUpdateHandler.promises.upsertDoc(
+ projectId,
+ folderId,
+ this.docName,
+ this.docLines,
+ this.source,
+ userId
+ )
})
it('tries to find the folder', function () {
@@ -945,12 +919,13 @@ describe('ProjectEntityUpdateHandler', function () {
})
it('returns the doc', function () {
- this.callback.calledWith(null, this.newDoc, true)
+ expect(upsertDocResponse.isNew).to.equal(true)
+ expect(upsertDocResponse.doc).to.equal(this.newDoc)
})
})
describe('upserting a new doc with an invalid name', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.folder = { _id: folderId, docs: [], fileRefs: [] }
this.newDoc = { _id: docId }
this.ProjectLocator.promises.findElement.resolves({
@@ -959,29 +934,30 @@ describe('ProjectEntityUpdateHandler', function () {
this.ProjectEntityUpdateHandler.promises.addDocWithRanges = {
withoutLock: sinon.stub().resolves({ doc: this.newDoc }),
}
-
- this.ProjectEntityUpdateHandler.upsertDoc(
- projectId,
- folderId,
- `*${this.docName}`,
- this.docLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.upsertDoc(
+ projectId,
+ folderId,
+ `*${this.docName}`,
+ this.docLines,
+ this.source,
+ userId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('upserting a doc on top of a file', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.newProject = {
name: 'new project',
overleaf: { history: { id: projectHistoryId } },
@@ -1010,17 +986,13 @@ describe('ProjectEntityUpdateHandler', function () {
)
this.TpdsUpdateSender.promises.addDoc.resolves()
- this.ProjectEntityUpdateHandler.upsertDoc(
+ await this.ProjectEntityUpdateHandler.promises.upsertDoc(
projectId,
folderId,
'foo.tex',
this.docLines,
this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
+ userId
)
})
@@ -1105,31 +1077,34 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('upserting into an invalid folder', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.ProjectLocator.promises.findElement.resolves({ element: null })
- this.ProjectEntityUpdateHandler.upsertFile(
- projectId,
- folderId,
- this.fileName,
- this.fileSystemPath,
- this.linkedFileData,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Error)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.upsertFile(
+ projectId,
+ folderId,
+ this.fileName,
+ this.fileSystemPath,
+ this.linkedFileData,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
describe('updating an existing file', function () {
- beforeEach(function (done) {
+ let upsertFileResult
+ beforeEach(async function () {
this.existingFile = { _id: fileId, name: this.fileName, rev: 1 }
this.newFile = {
_id: new ObjectId(),
@@ -1151,19 +1126,16 @@ describe('ProjectEntityUpdateHandler', function () {
newFileRef: this.newFile,
}
)
- this.ProjectEntityUpdateHandler.upsertFile(
- projectId,
- folderId,
- this.fileName,
- this.fileSystemPath,
- this.linkedFileData,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ upsertFileResult =
+ await this.ProjectEntityUpdateHandler.promises.upsertFile(
+ projectId,
+ folderId,
+ this.fileName,
+ this.fileSystemPath,
+ this.linkedFileData,
+ userId,
+ this.source
+ )
})
it('uploads a new version of the file', function () {
@@ -1181,7 +1153,8 @@ describe('ProjectEntityUpdateHandler', function () {
this.ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew.should.have.been.calledWith(
projectId,
this.existingFile._id,
- this.file
+ this.file,
+ userId
)
})
@@ -1198,13 +1171,6 @@ describe('ProjectEntityUpdateHandler', function () {
})
})
- it('should mark the project as updated', function () {
- const args = this.ProjectUpdater.promises.markAsUpdated.args[0]
- args[0].should.equal(projectId)
- args[1].should.exist
- args[2].should.equal(userId)
- })
-
it('updates the project structure in the doc updater', function () {
const oldFiles = [
{
@@ -1234,12 +1200,16 @@ describe('ProjectEntityUpdateHandler', function () {
})
it('returns the file', function () {
- this.callback.calledWith(null, this.existingFile, false)
+ expect(upsertFileResult.isNew).to.be.false
+ expect(upsertFileResult.fileRef.toString()).to.eql(
+ this.existingFile.toString()
+ )
})
})
describe('creating a new file', function () {
- beforeEach(function (done) {
+ let upsertFileResult
+ beforeEach(async function () {
this.folder = { _id: folderId, fileRefs: [], docs: [] }
this.newFile = {
_id: fileId,
@@ -1257,19 +1227,16 @@ describe('ProjectEntityUpdateHandler', function () {
mainTask: sinon.stub().resolves(this.newFile),
}
- this.ProjectEntityUpdateHandler.upsertFile(
- projectId,
- folderId,
- this.fileName,
- this.fileSystemPath,
- this.linkedFileData,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ upsertFileResult =
+ await this.ProjectEntityUpdateHandler.promises.upsertFile(
+ projectId,
+ folderId,
+ this.fileName,
+ this.fileSystemPath,
+ this.linkedFileData,
+ userId,
+ this.source
+ )
})
it('tries to find the folder', function () {
@@ -1295,12 +1262,13 @@ describe('ProjectEntityUpdateHandler', function () {
})
it('returns the file', function () {
- this.callback.calledWith(null, this.newFile, true)
+ expect(upsertFileResult.fileRef).to.eql(this.newFile)
+ expect(upsertFileResult.isNew).to.be.true
})
})
describe('upserting a new file with an invalid name', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.folder = { _id: folderId, fileRefs: [] }
this.newFile = { _id: fileId }
this.ProjectLocator.promises.findElement.resolves({
@@ -1309,30 +1277,31 @@ describe('ProjectEntityUpdateHandler', function () {
this.ProjectEntityUpdateHandler.promises.addFile = {
mainTask: sinon.stub().resolves(this.newFile),
}
-
- this.ProjectEntityUpdateHandler.upsertFile(
- projectId,
- folderId,
- `*${this.fileName}`,
- this.fileSystemPath,
- this.linkedFileData,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.upsertFile(
+ projectId,
+ folderId,
+ `*${this.fileName}`,
+ this.fileSystemPath,
+ this.linkedFileData,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('upserting file on top of a doc', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.path = '/path/to/doc'
this.existingDoc = { _id: new ObjectId(), name: this.fileName }
this.folder = {
@@ -1379,22 +1348,26 @@ describe('ProjectEntityUpdateHandler', function () {
this.newProject
)
- this.ProjectEntityUpdateHandler.upsertFile(
+ await this.ProjectEntityUpdateHandler.promises.upsertFile(
projectId,
folderId,
this.fileName,
this.fileSystemPath,
this.linkedFileData,
userId,
- this.source,
- done
+ this.source
)
})
it('replaces the existing doc with a file', function () {
expect(
this.ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile
- ).to.have.been.calledWith(projectId, this.existingDoc._id, this.newFile)
+ ).to.have.been.calledWith(
+ projectId,
+ this.existingDoc._id,
+ this.newFile,
+ userId
+ )
})
it('updates the doc structure', function () {
@@ -1443,7 +1416,8 @@ describe('ProjectEntityUpdateHandler', function () {
describe('upsertDocWithPath', function () {
describe('upserting a doc', function () {
- beforeEach(function (done) {
+ let upsertDocWithPathResult
+ beforeEach(async function () {
this.path = '/folder/doc.tex'
this.newFolders = ['mock-a', 'mock-b']
this.folder = { _id: folderId }
@@ -1460,22 +1434,19 @@ describe('ProjectEntityUpdateHandler', function () {
.resolves({ doc: this.doc, isNew: this.isNewDoc }),
}
- this.ProjectEntityUpdateHandler.upsertDocWithPath(
- projectId,
- this.path,
- this.docLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ upsertDocWithPathResult =
+ await this.ProjectEntityUpdateHandler.promises.upsertDocWithPath(
+ projectId,
+ this.path,
+ this.docLines,
+ this.source,
+ userId
+ )
})
it('creates any necessary folders', function () {
this.ProjectEntityUpdateHandler.promises.mkdirp.withoutLock
- .calledWith(projectId, '/folder')
+ .calledWith(projectId, '/folder', userId)
.should.equal(true)
})
@@ -1492,21 +1463,18 @@ describe('ProjectEntityUpdateHandler', function () {
.should.equal(true)
})
- it('calls the callback', function () {
- this.callback
- .calledWith(
- null,
- this.doc,
- this.isNewDoc,
- this.newFolders,
- this.folder
- )
- .should.equal(true)
+ it('returns a doc, the isNewDoc flag, newFolders and a folder', function () {
+ expect(upsertDocWithPathResult).to.eql({
+ doc: this.doc,
+ isNew: this.isNewDoc,
+ newFolders: this.newFolders,
+ folder: this.folder,
+ })
})
})
describe('upserting a doc with an invalid path', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.path = '/*folder/doc.tex'
this.newFolders = ['mock-a', 'mock-b']
this.folder = { _id: folderId }
@@ -1522,28 +1490,29 @@ describe('ProjectEntityUpdateHandler', function () {
.stub()
.resolves({ doc: this.doc, isNew: this.isNewDoc }),
}
-
- this.ProjectEntityUpdateHandler.upsertDocWithPath(
- projectId,
- this.path,
- this.docLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.upsertDocWithPath(
+ projectId,
+ this.path,
+ this.docLines,
+ this.source,
+ userId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('upserting a doc with an invalid name', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.path = '/folder/*doc.tex'
this.newFolders = ['mock-a', 'mock-b']
this.folder = { _id: folderId }
@@ -1559,30 +1528,32 @@ describe('ProjectEntityUpdateHandler', function () {
.stub()
.resolves({ doc: this.doc, isNew: this.isNewDoc }),
}
-
- this.ProjectEntityUpdateHandler.upsertDocWithPath(
- projectId,
- this.path,
- this.docLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.upsertDocWithPath(
+ projectId,
+ this.path,
+ this.docLines,
+ this.source,
+ userId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('upsertFileWithPath', function () {
describe('upserting a file', function () {
- beforeEach(function (done) {
+ let upsertFileWithPathResult
+ beforeEach(async function () {
this.path = '/folder/file.png'
this.newFolders = ['mock-a', 'mock-b']
this.folder = { _id: folderId }
@@ -1604,23 +1575,20 @@ describe('ProjectEntityUpdateHandler', function () {
.resolves({ fileRef: this.file, isNew: this.isNewFile }),
}
- this.ProjectEntityUpdateHandler.upsertFileWithPath(
- projectId,
- this.path,
- this.fileSystemPath,
- this.linkedFileData,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ upsertFileWithPathResult =
+ await this.ProjectEntityUpdateHandler.promises.upsertFileWithPath(
+ projectId,
+ this.path,
+ this.fileSystemPath,
+ this.linkedFileData,
+ userId,
+ this.source
+ )
})
it('creates any necessary folders', function () {
this.ProjectEntityUpdateHandler.promises.mkdirp.withoutLock
- .calledWith(projectId, '/folder')
+ .calledWith(projectId, '/folder', userId)
.should.equal(true)
})
@@ -1641,20 +1609,19 @@ describe('ProjectEntityUpdateHandler', function () {
)
})
- it('calls the callback', function () {
- this.callback.should.have.been.calledWith(
- null,
- this.file,
- this.isNewFile,
- undefined,
- this.newFolders,
- this.folder
- )
+ it('returns an object with the fileRef, isNew flag, undefined oldFileRef, newFolders, and folder', function () {
+ expect(upsertFileWithPathResult).to.eql({
+ fileRef: this.file,
+ isNew: this.isNewFile,
+ newFolders: this.newFolders,
+ folder: this.folder,
+ oldFileRef: undefined,
+ })
})
})
describe('upserting a file with an invalid path', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.path = '/*folder/file.png'
this.newFolders = ['mock-a', 'mock-b']
this.folder = { _id: folderId }
@@ -1670,29 +1637,30 @@ describe('ProjectEntityUpdateHandler', function () {
.stub()
.resolves({ doc: this.file, isNew: this.isNewFile }),
}
-
- this.ProjectEntityUpdateHandler.upsertFileWithPath(
- projectId,
- this.path,
- this.fileSystemPath,
- this.linkedFileData,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.upsertFileWithPath(
+ projectId,
+ this.path,
+ this.fileSystemPath,
+ this.linkedFileData,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('upserting a file with an invalid name', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.path = '/folder/*file.png'
this.newFolders = ['mock-a', 'mock-b']
this.folder = { _id: folderId }
@@ -1708,30 +1676,32 @@ describe('ProjectEntityUpdateHandler', function () {
.stub()
.resolves({ doc: this.file, isNew: this.isNewFile }),
}
-
- this.ProjectEntityUpdateHandler.upsertFileWithPath(
- projectId,
- this.path,
- this.fileSystemPath,
- this.linkedFileData,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.upsertFileWithPath(
+ projectId,
+ this.path,
+ this.fileSystemPath,
+ this.linkedFileData,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('deleteEntity', function () {
- beforeEach(function (done) {
+ let deleteEntityResult
+ beforeEach(async function () {
this.path = '/path/to/doc.tex'
this.doc = { _id: docId }
this.projectBeforeDeletion = { _id: projectId, name: 'project' }
@@ -1746,17 +1716,14 @@ describe('ProjectEntityUpdateHandler', function () {
.stub()
.resolves([{ type: 'doc', entity: this.doc, path: this.path }])
- this.ProjectEntityUpdateHandler.deleteEntity(
- projectId,
- docId,
- 'doc',
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ deleteEntityResult =
+ await this.ProjectEntityUpdateHandler.promises.deleteEntity(
+ projectId,
+ docId,
+ 'doc',
+ userId,
+ this.source
+ )
})
it('flushes the project to mongo', function () {
@@ -1767,7 +1734,7 @@ describe('ProjectEntityUpdateHandler', function () {
it('deletes the entity in mongo', function () {
this.ProjectEntityMongoUpdateHandler.promises.deleteEntity
- .calledWith(projectId, docId, 'doc')
+ .calledWith(projectId, docId, 'doc', userId)
.should.equal(true)
})
@@ -1797,13 +1764,13 @@ describe('ProjectEntityUpdateHandler', function () {
})
it('retuns the entity_id', function () {
- this.callback.calledWith(null, docId).should.equal(true)
+ expect(deleteEntityResult).to.equal(docId)
})
})
describe('deleteEntityWithPath', function () {
describe('when the entity exists', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.doc = { _id: docId }
this.ProjectLocator.promises.findElementByPath.resolves({
element: this.doc,
@@ -1813,12 +1780,11 @@ describe('ProjectEntityUpdateHandler', function () {
withoutLock: sinon.stub().resolves(),
}
this.path = '/path/to/doc.tex'
- this.ProjectEntityUpdateHandler.deleteEntityWithPath(
+ await this.ProjectEntityUpdateHandler.promises.deleteEntityWithPath(
projectId,
this.path,
userId,
- this.source,
- done
+ this.source
)
})
@@ -1844,109 +1810,116 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('when the entity does not exist', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.ProjectLocator.promises.findElementByPath.resolves({
element: null,
})
this.path = '/doc.tex'
- this.ProjectEntityUpdateHandler.deleteEntityWithPath(
- projectId,
- this.path,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- this.callback.should.have.been.calledWith(
- sinon.match.instanceOf(Errors.NotFoundError)
- )
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.deleteEntityWithPath(
+ projectId,
+ this.path,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.NotFoundError)
})
})
})
describe('mkdirp', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.docPath = '/folder/doc.tex'
this.ProjectEntityMongoUpdateHandler.promises.mkdirp.resolves({})
- this.ProjectEntityUpdateHandler.mkdirp(projectId, this.docPath, done)
- })
-
- it('calls ProjectEntityMongoUpdateHandler', function () {
- this.ProjectEntityMongoUpdateHandler.promises.mkdirp
- .calledWith(projectId, this.docPath)
- .should.equal(true)
- })
- })
-
- describe('mkdirpWithExactCase', function () {
- beforeEach(function (done) {
- this.docPath = '/folder/doc.tex'
- this.ProjectEntityMongoUpdateHandler.promises.mkdirp.resolves({})
- this.ProjectEntityUpdateHandler.mkdirpWithExactCase(
+ await this.ProjectEntityUpdateHandler.promises.mkdirp(
projectId,
this.docPath,
- done
+ userId
)
})
it('calls ProjectEntityMongoUpdateHandler', function () {
this.ProjectEntityMongoUpdateHandler.promises.mkdirp
- .calledWith(projectId, this.docPath, { exactCaseMatch: true })
+ .calledWith(projectId, this.docPath, userId)
+ .should.equal(true)
+ })
+ })
+
+ describe('mkdirpWithExactCase', function () {
+ beforeEach(async function () {
+ this.docPath = '/folder/doc.tex'
+ this.ProjectEntityMongoUpdateHandler.promises.mkdirp.resolves({})
+ await this.ProjectEntityUpdateHandler.promises.mkdirpWithExactCase(
+ projectId,
+ this.docPath,
+ userId
+ )
+ })
+
+ it('calls ProjectEntityMongoUpdateHandler', function () {
+ this.ProjectEntityMongoUpdateHandler.promises.mkdirp
+ .calledWith(projectId, this.docPath, userId, { exactCaseMatch: true })
.should.equal(true)
})
})
describe('addFolder', function () {
describe('adding a folder', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.parentFolderId = '123asdf'
this.folderName = 'new-folder'
this.ProjectEntityMongoUpdateHandler.promises.addFolder.resolves({})
- this.ProjectEntityUpdateHandler.addFolder(
+ await this.ProjectEntityUpdateHandler.promises.addFolder(
projectId,
this.parentFolderId,
this.folderName,
- done
+ userId
)
})
it('calls ProjectEntityMongoUpdateHandler', function () {
this.ProjectEntityMongoUpdateHandler.promises.addFolder
- .calledWith(projectId, this.parentFolderId, this.folderName)
+ .calledWith(projectId, this.parentFolderId, this.folderName, userId)
.should.equal(true)
})
})
describe('adding a folder with an invalid name', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.parentFolderId = '123asdf'
this.folderName = '*new-folder'
this.ProjectEntityMongoUpdateHandler.promises.addFolder.resolves({})
- this.ProjectEntityUpdateHandler.addFolder(
- projectId,
- this.parentFolderId,
- this.folderName,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.addFolder(
+ projectId,
+ this.parentFolderId,
+ this.folderName
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('moveEntity', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.project_name = 'project name'
this.startPath = '/a.tex'
this.endPath = '/folder/b.tex'
@@ -1960,20 +1933,19 @@ describe('ProjectEntityUpdateHandler', function () {
changes: this.changes,
})
- this.ProjectEntityUpdateHandler.moveEntity(
+ await this.ProjectEntityUpdateHandler.promises.moveEntity(
projectId,
docId,
folderId,
'doc',
userId,
- this.source,
- done
+ this.source
)
})
it('moves the entity in mongo', function () {
this.ProjectEntityMongoUpdateHandler.promises.moveEntity
- .calledWith(projectId, docId, folderId, 'doc')
+ .calledWith(projectId, docId, folderId, 'doc', userId)
.should.equal(true)
})
@@ -2007,7 +1979,7 @@ describe('ProjectEntityUpdateHandler', function () {
describe('renameEntity', function () {
describe('renaming an entity', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.project_name = 'project name'
this.startPath = '/folder/a.tex'
this.endPath = '/folder/b.tex'
@@ -2022,20 +1994,19 @@ describe('ProjectEntityUpdateHandler', function () {
changes: this.changes,
})
- this.ProjectEntityUpdateHandler.renameEntity(
+ await this.ProjectEntityUpdateHandler.promises.renameEntity(
projectId,
docId,
'doc',
this.newDocName,
userId,
- this.source,
- done
+ this.source
)
})
it('moves the entity in mongo', function () {
this.ProjectEntityMongoUpdateHandler.promises.renameEntity
- .calledWith(projectId, docId, 'doc', this.newDocName)
+ .calledWith(projectId, docId, 'doc', this.newDocName, userId)
.should.equal(true)
})
@@ -2074,7 +2045,7 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('renaming an entity to an invalid name', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.project_name = 'project name'
this.startPath = '/folder/a.tex'
this.endPath = '/folder/b.tex'
@@ -2088,29 +2059,30 @@ describe('ProjectEntityUpdateHandler', function () {
rev: this.rev,
changes: this.changes,
})
-
- this.ProjectEntityUpdateHandler.renameEntity(
- projectId,
- docId,
- 'doc',
- this.newDocName,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.renameEntity(
+ projectId,
+ docId,
+ 'doc',
+ this.newDocName,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('renaming an entity with a non-string value', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.project_name = 'project name'
this.startPath = '/folder/a.tex'
this.endPath = '/folder/b.tex'
@@ -2124,24 +2096,25 @@ describe('ProjectEntityUpdateHandler', function () {
rev: this.rev,
changes: this.changes,
})
-
- this.ProjectEntityUpdateHandler.renameEntity(
- projectId,
- docId,
- 'doc',
- this.newDocName,
- userId,
- this.source,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('returns an error', function () {
- const errorMatcher = sinon.match.instanceOf(Error)
- this.callback.calledWithMatch(errorMatcher).should.equal(true)
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.renameEntity(
+ projectId,
+ docId,
+ 'doc',
+ this.newDocName,
+ userId,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
expect(
this.ProjectEntityMongoUpdateHandler.promises.renameEntity.called
).to.equal(false)
@@ -2151,58 +2124,52 @@ describe('ProjectEntityUpdateHandler', function () {
describe('resyncProjectHistory', function () {
describe('a deleted project', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.ProjectGetter.promises.getProject.resolves({})
-
- this.ProjectEntityUpdateHandler.resyncProjectHistory(
- projectId,
- {},
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('should return an error', function () {
- expect(this.callback).to.have.been.calledWith(
- sinon.match
- .instanceOf(Errors.ProjectHistoryDisabledError)
- .and(
- sinon.match.has(
- 'message',
- `project history not enabled for ${projectId}`
- )
- )
+ it('should return an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
+ projectId,
+ {}
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.ProjectHistoryDisabledError)
+ expect(error).to.have.property(
+ 'message',
+ `project history not enabled for ${projectId}`
)
})
})
describe('a project without project-history enabled', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.project.overleaf = {}
this.ProjectGetter.promises.getProject.resolves(this.project)
-
- this.ProjectEntityUpdateHandler.resyncProjectHistory(
- projectId,
- {},
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('should return an error', function () {
- expect(this.callback).to.have.been.calledWith(
- sinon.match
- .instanceOf(Errors.ProjectHistoryDisabledError)
- .and(
- sinon.match.has(
- 'message',
- `project history not enabled for ${projectId}`
- )
- )
+ it('should return an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
+ projectId,
+ {}
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.ProjectHistoryDisabledError)
+ expect(error).to.have.property(
+ 'message',
+ `project history not enabled for ${projectId}`
)
})
})
@@ -2215,7 +2182,7 @@ describe('ProjectEntityUpdateHandler', function () {
path: 'universe.png',
},
]
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectGetter.promises.getProject.resolves(this.project)
const folders = []
this.ProjectEntityHandler.getAllEntitiesFromProject.returns({
@@ -2223,13 +2190,10 @@ describe('ProjectEntityUpdateHandler', function () {
files,
folders,
})
- this.ProjectEntityUpdateHandler.resyncProjectHistory(
+
+ await this.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
- {},
- (...args) => {
- this.callback(...args)
- done()
- }
+ {}
)
})
@@ -2254,14 +2218,10 @@ describe('ProjectEntityUpdateHandler', function () {
.calledWith(projectId, projectHistoryId, docs, files)
.should.equal(true)
})
-
- it('calls the callback', function () {
- this.callback.called.should.equal(true)
- })
})
describe('a project with duplicate filenames', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectGetter.promises.getProject.resolves(this.project)
this.docs = [
{ doc: { _id: 'doc1', name: 'main.tex' }, path: 'main.tex' },
@@ -2305,10 +2265,9 @@ describe('ProjectEntityUpdateHandler', function () {
files: this.files,
folders: [],
})
- this.ProjectEntityUpdateHandler.resyncProjectHistory(
+ await this.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
- {},
- done
+ {}
)
})
@@ -2320,25 +2279,29 @@ describe('ProjectEntityUpdateHandler', function () {
projectId,
'doc3',
'doc',
- 'duplicate.tex (1)'
+ 'duplicate.tex (1)',
+ null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'doc5',
'doc',
- 'duplicate.tex (2)'
+ 'duplicate.tex (2)',
+ null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'file3',
'file',
- 'duplicate.jpg (1)'
+ 'duplicate.jpg (1)',
+ null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'file4',
'file',
- 'another dupe (23)'
+ 'another dupe (23)',
+ null
)
})
@@ -2368,7 +2331,7 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('a project with bad filenames', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectGetter.promises.getProject.resolves(this.project)
this.docs = [
{
@@ -2395,10 +2358,9 @@ describe('ProjectEntityUpdateHandler', function () {
files: this.files,
folders: [],
})
- this.ProjectEntityUpdateHandler.resyncProjectHistory(
+ await this.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
- {},
- done
+ {}
)
})
@@ -2410,25 +2372,29 @@ describe('ProjectEntityUpdateHandler', function () {
projectId,
'doc1',
'doc',
- '_d_e_f_test.tex'
+ '_d_e_f_test.tex',
+ null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'doc2',
'doc',
- 'untitled'
+ 'untitled',
+ null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'file1',
'file',
- 'A_.png'
+ 'A_.png',
+ null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'file2',
'file',
- 'A_.png (1)'
+ 'A_.png (1)',
+ null
)
})
@@ -2479,17 +2445,16 @@ describe('ProjectEntityUpdateHandler', function () {
},
]
const files = []
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectGetter.promises.getProject.resolves(this.project)
this.ProjectEntityHandler.getAllEntitiesFromProject.returns({
docs,
files,
folders,
})
- this.ProjectEntityUpdateHandler.resyncProjectHistory(
+ await this.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
- {},
- done
+ {}
)
})
@@ -2501,7 +2466,8 @@ describe('ProjectEntityUpdateHandler', function () {
projectId,
'folder2',
'folder',
- 'bad_'
+ 'bad_',
+ null
)
})
@@ -2536,17 +2502,16 @@ describe('ProjectEntityUpdateHandler', function () {
},
]
const files = []
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectGetter.promises.getProject.resolves(this.project)
this.ProjectEntityHandler.getAllEntitiesFromProject.returns({
docs,
files,
folders,
})
- this.ProjectEntityUpdateHandler.resyncProjectHistory(
+ await this.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
- {},
- done
+ {}
)
})
@@ -2558,7 +2523,8 @@ describe('ProjectEntityUpdateHandler', function () {
projectId,
'doc1',
'doc',
- 'chapters (1)'
+ 'chapters (1)',
+ null
)
})
@@ -2576,24 +2542,25 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('a project with an invalid file tree', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.callback = sinon.stub()
this.ProjectGetter.promises.getProject.resolves(this.project)
this.ProjectEntityHandler.getAllEntitiesFromProject.throws()
- this.ProjectEntityUpdateHandler.resyncProjectHistory(
- projectId,
- {},
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('calls the callback with an error', function () {
- expect(this.callback).to.have.been.calledWith(
- sinon.match.instanceOf(Error)
- )
+ it('calls the callback with an error', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
+ projectId,
+ {}
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
})
@@ -2925,13 +2892,12 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('successfully', function () {
- beforeEach(function (done) {
- this.ProjectEntityUpdateHandler.convertDocToFile(
+ beforeEach(async function () {
+ await this.ProjectEntityUpdateHandler.promises.convertDocToFile(
this.project._id,
this.doc._id,
- this.user._id,
- this.source,
- done
+ userId,
+ this.source
)
})
@@ -2960,7 +2926,12 @@ describe('ProjectEntityUpdateHandler', function () {
it('replaces the doc with the file', function () {
expect(
this.ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile
- ).to.have.been.calledWith(this.project._id, this.doc._id, this.file)
+ ).to.have.been.calledWith(
+ this.project._id,
+ this.doc._id,
+ this.file,
+ userId
+ )
})
it('notifies document updater of changes', function () {
@@ -2969,7 +2940,7 @@ describe('ProjectEntityUpdateHandler', function () {
).to.have.been.calledWith(
this.project._id,
this.project.overleaf.history.id,
- this.user._id,
+ userId,
{
oldDocs: [{ doc: this.doc, path: this.path }],
newFiles: [
@@ -3012,7 +2983,7 @@ describe('ProjectEntityUpdateHandler', function () {
})
describe('when the doc has ranges', function () {
- it('should throw a DocHasRangesError', function (done) {
+ it('should throw a DocHasRangesError', async function () {
this.ranges = { comments: [{ id: 123 }] }
this.DocstoreManager.promises.getDoc
.withArgs(this.project._id, this.doc._id)
@@ -3022,16 +2993,20 @@ describe('ProjectEntityUpdateHandler', function () {
version: 'version',
ranges: this.ranges,
})
- this.ProjectEntityUpdateHandler.convertDocToFile(
- this.project._id,
- this.doc._id,
- this.user._id,
- this.source,
- err => {
- expect(err).to.be.instanceof(Errors.DocHasRangesError)
- done()
- }
- )
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.convertDocToFile(
+ this.project._id,
+ this.doc._id,
+ this.user._id,
+ this.source
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.DocHasRangesError)
})
})
})
@@ -3076,7 +3051,7 @@ describe('ProjectEntityUpdateHandler', function () {
describe('setMainBibliographyDoc', function () {
describe('on success', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.doc = {
_id: new ObjectId(),
name: 'test.bib',
@@ -3086,12 +3061,9 @@ describe('ProjectEntityUpdateHandler', function () {
.withArgs(this.project._id, this.doc._id)
.resolves(this.path)
- this.callback = sinon.stub().callsFake(() => done())
-
- this.ProjectEntityUpdateHandler.setMainBibliographyDoc(
+ await this.ProjectEntityUpdateHandler.promises.setMainBibliographyDoc(
this.project._id,
- this.doc._id,
- this.callback
+ this.doc._id
)
})
@@ -3105,7 +3077,8 @@ describe('ProjectEntityUpdateHandler', function () {
describe('on failure', function () {
describe("when document can't be found", function () {
- beforeEach(function (done) {
+ let setMainBibliographyDocPromise
+ beforeEach(function () {
this.doc = {
_id: new ObjectId(),
name: 'test.bib',
@@ -3113,29 +3086,42 @@ describe('ProjectEntityUpdateHandler', function () {
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId
.withArgs(this.project._id, this.doc._id)
.rejects(new Error('error'))
-
- this.callback = sinon.stub().callsFake(() => done())
-
- this.ProjectEntityUpdateHandler.setMainBibliographyDoc(
- this.project._id,
- this.doc._id,
- this.callback
- )
+ setMainBibliographyDocPromise =
+ this.ProjectEntityUpdateHandler.promises.setMainBibliographyDoc(
+ this.project._id,
+ this.doc._id
+ )
})
- it('should call the callback with an error', function () {
- expect(this.callback).to.have.been.calledWith(
- sinon.match.instanceOf(Error)
- )
+ it('should call the callback with an error', async function () {
+ let error
+
+ try {
+ await setMainBibliographyDocPromise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
- it('should not update the project with the new main bibliography doc', function () {
+ it('should not update the project with the new main bibliography doc', async function () {
+ let error
+
+ try {
+ await setMainBibliographyDocPromise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
expect(this.ProjectModel.updateOne).to.not.have.been.called
})
})
describe("when path is not a bib file can't be found", function () {
- beforeEach(function (done) {
+ let setMainBibliographyDocPromise
+ beforeEach(function () {
this.doc = {
_id: new ObjectId(),
name: 'test.bib',
@@ -3145,23 +3131,35 @@ describe('ProjectEntityUpdateHandler', function () {
this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId
.withArgs(this.project._id, this.doc._id)
.resolves(this.path)
-
- this.callback = sinon.stub().callsFake(() => done())
-
- this.ProjectEntityUpdateHandler.setMainBibliographyDoc(
- this.project._id,
- this.doc._id,
- this.callback
- )
+ setMainBibliographyDocPromise =
+ this.ProjectEntityUpdateHandler.promises.setMainBibliographyDoc(
+ this.project._id,
+ this.doc._id
+ )
})
- it('should call the callback with an error', function () {
- expect(this.callback).to.have.been.calledWith(
- sinon.match.instanceOf(Error)
- )
+ it('should reject with an error', async function () {
+ let error
+
+ try {
+ await setMainBibliographyDocPromise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
- it('should not update the project with the new main bibliography doc', function () {
+ it('should not update the project with the new main bibliography doc', async function () {
+ let error
+
+ try {
+ await setMainBibliographyDocPromise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
expect(this.ProjectModel.updateOne).to.not.have.been.called
})
})
@@ -3170,40 +3168,54 @@ describe('ProjectEntityUpdateHandler', function () {
describe('appendToDoc', function () {
describe('when document cannot be found', function () {
- beforeEach(function (done) {
+ let appendToDocPromise
+ beforeEach(function () {
this.appendedLines = ['5678', 'def']
this.DocumentUpdaterHandler.promises.appendToDocument = sinon.stub()
this.ProjectLocator.promises.findElement = sinon.stub()
this.ProjectLocator.promises.findElement
.withArgs({ project_id: projectId, element_id: docId, type: 'doc' })
.rejects(new Errors.NotFoundError())
- this.ProjectEntityUpdateHandler.appendToDoc(
- projectId,
- docId,
- this.appendedLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ appendToDocPromise =
+ this.ProjectEntityUpdateHandler.promises.appendToDocWithPath(
+ projectId,
+ docId,
+ this.appendedLines,
+ this.source,
+ userId
+ )
})
- it('should not talk to DocumentUpdaterHandler', function () {
+ it('should not talk to DocumentUpdaterHandler', async function () {
+ let error
+
+ try {
+ await appendToDocPromise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
this.DocumentUpdaterHandler.promises.appendToDocument.should.not.have
.been.called
})
- it('should throw the error', function () {
- this.callback.should.have.been.calledWith(
- sinon.match.instanceOf(Errors.NotFoundError)
- )
+ it('should throw the error', async function () {
+ let error
+
+ try {
+ await appendToDocPromise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.NotFoundError)
})
})
describe('when document is found', function () {
- beforeEach(function (done) {
+ let appendToDocResult
+ beforeEach(async function () {
this.appendedLines = ['5678', 'def']
this.DocumentUpdaterHandler.promises.appendToDocument = sinon.stub()
this.DocumentUpdaterHandler.promises.appendToDocument
@@ -3213,17 +3225,14 @@ describe('ProjectEntityUpdateHandler', function () {
this.ProjectLocator.promises.findElement
.withArgs({ project_id: projectId, element_id: docId, type: 'doc' })
.resolves({ element: { _id: docId } })
- this.ProjectEntityUpdateHandler.appendToDoc(
- projectId,
- docId,
- this.appendedLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
+ appendToDocResult =
+ await this.ProjectEntityUpdateHandler.promises.appendToDocWithPath(
+ projectId,
+ docId,
+ this.appendedLines,
+ this.source,
+ userId
+ )
})
it('should forward call to DocumentUpdaterHandler.appendToDocument', function () {
@@ -3237,12 +3246,12 @@ describe('ProjectEntityUpdateHandler', function () {
})
it('should return the response from DocumentUpdaterHandler', function () {
- this.callback.should.have.been.calledWith(null, { rev: 1 })
+ expect(appendToDocResult).to.eql({ rev: 1 })
})
})
describe('when DocumentUpdater throws an error', function () {
- beforeEach(function (done) {
+ beforeEach(function () {
this.appendedLines = ['5678', 'def']
this.DocumentUpdaterHandler.promises.appendToDocument = sinon.stub()
this.DocumentUpdaterHandler.promises.appendToDocument.rejects(
@@ -3252,21 +3261,24 @@ describe('ProjectEntityUpdateHandler', function () {
this.ProjectLocator.promises.findElement
.withArgs({ project_id: projectId, element_id: docId, type: 'doc' })
.resolves({ element: { _id: docId } })
- this.ProjectEntityUpdateHandler.appendToDoc(
- projectId,
- docId,
- this.appendedLines,
- this.source,
- userId,
- (...args) => {
- this.callback(...args)
- done()
- }
- )
})
- it('should return the response from DocumentUpdaterHandler', function () {
- this.callback.should.have.been.calledWith(sinon.match.instanceOf(Error))
+ it('should return the response from DocumentUpdaterHandler', async function () {
+ let error
+
+ try {
+ await this.ProjectEntityUpdateHandler.promises.appendToDocWithPath(
+ projectId,
+ docId,
+ this.appendedLines,
+ this.source,
+ userId
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Error)
})
})
})
diff --git a/services/web/test/unit/src/Project/ProjectLocatorTests.js b/services/web/test/unit/src/Project/ProjectLocatorTests.js
index 4647dd0a55..14de550401 100644
--- a/services/web/test/unit/src/Project/ProjectLocatorTests.js
+++ b/services/web/test/unit/src/Project/ProjectLocatorTests.js
@@ -5,7 +5,6 @@ const sinon = require('sinon')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const project = { _id: '1234566', rootFolder: [] }
-class Project {}
const rootDoc = { name: 'rootDoc', _id: 'das239djd' }
const doc1 = { name: 'otherDoc.txt', _id: 'dsad2ddd' }
const doc2 = { name: 'docname.txt', _id: 'dsad2ddddd' }
@@ -40,9 +39,6 @@ project.rootDoc_id = rootDoc._id
describe('ProjectLocator', function () {
beforeEach(function () {
- Project.findById = (projectId, callback) => {
- callback(null, project)
- }
this.ProjectGetter = {
getProject: sinon.stub().callsArgWith(2, null, project),
}
@@ -53,7 +49,6 @@ describe('ProjectLocator', function () {
}
this.locator = SandboxedModule.require(modulePath, {
requires: {
- '../../models/Project': { Project },
'../../models/User': { User: this.User },
'./ProjectGetter': this.ProjectGetter,
'./ProjectHelper': this.ProjectHelper,
@@ -62,170 +57,152 @@ describe('ProjectLocator', function () {
})
describe('finding a doc', function () {
- it('finds one at the root level', function (done) {
- this.locator.findElement(
- { project_id: project._id, element_id: doc2._id, type: 'docs' },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(doc2._id)
- path.fileSystem.should.equal(`/${doc2.name}`)
- parentFolder._id.should.equal(project.rootFolder[0]._id)
- path.mongo.should.equal('rootFolder.0.docs.1')
- done()
+ it('finds one at the root level', async function () {
+ const { element, path, folder } = await this.locator.promises.findElement(
+ {
+ project_id: project._id,
+ element_id: doc2._id,
+ type: 'docs',
}
)
+ element._id.should.equal(doc2._id)
+ path.fileSystem.should.equal(`/${doc2.name}`)
+ folder._id.should.equal(project.rootFolder[0]._id)
+ path.mongo.should.equal('rootFolder.0.docs.1')
})
- it('when it is nested', function (done) {
- this.locator.findElement(
- { project_id: project._id, element_id: subSubDoc._id, type: 'doc' },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- expect(foundElement._id).to.equal(subSubDoc._id)
- path.fileSystem.should.equal(
- `/${subFolder.name}/${secondSubFolder.name}/${subSubDoc.name}`
- )
- parentFolder._id.should.equal(secondSubFolder._id)
- path.mongo.should.equal('rootFolder.0.folders.1.folders.0.docs.0')
- done()
+ it('when it is nested', async function () {
+ const { element, path, folder } = await this.locator.promises.findElement(
+ {
+ project_id: project._id,
+ element_id: subSubDoc._id,
+ type: 'doc',
}
)
+ expect(element._id).to.equal(subSubDoc._id)
+ path.fileSystem.should.equal(
+ `/${subFolder.name}/${secondSubFolder.name}/${subSubDoc.name}`
+ )
+ folder._id.should.equal(secondSubFolder._id)
+ path.mongo.should.equal('rootFolder.0.folders.1.folders.0.docs.0')
})
- it('should give error if element could not be found', function (done) {
- this.locator.findElement(
- { project_id: project._id, element_id: 'ddsd432nj42', type: 'docs' },
- (err, foundElement, path, parentFolder) => {
- expect(err).to.be.instanceOf(Errors.NotFoundError)
- expect(err).to.have.property('message', 'entity not found')
- done()
- }
+ it('should give error if element could not be found', async function () {
+ await expect(
+ this.locator.promises.findElement({
+ project_id: project._id,
+ element_id: 'ddsd432nj42',
+ type: 'docs',
+ })
)
+ .to.eventually.be.rejectedWith(Errors.NotFoundError)
+ .and.eventually.have.property('message', 'entity not found')
})
})
describe('finding a folder', function () {
- it('should return root folder when looking for root folder', function (done) {
- this.locator.findElement(
- { project_id: project._id, element_id: rootFolder._id, type: 'folder' },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(rootFolder._id)
- done()
- }
- )
- })
-
- it('when at root', function (done) {
- this.locator.findElement(
- { project_id: project._id, element_id: subFolder._id, type: 'folder' },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(subFolder._id)
- path.fileSystem.should.equal(`/${subFolder.name}`)
- parentFolder._id.should.equal(rootFolder._id)
- path.mongo.should.equal('rootFolder.0.folders.1')
- done()
- }
- )
- })
-
- it('when deeply nested', function (done) {
- this.locator.findElement(
+ it('should return root folder when looking for root folder', async function () {
+ const { element: foundElement } = await this.locator.promises.findElement(
{
project_id: project._id,
- element_id: secondSubFolder._id,
+ element_id: rootFolder._id,
type: 'folder',
- },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(secondSubFolder._id)
- path.fileSystem.should.equal(
- `/${subFolder.name}/${secondSubFolder.name}`
- )
- parentFolder._id.should.equal(subFolder._id)
- path.mongo.should.equal('rootFolder.0.folders.1.folders.0')
- done()
}
)
+ foundElement._id.should.equal(rootFolder._id)
+ })
+
+ it('when at root', async function () {
+ const {
+ element: foundElement,
+ path,
+ folder: parentFolder,
+ } = await this.locator.promises.findElement({
+ project_id: project._id,
+ element_id: subFolder._id,
+ type: 'folder',
+ })
+ foundElement._id.should.equal(subFolder._id)
+ path.fileSystem.should.equal(`/${subFolder.name}`)
+ parentFolder._id.should.equal(rootFolder._id)
+ path.mongo.should.equal('rootFolder.0.folders.1')
+ })
+
+ it('when deeply nested', async function () {
+ const {
+ element: foundElement,
+ path,
+ folder: parentFolder,
+ } = await this.locator.promises.findElement({
+ project_id: project._id,
+ element_id: secondSubFolder._id,
+ type: 'folder',
+ })
+
+ foundElement._id.should.equal(secondSubFolder._id)
+ path.fileSystem.should.equal(`/${subFolder.name}/${secondSubFolder.name}`)
+ parentFolder._id.should.equal(subFolder._id)
+ path.mongo.should.equal('rootFolder.0.folders.1.folders.0')
})
})
describe('finding a file', function () {
- it('when at root', function (done) {
- this.locator.findElement(
- { project_id: project._id, element_id: file1._id, type: 'fileRefs' },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(file1._id)
- path.fileSystem.should.equal(`/${file1.name}`)
- parentFolder._id.should.equal(rootFolder._id)
- path.mongo.should.equal('rootFolder.0.fileRefs.0')
- done()
- }
- )
+ it('when at root', async function () {
+ const {
+ element: foundElement,
+ path,
+ folder: parentFolder,
+ } = await this.locator.promises.findElement({
+ project_id: project._id,
+ element_id: file1._id,
+ type: 'fileRefs',
+ })
+ foundElement._id.should.equal(file1._id)
+ path.fileSystem.should.equal(`/${file1.name}`)
+ parentFolder._id.should.equal(rootFolder._id)
+ path.mongo.should.equal('rootFolder.0.fileRefs.0')
})
- it('when deeply nested', function (done) {
- this.locator.findElement(
- {
- project_id: project._id,
- element_id: subSubFile._id,
- type: 'fileRefs',
- },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(subSubFile._id)
- path.fileSystem.should.equal(
- `/${subFolder.name}/${secondSubFolder.name}/${subSubFile.name}`
- )
- parentFolder._id.should.equal(secondSubFolder._id)
- path.mongo.should.equal('rootFolder.0.folders.1.folders.0.fileRefs.0')
- done()
- }
+ it('when deeply nested', async function () {
+ const {
+ element: foundElement,
+ path,
+ folder: parentFolder,
+ } = await this.locator.promises.findElement({
+ project_id: project._id,
+ element_id: subSubFile._id,
+ type: 'fileRefs',
+ })
+ foundElement._id.should.equal(subSubFile._id)
+ path.fileSystem.should.equal(
+ `/${subFolder.name}/${secondSubFolder.name}/${subSubFile.name}`
)
+ parentFolder._id.should.equal(secondSubFolder._id)
+ path.mongo.should.equal('rootFolder.0.folders.1.folders.0.fileRefs.0')
})
})
describe('finding an element with wrong element type', function () {
- it('should add an s onto the element type', function (done) {
- this.locator.findElement(
- { project_id: project._id, element_id: subSubDoc._id, type: 'doc' },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(subSubDoc._id)
- done()
+ it('should add an s onto the element type', async function () {
+ const { element: foundElement } = await this.locator.promises.findElement(
+ {
+ project_id: project._id,
+ element_id: subSubDoc._id,
+ type: 'doc',
}
)
+ foundElement._id.should.equal(subSubDoc._id)
})
- it('should convert file to fileRefs', function (done) {
- this.locator.findElement(
- { project_id: project._id, element_id: file1._id, type: 'fileRefs' },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(file1._id)
- done()
+ it('should convert file to fileRefs', async function () {
+ const { element: foundElement } = await this.locator.promises.findElement(
+ {
+ project_id: project._id,
+ element_id: file1._id,
+ type: 'fileRefs',
}
)
+ foundElement._id.should.equal(file1._id)
})
})
@@ -242,233 +219,173 @@ describe('ProjectLocator', function () {
_id: '1234566',
rootFolder: [rootFolder2],
}
- it('should find doc in project', function (done) {
- this.locator.findElement(
- { project: project2, element_id: doc3._id, type: 'docs' },
- (err, foundElement, path, parentFolder) => {
- if (err != null) {
- return done(err)
- }
- foundElement._id.should.equal(doc3._id)
- path.fileSystem.should.equal(`/${doc3.name}`)
- parentFolder._id.should.equal(project2.rootFolder[0]._id)
- path.mongo.should.equal('rootFolder.0.docs.0')
- done()
- }
- )
+ it('should find doc in project', async function () {
+ const {
+ element: foundElement,
+ path,
+ folder: parentFolder,
+ } = await this.locator.promises.findElement({
+ project: project2,
+ element_id: doc3._id,
+ type: 'docs',
+ })
+ foundElement._id.should.equal(doc3._id)
+ path.fileSystem.should.equal(`/${doc3.name}`)
+ parentFolder._id.should.equal(project2.rootFolder[0]._id)
+ path.mongo.should.equal('rootFolder.0.docs.0')
})
})
describe('finding root doc', function () {
- it('should return root doc when passed project', function (done) {
- this.locator.findRootDoc(project, (err, doc) => {
- if (err != null) {
- return done(err)
- }
- doc._id.should.equal(rootDoc._id)
- done()
- })
+ it('should return root doc when passed project', async function () {
+ const { element: doc } = await this.locator.promises.findRootDoc(project)
+ doc._id.should.equal(rootDoc._id)
})
- it('should return root doc when passed project_id', function (done) {
- this.locator.findRootDoc(project._id, (err, doc) => {
- if (err != null) {
- return done(err)
- }
- doc._id.should.equal(rootDoc._id)
- done()
- })
+ it('should return root doc when passed project_id', async function () {
+ const { element: doc } = await this.locator.promises.findRootDoc(
+ project._id
+ )
+ doc._id.should.equal(rootDoc._id)
})
- it('should return null when the project has no rootDoc', function (done) {
+ it('should return null when the project has no rootDoc', async function () {
project.rootDoc_id = null
- this.locator.findRootDoc(project, (err, doc) => {
- if (err != null) {
- return done(err)
- }
- expect(doc).to.equal(null)
- done()
- })
+ const { element: rootDoc } =
+ await this.locator.promises.findRootDoc(project)
+ expect(rootDoc).to.equal(null)
})
- it('should return null when the rootDoc_id no longer exists', function (done) {
+ it('should return null when the rootDoc_id no longer exists', async function () {
project.rootDoc_id = 'doesntexist'
- this.locator.findRootDoc(project, (err, doc) => {
- if (err != null) {
- return done(err)
- }
- expect(doc).to.equal(null)
- done()
- })
+ const { element: rootDoc } =
+ await this.locator.promises.findRootDoc(project)
+ expect(rootDoc).to.equal(null)
})
})
describe('findElementByPath', function () {
- it('should take a doc path and return the element for a root level document', function (done) {
+ it('should take a doc path and return the element for a root level document', async function () {
const path = `${doc1.name}`
- this.locator.findElementByPath(
- { project, path },
- (err, element, type, folder) => {
- if (err != null) {
- return done(err)
- }
- element.should.deep.equal(doc1)
- expect(type).to.equal('doc')
- expect(folder).to.equal(rootFolder)
- done()
- }
- )
+ const { element, type, folder } =
+ await this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ element.should.deep.equal(doc1)
+ expect(type).to.equal('doc')
+ expect(folder).to.equal(rootFolder)
})
- it('should take a doc path and return the element for a root level document with a starting slash', function (done) {
+ it('should take a doc path and return the element for a root level document with a starting slash', async function () {
const path = `/${doc1.name}`
- this.locator.findElementByPath(
- { project, path },
- (err, element, type, folder) => {
- if (err != null) {
- return done(err)
- }
- element.should.deep.equal(doc1)
- expect(type).to.equal('doc')
- expect(folder).to.equal(rootFolder)
- done()
- }
- )
+ const { element, type, folder } =
+ await this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ element.should.deep.equal(doc1)
+ expect(type).to.equal('doc')
+ expect(folder).to.equal(rootFolder)
})
- it('should take a doc path and return the element for a nested document', function (done) {
+ it('should take a doc path and return the element for a nested document', async function () {
const path = `${subFolder.name}/${secondSubFolder.name}/${subSubDoc.name}`
- this.locator.findElementByPath(
- { project, path },
- (err, element, type, folder) => {
- if (err != null) {
- return done(err)
- }
- element.should.deep.equal(subSubDoc)
- expect(type).to.equal('doc')
- expect(folder).to.equal(secondSubFolder)
- done()
- }
- )
+ const { element, type, folder } =
+ await this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ element.should.deep.equal(subSubDoc)
+ expect(type).to.equal('doc')
+ expect(folder).to.equal(secondSubFolder)
})
- it('should take a file path and return the element for a root level document', function (done) {
+ it('should take a file path and return the element for a root level document', async function () {
const path = `${file1.name}`
- this.locator.findElementByPath(
- { project, path },
- (err, element, type, folder) => {
- if (err != null) {
- return done(err)
- }
- element.should.deep.equal(file1)
- expect(type).to.equal('file')
- expect(folder).to.equal(rootFolder)
- done()
- }
- )
+ const { element, type, folder } =
+ await this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ element.should.deep.equal(file1)
+ expect(type).to.equal('file')
+ expect(folder).to.equal(rootFolder)
})
- it('should take a file path and return the element for a nested document', function (done) {
+ it('should take a file path and return the element for a nested document', async function () {
const path = `${subFolder.name}/${secondSubFolder.name}/${subSubFile.name}`
- this.locator.findElementByPath(
- { project, path },
- (err, element, type, folder) => {
- if (err != null) {
- return done(err)
- }
- element.should.deep.equal(subSubFile)
- expect(type).to.equal('file')
- expect(folder).to.equal(secondSubFolder)
- done()
- }
- )
+ const { element, type, folder } =
+ await this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ element.should.deep.equal(subSubFile)
+ expect(type).to.equal('file')
+ expect(folder).to.equal(secondSubFolder)
})
- it('should take a file path and return the element for a nested document case insenstive', function (done) {
+ it('should take a file path and return the element for a nested document case insenstive', async function () {
const path = `${subFolder.name.toUpperCase()}/${secondSubFolder.name.toUpperCase()}/${subSubFile.name.toUpperCase()}`
- this.locator.findElementByPath(
- { project, path },
- (err, element, type, folder) => {
- if (err != null) {
- return done(err)
- }
- element.should.deep.equal(subSubFile)
- expect(type).to.equal('file')
- expect(folder).to.equal(secondSubFolder)
- done()
- }
- )
+ const { element, type, folder } =
+ await this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ element.should.deep.equal(subSubFile)
+ expect(type).to.equal('file')
+ expect(folder).to.equal(secondSubFolder)
})
- it('should not return elements with a case-insensitive match when exactCaseMatch is true', function (done) {
+ it('should not return elements with a case-insensitive match when exactCaseMatch is true', async function () {
const path = `${subFolder.name.toUpperCase()}/${secondSubFolder.name.toUpperCase()}/${subSubFile.name.toUpperCase()}`
- this.locator.findElementByPath(
- { project, path, exactCaseMatch: true },
- (err, element, type, folder) => {
- err.should.not.equal(undefined)
- expect(element).to.be.undefined
- expect(type).to.be.undefined
- done()
- }
- )
+ await expect(
+ this.locator.promises.findElementByPath({
+ project,
+ path,
+ exactCaseMatch: true,
+ })
+ ).to.eventually.be.rejected
})
- it('should take a file path and return the element for a nested folder', function (done) {
+ it('should take a file path and return the element for a nested folder', async function () {
const path = `${subFolder.name}/${secondSubFolder.name}`
- this.locator.findElementByPath(
- { project, path },
- (err, element, type, folder) => {
- if (err != null) {
- return done(err)
- }
- element.should.deep.equal(secondSubFolder)
- expect(type).to.equal('folder')
- expect(folder).to.equal(subFolder)
- done()
- }
- )
+ const { element, type, folder } =
+ await this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ element.should.deep.equal(secondSubFolder)
+ expect(type).to.equal('folder')
+ expect(folder).to.equal(subFolder)
})
- it('should take a file path and return the root folder', function (done) {
+ it('should take a file path and return the root folder', async function () {
const path = '/'
- this.locator.findElementByPath(
- { project, path },
- (err, element, type, folder) => {
- if (err != null) {
- return done(err)
- }
- element.should.deep.equal(rootFolder)
- expect(type).to.equal('folder')
- expect(folder).to.equal(null)
- done()
- }
- )
+ const { element, type, folder } =
+ await this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ element.should.deep.equal(rootFolder)
+ expect(type).to.equal('folder')
+ expect(folder).to.equal(null)
})
- it('should return an error if the file can not be found inside know folder', function (done) {
+ it('should return an error if the file can not be found inside know folder', async function () {
const path = `${subFolder.name}/${secondSubFolder.name}/exist.txt`
- this.locator.findElementByPath(
- { project, path },
- (err, element, type) => {
- err.should.not.equal(undefined)
- expect(element).to.be.undefined
- expect(type).to.be.undefined
- done()
- }
- )
+ await expect(this.locator.promises.findElementByPath({ project, path }))
+ .to.eventually.be.rejected
})
- it('should return an error if the file can not be found inside unknown folder', function (done) {
+ it('should return an error if the file can not be found inside unknown folder', async function () {
const path = 'this/does/not/exist.txt'
- this.locator.findElementByPath(
- { project, path },
- (err, element, type) => {
- err.should.not.equal(undefined)
- expect(element).to.be.undefined
- expect(type).to.be.undefined
- done()
- }
- )
+ await expect(
+ this.locator.promises.findElementByPath({
+ project,
+ path,
+ })
+ ).to.eventually.be.rejected
})
describe('where duplicate folder exists', function () {
@@ -498,18 +415,20 @@ describe('ProjectLocator', function () {
}
})
- it('should not call the callback more than once', function (done) {
+ it('should not call the callback more than once', async function () {
const path = `${this.duplicateFolder.name}/${this.doc.name}`
- this.locator.findElementByPath({ project: this.project, path }, () =>
- done()
- )
+ await this.locator.promises.findElementByPath({
+ project: this.project,
+ path,
+ })
}) // mocha will throw exception if done called multiple times
- it('should not call the callback more than once when the path is longer than 1 level below the duplicate level', function (done) {
+ it('should not call the callback more than once when the path is longer than 1 level below the duplicate level', async function () {
const path = `${this.duplicateFolder.name}/1/main.tex`
- this.locator.findElementByPath({ project: this.project, path }, () =>
- done()
- )
+ await this.locator.promises.findElementByPath({
+ project: this.project,
+ path,
+ })
})
}) // mocha will throw exception if done called multiple times
@@ -526,18 +445,13 @@ describe('ProjectLocator', function () {
}
})
- it('should not crash with a null', function (done) {
+ it('should not crash with a null', async function () {
const path = '/other.tex'
- this.locator.findElementByPath(
- { project: this.project, path },
- (err, element) => {
- if (err != null) {
- return done(err)
- }
- element.name.should.equal('other.tex')
- done()
- }
- )
+ const { element } = await this.locator.promises.findElementByPath({
+ project: this.project,
+ path,
+ })
+ element.name.should.equal('other.tex')
})
})
@@ -546,35 +460,31 @@ describe('ProjectLocator', function () {
this.ProjectGetter = { getProject: sinon.stub().callsArg(2) }
})
- it('should not crash with a null', function (done) {
+ it('should not crash with a null', async function () {
const path = '/other.tex'
- this.locator.findElementByPath(
- { project_id: project._id, path },
- (err, element) => {
- expect(err).to.exist
- done()
- }
- )
+ await expect(
+ this.locator.promises.findElementByPath({
+ project_id: project._id,
+ path,
+ })
+ ).to.be.rejected
})
})
describe('with a project_id', function () {
- it('should take a doc path and return the element for a root level document', function (done) {
+ it('should take a doc path and return the element for a root level document', async function () {
const path = `${doc1.name}`
- this.locator.findElementByPath(
- { project_id: project._id, path },
- (err, element, type) => {
- if (err != null) {
- return done(err)
- }
- this.ProjectGetter.getProject
- .calledWith(project._id, { rootFolder: true, rootDoc_id: true })
- .should.equal(true)
- element.should.deep.equal(doc1)
- expect(type).to.equal('doc')
- done()
+ const { element, type } = await this.locator.promises.findElementByPath(
+ {
+ project_id: project._id,
+ path,
}
)
+ this.ProjectGetter.getProject
+ .calledWith(project._id, { rootFolder: true, rootDoc_id: true })
+ .should.equal(true)
+ element.should.deep.equal(doc1)
+ expect(type).to.equal('doc')
})
})
})
diff --git a/services/web/test/unit/src/Project/ProjectRootDocManagerTests.js b/services/web/test/unit/src/Project/ProjectRootDocManagerTests.js
index 41b7516baa..ec4ea57778 100644
--- a/services/web/test/unit/src/Project/ProjectRootDocManagerTests.js
+++ b/services/web/test/unit/src/Project/ProjectRootDocManagerTests.js
@@ -1,16 +1,3 @@
-/* eslint-disable
- max-len,
- no-return-assign,
- no-unused-vars,
- no-useless-escape,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const sinon = require('sinon')
@@ -33,16 +20,13 @@ describe('ProjectRootDocManager', function () {
this.sl_req_id = 'sl-req-id-123'
this.callback = sinon.stub()
this.globbyFiles = ['a.tex', 'b.tex', 'main.tex']
- this.globby = sinon.stub().returns(
- new Promise(resolve => {
- return resolve(this.globbyFiles)
- })
- )
+ this.globby = sinon.stub().resolves(this.globbyFiles)
+
this.fs = {
readFile: sinon.stub().callsArgWith(2, new Error('file not found')),
stat: sinon.stub().callsArgWith(1, null, { size: 100 }),
}
- return (this.ProjectRootDocManager = SandboxedModule.require(modulePath, {
+ this.ProjectRootDocManager = SandboxedModule.require(modulePath, {
requires: {
'./ProjectEntityHandler': (this.ProjectEntityHandler = {}),
'./ProjectEntityUpdateHandler': (this.ProjectEntityUpdateHandler = {}),
@@ -56,7 +40,7 @@ describe('ProjectRootDocManager', function () {
globby: this.globby,
fs: this.fs,
},
- }))
+ })
})
describe('setRootDocAutomatically', function () {
@@ -67,7 +51,7 @@ describe('ProjectRootDocManager', function () {
.returns(true)
})
describe('when there is a suitable root doc', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.docs = {
'/chapter1.tex': {
_id: this.docId1,
@@ -98,27 +82,26 @@ describe('ProjectRootDocManager', function () {
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
- this.ProjectRootDocManager.setRootDocAutomatically(
- this.project_id,
- done
+ await this.ProjectRootDocManager.promises.setRootDocAutomatically(
+ this.project_id
)
})
it('should check the docs of the project', function () {
- return this.ProjectEntityHandler.getAllDocs
+ this.ProjectEntityHandler.getAllDocs
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc to the doc containing a documentclass', function () {
- return this.ProjectEntityUpdateHandler.setRootDoc
+ this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2)
.should.equal(true)
})
})
describe('when the root doc is an Rtex file', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.docs = {
'/chapter1.tex': {
_id: this.docId1,
@@ -132,21 +115,20 @@ describe('ProjectRootDocManager', function () {
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
- return this.ProjectRootDocManager.setRootDocAutomatically(
- this.project_id,
- done
+ await this.ProjectRootDocManager.promises.setRootDocAutomatically(
+ this.project_id
)
})
it('should set the root doc to the doc containing a documentclass', function () {
- return this.ProjectEntityUpdateHandler.setRootDoc
+ this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2)
.should.equal(true)
})
})
describe('when there is no suitable root doc', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.docs = {
'/chapter1.tex': {
_id: this.docId1,
@@ -160,16 +142,13 @@ describe('ProjectRootDocManager', function () {
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
- return this.ProjectRootDocManager.setRootDocAutomatically(
- this.project_id,
- done
+ await this.ProjectRootDocManager.promises.setRootDocAutomatically(
+ this.project_id
)
})
it('should not set the root doc to the doc containing a documentclass', function () {
- return this.ProjectEntityUpdateHandler.setRootDoc.called.should.equal(
- false
- )
+ this.ProjectEntityUpdateHandler.setRootDoc.called.should.equal(false)
})
})
})
@@ -191,13 +170,13 @@ describe('ProjectRootDocManager', function () {
this.fs.readFile
.withArgs('/foo/a/a.tex')
.callsArgWith(2, null, 'Potato? Potahto. Potootee!')
- return (this.documentclassContent = '% test\n\\documentclass\n% test')
+ this.documentclassContent = '% test\n\\documentclass\n% test'
})
describe('when there is a file in a subfolder', function () {
beforeEach(function () {
// have to splice globbyFiles weirdly because of the way the stubbed globby method handles references
- return this.globbyFiles.splice(
+ this.globbyFiles.splice(
0,
this.globbyFiles.length,
'c.tex',
@@ -207,70 +186,56 @@ describe('ProjectRootDocManager', function () {
)
})
- it('processes the root folder files first, and then the subfolder, in alphabetical order', function (done) {
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- (error, path) => {
- expect(error).not.to.exist
- expect(path).to.equal('a.tex')
- sinon.assert.callOrder(
- this.fs.readFile.withArgs('/foo/a.tex'),
- this.fs.readFile.withArgs('/foo/b.tex'),
- this.fs.readFile.withArgs('/foo/c.tex'),
- this.fs.readFile.withArgs('/foo/a/a.tex')
- )
- return done()
- }
+ it('processes the root folder files first, and then the subfolder, in alphabetical order', async function () {
+ const { path } =
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
+ )
+ expect(path).to.equal('a.tex')
+ sinon.assert.callOrder(
+ this.fs.readFile.withArgs('/foo/a.tex'),
+ this.fs.readFile.withArgs('/foo/b.tex'),
+ this.fs.readFile.withArgs('/foo/c.tex'),
+ this.fs.readFile.withArgs('/foo/a/a.tex')
)
})
- it('processes smaller files first', function (done) {
+ it('processes smaller files first', async function () {
this.fs.stat.withArgs('/foo/c.tex').callsArgWith(1, null, { size: 1 })
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- (error, path) => {
- expect(error).not.to.exist
- expect(path).to.equal('c.tex')
- sinon.assert.callOrder(
- this.fs.readFile.withArgs('/foo/c.tex'),
- this.fs.readFile.withArgs('/foo/a.tex'),
- this.fs.readFile.withArgs('/foo/b.tex'),
- this.fs.readFile.withArgs('/foo/a/a.tex')
- )
- return done()
- }
+ const { path } =
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
+ )
+ expect(path).to.equal('c.tex')
+ sinon.assert.callOrder(
+ this.fs.readFile.withArgs('/foo/c.tex'),
+ this.fs.readFile.withArgs('/foo/a.tex'),
+ this.fs.readFile.withArgs('/foo/b.tex'),
+ this.fs.readFile.withArgs('/foo/a/a.tex')
)
})
})
describe('when main.tex contains a documentclass', function () {
beforeEach(function () {
- return this.fs.readFile
+ this.fs.readFile
.withArgs('/foo/main.tex')
.callsArgWith(2, null, this.documentclassContent)
})
- it('returns main.tex', function (done) {
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- (error, path, content) => {
- expect(error).not.to.exist
- expect(path).to.equal('main.tex')
- expect(content).to.equal(this.documentclassContent)
- return done()
- }
- )
+ it('returns main.tex', async function () {
+ const { path, content } =
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
+ )
+ expect(path).to.equal('main.tex')
+ expect(content).to.equal(this.documentclassContent)
})
- it('processes main.text first and stops processing when it finds the content', function (done) {
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- () => {
- expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
- expect(this.fs.readFile).not.to.be.calledWith('/foo/a.tex')
- return done()
- }
- )
+ it('processes main.text first and stops processing when it finds the content', async function () {
+ await this.ProjectRootDocManager.findRootDocFileFromDirectory('/foo')
+ expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
+ expect(this.fs.readFile).not.to.be.calledWith('/foo/a.tex')
})
})
@@ -284,7 +249,7 @@ describe('ProjectRootDocManager', function () {
.callsArgWith(2, null, 'foo')
})
- it('returns the first .tex file from the root folder', function (done) {
+ it('returns the first .tex file from the root folder', async function () {
this.globbyFiles.splice(
0,
this.globbyFiles.length,
@@ -293,18 +258,15 @@ describe('ProjectRootDocManager', function () {
'nested/chapter1a.tex'
)
- this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- (error, path, content) => {
- expect(error).not.to.exist
- expect(path).to.equal('a.tex')
- expect(content).to.equal('foo')
- return done()
- }
- )
+ const { path, content } =
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
+ )
+ expect(path).to.equal('a.tex')
+ expect(content).to.equal('foo')
})
- it('returns main.tex file from the root folder', function (done) {
+ it('returns main.tex file from the root folder', async function () {
this.globbyFiles.splice(
0,
this.globbyFiles.length,
@@ -314,227 +276,206 @@ describe('ProjectRootDocManager', function () {
'nested/chapter1a.tex'
)
- this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- (error, path, content) => {
- expect(error).not.to.exist
- expect(path).to.equal('main.tex')
- expect(content).to.equal('foo')
- return done()
- }
- )
+ const { path, content } =
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
+ )
+ expect(path).to.equal('main.tex')
+ expect(content).to.equal('foo')
})
})
describe('when a.tex contains a documentclass', function () {
beforeEach(function () {
- return this.fs.readFile
+ this.fs.readFile
.withArgs('/foo/a.tex')
.callsArgWith(2, null, this.documentclassContent)
})
- it('returns a.tex', function (done) {
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- (error, path, content) => {
- expect(error).not.to.exist
- expect(path).to.equal('a.tex')
- expect(content).to.equal(this.documentclassContent)
- return done()
- }
- )
+ it('returns a.tex', async function () {
+ const { path, content } =
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
+ )
+ expect(path).to.equal('a.tex')
+ expect(content).to.equal(this.documentclassContent)
})
- it('processes main.text first and stops processing when it finds the content', function (done) {
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- () => {
- expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
- expect(this.fs.readFile).to.be.calledWith('/foo/a.tex')
- expect(this.fs.readFile).not.to.be.calledWith('/foo/b.tex')
- return done()
- }
+ it('processes main.text first and stops processing when it finds the content', async function () {
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
)
+ expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
+ expect(this.fs.readFile).to.be.calledWith('/foo/a.tex')
+ expect(this.fs.readFile).not.to.be.calledWith('/foo/b.tex')
})
})
describe('when there is no documentclass', function () {
- it('returns with no error', function (done) {
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- error => {
- expect(error).not.to.exist
- return done()
- }
+ it('returns with no error', async function () {
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
)
})
- it('processes all the files', function (done) {
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- () => {
- expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
- expect(this.fs.readFile).to.be.calledWith('/foo/a.tex')
- expect(this.fs.readFile).to.be.calledWith('/foo/b.tex')
- return done()
- }
+ it('processes all the files', async function () {
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
)
+ expect(this.fs.readFile).to.be.calledWith('/foo/main.tex')
+ expect(this.fs.readFile).to.be.calledWith('/foo/a.tex')
+ expect(this.fs.readFile).to.be.calledWith('/foo/b.tex')
})
})
describe('when there is an error reading a file', function () {
beforeEach(function () {
- return this.fs.readFile
+ this.fs.readFile
.withArgs('/foo/a.tex')
.callsArgWith(2, new Error('something went wrong'))
})
- it('returns an error', function (done) {
- return this.ProjectRootDocManager.findRootDocFileFromDirectory(
- '/foo',
- (error, path, content) => {
- expect(error).to.exist
- expect(path).not.to.exist
- expect(content).not.to.exist
- return done()
- }
- )
+ it('returns an error', async function () {
+ let error
+
+ try {
+ await this.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+ '/foo'
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
})
})
})
describe('setRootDocFromName', function () {
describe('when there is a suitable root doc', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
- return this.ProjectRootDocManager.setRootDocFromName(
+ await this.ProjectRootDocManager.promises.setRootDocFromName(
this.project_id,
- '/main.tex',
- done
+ '/main.tex'
)
})
it('should check the docs of the project', function () {
- return this.ProjectEntityHandler.getAllDocPathsFromProjectById
+ this.ProjectEntityHandler.getAllDocPathsFromProjectById
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc to main.tex', function () {
- return this.ProjectEntityUpdateHandler.setRootDoc
+ this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2.toString())
.should.equal(true)
})
})
describe('when there is a suitable root doc but the leading slash is missing', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
- return this.ProjectRootDocManager.setRootDocFromName(
+ await this.ProjectRootDocManager.promises.setRootDocFromName(
this.project_id,
- 'main.tex',
- done
+ 'main.tex'
)
})
it('should check the docs of the project', function () {
- return this.ProjectEntityHandler.getAllDocPathsFromProjectById
+ this.ProjectEntityHandler.getAllDocPathsFromProjectById
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc to main.tex', function () {
- return this.ProjectEntityUpdateHandler.setRootDoc
+ this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2.toString())
.should.equal(true)
})
})
describe('when there is a suitable root doc with a basename match', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
- return this.ProjectRootDocManager.setRootDocFromName(
+ await this.ProjectRootDocManager.promises.setRootDocFromName(
this.project_id,
- 'chapter1a.tex',
- done
+ 'chapter1a.tex'
)
})
it('should check the docs of the project', function () {
- return this.ProjectEntityHandler.getAllDocPathsFromProjectById
+ this.ProjectEntityHandler.getAllDocPathsFromProjectById
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc using the basename', function () {
- return this.ProjectEntityUpdateHandler.setRootDoc
+ this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId3.toString())
.should.equal(true)
})
})
describe('when there is a suitable root doc but the filename is in quotes', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
- return this.ProjectRootDocManager.setRootDocFromName(
+ await this.ProjectRootDocManager.promises.setRootDocFromName(
this.project_id,
- "'main.tex'",
- done
+ "'main.tex'"
)
})
it('should check the docs of the project', function () {
- return this.ProjectEntityHandler.getAllDocPathsFromProjectById
+ this.ProjectEntityHandler.getAllDocPathsFromProjectById
.calledWith(this.project_id)
.should.equal(true)
})
it('should set the root doc to main.tex', function () {
- return this.ProjectEntityUpdateHandler.setRootDoc
+ this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, this.docId2.toString())
.should.equal(true)
})
})
describe('when there is no suitable root doc', function () {
- beforeEach(function (done) {
+ beforeEach(async function () {
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
- return this.ProjectRootDocManager.setRootDocFromName(
+ await this.ProjectRootDocManager.promises.setRootDocFromName(
this.project_id,
- 'other.tex',
- done
+ 'other.tex'
)
})
it('should not set the root doc', function () {
- return this.ProjectEntityUpdateHandler.setRootDoc.called.should.equal(
- false
- )
+ this.ProjectEntityUpdateHandler.setRootDoc.called.should.equal(false)
})
})
})
@@ -545,73 +486,73 @@ describe('ProjectRootDocManager', function () {
this.ProjectGetter.getProject = sinon
.stub()
.callsArgWith(2, null, this.project)
- return (this.ProjectRootDocManager.setRootDocAutomatically = sinon
+ this.ProjectRootDocManager.setRootDocAutomatically = sinon
.stub()
- .callsArgWith(1, null))
+ .callsArgWith(1, null)
})
describe('when the root doc is set', function () {
beforeEach(function () {
this.project.rootDoc_id = this.docId2
- return this.ProjectRootDocManager.ensureRootDocumentIsSet(
+ this.ProjectRootDocManager.ensureRootDocumentIsSet(
this.project_id,
this.callback
)
})
it('should find the project fetching only the rootDoc_id field', function () {
- return this.ProjectGetter.getProject
+ this.ProjectGetter.getProject
.calledWith(this.project_id, { rootDoc_id: 1 })
.should.equal(true)
})
it('should not try to update the project rootDoc_id', function () {
- return this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
+ this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
false
)
})
it('should call the callback', function () {
- return this.callback.called.should.equal(true)
+ this.callback.called.should.equal(true)
})
})
describe('when the root doc is not set', function () {
beforeEach(function () {
- return this.ProjectRootDocManager.ensureRootDocumentIsSet(
+ this.ProjectRootDocManager.ensureRootDocumentIsSet(
this.project_id,
this.callback
)
})
it('should find the project with only the rootDoc_id field', function () {
- return this.ProjectGetter.getProject
+ this.ProjectGetter.getProject
.calledWith(this.project_id, { rootDoc_id: 1 })
.should.equal(true)
})
it('should update the project rootDoc_id', function () {
- return this.ProjectRootDocManager.setRootDocAutomatically
+ this.ProjectRootDocManager.setRootDocAutomatically
.calledWith(this.project_id)
.should.equal(true)
})
it('should call the callback', function () {
- return this.callback.called.should.equal(true)
+ this.callback.called.should.equal(true)
})
})
describe('when the project does not exist', function () {
beforeEach(function () {
this.ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, null)
- return this.ProjectRootDocManager.ensureRootDocumentIsSet(
+ this.ProjectRootDocManager.ensureRootDocumentIsSet(
this.project_id,
this.callback
)
})
it('should call the callback with an error', function () {
- return this.callback
+ this.callback
.calledWith(
sinon.match
.instanceOf(Error)
@@ -645,26 +586,26 @@ describe('ProjectRootDocManager', function () {
this.ProjectEntityHandler.getDocPathFromProjectByDocId = sinon
.stub()
.callsArgWith(2, null, this.docPaths[this.docId2])
- return this.ProjectRootDocManager.ensureRootDocumentIsValid(
+ this.ProjectRootDocManager.ensureRootDocumentIsValid(
this.project_id,
this.callback
)
})
it('should find the project without doc lines', function () {
- return this.ProjectGetter.getProjectWithoutDocLines
+ this.ProjectGetter.getProjectWithoutDocLines
.calledWith(this.project_id)
.should.equal(true)
})
it('should not try to update the project rootDoc_id', function () {
- return this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
+ this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
false
)
})
it('should call the callback', function () {
- return this.callback.called.should.equal(true)
+ this.callback.called.should.equal(true)
})
})
@@ -674,58 +615,58 @@ describe('ProjectRootDocManager', function () {
this.ProjectEntityHandler.getDocPathFromProjectByDocId = sinon
.stub()
.callsArgWith(2, null, null)
- return this.ProjectRootDocManager.ensureRootDocumentIsValid(
+ this.ProjectRootDocManager.ensureRootDocumentIsValid(
this.project_id,
this.callback
)
})
it('should find the project without doc lines', function () {
- return this.ProjectGetter.getProjectWithoutDocLines
+ this.ProjectGetter.getProjectWithoutDocLines
.calledWith(this.project_id)
.should.equal(true)
})
it('should unset the root doc', function () {
- return this.ProjectEntityUpdateHandler.unsetRootDoc
+ this.ProjectEntityUpdateHandler.unsetRootDoc
.calledWith(this.project_id)
.should.equal(true)
})
it('should try to find a new rootDoc', function () {
- return this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
+ this.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
true
)
})
it('should call the callback', function () {
- return this.callback.called.should.equal(true)
+ this.callback.called.should.equal(true)
})
})
})
describe('when the root doc is not set', function () {
beforeEach(function () {
- return this.ProjectRootDocManager.ensureRootDocumentIsValid(
+ this.ProjectRootDocManager.ensureRootDocumentIsValid(
this.project_id,
this.callback
)
})
it('should find the project without doc lines', function () {
- return this.ProjectGetter.getProjectWithoutDocLines
+ this.ProjectGetter.getProjectWithoutDocLines
.calledWith(this.project_id)
.should.equal(true)
})
it('should update the project rootDoc_id', function () {
- return this.ProjectRootDocManager.setRootDocAutomatically
+ this.ProjectRootDocManager.setRootDocAutomatically
.calledWith(this.project_id)
.should.equal(true)
})
it('should call the callback', function () {
- return this.callback.called.should.equal(true)
+ this.callback.called.should.equal(true)
})
})
@@ -734,14 +675,14 @@ describe('ProjectRootDocManager', function () {
this.ProjectGetter.getProjectWithoutDocLines = sinon
.stub()
.callsArgWith(1, null, null)
- return this.ProjectRootDocManager.ensureRootDocumentIsValid(
+ this.ProjectRootDocManager.ensureRootDocumentIsValid(
this.project_id,
this.callback
)
})
it('should call the callback with an error', function () {
- return this.callback
+ this.callback
.calledWith(
sinon.match
.instanceOf(Error)
diff --git a/services/web/test/unit/src/Publishers/PublishersGetterTests.js b/services/web/test/unit/src/Publishers/PublishersGetterTests.js
index bad46039de..b5a3124436 100644
--- a/services/web/test/unit/src/Publishers/PublishersGetterTests.js
+++ b/services/web/test/unit/src/Publishers/PublishersGetterTests.js
@@ -42,15 +42,10 @@ describe('PublishersGetter', function () {
})
describe('getManagedPublishers', function () {
- it('fetches v1 data before returning publisher list', function (done) {
- this.PublishersGetter.getManagedPublishers(
- this.userId,
- (error, publishers) => {
- expect(error).to.be.null
- publishers.length.should.equal(1)
- done()
- }
- )
+ it('fetches v1 data before returning publisher list', async function () {
+ const publishers =
+ await this.PublishersGetter.promises.getManagedPublishers(this.userId)
+ expect(publishers.length).to.equal(1)
})
})
})
diff --git a/services/web/test/unit/src/Security/LoginRateLimiterTests.js b/services/web/test/unit/src/Security/LoginRateLimiterTests.js
index 645002400e..6dadaad8c2 100644
--- a/services/web/test/unit/src/Security/LoginRateLimiterTests.js
+++ b/services/web/test/unit/src/Security/LoginRateLimiterTests.js
@@ -25,23 +25,17 @@ describe('LoginRateLimiter', function () {
})
describe('processLoginRequest', function () {
- it('should consume points', function (done) {
- this.LoginRateLimiter.processLoginRequest(this.email, (err, allow) => {
- if (err) {
- return done(err)
- }
- expect(this.rateLimiter.consume).to.have.been.calledWith(this.email)
- done()
- })
+ it('should consume points', async function () {
+ await this.LoginRateLimiter.promises.processLoginRequest(this.email)
+ expect(this.rateLimiter.consume).to.have.been.calledWith(this.email)
})
describe('when login is allowed', function () {
- it('should call pass allow=true', function (done) {
- this.LoginRateLimiter.processLoginRequest(this.email, (err, allow) => {
- expect(err).to.equal(null)
- expect(allow).to.equal(true)
- done()
- })
+ it('should call pass allow=true', async function () {
+ const allow = await this.LoginRateLimiter.promises.processLoginRequest(
+ this.email
+ )
+ expect(allow).to.equal(true)
})
})
@@ -50,12 +44,11 @@ describe('LoginRateLimiter', function () {
this.rateLimiter.consume.rejects({ remainingPoints: 0 })
})
- it('should call pass allow=false', function (done) {
- this.LoginRateLimiter.processLoginRequest(this.email, (err, allow) => {
- expect(err).to.equal(null)
- expect(allow).to.equal(false)
- done()
- })
+ it('should call pass allow=false', async function () {
+ const allow = await this.LoginRateLimiter.promises.processLoginRequest(
+ this.email
+ )
+ expect(allow).to.equal(false)
})
})
@@ -64,22 +57,24 @@ describe('LoginRateLimiter', function () {
this.rateLimiter.consume.rejects(new Error('woops'))
})
- it('should produce an error', function (done) {
- this.LoginRateLimiter.processLoginRequest(this.email, (err, allow) => {
- expect(err).to.not.equal(null)
- expect(err).to.be.instanceof(Error)
- done()
- })
+ it('should produce an error', async function () {
+ let error
+
+ try {
+ await this.LoginRateLimiter.promises.processLoginRequest(this.email)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
})
})
})
describe('recordSuccessfulLogin', function () {
- it('should clear the rate limit', function (done) {
- this.LoginRateLimiter.recordSuccessfulLogin(this.email, () => {
- expect(this.rateLimiter.delete).to.have.been.calledWith(this.email)
- done()
- })
+ it('should clear the rate limit', async function () {
+ await this.LoginRateLimiter.promises.recordSuccessfulLogin(this.email)
+ expect(this.rateLimiter.delete).to.have.been.calledWith(this.email)
})
})
})
diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js b/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js
index cf7d8d8683..8cdf313395 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/RecurlyEntities')
+} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater'
diff --git a/services/web/test/unit/src/Subscription/LimitationsManagerTests.js b/services/web/test/unit/src/Subscription/LimitationsManagerTests.js
index 29c97de6b6..96de680e82 100644
--- a/services/web/test/unit/src/Subscription/LimitationsManagerTests.js
+++ b/services/web/test/unit/src/Subscription/LimitationsManagerTests.js
@@ -434,6 +434,19 @@ describe('LimitationsManager', function () {
expect(hasSubscription).to.be.true
})
+ it('should return true if the paymentProvider field is set', async function () {
+ this.SubscriptionLocator.promises.getUsersSubscription = sinon
+ .stub()
+ .resolves({
+ paymentProvider: {
+ subscriptionId: '1234',
+ },
+ })
+ const { hasSubscription } =
+ await this.LimitationsManager.promises.userHasSubscription(this.user)
+ expect(hasSubscription).to.be.true
+ })
+
it('should return false if the recurly token is not set', async function () {
this.SubscriptionLocator.promises.getUsersSubscription.resolves({})
const { hasSubscription } =
diff --git a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js
similarity index 69%
rename from services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js
rename to services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js
index 22811ac04f..1a28130b94 100644
--- a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js
+++ b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js
@@ -5,16 +5,18 @@ const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Subscription/Errors')
const {
AI_ADD_ON_CODE,
- RecurlySubscriptionChangeRequest,
- RecurlySubscriptionChange,
- RecurlySubscription,
- RecurlySubscriptionAddOnUpdate,
-} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
+ PaymentProviderSubscriptionChangeRequest,
+ PaymentProviderSubscriptionUpdateRequest,
+ PaymentProviderSubscriptionChange,
+ PaymentProviderSubscription,
+ PaymentProviderSubscriptionAddOnUpdate,
+} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
-const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
+const MODULE_PATH =
+ '../../../../app/src/Features/Subscription/PaymentProviderEntities'
-describe('RecurlyEntities', function () {
- describe('RecurlySubscription', function () {
+describe('PaymentProviderEntities', function () {
+ describe('PaymentProviderSubscription', function () {
beforeEach(function () {
this.Settings = {
plans: [
@@ -26,7 +28,7 @@ describe('RecurlyEntities', function () {
features: [],
}
- this.RecurlyEntities = SandboxedModule.require(MODULE_PATH, {
+ this.PaymentProviderEntities = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': this.Settings,
'./Errors': Errors,
@@ -36,15 +38,17 @@ describe('RecurlyEntities', function () {
describe('with add-ons', function () {
beforeEach(function () {
- const { RecurlySubscription, RecurlySubscriptionAddOn } =
- this.RecurlyEntities
- this.addOn = new RecurlySubscriptionAddOn({
+ const {
+ PaymentProviderSubscription,
+ PaymentProviderSubscriptionAddOn,
+ } = this.PaymentProviderEntities
+ this.addOn = new PaymentProviderSubscriptionAddOn({
code: 'add-on-code',
name: 'My Add-On',
quantity: 1,
unitPrice: 2,
})
- this.subscription = new RecurlySubscription({
+ this.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
@@ -71,11 +75,12 @@ describe('RecurlyEntities', function () {
describe('getRequestForPlanChange()', function () {
it('returns a change request for upgrades', function () {
- const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+ const { PaymentProviderSubscriptionChangeRequest } =
+ this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForPlanChange('premium-plan')
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'premium-plan',
@@ -84,11 +89,12 @@ describe('RecurlyEntities', function () {
})
it('returns a change request for downgrades', function () {
- const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+ const { PaymentProviderSubscriptionChangeRequest } =
+ this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForPlanChange('cheap-plan')
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
@@ -97,17 +103,18 @@ describe('RecurlyEntities', function () {
})
it('preserves the AI add-on on upgrades', function () {
- const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+ const { PaymentProviderSubscriptionChangeRequest } =
+ this.PaymentProviderEntities
this.addOn.code = AI_ADD_ON_CODE
const changeRequest =
this.subscription.getRequestForPlanChange('premium-plan')
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'premium-plan',
addOnUpdates: [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
@@ -117,17 +124,18 @@ describe('RecurlyEntities', function () {
})
it('preserves the AI add-on on downgrades', function () {
- const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+ const { PaymentProviderSubscriptionChangeRequest } =
+ this.PaymentProviderEntities
this.addOn.code = AI_ADD_ON_CODE
const changeRequest =
this.subscription.getRequestForPlanChange('cheap-plan')
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
addOnUpdates: [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
@@ -137,18 +145,19 @@ describe('RecurlyEntities', function () {
})
it('preserves the AI add-on on upgrades from the standalone AI plan', function () {
- const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+ const { PaymentProviderSubscriptionChangeRequest } =
+ this.PaymentProviderEntities
this.subscription.planCode = 'assistant-annual'
this.subscription.addOns = []
const changeRequest =
this.subscription.getRequestForPlanChange('cheap-plan')
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
addOnUpdates: [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
@@ -161,22 +170,22 @@ describe('RecurlyEntities', function () {
describe('getRequestForAddOnPurchase()', function () {
it('returns a change request', function () {
const {
- RecurlySubscriptionChangeRequest,
- RecurlySubscriptionAddOnUpdate,
- } = this.RecurlyEntities
+ PaymentProviderSubscriptionChangeRequest,
+ PaymentProviderSubscriptionAddOnUpdate,
+ } = this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForAddOnPurchase('another-add-on')
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: this.addOn.quantity,
unitPrice: this.addOn.unitPrice,
}),
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: 'another-add-on',
quantity: 1,
}),
@@ -187,9 +196,9 @@ describe('RecurlyEntities', function () {
it('returns a change request with quantity and unit price specified', function () {
const {
- RecurlySubscriptionChangeRequest,
- RecurlySubscriptionAddOnUpdate,
- } = this.RecurlyEntities
+ PaymentProviderSubscriptionChangeRequest,
+ PaymentProviderSubscriptionAddOnUpdate,
+ } = this.PaymentProviderEntities
const quantity = 5
const unitPrice = 10
const changeRequest = this.subscription.getRequestForAddOnPurchase(
@@ -198,16 +207,16 @@ describe('RecurlyEntities', function () {
unitPrice
)
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: this.addOn.quantity,
unitPrice: this.addOn.unitPrice,
}),
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: 'another-add-on',
quantity,
unitPrice,
@@ -227,20 +236,20 @@ describe('RecurlyEntities', function () {
describe('getRequestForAddOnUpdate()', function () {
it('returns a change request', function () {
const {
- RecurlySubscriptionChangeRequest,
- RecurlySubscriptionAddOnUpdate,
- } = this.RecurlyEntities
+ PaymentProviderSubscriptionChangeRequest,
+ PaymentProviderSubscriptionAddOnUpdate,
+ } = this.PaymentProviderEntities
const newQuantity = 2
const changeRequest = this.subscription.getRequestForAddOnUpdate(
'add-on-code',
newQuantity
)
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: newQuantity,
unitPrice: this.addOn.unitPrice,
@@ -263,7 +272,7 @@ describe('RecurlyEntities', function () {
this.addOn.code
)
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
addOnUpdates: [],
@@ -283,13 +292,13 @@ describe('RecurlyEntities', function () {
const changeRequest =
this.subscription.getRequestForGroupPlanUpgrade('test_plan_code')
const addOns = [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: 'add-on-code',
quantity: 1,
}),
]
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: addOns,
@@ -299,10 +308,40 @@ describe('RecurlyEntities', function () {
})
})
+ describe('getRequestForPoNumberAndTermsAndConditionsUpdate()', function () {
+ it('returns a correct update request', function () {
+ const updateRequest =
+ this.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
+ 'O12345',
+ 'T&C copy'
+ )
+ expect(updateRequest).to.deep.equal(
+ new PaymentProviderSubscriptionUpdateRequest({
+ subscription: this.subscription,
+ poNumber: 'O12345',
+ termsAndConditions: 'T&C copy',
+ })
+ )
+ })
+ })
+
+ describe('getRequestForTermsAndConditionsUpdate()', function () {
+ it('returns a correct update request', function () {
+ const updateRequest =
+ this.subscription.getRequestForTermsAndConditionsUpdate('T&C copy')
+ expect(updateRequest).to.deep.equal(
+ new PaymentProviderSubscriptionUpdateRequest({
+ subscription: this.subscription,
+ termsAndConditions: 'T&C copy',
+ })
+ )
+ })
+ })
+
describe('without add-ons', function () {
beforeEach(function () {
- const { RecurlySubscription } = this.RecurlyEntities
- this.subscription = new RecurlySubscription({
+ const { PaymentProviderSubscription } = this.PaymentProviderEntities
+ this.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
@@ -325,17 +364,17 @@ describe('RecurlyEntities', function () {
describe('getRequestForAddOnPurchase()', function () {
it('returns a change request', function () {
const {
- RecurlySubscriptionChangeRequest,
- RecurlySubscriptionAddOnUpdate,
- } = this.RecurlyEntities
+ PaymentProviderSubscriptionChangeRequest,
+ PaymentProviderSubscriptionAddOnUpdate,
+ } = this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForAddOnPurchase('some-add-on')
expect(changeRequest).to.deep.equal(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: 'some-add-on',
quantity: 1,
}),
@@ -356,10 +395,10 @@ describe('RecurlyEntities', function () {
})
})
- describe('RecurlySubscriptionChange', function () {
+ describe('PaymentProviderSubscriptionChange', function () {
describe('constructor', function () {
it('rounds the amounts when calculating the taxes', function () {
- const subscription = new RecurlySubscription({
+ const subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'premium-plan',
@@ -373,8 +412,10 @@ describe('RecurlyEntities', function () {
periodStart: new Date(),
periodEnd: new Date(),
collectionMethod: 'automatic',
+ poNumber: '012345',
+ termsAndConditions: 'T&C copy',
})
- const change = new RecurlySubscriptionChange({
+ const change = new PaymentProviderSubscriptionChange({
subscription,
nextPlanCode: 'promotional-plan',
nextPlanName: 'Promotial plan',
diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js
index 91c16b1ac1..4ae415dca5 100644
--- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js
+++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js
@@ -3,10 +3,13 @@ const { expect } = require('chai')
const recurly = require('recurly')
const SandboxedModule = require('sandboxed-module')
const {
- RecurlySubscription,
- RecurlySubscriptionChangeRequest,
- RecurlySubscriptionAddOnUpdate,
-} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
+ PaymentProviderSubscription,
+ PaymentProviderSubscriptionChangeRequest,
+ PaymentProviderSubscriptionUpdateRequest,
+ PaymentProviderSubscriptionAddOnUpdate,
+ PaymentProviderAccount,
+ PaymentProviderCoupon,
+} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
@@ -17,6 +20,7 @@ describe('RecurlyClient', function () {
recurly: {
apiKey: 'nonsense',
privateKey: 'private_nonsense',
+ subdomain: 'test',
},
},
plans: [],
@@ -26,7 +30,10 @@ describe('RecurlyClient', function () {
this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
this.subscriptionChange = { id: 'subscription-change-123' }
this.recurlyAccount = new recurly.Account()
- Object.assign(this.recurlyAccount, { code: this.user._id })
+ Object.assign(this.recurlyAccount, {
+ code: this.user._id,
+ email: this.user.email,
+ })
this.subscriptionAddOn = {
code: 'addon-code',
@@ -36,7 +43,7 @@ describe('RecurlyClient', function () {
preTaxTotal: 2,
}
- this.subscription = new RecurlySubscription({
+ this.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
currency: 'EUR',
@@ -51,6 +58,8 @@ describe('RecurlyClient', function () {
periodStart: new Date(),
periodEnd: new Date(),
collectionMethod: 'automatic',
+ poNumber: '',
+ termsAndConditions: '',
})
this.recurlySubscription = {
@@ -81,6 +90,8 @@ describe('RecurlyClient', function () {
currentPeriodStartedAt: this.subscription.periodStart,
currentPeriodEndsAt: this.subscription.periodEnd,
collectionMethod: this.subscription.collectionMethod,
+ poNumber: this.subscription.poNumber,
+ termsAndConditions: this.subscription.termsAndConditions,
}
this.recurlySubscriptionChange = new recurly.SubscriptionChange()
@@ -101,6 +112,7 @@ describe('RecurlyClient', function () {
getAccount: sinon.stub(),
getBillingInfo: sinon.stub(),
listAccountSubscriptions: sinon.stub(),
+ listActiveCouponRedemptions: sinon.stub(),
previewSubscriptionChange: sinon.stub(),
}
this.recurly = {
@@ -147,20 +159,24 @@ describe('RecurlyClient', function () {
describe('getAccountForUserId', function () {
it('should return an Account if one exists', async function () {
this.client.getAccount = sinon.stub().resolves(this.recurlyAccount)
- await expect(
- this.RecurlyClient.promises.getAccountForUserId(this.user._id)
+ const account = await this.RecurlyClient.promises.getAccountForUserId(
+ this.user._id
)
- .to.eventually.be.an.instanceOf(recurly.Account)
- .that.has.property('code', this.user._id)
+ const expectedAccount = new PaymentProviderAccount({
+ code: this.user._id,
+ email: this.user.email,
+ hasPastDueInvoice: false,
+ })
+ expect(account).to.deep.equal(expectedAccount)
})
- it('should return nothing if no account found', async function () {
+ it('should return null if no account found', async function () {
this.client.getAccount = sinon
.stub()
.throws(new recurly.errors.NotFoundError())
- expect(
- this.RecurlyClient.promises.getAccountForUserId('nonsense')
- ).to.eventually.equal(undefined)
+ const account =
+ await this.RecurlyClient.promises.getAccountForUserId('nonsense')
+ expect(account).to.equal(null)
})
it('should re-throw caught errors', async function () {
@@ -189,6 +205,102 @@ describe('RecurlyClient', function () {
})
})
+ describe('getActiveCouponsForUserId', function () {
+ it('should return an empty array if no coupons returned', async function () {
+ this.client.listActiveCouponRedemptions.returns({
+ each: async function* () {},
+ })
+ const coupons =
+ await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+ expect(coupons).to.deep.equal([])
+ })
+
+ it('should return a coupons returned by recurly', async function () {
+ const recurlyCoupon = {
+ coupon: {
+ code: 'coupon-code',
+ name: 'Coupon Name',
+ hostedPageDescription: 'hosted page description',
+ invoiceDescription: 'invoice description',
+ },
+ }
+ this.client.listActiveCouponRedemptions.returns({
+ each: async function* () {
+ yield recurlyCoupon
+ },
+ })
+ const coupons =
+ await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+ const expectedCoupons = [
+ new PaymentProviderCoupon({
+ code: 'coupon-code',
+ name: 'Coupon Name',
+ description: 'hosted page description',
+ }),
+ ]
+ expect(coupons).to.deep.equal(expectedCoupons)
+ })
+
+ it('should not throw for Recurly not found error', async function () {
+ this.client.listActiveCouponRedemptions = sinon
+ .stub()
+ .throws(new recurly.errors.NotFoundError())
+ const coupons =
+ await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+ expect(coupons).to.deep.equal([])
+ })
+
+ it('should throw any other API errors', async function () {
+ this.client.listActiveCouponRedemptions = sinon.stub().throws()
+ await expect(
+ this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+ ).to.eventually.be.rejectedWith(Error)
+ })
+ })
+
+ describe('getCustomerManagementLink', function () {
+ it('should throw if recurly token is not returned', async function () {
+ this.client.getAccount.resolves({})
+ await expect(
+ this.RecurlyClient.promises.getCustomerManagementLink(
+ '12345',
+ 'account-management',
+ 'en-US'
+ )
+ ).to.be.rejectedWith('recurly account does not have hosted login token')
+ })
+
+ it('should generate the correct account management url', async function () {
+ this.client.getAccount.resolves({
+ hostedLoginToken: '987654321',
+ })
+ const result =
+ await this.RecurlyClient.promises.getCustomerManagementLink(
+ '12345',
+ 'account-management',
+ 'en-US'
+ )
+
+ expect(result).to.equal('https://test.recurly.com/account/987654321')
+ })
+
+ it('should generate the correct billing details url', async function () {
+ this.client.getAccount.resolves({
+ hostedLoginToken: '987654321',
+ })
+ const result =
+ await this.RecurlyClient.promises.getCustomerManagementLink(
+ '12345',
+ 'billing-details',
+ 'en-US'
+ )
+
+ expect(result).to.equal(
+ 'https://test.recurly.com/account/billing_info/edit?ht=987654321'
+ )
+ })
+ })
+
describe('getSubscription', function () {
it('should return the subscription found by recurly', async function () {
this.client.getSubscription = sinon
@@ -266,7 +378,7 @@ describe('RecurlyClient', function () {
it('handles plan changes', async function () {
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'new-plan',
@@ -280,11 +392,11 @@ describe('RecurlyClient', function () {
it('handles add-on changes', async function () {
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
- new RecurlySubscriptionAddOnUpdate({
+ new PaymentProviderSubscriptionAddOnUpdate({
code: 'new-add-on',
quantity: 2,
unitPrice: 8.99,
@@ -337,6 +449,37 @@ describe('RecurlyClient', function () {
})
})
+ describe('updateSubscriptionDetails', function () {
+ beforeEach(function () {
+ this.client.updateSubscription = sinon
+ .stub()
+ .resolves({ id: this.subscription.id })
+ })
+
+ it('handles subscription update', async function () {
+ await this.RecurlyClient.promises.updateSubscriptionDetails(
+ new PaymentProviderSubscriptionUpdateRequest({
+ subscription: this.subscription,
+ poNumber: '012345',
+ termsAndConditions: 'T&C',
+ })
+ )
+ expect(this.client.updateSubscription).to.be.calledWith(
+ 'uuid-subscription-id',
+ { poNumber: '012345', termsAndConditions: 'T&C' }
+ )
+ })
+
+ it('should throw any API errors', async function () {
+ this.client.updateSubscription = sinon.stub().throws()
+ await expect(
+ this.RecurlyClient.promises.updateSubscriptionDetails({
+ subscription: this.subscription,
+ })
+ ).to.eventually.be.rejectedWith(Error)
+ })
+ })
+
describe('removeSubscriptionChange', function () {
beforeEach(function () {
this.client.removeSubscriptionChange = sinon.stub().resolves()
@@ -451,7 +594,7 @@ describe('RecurlyClient', function () {
})
const { immediateCharge } =
await this.RecurlyClient.promises.previewSubscriptionChange(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'new-plan',
@@ -483,7 +626,7 @@ describe('RecurlyClient', function () {
})
const { immediateCharge } =
await this.RecurlyClient.promises.previewSubscriptionChange(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'new-plan',
@@ -507,7 +650,7 @@ describe('RecurlyClient', function () {
.throws(new ValidationError())
await expect(
this.RecurlyClient.promises.previewSubscriptionChange(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'new-plan',
@@ -520,7 +663,7 @@ describe('RecurlyClient', function () {
this.client.previewSubscriptionChange = sinon.stub().throws(new Error())
await expect(
this.RecurlyClient.promises.previewSubscriptionChange(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'new-plan',
@@ -547,4 +690,29 @@ describe('RecurlyClient', function () {
).to.be.rejectedWith(Error)
})
})
+
+ describe('getCountryCode', function () {
+ it('should return the country code from the account info', async function () {
+ this.client.getAccount = sinon.stub().resolves({
+ address: {
+ country: 'GB',
+ },
+ })
+ const countryCode = await this.RecurlyClient.promises.getCountryCode(
+ this.user._id
+ )
+ expect(countryCode).to.equal('GB')
+ })
+
+ it('should throw if country code doesn’t exist', async function () {
+ this.client.getAccount = sinon.stub().resolves({
+ address: {
+ country: '',
+ },
+ })
+ await expect(
+ this.RecurlyClient.promises.getCountryCode(this.user._id)
+ ).to.be.rejectedWith(Error, 'Country code not found')
+ })
+ })
})
diff --git a/services/web/test/unit/src/Subscription/RecurlyWrapperTests.js b/services/web/test/unit/src/Subscription/RecurlyWrapperTests.js
index ac353cddd7..6f768e680e 100644
--- a/services/web/test/unit/src/Subscription/RecurlyWrapperTests.js
+++ b/services/web/test/unit/src/Subscription/RecurlyWrapperTests.js
@@ -1,4 +1,4 @@
-const { expect } = require('chai')
+const { assert, expect } = require('chai')
const sinon = require('sinon')
const modulePath = '../../../../app/src/Features/Subscription/RecurlyWrapper'
const SandboxedModule = require('sandboxed-module')
@@ -107,6 +107,7 @@ const mockApiRequest = function (options) {
describe('RecurlyWrapper', function () {
beforeEach(function () {
+ tk.freeze(Date.now()) // freeze the time for these tests
this.settings = {
plans: [
{
@@ -134,7 +135,6 @@ describe('RecurlyWrapper', function () {
fetchStringWithResponse: sinon.stub(),
RequestFailedError,
}
- tk.freeze(Date.now()) // freeze the time for these tests
this.RecurlyWrapper = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': this.settings,
@@ -294,7 +294,7 @@ describe('RecurlyWrapper', function () {
})
describe('updateAccountEmailAddress, with invalid XML', function () {
- beforeEach(async function (done) {
+ beforeEach(async function () {
this.recurlyAccountId = 'account-id-123'
this.newEmail = '\uD800@example.com'
this.apiRequest = sinon
@@ -307,22 +307,24 @@ describe('RecurlyWrapper', function () {
body: fixtures['accounts/104'],
}
})
- done()
})
afterEach(function () {
this.RecurlyWrapper.promises.apiRequest.restore()
})
- it('should produce an error', function (done) {
- this.RecurlyWrapper.promises
- .updateAccountEmailAddress(this.recurlyAccountId, this.newEmail)
- .catch(error => {
- expect(error).to.exist
- expect(error.message.startsWith('Invalid character')).to.equal(true)
- expect(this.apiRequest.called).to.equal(false)
- done()
- })
+ it('should produce an error', async function () {
+ try {
+ await this.RecurlyWrapper.promises.updateAccountEmailAddress(
+ this.recurlyAccountId,
+ this.newEmail
+ )
+ assert.fail('Expected error not thrown')
+ } catch (error) {
+ expect(error).to.have.property('message')
+ expect(error.message.startsWith('Invalid character')).to.be.true
+ expect(this.apiRequest.called).to.equal(false)
+ }
})
})
@@ -702,19 +704,27 @@ describe('RecurlyWrapper', function () {
})
})
- it('should produce an error', function (done) {
- this.call().catch(err => {
- expect(err).to.be.instanceof(
- SubscriptionErrors.RecurlyTransactionError
- )
- expect(err.info.public.message).to.be.equal(
- 'Your card must be authenticated with 3D Secure before continuing.'
- )
- expect(err.info.public.threeDSecureActionTokenId).to.be.equal(
- 'mock_three_d_secure_action_token'
- )
- done()
- })
+ it('should produce an error', async function () {
+ const promise = this.call()
+ let error
+
+ try {
+ await promise
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(
+ SubscriptionErrors.RecurlyTransactionError
+ )
+ expect(error).to.have.nested.property(
+ 'info.public.message',
+ 'Your card must be authenticated with 3D Secure before continuing.'
+ )
+ expect(error).to.have.nested.property(
+ 'info.public.threeDSecureActionTokenId',
+ 'mock_three_d_secure_action_token'
+ )
})
})
diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
index ef4b055dc3..27ba5bf85b 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
@@ -155,7 +155,11 @@ describe('SubscriptionController', function () {
'./RecurlyEventHandler': {
sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
},
- './FeaturesUpdater': (this.FeaturesUpdater = {}),
+ './FeaturesUpdater': (this.FeaturesUpdater = {
+ promises: {
+ hasFeaturesViaWritefull: sinon.stub().resolves(false),
+ },
+ }),
'./GroupPlansData': (this.GroupPlansData = {}),
'./V1SubscriptionManager': (this.V1SubscriptionManager = {}),
'../Errors/HttpErrorHandler': (this.HttpErrorHandler = {
diff --git a/services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js b/services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js
index 972f6d8961..0ee781d54f 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js
@@ -3,298 +3,30 @@ const SubscriptionFormatters = require('../../../../app/src/Features/Subscriptio
const { expect } = chai
-/*
- Users can select any language we support, regardless of the country where they are located.
- Which mean that any combination of "supported language"-"supported currency" can be displayed
- on the user's screen.
-
- Users located in the USA visiting https://fr.overleaf.com/user/subscription/plans
- should see amounts in USD (because of their IP address),
- but with French text, number formatting and currency formats (because of language choice).
- (e.g. 1 000,00 $)
-
- Users located in the France visiting https://www.overleaf.com/user/subscription/plans
- should see amounts in EUR (because of their IP address),
- but with English text, number formatting and currency formats (because of language choice).
- (e.g. €1,000.00)
- */
-
-describe('SubscriptionFormatters.formatPrice', function () {
- describe('en', function () {
- const format = currency => priceInCents =>
- SubscriptionFormatters.formatPriceLocalized(priceInCents, currency)
-
- describe('USD', function () {
- const formatUSD = format('USD')
-
- it('should format basic amounts', function () {
- expect(formatUSD(0)).to.equal('$0.00')
- expect(formatUSD(1234)).to.equal('$12.34')
- })
-
- it('should format thousand separators', function () {
- expect(formatUSD(100_000)).to.equal('$1,000.00')
- expect(formatUSD(9_876_543_210)).to.equal('$98,765,432.10')
- })
-
- it('should format negative amounts', function () {
- expect(formatUSD(-1)).to.equal('-$0.01')
- expect(formatUSD(-1234)).to.equal('-$12.34')
- })
+describe('SubscriptionFormatters', function () {
+ describe('formatDateTime', function () {
+ it('should return null if no date', function () {
+ const result = SubscriptionFormatters.formatDateTime(null)
+ expect(result).to.equal(null)
})
- describe('EUR', function () {
- const formatEUR = format('EUR')
-
- it('should format basic amounts', function () {
- expect(formatEUR(0)).to.equal('€0.00')
- expect(formatEUR(1234)).to.equal('€12.34')
- })
-
- it('should format thousand separators', function () {
- expect(formatEUR(100_000)).to.equal('€1,000.00')
- expect(formatEUR(9_876_543_210)).to.equal('€98,765,432.10')
- })
-
- it('should format negative amounts', function () {
- expect(formatEUR(-1)).to.equal('-€0.01')
- expect(formatEUR(-1234)).to.equal('-€12.34')
- })
- })
-
- describe('HUF', function () {
- const formatHUF = format('HUF')
-
- it('should format basic amounts', function () {
- expect(formatHUF(0)).to.equal('Ft 0.00')
- expect(formatHUF(1234)).to.equal('Ft 12.34')
- })
-
- it('should format thousand separators', function () {
- expect(formatHUF(100_000)).to.equal('Ft 1,000.00')
- expect(formatHUF(9_876_543_210)).to.equal('Ft 98,765,432.10')
- })
-
- it('should format negative amounts', function () {
- expect(formatHUF(-1)).to.equal('-Ft 0.01')
- expect(formatHUF(-1234)).to.equal('-Ft 12.34')
- })
- })
-
- describe('CLP', function () {
- const formatCLP = format('CLP')
-
- it('should format basic amounts', function () {
- expect(formatCLP(0)).to.equal('$0')
- expect(formatCLP(1234)).to.equal('$1,234')
- })
-
- it('should format thousand separators', function () {
- expect(formatCLP(100_000)).to.equal('$100,000')
- expect(formatCLP(9_876_543_210)).to.equal('$9,876,543,210')
- })
-
- it('should format negative amounts', function () {
- expect(formatCLP(-1)).to.equal('-$1')
- expect(formatCLP(-1234)).to.equal('-$1,234')
- })
- })
-
- describe('all currencies', function () {
- it('should format 100 "minimal atomic units"', function () {
- const amount = 100
-
- // "no cents currencies"
- expect(format('CLP')(amount)).to.equal('$100')
- expect(format('JPY')(amount)).to.equal('¥100')
- expect(format('KRW')(amount)).to.equal('₩100')
- expect(format('VND')(amount)).to.equal('₫100')
-
- // other currencies
- expect(format('AUD')(amount)).to.equal('$1.00')
- expect(format('BRL')(amount)).to.equal('R$1.00')
- expect(format('CAD')(amount)).to.equal('$1.00')
- expect(format('CHF')(amount)).to.equal('CHF 1.00')
- expect(format('CNY')(amount)).to.equal('¥1.00')
- expect(format('COP')(amount)).to.equal('$1.00')
- expect(format('DKK')(amount)).to.equal('kr 1.00')
- expect(format('EUR')(amount)).to.equal('€1.00')
- expect(format('GBP')(amount)).to.equal('£1.00')
- expect(format('HUF')(amount)).to.equal('Ft 1.00')
- expect(format('IDR')(amount)).to.equal('Rp 1.00')
- expect(format('INR')(amount)).to.equal('₹1.00')
- expect(format('MXN')(amount)).to.equal('$1.00')
- expect(format('MYR')(amount)).to.equal('RM 1.00')
- expect(format('NOK')(amount)).to.equal('kr 1.00')
- expect(format('NZD')(amount)).to.equal('$1.00')
- expect(format('PEN')(amount)).to.equal('PEN 1.00')
- expect(format('PHP')(amount)).to.equal('₱1.00')
- expect(format('SEK')(amount)).to.equal('kr 1.00')
- expect(format('SGD')(amount)).to.equal('$1.00')
- expect(format('THB')(amount)).to.equal('฿1.00')
- expect(format('USD')(amount)).to.equal('$1.00')
- })
-
- it('should format 123_456_789.987_654 "minimal atomic units"', function () {
- const amount = 123_456_789.987_654
-
- // "no cents currencies"
- expect(format('CLP')(amount)).to.equal('$123,456,790')
- expect(format('JPY')(amount)).to.equal('¥123,456,790')
- expect(format('KRW')(amount)).to.equal('₩123,456,790')
- expect(format('VND')(amount)).to.equal('₫123,456,790')
-
- // other currencies
- expect(format('AUD')(amount)).to.equal('$1,234,567.90')
- expect(format('BRL')(amount)).to.equal('R$1,234,567.90')
- expect(format('CAD')(amount)).to.equal('$1,234,567.90')
- expect(format('CHF')(amount)).to.equal('CHF 1,234,567.90')
- expect(format('CNY')(amount)).to.equal('¥1,234,567.90')
- expect(format('COP')(amount)).to.equal('$1,234,567.90')
- expect(format('DKK')(amount)).to.equal('kr 1,234,567.90')
- expect(format('EUR')(amount)).to.equal('€1,234,567.90')
- expect(format('GBP')(amount)).to.equal('£1,234,567.90')
- expect(format('HUF')(amount)).to.equal('Ft 1,234,567.90')
- expect(format('IDR')(amount)).to.equal('Rp 1,234,567.90')
- expect(format('INR')(amount)).to.equal('₹1,234,567.90')
- expect(format('MXN')(amount)).to.equal('$1,234,567.90')
- expect(format('MYR')(amount)).to.equal('RM 1,234,567.90')
- expect(format('NOK')(amount)).to.equal('kr 1,234,567.90')
- expect(format('NZD')(amount)).to.equal('$1,234,567.90')
- expect(format('PEN')(amount)).to.equal('PEN 1,234,567.90')
- expect(format('PHP')(amount)).to.equal('₱1,234,567.90')
- expect(format('SEK')(amount)).to.equal('kr 1,234,567.90')
- expect(format('SGD')(amount)).to.equal('$1,234,567.90')
- expect(format('THB')(amount)).to.equal('฿1,234,567.90')
- expect(format('USD')(amount)).to.equal('$1,234,567.90')
- })
+ it('should format date with time', function () {
+ const date = new Date(1639904485000)
+ const result = SubscriptionFormatters.formatDateTime(date)
+ expect(result).to.equal('December 19th, 2021 9:01 AM UTC')
})
})
- describe('fr', function () {
- const format = currency => priceInCents =>
- SubscriptionFormatters.formatPriceLocalized(priceInCents, currency, 'fr')
-
- describe('USD', function () {
- const formatUSD = format('USD')
-
- it('should format basic amounts', function () {
- expect(formatUSD(0)).to.equal('0,00 $')
- expect(formatUSD(1234)).to.equal('12,34 $')
- })
-
- it('should format thousand separators', function () {
- expect(formatUSD(100_000)).to.equal('1 000,00 $')
- expect(formatUSD(9_876_543_210)).to.equal('98 765 432,10 $')
- })
-
- it('should format negative amounts', function () {
- expect(formatUSD(-1)).to.equal('-0,01 $')
- expect(formatUSD(-1234)).to.equal('-12,34 $')
- })
+ describe('formatDate', function () {
+ it('should return null if no date', function () {
+ const result = SubscriptionFormatters.formatDate(null)
+ expect(result).to.equal(null)
})
- describe('EUR', function () {
- const formatEUR = format('EUR')
-
- it('should format basic amounts', function () {
- expect(formatEUR(0)).to.equal('0,00 €')
- expect(formatEUR(1234)).to.equal('12,34 €')
- })
-
- it('should format thousand separators', function () {
- expect(formatEUR(100_000)).to.equal('1 000,00 €')
- expect(formatEUR(9_876_543_210)).to.equal('98 765 432,10 €')
- })
-
- it('should format negative amounts', function () {
- expect(formatEUR(-1)).to.equal('-0,01 €')
- expect(formatEUR(-1234)).to.equal('-12,34 €')
- })
- })
-
- describe('HUF', function () {
- const formatHUF = format('HUF')
-
- it('should format basic amounts', function () {
- expect(formatHUF(0)).to.equal('0,00 Ft')
- expect(formatHUF(1234)).to.equal('12,34 Ft')
- })
-
- it('should format thousand separators', function () {
- expect(formatHUF(100_000)).to.equal('1 000,00 Ft')
- expect(formatHUF(9_876_543_210)).to.equal('98 765 432,10 Ft')
- })
-
- it('should format negative amounts', function () {
- expect(formatHUF(-1)).to.equal('-0,01 Ft')
- expect(formatHUF(-1234)).to.equal('-12,34 Ft')
- })
- })
-
- describe('CLP', function () {
- const formatCLP = format('CLP')
-
- it('should format basic amounts', function () {
- expect(formatCLP(0)).to.equal('0 $')
- expect(formatCLP(1234)).to.equal('1 234 $')
- })
-
- it('should format thousand separators', function () {
- expect(formatCLP(100_000)).to.equal('100 000 $')
- expect(formatCLP(9_876_543_210)).to.equal('9 876 543 210 $')
- })
-
- it('should format negative amounts', function () {
- expect(formatCLP(-1)).to.equal('-1 $')
- expect(formatCLP(-1234)).to.equal('-1 234 $')
- })
- })
-
- describe('all currencies', function () {
- it('should format 100 "minimal atomic units"', function () {
- const amount = 100
-
- // "no cents currencies"
- expect(format('CLP')(amount)).to.equal('100 $')
- expect(format('JPY')(amount)).to.equal('100 ¥')
- expect(format('KRW')(amount)).to.equal('100 ₩')
- expect(format('VND')(amount)).to.equal('100 ₫')
-
- // other currencies
- expect(format('AUD')(amount)).to.equal('1,00 $')
- expect(format('BRL')(amount)).to.equal('1,00 R$')
- expect(format('CAD')(amount)).to.equal('1,00 $')
- expect(format('CHF')(amount)).to.equal('1,00 CHF')
- expect(format('CNY')(amount)).to.equal('1,00 ¥')
- expect(format('COP')(amount)).to.equal('1,00 $')
-
- expect(format('EUR')(amount)).to.equal('1,00 €')
- expect(format('GBP')(amount)).to.equal('1,00 £')
- expect(format('USD')(amount)).to.equal('1,00 $')
- })
-
- it('should format 123_456_789.987_654 "minimal atomic units"', function () {
- const amount = 123_456_789.987_654
-
- // "no cents currencies"
- expect(format('CLP')(amount)).to.equal('123 456 790 $')
- expect(format('JPY')(amount)).to.equal('123 456 790 ¥')
- expect(format('KRW')(amount)).to.equal('123 456 790 ₩')
- expect(format('VND')(amount)).to.equal('123 456 790 ₫')
-
- // other currencies
- expect(format('AUD')(amount)).to.equal('1 234 567,90 $')
- expect(format('BRL')(amount)).to.equal('1 234 567,90 R$')
- expect(format('CAD')(amount)).to.equal('1 234 567,90 $')
- expect(format('CHF')(amount)).to.equal('1 234 567,90 CHF')
- expect(format('CNY')(amount)).to.equal('1 234 567,90 ¥')
- expect(format('COP')(amount)).to.equal('1 234 567,90 $')
-
- expect(format('EUR')(amount)).to.equal('1 234 567,90 €')
- expect(format('GBP')(amount)).to.equal('1 234 567,90 £')
- expect(format('USD')(amount)).to.equal('1 234 567,90 $')
- })
+ it('should format date', function () {
+ const date = new Date(1639904485000)
+ const result = SubscriptionFormatters.formatDate(date)
+ expect(result).to.equal('December 19th, 2021')
})
})
})
diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
index b3b6b66dbd..1d86199f9d 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
+++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
@@ -34,6 +34,12 @@ describe('SubscriptionGroupController', function () {
canUseFlexibleLicensing: true,
}
+ this.recurlySubscription = {
+ get isCollectionMethodManual() {
+ return true
+ },
+ }
+
this.previewSubscriptionChangeData = {
change: {},
currency: 'USD',
@@ -41,13 +47,15 @@ describe('SubscriptionGroupController', function () {
this.createSubscriptionChangeData = { adding: 1 }
+ this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
+
this.SubscriptionGroupHandler = {
promises: {
removeUserFromGroup: sinon.stub().resolves(),
getUsersGroupSubscriptionDetails: sinon.stub().resolves({
subscription: this.subscription,
plan: this.plan,
- recurlySubscription: {},
+ recurlySubscription: this.recurlySubscription,
}),
previewAddSeatsSubscriptionChange: sinon
.stub()
@@ -62,6 +70,8 @@ describe('SubscriptionGroupController', function () {
getGroupPlanUpgradePreview: sinon
.stub()
.resolves(this.previewSubscriptionChangeData),
+ checkBillingInfoExistence: sinon.stub().resolves(this.paymentMethod),
+ updateSubscriptionPaymentTerms: sinon.stub().resolves(),
},
}
@@ -96,7 +106,7 @@ describe('SubscriptionGroupController', function () {
this.SplitTestHandler = {
promises: {
- getAssignment: sinon.stub().resolves(),
+ getAssignment: sinon.stub().resolves({ variant: 'enabled' }),
},
}
@@ -111,7 +121,6 @@ describe('SubscriptionGroupController', function () {
this.RecurlyClient = {
promises: {
getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
- // getSubscription: sinon.stub().resolves(this.subscription),
},
}
@@ -355,14 +364,21 @@ describe('SubscriptionGroupController', function () {
this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
.calledWith(this.plan)
.should.equal(true)
+ this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges
+ .calledWith(this.recurlySubscription)
+ .should.equal(true)
this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
.calledWith(this.subscription)
.should.equal(true)
+ this.SubscriptionGroupHandler.promises.checkBillingInfoExistence
+ .calledWith(this.recurlySubscription, this.adminUserId)
+ .should.equal(true)
page.should.equal('subscriptions/add-seats')
props.subscriptionId.should.equal(this.subscriptionId)
props.groupName.should.equal(this.subscription.teamName)
props.totalLicenses.should.equal(this.subscription.membersLimit)
props.isProfessional.should.equal(false)
+ props.isCollectionMethodManual.should.equal(true)
done()
},
}
@@ -399,7 +415,7 @@ describe('SubscriptionGroupController', function () {
})
it('should redirect to missing billing information page when billing information is missing', function (done) {
- this.RecurlyClient.promises.getPaymentMethod = sinon
+ this.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon
.stub()
.throws(new this.Errors.MissingBillingInfoError())
@@ -415,22 +431,6 @@ describe('SubscriptionGroupController', function () {
this.Controller.addSeatsToGroupSubscription(this.req, res)
})
- it('should redirect to manually collected subscription error page when collection method is manual', function (done) {
- this.SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual =
- sinon.stub().throws(new this.Errors.ManuallyCollectedError())
-
- const res = {
- redirect: url => {
- url.should.equal(
- '/user/subscription/group/manually-collected-subscription'
- )
- done()
- },
- }
-
- this.Controller.addSeatsToGroupSubscription(this.req, res)
- })
-
it('should redirect to subscription page when there is a pending change', function (done) {
this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
sinon.stub().throws(new this.Errors.PendingChangeError())
@@ -586,10 +586,16 @@ describe('SubscriptionGroupController', function () {
describe('submitForm', function () {
it('should build and pass the request body to the sales submit handler', function (done) {
const adding = 100
- this.req.body = { adding }
+ const poNumber = 'PO123456'
+ this.req.body = { adding, poNumber }
const res = {
sendStatus: code => {
+ this.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
+ this.adminUserId,
+ this.recurlySubscription,
+ poNumber
+ )
this.Modules.promises.hooks.fire
.calledWith('sendSupportRequest', {
email: this.user.email,
@@ -602,6 +608,8 @@ describe('SubscriptionGroupController', function () {
'\n' +
`**Estimated Number of Users:** ${adding}\n` +
'\n' +
+ `**PO Number:** ${poNumber}\n` +
+ '\n' +
`**Message:** This email has been generated on behalf of user with email **${this.user.email}** to request an increase in the total number of users for their subscription.`,
inbox: 'sales',
})
diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
index cdb3c15089..d9e42d645c 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
@@ -1,7 +1,11 @@
const SandboxedModule = require('sandboxed-module')
+const { ObjectId } = require('mongodb-legacy')
const sinon = require('sinon')
const { expect } = require('chai')
const MockRequest = require('../helpers/MockRequest')
+const {
+ InvalidEmailError,
+} = require('../../../../app/src/Features/Errors/Errors')
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler'
@@ -15,18 +19,19 @@ describe('SubscriptionGroupHandler', function () {
this.subscription_id = '31DSd1123D'
this.adding = 1
this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
- this.RecurlyEntities = {
+ this.PaymentProviderEntities = {
MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
}
this.localPlanInSettings = {
membersLimit: 5,
- membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ membersLimitAddOn: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
}
this.subscription = {
admin_id: this.adminUser_id,
manager_ids: [this.adminUser_id],
_id: this.subscription_id,
+ membersLimit: 100,
}
this.changeRequest = {
@@ -36,11 +41,20 @@ describe('SubscriptionGroupHandler', function () {
},
}
+ this.termsAndConditionsUpdate = {
+ termsAndConditions: 'T&C copy',
+ }
+
+ this.poNumberAndTermsAndConditionsUpdate = {
+ poNumber: '4444',
+ ...this.termsAndConditionsUpdate,
+ }
+
this.recurlySubscription = {
id: 123,
addOns: [
{
- code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 1,
},
],
@@ -50,10 +64,19 @@ describe('SubscriptionGroupHandler', function () {
getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
.stub()
.returns(this.changeRequest),
+ getRequestForPoNumberAndTermsAndConditionsUpdate: sinon
+ .stub()
+ .returns(this.poNumberAndTermsAndConditionsUpdate),
+ getRequestForTermsAndConditionsUpdate: sinon
+ .stub()
+ .returns(this.termsAndConditionsUpdate),
currency: 'USD',
hasAddOn(code) {
return this.addOns.some(addOn => addOn.code === code)
},
+ get isCollectionMethodManual() {
+ return false
+ },
}
this.SubscriptionLocator = {
@@ -91,6 +114,10 @@ describe('SubscriptionGroupHandler', function () {
findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
}
+ this.User = {
+ find: sinon.stub().returns({ exec: sinon.stub().resolves }),
+ }
+
this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.user._id),
}
@@ -98,7 +125,7 @@ describe('SubscriptionGroupHandler', function () {
this.previewSubscriptionChange = {
nextAddOns: [
{
- code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.recurlySubscription.addOns[0].quantity + this.adding,
},
],
@@ -119,6 +146,8 @@ describe('SubscriptionGroupHandler', function () {
applySubscriptionChangeRequest: sinon
.stub()
.resolves(this.applySubscriptionChange),
+ getCountryCode: sinon.stub().resolves('BG'),
+ updateSubscriptionDetails: sinon.stub().resolves(),
},
}
@@ -132,6 +161,13 @@ describe('SubscriptionGroupHandler', function () {
},
}
+ this.TeamInvitesHandler = {
+ promises: {
+ revokeInvite: sinon.stub().resolves(),
+ createInvite: sinon.stub().resolves(),
+ },
+ }
+
this.GroupPlansData = {
enterprise: {
collaborator: {
@@ -155,20 +191,38 @@ describe('SubscriptionGroupHandler', function () {
},
}
+ this.Modules = {
+ promises: {
+ hooks: {
+ fire: sinon.stub().callsFake(hookName => {
+ if (hookName === 'generateTermsAndConditions') {
+ return Promise.resolve(['T&Cs'])
+ }
+ return Promise.resolve()
+ }),
+ },
+ },
+ }
+
this.Handler = SandboxedModule.require(modulePath, {
requires: {
'./SubscriptionUpdater': this.SubscriptionUpdater,
'./SubscriptionLocator': this.SubscriptionLocator,
'./SubscriptionController': this.SubscriptionController,
'./SubscriptionHandler': this.SubscriptionHandler,
+ './TeamInvitesHandler': this.TeamInvitesHandler,
'../../models/Subscription': {
Subscription: this.Subscription,
},
+ '../../models/User': {
+ User: this.User,
+ },
'./RecurlyClient': this.RecurlyClient,
'./PlansLocator': this.PlansLocator,
- './RecurlyEntities': this.RecurlyEntities,
+ './PaymentProviderEntities': this.PaymentProviderEntities,
'../Authentication/SessionManager': this.SessionManager,
'./GroupPlansData': this.GroupPlansData,
+ '../../infrastructure/Modules': this.Modules,
},
})
})
@@ -325,7 +379,8 @@ describe('SubscriptionGroupHandler', function () {
},
plan: {
membersLimit: 5,
- membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ membersLimitAddOn:
+ this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
canUseFlexibleLicensing: true,
},
recurlySubscription: this.recurlySubscription,
@@ -347,14 +402,14 @@ describe('SubscriptionGroupHandler', function () {
beforeEach(function () {
this.recurlySubscription.addOns = [
{
- code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 6,
},
]
this.prevQuantity = this.recurlySubscription.addOns[0].quantity
this.previewSubscriptionChange.nextAddOns = [
{
- code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.prevQuantity + this.adding,
},
]
@@ -367,7 +422,7 @@ describe('SubscriptionGroupHandler', function () {
this.recurlySubscription.getRequestForAddOnUpdate
.calledWith(
- this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.recurlySubscription.addOns[0].quantity + this.adding
)
.should.equal(true)
@@ -391,14 +446,13 @@ describe('SubscriptionGroupHandler', function () {
{
type: 'add-on-update',
addOn: {
- code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
this.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: this.prevQuantity,
},
},
- this.previewSubscriptionChange,
- this.paymentMethod
+ this.previewSubscriptionChange
)
.should.equal(true)
preview.should.equal(this.changePreview)
@@ -407,12 +461,30 @@ describe('SubscriptionGroupHandler', function () {
describe('createAddSeatsSubscriptionChange', function () {
it('should change the subscription', async function () {
+ this.recurlySubscription = {
+ ...this.recurlySubscription,
+ get isCollectionMethodManual() {
+ return true
+ },
+ }
+ this.RecurlyClient.promises.getSubscription = sinon
+ .stub()
+ .resolves(this.recurlySubscription)
+
const result =
await this.Handler.promises.createAddSeatsSubscriptionChange(
this.adminUser_id,
- this.adding
+ this.adding,
+ '123'
)
+ this.RecurlyClient.promises.updateSubscriptionDetails
+ .calledWith(
+ sinon.match
+ .has('poNumber')
+ .and(sinon.match.has('termsAndConditions'))
+ )
+ .should.equal(true)
this.RecurlyClient.promises.applySubscriptionChangeRequest
.calledWith(this.changeRequest)
.should.equal(true)
@@ -429,13 +501,55 @@ describe('SubscriptionGroupHandler', function () {
})
})
+ describe('updateSubscriptionPaymentTerms', function () {
+ describe('accounts with PO number', function () {
+ it('should update the subscription PO number and T&C', async function () {
+ this.RecurlyClient.promises.getCountryCode = sinon
+ .stub()
+ .resolves('GB')
+ await this.Handler.promises.updateSubscriptionPaymentTerms(
+ this.adminUser_id,
+ this.recurlySubscription,
+ this.poNumberAndTermsAndConditionsUpdate.poNumber
+ )
+ this.recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate
+ .calledWithMatch(
+ this.poNumberAndTermsAndConditionsUpdate.poNumber,
+ 'T&Cs'
+ )
+ .should.equal(true)
+ this.RecurlyClient.promises.updateSubscriptionDetails
+ .calledWith(this.poNumberAndTermsAndConditionsUpdate)
+ .should.equal(true)
+ })
+ })
+
+ describe('accounts with no PO number', function () {
+ it('should update the subscription T&C only', async function () {
+ this.RecurlyClient.promises.getCountryCode = sinon
+ .stub()
+ .resolves('GB')
+ await this.Handler.promises.updateSubscriptionPaymentTerms(
+ this.adminUser_id,
+ this.recurlySubscription
+ )
+ this.recurlySubscription.getRequestForTermsAndConditionsUpdate
+ .calledWithMatch('T&Cs')
+ .should.equal(true)
+ this.RecurlyClient.promises.updateSubscriptionDetails
+ .calledWith(this.termsAndConditionsUpdate)
+ .should.equal(true)
+ })
+ })
+ })
+
describe('has no "additional-license" add-on', function () {
beforeEach(function () {
this.recurlySubscription.addOns = []
this.prevQuantity = this.recurlySubscription.addOns[0]?.quantity ?? 0
this.previewSubscriptionChange.nextAddOns = [
{
- code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.prevQuantity + this.adding,
},
]
@@ -467,14 +581,13 @@ describe('SubscriptionGroupHandler', function () {
{
type: 'add-on-update',
addOn: {
- code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
this.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: this.prevQuantity,
},
},
- this.previewSubscriptionChange,
- this.paymentMethod
+ this.previewSubscriptionChange
)
.should.equal(true)
preview.should.equal(this.changePreview)
@@ -493,7 +606,7 @@ describe('SubscriptionGroupHandler', function () {
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
- this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
this.GroupPlansData.enterprise.collaborator.USD[5]
.additional_license_legacy_price_in_cents / 100
@@ -513,7 +626,7 @@ describe('SubscriptionGroupHandler', function () {
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
- this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
undefined
)
@@ -538,7 +651,7 @@ describe('SubscriptionGroupHandler', function () {
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
- this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
this.GroupPlansData.enterprise.collaborator.USD[5]
.additional_license_legacy_price_in_cents / 100
@@ -563,7 +676,7 @@ describe('SubscriptionGroupHandler', function () {
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
- this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+ this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
undefined
)
@@ -742,4 +855,355 @@ describe('SubscriptionGroupHandler', function () {
result.should.equal(this.changePreview)
})
})
+
+ describe('checkBillingInfoExistence', function () {
+ it('should invoke the payment method function when collection method is "automatic"', async function () {
+ await this.Handler.promises.checkBillingInfoExistence(
+ this.recurlySubscription,
+ this.adminUser_id
+ )
+ this.RecurlyClient.promises.getPaymentMethod
+ .calledWith(this.adminUser_id)
+ .should.equal(true)
+ })
+
+ it('shouldn’t invoke the payment method function when collection method is "manual"', async function () {
+ const recurlySubscription = {
+ ...this.recurlySubscription,
+ get isCollectionMethodManual() {
+ return true
+ },
+ }
+ await this.Handler.promises.checkBillingInfoExistence(
+ recurlySubscription,
+ this.adminUser_id
+ )
+ this.RecurlyClient.promises.getPaymentMethod.should.not.have.been.called
+ })
+ })
+
+ describe('updateGroupMembersBulk', function () {
+ const inviterId = new ObjectId()
+
+ let members
+ let emailList
+ let callUpdateGroupMembersBulk
+
+ beforeEach(function () {
+ members = [
+ {
+ _id: new ObjectId(),
+ email: 'user1@example.com',
+ emails: [{ email: 'user1@example.com' }],
+ },
+ {
+ _id: new ObjectId(),
+ email: 'user2-alias@example.com',
+ emails: [
+ {
+ email: 'user2-alias@example.com',
+ },
+ {
+ email: 'user2@example.com',
+ },
+ ],
+ },
+ {
+ _id: new ObjectId(),
+ email: 'user3@example.com',
+ emails: [{ email: 'user3@example.com' }],
+ },
+ ]
+
+ emailList = [
+ 'user1@example.com',
+ 'user2@example.com',
+ 'new-user@example.com', // primary email of existing user
+ 'new-user-2@example.com', // secondary email of existing user
+ ]
+ callUpdateGroupMembersBulk = async (options = {}) => {
+ this.Subscription.findOne = sinon
+ .stub()
+ .returns({ exec: sinon.stub().resolves(this.subscription) })
+
+ this.User.find = sinon
+ .stub()
+ .returns({ exec: sinon.stub().resolves(members) })
+
+ return await this.Handler.promises.updateGroupMembersBulk(
+ inviterId,
+ this.subscription._id,
+ emailList,
+ options
+ )
+ }
+ })
+
+ it('throws an error when any of the emails is invalid', async function () {
+ emailList.push('invalid@email')
+
+ await expect(
+ callUpdateGroupMembersBulk({ commit: true })
+ ).to.be.rejectedWith(InvalidEmailError)
+ })
+
+ describe('with commit = false', function () {
+ describe('with removeMembersNotIncluded = false', function () {
+ it('should preview zero users to delete, and should not send invites', async function () {
+ const result = await callUpdateGroupMembersBulk()
+
+ expect(result).to.deep.equal({
+ emailsToSendInvite: [
+ 'new-user@example.com',
+ 'new-user-2@example.com',
+ ],
+ emailsToRevokeInvite: [],
+ membersToRemove: [],
+ currentMemberCount: 3,
+ newTotalCount: 5,
+ membersLimit: this.subscription.membersLimit,
+ })
+
+ expect(this.TeamInvitesHandler.promises.createInvite).not.to.have.been
+ .called
+
+ expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
+ .have.been.called
+ })
+ })
+
+ describe('with removeMembersNotIncluded = true', function () {
+ it('should preview the users to be deleted, and should not send invites', async function () {
+ const result = await callUpdateGroupMembersBulk({
+ removeMembersNotIncluded: true,
+ })
+
+ expect(result).to.deep.equal({
+ emailsToSendInvite: [
+ 'new-user@example.com',
+ 'new-user-2@example.com',
+ ],
+ emailsToRevokeInvite: [],
+ membersToRemove: [members[2]._id],
+ currentMemberCount: 3,
+ newTotalCount: 4,
+ membersLimit: this.subscription.membersLimit,
+ })
+
+ expect(this.TeamInvitesHandler.promises.createInvite).not.to.have.been
+ .called
+
+ expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
+ .have.been.called
+ })
+
+ it('should preview but not revoke invites to emails that are no longer invited', async function () {
+ this.subscription.teamInvites = [
+ { email: 'new-user@example.com' },
+ { email: 'no-longer-invited@example.com' },
+ ]
+
+ const result = await callUpdateGroupMembersBulk({
+ removeMembersNotIncluded: true,
+ })
+
+ expect(result.emailsToRevokeInvite).to.deep.equal([
+ 'no-longer-invited@example.com',
+ ])
+
+ expect(this.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
+ .called
+ })
+ })
+
+ it('does not throw an error when the member limit is reached', async function () {
+ this.subscription.membersLimit = 3
+ const result = await callUpdateGroupMembersBulk()
+
+ expect(result.membersLimit).to.equal(3)
+ expect(result.newTotalCount).to.equal(5)
+ })
+ })
+
+ describe('with commit = true', function () {
+ describe('with removeMembersNotIncluded = false', function () {
+ it('should preview zero users to delete, and should send invites', async function () {
+ const result = await callUpdateGroupMembersBulk({ commit: true })
+
+ expect(result).to.deep.equal({
+ emailsToSendInvite: [
+ 'new-user@example.com',
+ 'new-user-2@example.com',
+ ],
+ emailsToRevokeInvite: [],
+ membersToRemove: [],
+ currentMemberCount: 3,
+ newTotalCount: 5,
+ membersLimit: this.subscription.membersLimit,
+ })
+
+ expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
+ .have.been.called
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite.callCount
+ ).to.equal(2)
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite
+ ).to.have.been.calledWith(
+ inviterId,
+ this.subscription,
+ 'new-user@example.com'
+ )
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite
+ ).to.have.been.calledWith(
+ inviterId,
+ this.subscription,
+ 'new-user-2@example.com'
+ )
+ })
+
+ it('should not send invites to emails already invited', async function () {
+ this.subscription.teamInvites = [{ email: 'new-user@example.com' }]
+
+ const result = await callUpdateGroupMembersBulk({ commit: true })
+
+ expect(result.emailsToSendInvite).to.deep.equal([
+ 'new-user-2@example.com',
+ ])
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite.callCount
+ ).to.equal(1)
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite
+ ).to.have.been.calledWith(
+ inviterId,
+ this.subscription,
+ 'new-user-2@example.com'
+ )
+ })
+
+ it('should preview and not revoke invites to emails that are no longer invited', async function () {
+ this.subscription.teamInvites = [
+ { email: 'new-user@example.com' },
+ { email: 'no-longer-invited@example.com' },
+ ]
+
+ const result = await callUpdateGroupMembersBulk({
+ commit: true,
+ })
+
+ expect(result.emailsToRevokeInvite).to.deep.equal([])
+
+ expect(this.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
+ .called
+ })
+ })
+
+ describe('with removeMembersNotIncluded = true', function () {
+ it('should remove users from group, and should send invites', async function () {
+ const result = await callUpdateGroupMembersBulk({
+ commit: true,
+ removeMembersNotIncluded: true,
+ })
+
+ expect(result).to.deep.equal({
+ emailsToSendInvite: [
+ 'new-user@example.com',
+ 'new-user-2@example.com',
+ ],
+ emailsToRevokeInvite: [],
+ membersToRemove: [members[2]._id],
+ currentMemberCount: 3,
+ newTotalCount: 4,
+ membersLimit: this.subscription.membersLimit,
+ })
+
+ expect(
+ this.SubscriptionUpdater.promises.removeUserFromGroup.callCount
+ ).to.equal(1)
+
+ expect(
+ this.SubscriptionUpdater.promises.removeUserFromGroup
+ ).to.have.been.calledWith(this.subscription._id, members[2]._id)
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite.callCount
+ ).to.equal(2)
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite
+ ).to.have.been.calledWith(
+ inviterId,
+ this.subscription,
+ 'new-user@example.com'
+ )
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite
+ ).to.have.been.calledWith(
+ inviterId,
+ this.subscription,
+ 'new-user-2@example.com'
+ )
+ })
+
+ it('should send invites and revoke invites to emails no longer invited', async function () {
+ this.subscription.teamInvites = [
+ { email: 'new-user@example.com' },
+ { email: 'no-longer-invited@example.com' },
+ ]
+
+ const result = await callUpdateGroupMembersBulk({
+ commit: true,
+ removeMembersNotIncluded: true,
+ })
+
+ expect(result.emailsToSendInvite).to.deep.equal([
+ 'new-user-2@example.com',
+ ])
+
+ expect(result.emailsToRevokeInvite).to.deep.equal([
+ 'no-longer-invited@example.com',
+ ])
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite.callCount
+ ).to.equal(1)
+
+ expect(
+ this.TeamInvitesHandler.promises.createInvite
+ ).to.have.been.calledWith(
+ inviterId,
+ this.subscription,
+ 'new-user-2@example.com'
+ )
+
+ expect(
+ this.TeamInvitesHandler.promises.revokeInvite.callCount
+ ).to.equal(1)
+
+ expect(
+ this.TeamInvitesHandler.promises.revokeInvite
+ ).to.have.been.calledWith(
+ inviterId,
+ this.subscription,
+ 'no-longer-invited@example.com'
+ )
+ })
+ })
+
+ it('throws an error when the member limit is reached', async function () {
+ this.subscription.membersLimit = 3
+ await expect(
+ callUpdateGroupMembersBulk({ commit: true })
+ ).to.be.rejectedWith('limit reached')
+ })
+ })
+ })
})
diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
index 64e9381b49..0517e451d4 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
@@ -3,9 +3,9 @@ const sinon = require('sinon')
const chai = require('chai')
const { expect } = chai
const {
- RecurlySubscription,
- RecurlySubscriptionChangeRequest,
-} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
+ PaymentProviderSubscription,
+ PaymentProviderSubscriptionChangeRequest,
+} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
const MODULE_PATH =
'../../../../app/src/Features/Subscription/SubscriptionHandler'
@@ -27,7 +27,7 @@ const mockRecurlySubscriptions = {
}
const mockRecurlyClientSubscriptions = {
- 'subscription-123-active': new RecurlySubscription({
+ 'subscription-123-active': new PaymentProviderSubscription({
id: 'subscription-123-active',
userId: 'user-id',
planCode: 'collaborator',
@@ -114,6 +114,7 @@ describe('SubscriptionHandler', function () {
promises: {
updateSubscriptionFromRecurly: sinon.stub().resolves(),
syncSubscription: sinon.stub().resolves(),
+ syncStripeSubscription: sinon.stub().resolves(),
startFreeTrial: sinon.stub().resolves(),
},
}
@@ -155,6 +156,13 @@ describe('SubscriptionHandler', function () {
'../Email/EmailHandler': this.EmailHandler,
'../Analytics/AnalyticsManager': this.AnalyticsManager,
'../User/UserUpdater': this.UserUpdater,
+ '../../infrastructure/Modules': (this.Modules = {
+ promises: {
+ hooks: {
+ fire: sinon.stub(),
+ },
+ },
+ }),
},
})
})
@@ -275,7 +283,7 @@ describe('SubscriptionHandler', function () {
expect(
this.RecurlyClient.promises.applySubscriptionChangeRequest
).to.have.been.calledWith(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.activeRecurlyClientSubscription,
timeframe: 'now',
planCode: this.plan_code,
@@ -388,7 +396,7 @@ describe('SubscriptionHandler', function () {
expect(
this.RecurlyClient.promises.applySubscriptionChangeRequest
).to.be.calledWith(
- new RecurlySubscriptionChangeRequest({
+ new PaymentProviderSubscriptionChangeRequest({
subscription: this.activeRecurlyClientSubscription,
timeframe: 'now',
planCode: this.plan_code,
@@ -425,12 +433,10 @@ describe('SubscriptionHandler', function () {
})
it('should cancel the subscription', function () {
- this.RecurlyClient.promises.cancelSubscriptionByUuid.called.should.equal(
- true
+ expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
+ 'cancelPaidSubscription',
+ this.subscription
)
- this.RecurlyClient.promises.cancelSubscriptionByUuid
- .calledWith(this.subscription.recurlySubscription_id)
- .should.equal(true)
})
it('should send the email after 1 hour', function () {
@@ -625,12 +631,10 @@ describe('SubscriptionHandler', function () {
})
it('should reactivate the subscription', function () {
- this.RecurlyClient.promises.reactivateSubscriptionByUuid.called.should.equal(
- true
+ expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
+ 'reactivatePaidSubscription',
+ this.subscription
)
- this.RecurlyClient.promises.reactivateSubscriptionByUuid
- .calledWith(this.subscription.recurlySubscription_id)
- .should.equal(true)
})
it('should send a notification email', function () {
diff --git a/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js b/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js
index 666f7ffda9..09644bc7b1 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js
@@ -801,4 +801,38 @@ describe('SubscriptionUpdater', function () {
}
})
})
+
+ describe('scheduleRefreshFeatures', function () {
+ it('should call upgrades feature for personal subscription from admin_id', async function () {
+ this.subscription = {
+ _id: new ObjectId().toString(),
+ mock: 'subscription',
+ admin_id: new ObjectId(),
+ }
+
+ await this.SubscriptionUpdater.promises.scheduleRefreshFeatures(
+ this.subscription
+ )
+
+ expect(
+ this.FeaturesUpdater.promises.scheduleRefreshFeatures
+ ).to.have.been.calledOnceWith(this.subscription.admin_id)
+ })
+
+ it('should call upgrades feature for group subscription from admin_id and member_ids', async function () {
+ this.subscription = {
+ _id: new ObjectId().toString(),
+ mock: 'subscription',
+ admin_id: new ObjectId(),
+ member_ids: [new ObjectId(), new ObjectId(), new ObjectId()],
+ }
+ await this.SubscriptionUpdater.promises.scheduleRefreshFeatures(
+ this.subscription
+ )
+
+ expect(
+ this.FeaturesUpdater.promises.scheduleRefreshFeatures.callCount
+ ).to.equal(4)
+ })
+ })
})
diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
index c1075f1a60..e969cf381c 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
@@ -1,6 +1,13 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { assert } = require('chai')
+const {
+ PaymentProviderAccount,
+ PaymentProviderSubscription,
+ PaymentProviderSubscriptionAddOn,
+ PaymentProviderSubscriptionChange,
+} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
+
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder'
@@ -26,6 +33,29 @@ describe('SubscriptionViewModelBuilder', function () {
state: 'active',
},
}
+ this.paymentRecord = new PaymentProviderSubscription({
+ id: this.recurlySubscription_id,
+ userId: this.user._id,
+ currency: 'EUR',
+ planCode: 'plan-code',
+ planName: 'plan-name',
+ planPrice: 13,
+ addOns: [
+ new PaymentProviderSubscriptionAddOn({
+ code: 'addon-code',
+ name: 'addon name',
+ quantity: 1,
+ unitPrice: 2,
+ }),
+ ],
+ subtotal: 15,
+ taxRate: 0.1,
+ taxAmount: 1.5,
+ total: 16.5,
+ periodStart: new Date('2025-01-20T12:00:00.000Z'),
+ periodEnd: new Date('2025-02-20T12:00:00.000Z'),
+ collectionMethod: 'automatic',
+ })
this.individualCustomSubscription = {
planCode: this.planCode,
@@ -42,6 +72,8 @@ describe('SubscriptionViewModelBuilder', function () {
this.groupPlan = {
planCode: this.groupPlanCode,
features: this.groupPlanFeatures,
+ membersLimit: 4,
+ membersLimitAddOn: 'additional-license',
}
this.groupSubscription = {
planCode: this.groupPlanCode,
@@ -75,18 +107,29 @@ describe('SubscriptionViewModelBuilder', function () {
getUsersSubscription: sinon.stub().resolves(),
getMemberSubscriptions: sinon.stub().resolves(),
},
+ getUsersSubscription: sinon.stub().yields(),
+ getMemberSubscriptions: sinon.stub().yields(null, []),
+ getManagedGroupSubscriptions: sinon.stub().yields(null, []),
findLocalPlanInSettings: sinon.stub(),
}
this.InstitutionsGetter = {
promises: {
getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
},
+ getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []),
+ getManagedInstitutions: sinon.stub().yields(null, []),
}
this.InstitutionsManager = {
promises: {
fetchV1Data: sinon.stub().resolves(),
},
}
+ this.PublishersGetter = {
+ promises: {
+ fetchV1Data: sinon.stub().resolves(),
+ },
+ getManagedPublishers: sinon.stub().yields(null, []),
+ }
this.RecurlyWrapper = {
promises: {
getSubscription: sinon.stub().resolves(),
@@ -109,9 +152,13 @@ describe('SubscriptionViewModelBuilder', function () {
'./RecurlyWrapper': this.RecurlyWrapper,
'./SubscriptionUpdater': this.SubscriptionUpdater,
'./PlansLocator': this.PlansLocator,
+ '../../infrastructure/Modules': (this.Modules = {
+ hooks: {
+ fire: sinon.stub().yields(null, []),
+ },
+ }),
'./V1SubscriptionManager': {},
- './SubscriptionFormatters': {},
- '../Publishers/PublishersGetter': {},
+ '../Publishers/PublishersGetter': this.PublishersGetter,
'./SubscriptionHelper': {},
},
})
@@ -219,7 +266,7 @@ describe('SubscriptionViewModelBuilder', function () {
plan: this.plan,
recurlySubscription_id: this.recurlySubscription_id,
}
- this.recurlySubscription = {
+ this.paymentRecord = {
state: 'active',
}
this.SubscriptionLocator.promises.getUsersSubscription
@@ -233,7 +280,7 @@ describe('SubscriptionViewModelBuilder', function () {
.withArgs(this.individualSubscription.recurlySubscription_id, {
includeAccount: true,
})
- .resolves(this.recurlySubscription)
+ .resolves(this.paymentRecord)
const usersBestSubscription =
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
@@ -247,7 +294,7 @@ describe('SubscriptionViewModelBuilder', function () {
)
sinon.assert.calledWith(
this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly,
- this.recurlySubscription,
+ this.paymentRecord,
this.individualSubscriptionWithoutRecurly
)
assert.deepEqual(usersBestSubscription, {
@@ -309,152 +356,420 @@ describe('SubscriptionViewModelBuilder', function () {
plan: this.commonsPlan,
})
})
+
+ describe('with multiple subscriptions', function () {
+ beforeEach(function () {
+ this.SubscriptionLocator.promises.getUsersSubscription
+ .withArgs(this.user)
+ .resolves(this.individualSubscription)
+ this.SubscriptionLocator.promises.getMemberSubscriptions
+ .withArgs(this.user)
+ .resolves([this.groupSubscription])
+ this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
+ .withArgs(this.user._id)
+ .resolves([this.commonsSubscription])
+ })
+
+ it('should return individual when the individual subscription has the best feature set', async function () {
+ this.commonsPlan.features = {
+ compileGroup: 'standard',
+ collaborators: 1,
+ compileTimeout: 60,
+ }
+
+ const usersBestSubscription =
+ await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+ this.user
+ )
+
+ assert.deepEqual(usersBestSubscription, {
+ type: 'individual',
+ subscription: this.individualSubscription,
+ plan: this.plan,
+ remainingTrialDays: -1,
+ })
+ })
+
+ it('should return group when the group subscription has the best feature set', async function () {
+ this.plan.features = {
+ compileGroup: 'standard',
+ collaborators: 1,
+ compileTimeout: 60,
+ }
+ this.commonsPlan.features = {
+ compileGroup: 'standard',
+ collaborators: 1,
+ compileTimeout: 60,
+ }
+
+ const usersBestSubscription =
+ await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+ this.user
+ )
+
+ assert.deepEqual(usersBestSubscription, {
+ type: 'group',
+ subscription: {},
+ plan: this.groupPlan,
+ remainingTrialDays: -1,
+ })
+ })
+
+ it('should return commons when the commons affiliation has the best feature set', async function () {
+ this.plan.features = {
+ compileGroup: 'priority',
+ collaborators: 5,
+ compileTimeout: 240,
+ }
+ this.groupPlan.features = {
+ compileGroup: 'standard',
+ collaborators: 1,
+ compileTimeout: 60,
+ }
+ this.commonsPlan.features = {
+ compileGroup: 'priority',
+ collaborators: -1,
+ compileTimeout: 240,
+ }
+
+ const usersBestSubscription =
+ await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+ this.user
+ )
+
+ assert.deepEqual(usersBestSubscription, {
+ type: 'commons',
+ subscription: this.commonsSubscription,
+ plan: this.commonsPlan,
+ })
+ })
+
+ it('should return individual with equal feature sets', async function () {
+ this.plan.features = {
+ compileGroup: 'priority',
+ collaborators: -1,
+ compileTimeout: 240,
+ }
+ this.groupPlan.features = {
+ compileGroup: 'priority',
+ collaborators: -1,
+ compileTimeout: 240,
+ }
+ this.commonsPlan.features = {
+ compileGroup: 'priority',
+ collaborators: -1,
+ compileTimeout: 240,
+ }
+
+ const usersBestSubscription =
+ await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+ this.user
+ )
+
+ assert.deepEqual(usersBestSubscription, {
+ type: 'individual',
+ subscription: this.individualSubscription,
+ plan: this.plan,
+ remainingTrialDays: -1,
+ })
+ })
+
+ it('should return group over commons with equal feature sets', async function () {
+ this.plan.features = {
+ compileGroup: 'standard',
+ collaborators: 1,
+ compileTimeout: 60,
+ }
+ this.groupPlan.features = {
+ compileGroup: 'priority',
+ collaborators: -1,
+ compileTimeout: 240,
+ }
+ this.commonsPlan.features = {
+ compileGroup: 'priority',
+ collaborators: -1,
+ compileTimeout: 240,
+ }
+
+ const usersBestSubscription =
+ await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+ this.user
+ )
+
+ assert.deepEqual(usersBestSubscription, {
+ type: 'group',
+ subscription: {},
+ plan: this.groupPlan,
+ remainingTrialDays: -1,
+ })
+ })
+ })
})
- describe('with multiple subscriptions', function () {
+ describe('buildUsersSubscriptionViewModel', function () {
beforeEach(function () {
- this.SubscriptionLocator.promises.getUsersSubscription
- .withArgs(this.user)
- .resolves(this.individualSubscription)
- this.SubscriptionLocator.promises.getMemberSubscriptions
- .withArgs(this.user)
- .resolves([this.groupSubscription])
- this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
- .withArgs(this.user._id)
- .resolves([this.commonsSubscription])
+ this.SubscriptionLocator.getUsersSubscription.yields(
+ null,
+ this.individualSubscription
+ )
+ this.Modules.hooks.fire
+ .withArgs('getPaymentFromRecord', this.individualSubscription)
+ .yields(null, [
+ {
+ subscription: this.paymentRecord,
+ account: new PaymentProviderAccount({}),
+ coupons: [],
+ },
+ ])
})
- it('should return individual when the individual subscription has the best feature set', async function () {
- this.commonsPlan.features = {
- compileGroup: 'standard',
- collaborators: 1,
- compileTimeout: 60,
- }
-
- const usersBestSubscription =
- await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
- this.user
- )
-
- assert.deepEqual(usersBestSubscription, {
- type: 'individual',
- subscription: this.individualSubscription,
- plan: this.plan,
- remainingTrialDays: -1,
+ describe('with a paid subscription', function () {
+ it('adds payment data to the personal subscription', async function () {
+ this.Modules.hooks.fire
+ .withArgs('getPaymentFromRecord', this.individualSubscription)
+ .yields(null, [
+ {
+ subscription: this.paymentRecord,
+ account: new PaymentProviderAccount({
+ email: 'example@example.com',
+ hasPastDueInvoice: false,
+ }),
+ coupons: [],
+ },
+ ])
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.deepEqual(result.personalSubscription.payment, {
+ taxRate: 0.1,
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink:
+ '/user/subscription/payment/account-management',
+ additionalLicenses: 0,
+ addOns: [
+ {
+ code: 'addon-code',
+ name: 'addon name',
+ quantity: 1,
+ unitPrice: 2,
+ preTaxTotal: 2,
+ },
+ ],
+ totalLicenses: 0,
+ nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC',
+ nextPaymentDueDate: 'February 20th, 2025',
+ currency: 'EUR',
+ state: 'active',
+ trialEndsAtFormatted: null,
+ trialEndsAt: null,
+ activeCoupons: [],
+ accountEmail: 'example@example.com',
+ hasPastDueInvoice: false,
+ pausedAt: null,
+ remainingPauseCycles: null,
+ displayPrice: '€16.50',
+ planOnlyDisplayPrice: '€14.30',
+ addOnDisplayPricesWithoutAdditionalLicense: {
+ 'addon-code': '€2.20',
+ },
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
+ })
})
- })
- it('should return group when the group subscription has the best feature set', async function () {
- this.plan.features = {
- compileGroup: 'standard',
- collaborators: 1,
- compileTimeout: 60,
- }
- this.commonsPlan.features = {
- compileGroup: 'standard',
- collaborators: 1,
- compileTimeout: 60,
- }
+ describe('isEligibleForGroupPlan', function () {
+ it('is false for Stripe subscriptions', async function () {
+ this.paymentRecord.service = 'stripe'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(
+ result.personalSubscription.payment.isEligibleForGroupPlan
+ )
+ })
- const usersBestSubscription =
- await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
- this.user
- )
+ it('is false when in trial', async function () {
+ const msIn24Hours = 24 * 60 * 60 * 1000
+ const tomorrow = new Date(Date.now() + msIn24Hours)
+ this.paymentRecord.trialPeriodEnd = tomorrow
+ this.paymentRecord.service = 'recurly'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(
+ result.personalSubscription.payment.isEligibleForGroupPlan
+ )
+ })
- assert.deepEqual(usersBestSubscription, {
- type: 'group',
- subscription: {},
- plan: this.groupPlan,
- remainingTrialDays: -1,
+ it('is true when not in trial and for a Recurly subscription', async function () {
+ this.paymentRecord.service = 'recurly'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isTrue(
+ result.personalSubscription.payment.isEligibleForGroupPlan
+ )
+ })
})
- })
- it('should return commons when the commons affiliation has the best feature set', async function () {
- this.plan.features = {
- compileGroup: 'priority',
- collaborators: 5,
- compileTimeout: 240,
- }
- this.groupPlan.features = {
- compileGroup: 'standard',
- collaborators: 1,
- compileTimeout: 60,
- }
- this.commonsPlan.features = {
- compileGroup: 'priority',
- collaborators: -1,
- compileTimeout: 240,
- }
+ describe('isEligibleForPause', function () {
+ it('is false for Stripe subscriptions', async function () {
+ this.paymentRecord.service = 'stripe'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
- const usersBestSubscription =
- await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
- this.user
- )
+ it('is false for subscriptions with pending plan', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.individualSubscription.pendingPlan = {} // anything
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
- assert.deepEqual(usersBestSubscription, {
- type: 'commons',
- subscription: this.commonsSubscription,
- plan: this.commonsPlan,
+ it('is false for a group subscription', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.individualSubscription.groupPlan = true
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is false when in trial', async function () {
+ this.paymentRecord.service = 'recurly'
+ const msIn24Hours = 24 * 60 * 60 * 1000
+ const tomorrow = new Date(Date.now() + msIn24Hours)
+ this.paymentRecord.trialPeriodEnd = tomorrow
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is false for annual subscriptions', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.paymentRecord.planCode = 'collaborator-annual'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is false for subscriptions with add-ons', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.paymentRecord.addOns = [{}] // anything
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is true when conditions are met', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.paymentRecord.addOns = []
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
+ })
})
- })
- it('should return individual with equal feature sets', async function () {
- this.plan.features = {
- compileGroup: 'priority',
- collaborators: -1,
- compileTimeout: 240,
- }
- this.groupPlan.features = {
- compileGroup: 'priority',
- collaborators: -1,
- compileTimeout: 240,
- }
- this.commonsPlan.features = {
- compileGroup: 'priority',
- collaborators: -1,
- compileTimeout: 240,
- }
-
- const usersBestSubscription =
- await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
- this.user
+ it('includes pending changes', async function () {
+ this.paymentRecord.pendingChange =
+ new PaymentProviderSubscriptionChange({
+ subscription: this.paymentRecord,
+ nextPlanCode: this.groupPlanCode,
+ nextPlanName: 'Group Collaborator (Annual) 4 licenses',
+ nextPlanPrice: 1400,
+ nextAddOns: [
+ new PaymentProviderSubscriptionAddOn({
+ code: 'additional-license',
+ name: 'additional license',
+ quantity: 8,
+ unitPrice: 24.4,
+ }),
+ new PaymentProviderSubscriptionAddOn({
+ code: 'addon-code',
+ name: 'addon name',
+ quantity: 1,
+ unitPrice: 2,
+ }),
+ ],
+ })
+ this.Modules.hooks.fire
+ .withArgs('getPaymentFromRecord', this.individualSubscription)
+ .yields(null, [
+ {
+ subscription: this.paymentRecord,
+ account: {},
+ coupons: [],
+ },
+ ])
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.equal(
+ result.personalSubscription.payment.displayPrice,
+ '€1,756.92'
+ )
+ assert.equal(
+ result.personalSubscription.payment.planOnlyDisplayPrice,
+ '€1,754.72'
+ )
+ assert.deepEqual(
+ result.personalSubscription.payment
+ .addOnDisplayPricesWithoutAdditionalLicense,
+ { 'addon-code': '€2.20' }
+ )
+ assert.equal(
+ result.personalSubscription.payment.pendingAdditionalLicenses,
+ 8
+ )
+ assert.equal(
+ result.personalSubscription.payment.pendingTotalLicenses,
+ 12
)
-
- assert.deepEqual(usersBestSubscription, {
- type: 'individual',
- subscription: this.individualSubscription,
- plan: this.plan,
- remainingTrialDays: -1,
})
- })
- it('should return group over commons with equal feature sets', async function () {
- this.plan.features = {
- compileGroup: 'standard',
- collaborators: 1,
- compileTimeout: 60,
- }
- this.groupPlan.features = {
- compileGroup: 'priority',
- collaborators: -1,
- compileTimeout: 240,
- }
- this.commonsPlan.features = {
- compileGroup: 'priority',
- collaborators: -1,
- compileTimeout: 240,
- }
-
- const usersBestSubscription =
- await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
- this.user
+ it('does not add a billing details link for a Stripe subscription', async function () {
+ this.paymentRecord.service = 'stripe'
+ this.Modules.hooks.fire
+ .withArgs('getPaymentFromRecord', this.individualSubscription)
+ .yields(null, [
+ {
+ subscription: this.paymentRecord,
+ account: new PaymentProviderAccount({}),
+ coupons: [],
+ },
+ ])
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.equal(
+ result.personalSubscription.payment.billingDetailsLink,
+ undefined
+ )
+ assert.equal(
+ result.personalSubscription.payment.accountManagementLink,
+ '/user/subscription/payment/account-management'
)
-
- assert.deepEqual(usersBestSubscription, {
- type: 'group',
- subscription: {},
- plan: this.groupPlan,
- remainingTrialDays: -1,
})
})
})
diff --git a/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js b/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js
index 0b762823a4..fdd247bf96 100644
--- a/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js
+++ b/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js
@@ -131,174 +131,149 @@ describe('TeamInvitesHandler', function () {
})
describe('getInvite', function () {
- it("returns the invite if there's one", function (done) {
- this.TeamInvitesHandler.getInvite(
- this.token,
- (err, invite, subscription) => {
- expect(err).to.eq(null)
- expect(invite).to.deep.eq(this.teamInvite)
- expect(subscription).to.deep.eq(this.subscription)
- done()
- }
- )
+ it("returns the invite if there's one", async function () {
+ const { invite, subscription } =
+ await this.TeamInvitesHandler.promises.getInvite(this.token)
+
+ expect(invite).to.deep.eq(this.teamInvite)
+ expect(subscription).to.deep.eq(this.subscription)
})
- it("returns teamNotFound if there's none", function (done) {
+ it("returns teamNotFound if there's none", async function () {
this.Subscription.findOne = sinon.stub().resolves(null)
- this.TeamInvitesHandler.getInvite(
- this.token,
- (err, invite, subscription) => {
- expect(err).to.be.instanceof(Errors.NotFoundError)
- done()
- }
- )
+ let error
+ try {
+ await this.TeamInvitesHandler.promises.getInvite(this.token)
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(Errors.NotFoundError)
})
})
describe('createInvite', function () {
- it('adds the team invite to the subscription', function (done) {
- this.TeamInvitesHandler.createInvite(
+ it('adds the team invite to the subscription', async function () {
+ const invite = await this.TeamInvitesHandler.promises.createInvite(
this.manager._id,
this.subscription,
- 'John.Snow@example.com',
- (err, invite) => {
- expect(err).to.eq(null)
- expect(invite.token).to.eq(this.newToken)
- expect(invite.email).to.eq('john.snow@example.com')
- expect(invite.inviterName).to.eq(
- 'Daenerys Targaryen (daenerys@example.com)'
- )
- expect(invite.invite).to.be.true
- expect(this.subscription.teamInvites).to.deep.include(invite)
- done()
- }
+ 'John.Snow@example.com'
)
+ expect(invite.token).to.eq(this.newToken)
+ expect(invite.email).to.eq('john.snow@example.com')
+ expect(invite.inviterName).to.eq(
+ 'Daenerys Targaryen (daenerys@example.com)'
+ )
+ expect(invite.invite).to.be.true
+ expect(this.subscription.teamInvites).to.deep.include(invite)
})
- it('sends an email', function (done) {
- this.TeamInvitesHandler.createInvite(
+ it('sends an email', async function () {
+ await this.TeamInvitesHandler.promises.createInvite(
this.manager._id,
this.subscription,
- 'John.Snow@example.com',
- (err, invite) => {
- this.EmailHandler.promises.sendEmail
- .calledWith(
- 'verifyEmailToJoinTeam',
- sinon.match({
- to: 'john.snow@example.com',
- inviter: this.manager,
- acceptInviteUrl: `http://example.com/subscription/invites/${this.newToken}/`,
- })
- )
- .should.equal(true)
- done(err)
- }
+ 'John.Snow@example.com'
)
+
+ this.EmailHandler.promises.sendEmail
+ .calledWith(
+ 'verifyEmailToJoinTeam',
+ sinon.match({
+ to: 'john.snow@example.com',
+ inviter: this.manager,
+ acceptInviteUrl: `http://example.com/subscription/invites/${this.newToken}/`,
+ })
+ )
+ .should.equal(true)
})
- it('refreshes the existing invite if the email has already been invited', function (done) {
+ it('refreshes the existing invite if the email has already been invited', async function () {
const originalInvite = Object.assign({}, this.teamInvite)
- this.TeamInvitesHandler.createInvite(
+ const invite = await this.TeamInvitesHandler.promises.createInvite(
this.manager._id,
this.subscription,
- originalInvite.email,
- (err, invite) => {
- expect(err).to.eq(null)
- expect(invite).to.exist
-
- expect(this.subscription.teamInvites.length).to.eq(1)
- expect(this.subscription.teamInvites).to.deep.include(invite)
-
- expect(invite.email).to.eq(originalInvite.email)
-
- this.subscription.save.calledOnce.should.eq(true)
-
- done()
- }
+ originalInvite.email
)
+ expect(invite).to.exist
+
+ expect(this.subscription.teamInvites.length).to.eq(1)
+ expect(this.subscription.teamInvites).to.deep.include(invite)
+
+ expect(invite.email).to.eq(originalInvite.email)
+
+ this.subscription.save.calledOnce.should.eq(true)
})
- it('removes any legacy invite from the subscription', function (done) {
- this.TeamInvitesHandler.createInvite(
+ it('removes any legacy invite from the subscription', async function () {
+ await this.TeamInvitesHandler.promises.createInvite(
this.manager._id,
this.subscription,
- 'John.Snow@example.com',
- (err, invite) => {
- this.Subscription.updateOne
- .calledWith(
- { _id: new ObjectId('55153a8014829a865bbf700d') },
- { $pull: { invited_emails: 'john.snow@example.com' } }
- )
- .should.eq(true)
- done(err)
- }
+ 'John.Snow@example.com'
)
+
+ this.Subscription.updateOne
+ .calledWith(
+ { _id: new ObjectId('55153a8014829a865bbf700d') },
+ { $pull: { invited_emails: 'john.snow@example.com' } }
+ )
+ .should.eq(true)
})
- it('add user to subscription if inviting self', function (done) {
- this.TeamInvitesHandler.createInvite(
+ it('add user to subscription if inviting self', async function () {
+ const invite = await this.TeamInvitesHandler.promises.createInvite(
this.manager._id,
this.subscription,
- this.manager.email,
- (err, invite) => {
- sinon.assert.calledWith(
- this.SubscriptionUpdater.promises.addUserToGroup,
- this.subscription._id,
- this.manager._id
- )
- sinon.assert.notCalled(this.subscription.save)
- expect(invite.token).to.not.exist
- expect(invite.email).to.eq(this.manager.email)
- expect(invite.first_name).to.eq(this.manager.first_name)
- expect(invite.last_name).to.eq(this.manager.last_name)
- expect(invite.invite).to.be.false
- done(err)
- }
+ this.manager.email
)
+ sinon.assert.calledWith(
+ this.SubscriptionUpdater.promises.addUserToGroup,
+ this.subscription._id,
+ this.manager._id
+ )
+ sinon.assert.notCalled(this.subscription.save)
+ expect(invite.token).to.not.exist
+ expect(invite.email).to.eq(this.manager.email)
+ expect(invite.first_name).to.eq(this.manager.first_name)
+ expect(invite.last_name).to.eq(this.manager.last_name)
+ expect(invite.invite).to.be.false
})
- it('sends an SSO invite if SSO is enabled and inviting self', function (done) {
+ it('sends an SSO invite if SSO is enabled and inviting self', async function () {
this.subscription.ssoConfig = new ObjectId('abc123abc123abc123abc123')
this.SSOConfig.findById
.withArgs(this.subscription.ssoConfig)
.resolves({ enabled: true })
- this.TeamInvitesHandler.createInvite(
+ await this.TeamInvitesHandler.promises.createInvite(
this.manager._id,
this.subscription,
- this.manager.email,
- (err, invite) => {
- sinon.assert.calledWith(
- this.Modules.promises.hooks.fire,
- 'sendGroupSSOReminder',
- this.manager._id,
- this.subscription._id
- )
- done(err)
- }
+ this.manager.email
+ )
+ sinon.assert.calledWith(
+ this.Modules.promises.hooks.fire,
+ 'sendGroupSSOReminder',
+ this.manager._id,
+ this.subscription._id
)
})
- it('does not send an SSO invite if SSO is disabled and inviting self', function (done) {
+ it('does not send an SSO invite if SSO is disabled and inviting self', async function () {
this.subscription.ssoConfig = new ObjectId('abc123abc123abc123abc123')
this.SSOConfig.findById
.withArgs(this.subscription.ssoConfig)
.resolves({ enabled: false })
- this.TeamInvitesHandler.createInvite(
+ await this.TeamInvitesHandler.promises.createInvite(
this.manager._id,
this.subscription,
- this.manager.email,
- (err, invite) => {
- sinon.assert.notCalled(this.Modules.promises.hooks.fire)
- done(err)
- }
+ this.manager.email
)
+ sinon.assert.notCalled(this.Modules.promises.hooks.fire)
})
- it('sends a notification if inviting registered user', function (done) {
+ it('sends a notification if inviting registered user', async function () {
const id = new ObjectId('6a6b3a8014829a865bbf700d')
const managedUsersEnabled = false
@@ -308,22 +283,19 @@ describe('TeamInvitesHandler', function () {
_id: id,
})
- this.TeamInvitesHandler.createInvite(
+ const invite = await this.TeamInvitesHandler.promises.createInvite(
this.manager._id,
this.subscription,
- 'John.Snow@example.com',
- (err, invite) => {
- this.NotificationsBuilder.promises
- .groupInvitation(
- id.toString(),
- this.subscription._id,
- managedUsersEnabled
- )
- .create.calledWith(invite)
- .should.eq(true)
- done(err)
- }
+ 'John.Snow@example.com'
)
+ this.NotificationsBuilder.promises
+ .groupInvitation(
+ id.toString(),
+ this.subscription._id,
+ managedUsersEnabled
+ )
+ .create.calledWith(invite)
+ .should.eq(true)
})
})
@@ -375,112 +347,115 @@ describe('TeamInvitesHandler', function () {
})
describe('with standard group', function () {
- it('adds the user to the team', function (done) {
- this.TeamInvitesHandler.acceptInvite('dddddddd', this.user.id, () => {
- this.SubscriptionUpdater.promises.addUserToGroup
- .calledWith(this.subscription._id, this.user.id)
- .should.eq(true)
- done()
- })
+ it('adds the user to the team', async function () {
+ await this.TeamInvitesHandler.promises.acceptInvite(
+ 'dddddddd',
+ this.user.id
+ )
+ this.SubscriptionUpdater.promises.addUserToGroup
+ .calledWith(this.subscription._id, this.user.id)
+ .should.eq(true)
})
- it('removes the invite from the subscription', function (done) {
- this.TeamInvitesHandler.acceptInvite('dddddddd', this.user.id, () => {
- this.Subscription.updateOne
- .calledWith(
- { _id: new ObjectId('55153a8014829a865bbf700d') },
- { $pull: { teamInvites: { email: 'john.snow@example.com' } } }
- )
- .should.eq(true)
- done()
- })
+ it('removes the invite from the subscription', async function () {
+ await this.TeamInvitesHandler.promises.acceptInvite(
+ 'dddddddd',
+ this.user.id
+ )
+ this.Subscription.updateOne
+ .calledWith(
+ { _id: new ObjectId('55153a8014829a865bbf700d') },
+ { $pull: { teamInvites: { email: 'john.snow@example.com' } } }
+ )
+ .should.eq(true)
})
- it('removes dashboard notification after they accepted group invitation', function (done) {
+ it('removes dashboard notification after they accepted group invitation', async function () {
const managedUsersEnabled = false
- this.TeamInvitesHandler.acceptInvite('dddddddd', this.user.id, () => {
- sinon.assert.called(
- this.NotificationsBuilder.promises.groupInvitation(
- this.user.id,
- this.subscription._id,
- managedUsersEnabled
- ).read
- )
- done()
- })
+ await this.TeamInvitesHandler.promises.acceptInvite(
+ 'dddddddd',
+ this.user.id
+ )
+ sinon.assert.called(
+ this.NotificationsBuilder.promises.groupInvitation(
+ this.user.id,
+ this.subscription._id,
+ managedUsersEnabled
+ ).read
+ )
})
- it('should not schedule an SSO invite reminder', function (done) {
- this.TeamInvitesHandler.acceptInvite('dddddddd', this.user.id, () => {
- sinon.assert.notCalled(this.Modules.promises.hooks.fire)
- done()
- })
+ it('should not schedule an SSO invite reminder', async function () {
+ await this.TeamInvitesHandler.promises.acceptInvite(
+ 'dddddddd',
+ this.user.id
+ )
+ sinon.assert.notCalled(this.Modules.promises.hooks.fire)
})
})
describe('with managed group', function () {
- it('should enroll the group member', function (done) {
+ it('should enroll the group member', async function () {
this.subscription.managedUsersEnabled = true
- this.TeamInvitesHandler.acceptInvite('dddddddd', this.user.id, () => {
- sinon.assert.calledWith(
- this.Modules.promises.hooks.fire,
- 'enrollInManagedSubscription',
- this.user.id,
- this.subscription
- )
- done()
- })
+ await this.TeamInvitesHandler.promises.acceptInvite(
+ 'dddddddd',
+ this.user.id
+ )
+ sinon.assert.calledWith(
+ this.Modules.promises.hooks.fire,
+ 'enrollInManagedSubscription',
+ this.user.id,
+ this.subscription
+ )
})
})
describe('with group SSO enabled', function () {
- it('should schedule an SSO invite reminder', function (done) {
+ it('should schedule an SSO invite reminder', async function () {
this.subscription.ssoConfig = 'ssoconfig1'
this.SSOConfig.findById
.withArgs('ssoconfig1')
.resolves({ enabled: true })
- this.TeamInvitesHandler.acceptInvite('dddddddd', this.user.id, () => {
- sinon.assert.calledWith(
- this.Modules.promises.hooks.fire,
- 'scheduleGroupSSOReminder',
- this.user.id,
- this.subscription._id
- )
- done()
- })
+ await this.TeamInvitesHandler.promises.acceptInvite(
+ 'dddddddd',
+ this.user.id
+ )
+ sinon.assert.calledWith(
+ this.Modules.promises.hooks.fire,
+ 'scheduleGroupSSOReminder',
+ this.user.id,
+ this.subscription._id
+ )
})
})
})
describe('revokeInvite', function () {
- it('removes the team invite from the subscription', function (done) {
- this.TeamInvitesHandler.revokeInvite(
+ it('removes the team invite from the subscription', async function () {
+ await this.TeamInvitesHandler.promises.revokeInvite(
this.manager._id,
this.subscription,
- 'jorah@example.com',
- () => {
- this.Subscription.updateOne
- .calledWith(
- { _id: new ObjectId('55153a8014829a865bbf700d') },
- { $pull: { teamInvites: { email: 'jorah@example.com' } } }
- )
- .should.eq(true)
-
- this.Subscription.updateOne
- .calledWith(
- { _id: new ObjectId('55153a8014829a865bbf700d') },
- { $pull: { invited_emails: 'jorah@example.com' } }
- )
- .should.eq(true)
- done()
- }
+ 'jorah@example.com'
)
+ this.Subscription.updateOne
+ .calledWith(
+ { _id: new ObjectId('55153a8014829a865bbf700d') },
+ { $pull: { teamInvites: { email: 'jorah@example.com' } } }
+ )
+ .should.eq(true)
+
+ this.Subscription.updateOne
+ .calledWith(
+ { _id: new ObjectId('55153a8014829a865bbf700d') },
+ { $pull: { invited_emails: 'jorah@example.com' } }
+ )
+ .should.eq(true)
})
- it('removes dashboard notification for pending group invitation', function (done) {
+ it('removes dashboard notification for pending group invitation', async function () {
const managedUsersEnabled = false
const pendingUser = {
@@ -492,26 +467,23 @@ describe('TeamInvitesHandler', function () {
.withArgs(pendingUser.email)
.resolves(pendingUser)
- this.TeamInvitesHandler.revokeInvite(
+ await this.TeamInvitesHandler.promises.revokeInvite(
this.manager._id,
this.subscription,
- pendingUser.email,
- () => {
- sinon.assert.called(
- this.NotificationsBuilder.promises.groupInvitation(
- pendingUser.id,
- this.subscription._id,
- managedUsersEnabled
- ).read
- )
+ pendingUser.email
+ )
- done()
- }
+ sinon.assert.called(
+ this.NotificationsBuilder.promises.groupInvitation(
+ pendingUser.id,
+ this.subscription._id,
+ managedUsersEnabled
+ ).read
)
})
})
- describe('createTeamInvitesForLegacyInvitedEmail', function (done) {
+ describe('createTeamInvitesForLegacyInvitedEmail', function () {
beforeEach(function () {
this.subscription.invited_emails = [
'eddard@example.com',
@@ -523,58 +495,71 @@ describe('TeamInvitesHandler', function () {
.resolves([this.subscription])
})
- it('sends an invitation email to addresses in the legacy invited_emails field', function (done) {
- this.TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail(
- 'eddard@example.com',
- (err, invites) => {
- expect(err).not.to.exist
- expect(invites.length).to.eq(1)
+ it('sends an invitation email to addresses in the legacy invited_emails field', async function () {
+ const invites =
+ await this.TeamInvitesHandler.promises.createTeamInvitesForLegacyInvitedEmail(
+ 'eddard@example.com'
+ )
- const [invite] = invites
- expect(invite.token).to.eq(this.newToken)
- expect(invite.email).to.eq('eddard@example.com')
- expect(invite.inviterName).to.eq(
- 'Daenerys Targaryen (daenerys@example.com)'
- )
- expect(invite.invite).to.be.true
- expect(this.subscription.teamInvites).to.deep.include(invite)
+ expect(invites.length).to.eq(1)
- done()
- }
+ const [invite] = invites
+ expect(invite.token).to.eq(this.newToken)
+ expect(invite.email).to.eq('eddard@example.com')
+ expect(invite.inviterName).to.eq(
+ 'Daenerys Targaryen (daenerys@example.com)'
)
+ expect(invite.invite).to.be.true
+ expect(this.subscription.teamInvites).to.deep.include(invite)
})
})
describe('validation', function () {
- it("doesn't create an invite if the team limit has been reached", function (done) {
+ it("doesn't create an invite if the team limit has been reached", async function () {
this.LimitationsManager.teamHasReachedMemberLimit = sinon
.stub()
.returns(true)
- this.TeamInvitesHandler.createInvite(
- this.manager._id,
- this.subscription,
- 'John.Snow@example.com',
- (err, invite) => {
- expect(err).to.deep.equal({ limitReached: true })
- done()
- }
- )
+ let error
+
+ try {
+ await this.TeamInvitesHandler.promises.createInvite(
+ this.manager._id,
+ this.subscription,
+ 'John.Snow@example.com'
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+
+ expect(error).to.deep.equal({
+ limitReached: true,
+ })
})
- it("doesn't create an invite if the subscription is not in a group plan", function (done) {
+ it("doesn't create an invite if the subscription is not in a group plan", async function () {
this.subscription.groupPlan = false
- this.TeamInvitesHandler.createInvite(
- this.manager._id,
- this.subscription,
- 'John.Snow@example.com',
- (err, invite) => {
- expect(err).to.deep.equal({ wrongPlan: true })
- done()
- }
- )
+ let error
+
+ try {
+ await this.TeamInvitesHandler.promises.createInvite(
+ this.manager._id,
+ this.subscription,
+ 'John.Snow@example.com'
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+
+ expect(error).to.deep.equal({
+ wrongPlan: true,
+ })
})
- it("doesn't create an invite if the user is already part of the team", function (done) {
+ it("doesn't create an invite if the user is already part of the team", async function () {
const member = {
id: '1a2b',
_id: '1a2b',
@@ -586,16 +571,23 @@ describe('TeamInvitesHandler', function () {
.withArgs(member.email)
.resolves(member)
- this.TeamInvitesHandler.createInvite(
- this.manager._id,
- this.subscription,
- 'tyrion@example.com',
- (err, invite) => {
- expect(err).to.deep.equal({ alreadyInTeam: true })
- expect(invite).not.to.exist
- done()
- }
- )
+ let error
+
+ try {
+ await this.TeamInvitesHandler.promises.createInvite(
+ this.manager._id,
+ this.subscription,
+ 'tyrion@example.com'
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+
+ expect(error).to.eql({
+ alreadyInTeam: true,
+ })
})
})
})
diff --git a/services/web/test/unit/src/Tags/TagsHandlerTests.js b/services/web/test/unit/src/Tags/TagsHandlerTests.js
index f30a649681..0ea36d7e17 100644
--- a/services/web/test/unit/src/Tags/TagsHandlerTests.js
+++ b/services/web/test/unit/src/Tags/TagsHandlerTests.js
@@ -30,78 +30,72 @@ describe('TagsHandler', function () {
})
describe('finding users tags', function () {
- it('should find all the documents with that user id', function (done) {
+ it('should find all the documents with that user id', async function () {
const stubbedTags = [{ name: 'tag1' }, { name: 'tag2' }, { name: 'tag3' }]
this.TagMock.expects('find')
.once()
.withArgs({ user_id: this.userId })
.resolves(stubbedTags)
- this.TagsHandler.getAllTags(this.userId, (err, result) => {
- expect(err).to.not.exist
- this.TagMock.verify()
- expect(result).to.deep.equal(stubbedTags)
- done()
- })
+ const result = await this.TagsHandler.promises.getAllTags(this.userId)
+ this.TagMock.verify()
+ expect(result).to.deep.equal(stubbedTags)
})
})
describe('createTag', function () {
describe('when insert succeeds', function () {
- it('should call insert in mongo', function (done) {
+ it('should call insert in mongo', async function () {
this.TagMock.expects('create')
.withArgs(this.tag)
.once()
.resolves(this.tag)
- this.TagsHandler.createTag(
+ const resultTag = await this.TagsHandler.promises.createTag(
this.tag.user_id,
this.tag.name,
- this.tag.color,
- (err, resultTag) => {
- expect(err).to.not.exist
- this.TagMock.verify()
- expect(resultTag.user_id).to.equal(this.tag.user_id)
- expect(resultTag.name).to.equal(this.tag.name)
- expect(resultTag.color).to.equal(this.tag.color)
- done()
- }
+ this.tag.color
)
+ this.TagMock.verify()
+ expect(resultTag.user_id).to.equal(this.tag.user_id)
+ expect(resultTag.name).to.equal(this.tag.name)
+ expect(resultTag.color).to.equal(this.tag.color)
})
})
describe('when truncate=true, and tag is too long', function () {
- it('should truncate the tag name', function (done) {
+ it('should truncate the tag name', async function () {
// Expect the tag to end up with this truncated name
this.tag.name = 'a comically long tag that will be truncated intern'
this.TagMock.expects('create')
.withArgs(this.tag)
.once()
.resolves(this.tag)
- this.TagsHandler.createTag(
+ const resultTag = await this.TagsHandler.promises.createTag(
this.tag.user_id,
// Pass this too-long name
'a comically long tag that will be truncated internally and not throw an error',
this.tag.color,
- { truncate: true },
- (err, resultTag) => {
- expect(err).to.not.exist
- expect(resultTag.name).to.equal(this.tag.name)
- done()
- }
+ { truncate: true }
)
+ expect(resultTag.name).to.equal(this.tag.name)
})
})
describe('when tag is too long', function () {
- it('should throw an error', function (done) {
- this.TagsHandler.createTag(
- this.tag.user_id,
- 'this is a tag that is very very very very very very long',
- undefined,
- err => {
- expect(err.message).to.equal('Exceeded max tag length')
- done()
- }
- )
+ it('should throw an error', async function () {
+ let error
+
+ try {
+ await this.TagsHandler.promises.createTag(
+ this.tag.user_id,
+ 'this is a tag that is very very very very very very long',
+ undefined
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+ expect(error).to.have.property('message', 'Exceeded max tag length')
})
})
@@ -111,7 +105,7 @@ describe('TagsHandler', function () {
this.duplicateKeyError.code = 11000
})
- it('should get tag with findOne and return that tag', function (done) {
+ it('should get tag with findOne and return that tag', async function () {
this.TagMock.expects('create')
.withArgs(this.tag)
.once()
@@ -120,26 +114,22 @@ describe('TagsHandler', function () {
.withArgs({ user_id: this.tag.user_id, name: this.tag.name })
.once()
.resolves(this.tag)
- this.TagsHandler.createTag(
+ const resultTag = await this.TagsHandler.promises.createTag(
this.tag.user_id,
this.tag.name,
- this.tag.color,
- (err, resultTag) => {
- expect(err).to.not.exist
- this.TagMock.verify()
- expect(resultTag.user_id).to.equal(this.tag.user_id)
- expect(resultTag.name).to.equal(this.tag.name)
- expect(resultTag.color).to.equal(this.tag.color)
- done()
- }
+ this.tag.color
)
+ this.TagMock.verify()
+ expect(resultTag.user_id).to.equal(this.tag.user_id)
+ expect(resultTag.name).to.equal(this.tag.name)
+ expect(resultTag.color).to.equal(this.tag.color)
})
})
})
describe('addProjectToTag', function () {
describe('with a valid tag_id', function () {
- it('should call update in mongo', function (done) {
+ it('should call update in mongo', async function () {
this.TagMock.expects('findOneAndUpdate')
.once()
.withArgs(
@@ -147,23 +137,19 @@ describe('TagsHandler', function () {
{ $addToSet: { project_ids: this.projectId } }
)
.resolves()
- this.TagsHandler.addProjectToTag(
+ await this.TagsHandler.promises.addProjectToTag(
this.userId,
this.tagId,
- this.projectId,
- err => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- }
+ this.projectId
)
+ this.TagMock.verify()
})
})
})
describe('addProjectsToTag', function () {
describe('with a valid tag_id', function () {
- it('should call update in mongo', function (done) {
+ it('should call update in mongo', async function () {
this.TagMock.expects('findOneAndUpdate')
.once()
.withArgs(
@@ -171,22 +157,18 @@ describe('TagsHandler', function () {
{ $addToSet: { project_ids: { $each: this.projectIds } } }
)
.resolves()
- this.TagsHandler.addProjectsToTag(
+ await this.TagsHandler.promises.addProjectsToTag(
this.userId,
this.tagId,
- this.projectIds,
- err => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- }
+ this.projectIds
)
+ this.TagMock.verify()
})
})
})
describe('addProjectToTagName', function () {
- it('should call update in mongo', function (done) {
+ it('should call update in mongo', async function () {
this.TagMock.expects('updateOne')
.once()
.withArgs(
@@ -195,22 +177,18 @@ describe('TagsHandler', function () {
{ upsert: true }
)
.resolves()
- this.TagsHandler.addProjectToTagName(
+ await this.TagsHandler.promises.addProjectToTagName(
this.tag.userId,
this.tag.name,
- this.projectId,
- err => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- }
+ this.projectId
)
+ this.TagMock.verify()
})
})
describe('removeProjectFromTag', function () {
describe('with a valid tag_id', function () {
- it('should call update in mongo', function (done) {
+ it('should call update in mongo', async function () {
this.TagMock.expects('updateOne')
.once()
.withArgs(
@@ -223,23 +201,20 @@ describe('TagsHandler', function () {
}
)
.resolves()
- this.TagsHandler.removeProjectFromTag(
+ await this.TagsHandler.promises.removeProjectFromTag(
this.userId,
this.tagId,
- this.projectId,
- err => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- }
+ this.projectId
)
+
+ this.TagMock.verify()
})
})
})
describe('removeProjectsFromTag', function () {
describe('with a valid tag_id', function () {
- it('should call update in mongo', function (done) {
+ it('should call update in mongo', async function () {
this.TagMock.expects('updateOne')
.once()
.withArgs(
@@ -252,22 +227,18 @@ describe('TagsHandler', function () {
}
)
.resolves()
- this.TagsHandler.removeProjectsFromTag(
+ await this.TagsHandler.promises.removeProjectsFromTag(
this.userId,
this.tagId,
- this.projectIds,
- err => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- }
+ this.projectIds
)
+ this.TagMock.verify()
})
})
})
describe('removeProjectFromAllTags', function () {
- it('should pull the project id from the tag', function (done) {
+ it('should pull the project id from the tag', async function () {
this.TagMock.expects('updateMany')
.once()
.withArgs(
@@ -279,20 +250,16 @@ describe('TagsHandler', function () {
}
)
.resolves()
- this.TagsHandler.removeProjectFromAllTags(
+ await this.TagsHandler.promises.removeProjectFromAllTags(
this.userId,
- this.projectId,
- err => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- }
+ this.projectId
)
+ this.TagMock.verify()
})
})
describe('addProjectToTags', function () {
- it('should add the project id to each tag', function (done) {
+ it('should add the project id to each tag', async function () {
const tagIds = []
this.TagMock.expects('updateMany')
@@ -307,38 +274,31 @@ describe('TagsHandler', function () {
}
)
.resolves()
- this.TagsHandler.addProjectToTags(
+ await this.TagsHandler.promises.addProjectToTags(
this.userId,
tagIds,
- this.projectId,
- (err, result) => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- }
+ this.projectId
)
+ this.TagMock.verify()
})
})
describe('deleteTag', function () {
describe('with a valid tag_id', function () {
- it('should call remove in mongo', function (done) {
+ it('should call remove in mongo', async function () {
this.TagMock.expects('deleteOne')
.once()
.withArgs({ _id: this.tagId, user_id: this.userId })
.resolves()
- this.TagsHandler.deleteTag(this.userId, this.tagId, err => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- })
+ await this.TagsHandler.promises.deleteTag(this.userId, this.tagId)
+ this.TagMock.verify()
})
})
})
describe('renameTag', function () {
describe('with a valid tag_id', function () {
- it('should call remove in mongo', function (done) {
+ it('should call remove in mongo', async function () {
this.newName = 'new name'
this.TagMock.expects('updateOne')
.once()
@@ -347,30 +307,31 @@ describe('TagsHandler', function () {
{ $set: { name: this.newName } }
)
.resolves()
- this.TagsHandler.renameTag(
+ await this.TagsHandler.promises.renameTag(
this.userId,
this.tagId,
- this.newName,
- err => {
- expect(err).to.not.exist
- this.TagMock.verify()
- done()
- }
+ this.newName
)
+ this.TagMock.verify()
})
})
describe('when tag is too long', function () {
- it('should throw an error', function (done) {
- this.TagsHandler.renameTag(
- this.userId,
- this.tagId,
- 'this is a tag that is very very very very very very long',
- err => {
- expect(err.message).to.equal('Exceeded max tag length')
- done()
- }
- )
+ it('should throw an error', async function () {
+ let error
+
+ try {
+ await this.TagsHandler.promises.renameTag(
+ this.userId,
+ this.tagId,
+ 'this is a tag that is very very very very very very long'
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.exist
+ expect(error).to.have.property('message', 'Exceeded max tag length')
})
})
})
diff --git a/services/web/test/unit/src/Templates/TemplatesControllerTests.js b/services/web/test/unit/src/Templates/TemplatesControllerTests.js
index 911a16a515..282f3121f9 100644
--- a/services/web/test/unit/src/Templates/TemplatesControllerTests.js
+++ b/services/web/test/unit/src/Templates/TemplatesControllerTests.js
@@ -18,6 +18,11 @@ describe('TemplatesController', function () {
'./TemplatesManager': (this.TemplatesManager = {
promises: { createProjectFromV1Template: sinon.stub() },
}),
+ '../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
+ promises: {
+ getAssignment: sinon.stub().resolves({ variant: 'default' }),
+ },
+ }),
},
})
this.next = sinon.stub()
diff --git a/services/web/test/unit/src/Templates/TemplatesManagerTests.js b/services/web/test/unit/src/Templates/TemplatesManagerTests.js
index f75827094d..2dcf821a05 100644
--- a/services/web/test/unit/src/Templates/TemplatesManagerTests.js
+++ b/services/web/test/unit/src/Templates/TemplatesManagerTests.js
@@ -121,6 +121,9 @@ describe('TemplatesManager', function () {
fs: this.fs,
'../../models/Project': { Project: this.Project },
'stream/promises': { pipeline: this.pipeline },
+ '../Compile/ClsiCacheManager': {
+ prepareClsiCache: sinon.stub().rejects(new Error('ignore this')),
+ },
},
}).promises
return (this.zipUrl =
diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandlerTests.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandlerTests.mjs
index 5d52417434..a5ca099b5b 100644
--- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandlerTests.mjs
+++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandlerTests.mjs
@@ -540,7 +540,8 @@ function expectFolderUpdateProcessed() {
it('processes the folder update', function () {
expect(this.UpdateMerger.promises.createFolder).to.have.been.calledWith(
this.projects.active1._id,
- this.folderPath
+ this.folderPath,
+ this.userId
)
})
}
diff --git a/services/web/test/unit/src/Uploads/FileTypeManagerTests.js b/services/web/test/unit/src/Uploads/FileTypeManagerTests.js
index ae8645b034..032f11ed57 100644
--- a/services/web/test/unit/src/Uploads/FileTypeManagerTests.js
+++ b/services/web/test/unit/src/Uploads/FileTypeManagerTests.js
@@ -65,22 +65,25 @@ describe('FileTypeManager', function () {
describe('when it is a directory', function () {
beforeEach(function () {
this.stats.isDirectory.returns(true)
- this.FileTypeManager.isDirectory('/some/path', this.callback)
})
- it('should return true', function () {
- this.callback.should.have.been.calledWith(null, true)
+ it('should return true', async function () {
+ const result =
+ await this.FileTypeManager.promises.isDirectory('/some/path')
+
+ expect(result).to.equal(true)
})
})
describe('when it is not a directory', function () {
beforeEach(function () {
this.stats.isDirectory.returns(false)
- this.FileTypeManager.isDirectory('/some/path', this.callback)
})
- it('should return false', function () {
- this.callback.should.have.been.calledWith(null, false)
+ it('should return false', async function () {
+ const result =
+ await this.FileTypeManager.promises.isDirectory('/some/path')
+ expect(result).to.equal(false)
})
})
})
@@ -113,223 +116,158 @@ describe('FileTypeManager', function () {
'/GNUMakefile',
]
TEXT_FILENAMES.forEach(filename => {
- it(`should classify ${filename} as text`, function (done) {
- this.FileTypeManager.getType(
+ it(`should classify ${filename} as text`, async function () {
+ const { binary } = await this.FileTypeManager.promises.getType(
filename,
'utf8.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(false)
- done()
- }
+ null
)
+
+ binary.should.equal(false)
})
})
- it('should not classify short text files as binary', function (done) {
+ it('should not classify short text files as binary', async function () {
this.stats.size = 2 * 1024 * 1024 // 2MB
- this.FileTypeManager.getType(
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.tex',
'text-short.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(false)
- done()
- }
+ null
)
+
+ binary.should.equal(false)
})
- it('should not classify text files just under the size limit as binary', function (done) {
+ it('should not classify text files just under the size limit as binary', async function () {
this.stats.size = 2 * 1024 * 1024 // 2MB
- this.FileTypeManager.getType(
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.tex',
'text-smaller.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(false)
- done()
- }
+ null
)
+
+ binary.should.equal(false)
})
- it('should classify text files at the size limit as binary', function (done) {
+ it('should classify text files at the size limit as binary', async function () {
this.stats.size = 2 * 1024 * 1024 // 2MB
- this.FileTypeManager.getType(
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.tex',
'text-exact.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(true)
- done()
- }
+ null
)
+
+ binary.should.equal(true)
})
- it('should classify long text files as binary', function (done) {
+ it('should classify long text files as binary', async function () {
this.stats.size = 2 * 1024 * 1024 // 2MB
- this.FileTypeManager.getType(
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.tex',
'text-long.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(true)
- done()
- }
+ null
)
+
+ binary.should.equal(true)
})
- it('should classify large text files as binary', function (done) {
+ it('should classify large text files as binary', async function () {
this.stats.size = 8 * 1024 * 1024 // 8MB
- this.FileTypeManager.getType(
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.tex',
'utf8.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(true)
- done()
- }
+ null
)
+
+ binary.should.equal(true)
})
- it('should not try to determine the encoding of large files', function (done) {
+ it('should not try to determine the encoding of large files', async function () {
this.stats.size = 8 * 1024 * 1024 // 8MB
- this.FileTypeManager.getType('/file.tex', 'utf8.tex', null, err => {
- if (err) {
- return done(err)
- }
- sinon.assert.notCalled(this.isUtf8)
- done()
- })
- })
-
- it('should detect the encoding of a utf8 file', function (done) {
- this.FileTypeManager.getType(
+ await this.FileTypeManager.promises.getType(
'/file.tex',
'utf8.tex',
- null,
- (err, { binary, encoding }) => {
- if (err) {
- return done(err)
- }
- sinon.assert.calledOnce(this.isUtf8)
- this.isUtf8.returned(true).should.equal(true)
- encoding.should.equal('utf-8')
- done()
- }
+ null
)
+
+ sinon.assert.notCalled(this.isUtf8)
})
- it("should return 'latin1' for non-unicode encodings", function (done) {
- this.FileTypeManager.getType(
+ it('should detect the encoding of a utf8 file', async function () {
+ const { encoding } = await this.FileTypeManager.promises.getType(
+ '/file.tex',
+ 'utf8.tex',
+ null
+ )
+
+ sinon.assert.calledOnce(this.isUtf8)
+ this.isUtf8.returned(true).should.equal(true)
+ encoding.should.equal('utf-8')
+ })
+
+ it("should return 'latin1' for non-unicode encodings", async function () {
+ const { encoding } = await this.FileTypeManager.promises.getType(
'/file.tex',
'latin1.tex',
- null,
- (err, { binary, encoding }) => {
- if (err) {
- return done(err)
- }
- sinon.assert.calledOnce(this.isUtf8)
- this.isUtf8.returned(false).should.equal(true)
- encoding.should.equal('latin1')
- done()
- }
+ null
)
+
+ sinon.assert.calledOnce(this.isUtf8)
+ this.isUtf8.returned(false).should.equal(true)
+ encoding.should.equal('latin1')
})
- it('should classify utf16 with BOM as utf-16', function (done) {
- this.FileTypeManager.getType(
+ it('should classify utf16 with BOM as utf-16', async function () {
+ const { encoding } = await this.FileTypeManager.promises.getType(
'/file.tex',
'utf16.tex',
- null,
- (err, { binary, encoding }) => {
- if (err) {
- return done(err)
- }
- sinon.assert.calledOnce(this.isUtf8)
- this.isUtf8.returned(false).should.equal(true)
- encoding.should.equal('utf-16le')
- done()
- }
+ null
)
+
+ sinon.assert.calledOnce(this.isUtf8)
+ this.isUtf8.returned(false).should.equal(true)
+ encoding.should.equal('utf-16le')
})
- it('should classify latin1 files with a null char as binary', function (done) {
- this.FileTypeManager.getType(
+ it('should classify latin1 files with a null char as binary', async function () {
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.tex',
'latin1-null.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- expect(binary).to.equal(true)
- done()
- }
+ null
)
+ expect(binary).to.equal(true)
})
- it('should classify utf8 files with a null char as binary', function (done) {
- this.FileTypeManager.getType(
+ it('should classify utf8 files with a null char as binary', async function () {
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.tex',
'utf8-null.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- expect(binary).to.equal(true)
- done()
- }
+ null
)
+
+ expect(binary).to.equal(true)
})
- it('should classify utf8 files with non-BMP chars as binary', function (done) {
- this.FileTypeManager.getType(
+ it('should classify utf8 files with non-BMP chars as binary', async function () {
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.tex',
'utf8-non-bmp.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- expect(binary).to.equal(true)
- done()
- }
+ null
)
+
+ expect(binary).to.equal(true)
})
- it('should classify utf8 files with ascii control chars as utf-8', function (done) {
- this.FileTypeManager.getType(
- '/file.tex',
- 'utf8-control-chars.tex',
- null,
- (err, { binary, encoding }) => {
- if (err) {
- return done(err)
- }
- expect(binary).to.equal(false)
- expect(encoding).to.equal('utf-8')
- done()
- }
- )
+ it('should classify utf8 files with ascii control chars as utf-8', async function () {
+ const { binary, encoding } =
+ await this.FileTypeManager.promises.getType(
+ '/file.tex',
+ 'utf8-control-chars.tex',
+ null
+ )
+
+ expect(binary).to.equal(false)
+ expect(encoding).to.equal('utf-8')
})
})
@@ -342,189 +280,132 @@ describe('FileTypeManager', function () {
'/tex',
]
BINARY_FILENAMES.forEach(filename => {
- it(`should classify ${filename} as binary`, function (done) {
- this.FileTypeManager.getType(
+ it(`should classify ${filename} as binary`, async function () {
+ const { binary } = await this.FileTypeManager.promises.getType(
filename,
'latin1.tex', // even if the content is not binary
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(true)
- done()
- }
+ null
)
+
+ binary.should.equal(true)
})
})
- it('should not try to get the character encoding', function (done) {
- this.FileTypeManager.getType('/file.png', 'utf8.tex', null, err => {
- if (err) {
- return done(err)
- }
- sinon.assert.notCalled(this.isUtf8)
- done()
- })
+ it('should not try to get the character encoding', async function () {
+ await this.FileTypeManager.promises.getType(
+ '/file.png',
+ 'utf8.tex',
+ null
+ )
+
+ sinon.assert.notCalled(this.isUtf8)
})
- it('should recognise new binary files as binary', function (done) {
- this.FileTypeManager.getType(
+ it('should recognise new binary files as binary', async function () {
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.py',
'latin1.tex',
- null,
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(true)
- done()
- }
+ null
)
+
+ binary.should.equal(true)
})
- it('should recognise existing binary files as binary', function (done) {
- this.FileTypeManager.getType(
+ it('should recognise existing binary files as binary', async function () {
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.py',
'latin1.tex',
- 'file',
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(true)
- done()
- }
+ 'file'
)
+
+ binary.should.equal(true)
})
- it('should preserve existing non-binary files as non-binary', function (done) {
- this.FileTypeManager.getType(
+ it('should preserve existing non-binary files as non-binary', async function () {
+ const { binary } = await this.FileTypeManager.promises.getType(
'/file.py',
'latin1.tex',
- 'doc',
- (err, { binary }) => {
- if (err) {
- return done(err)
- }
- binary.should.equal(false)
- done()
- }
+ 'doc'
)
+
+ binary.should.equal(false)
})
})
})
describe('shouldIgnore', function () {
- it('should ignore tex auxiliary files', function (done) {
- this.FileTypeManager.shouldIgnore('file.aux', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(true)
- done()
- })
+ it('should ignore tex auxiliary files', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('file.aux')
+ ignore.should.equal(true)
})
- it('should ignore dotfiles', function (done) {
- this.FileTypeManager.shouldIgnore('path/.git', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(true)
- done()
- })
+ it('should ignore dotfiles', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('path/.git')
+
+ ignore.should.equal(true)
})
- it('should ignore .git directories and contained files', function (done) {
- this.FileTypeManager.shouldIgnore('path/.git/info', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(true)
- done()
- })
+ it('should ignore .git directories and contained files', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('path/.git/info')
+
+ ignore.should.equal(true)
})
- it('should not ignore .latexmkrc dotfile', function (done) {
- this.FileTypeManager.shouldIgnore('path/.latexmkrc', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(false)
- done()
- })
+ it('should not ignore .latexmkrc dotfile', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('path/.latexmkrc')
+
+ ignore.should.equal(false)
})
- it('should ignore __MACOSX', function (done) {
- this.FileTypeManager.shouldIgnore('path/__MACOSX', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(true)
- done()
- })
+ it('should ignore __MACOSX', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('path/__MACOSX')
+
+ ignore.should.equal(true)
})
- it('should ignore synctex files', function (done) {
- this.FileTypeManager.shouldIgnore('file.synctex', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(true)
- done()
- })
+ it('should ignore synctex files', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('file.synctex')
+
+ ignore.should.equal(true)
})
- it('should ignore synctex(busy) files', function (done) {
- this.FileTypeManager.shouldIgnore('file.synctex(busy)', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(true)
- done()
- })
+ it('should ignore synctex(busy) files', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('file.synctex(busy)')
+
+ ignore.should.equal(true)
})
- it('should not ignore .tex files', function (done) {
- this.FileTypeManager.shouldIgnore('file.tex', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(false)
- done()
- })
+ it('should not ignore .tex files', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('file.tex')
+
+ ignore.should.equal(false)
})
- it('should ignore the case of the extension', function (done) {
- this.FileTypeManager.shouldIgnore('file.AUX', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(true)
- done()
- })
+ it('should ignore the case of the extension', async function () {
+ const ignore =
+ await this.FileTypeManager.promises.shouldIgnore('file.AUX')
+
+ ignore.should.equal(true)
})
- it('should not ignore files with an ignored extension as full name', function (done) {
- this.FileTypeManager.shouldIgnore('dvi', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(false)
- done()
- })
+ it('should not ignore files with an ignored extension as full name', async function () {
+ const ignore = await this.FileTypeManager.promises.shouldIgnore('dvi')
+ ignore.should.equal(false)
})
- it('should not ignore directories with an ignored extension as full name', function (done) {
+ it('should not ignore directories with an ignored extension as full name', async function () {
this.stats.isDirectory.returns(true)
- this.FileTypeManager.shouldIgnore('dvi', (err, ignore) => {
- if (err) {
- return done(err)
- }
- ignore.should.equal(false)
- done()
- })
+ const ignore = await this.FileTypeManager.promises.shouldIgnore('dvi')
+
+ ignore.should.equal(false)
})
})
})
diff --git a/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.mjs b/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.mjs
index 8db1e9536e..35682f346c 100644
--- a/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.mjs
+++ b/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.mjs
@@ -267,7 +267,8 @@ describe('ProjectUploadController', function () {
this.EditorController.promises.mkdirp.should.be.calledWith(
this.project_id,
- '/test/foo/bar'
+ '/test/foo/bar',
+ this.user_id
)
this.FileSystemImportManager.addEntity.should.be.calledOnceWith(
diff --git a/services/web/test/unit/src/User/ThirdPartyIdentityManagerTests.js b/services/web/test/unit/src/User/ThirdPartyIdentityManagerTests.js
index d944262518..2dbbf64991 100644
--- a/services/web/test/unit/src/User/ThirdPartyIdentityManagerTests.js
+++ b/services/web/test/unit/src/User/ThirdPartyIdentityManagerTests.js
@@ -2,6 +2,9 @@ const sinon = require('sinon')
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const OError = require('@overleaf/o-error')
+const {
+ ThirdPartyUserNotFoundError,
+} = require('../../../../app/src/Features/Errors/Errors')
const modulePath =
'../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
@@ -77,16 +80,19 @@ describe('ThirdPartyIdentityManager', function () {
expect(user).to.deep.equal(this.user)
})
})
- it('should return ThirdPartyUserNotFoundError when no user linked', function (done) {
- this.ThirdPartyIdentityManager.getUser(
- 'google',
- 'an-id-not-linked',
- (error, user) => {
- expect(error).to.exist
- expect(error.name).to.equal('ThirdPartyUserNotFoundError')
- done()
- }
- )
+ it('should return ThirdPartyUserNotFoundError when no user linked', async function () {
+ let error
+
+ try {
+ await this.ThirdPartyIdentityManager.promises.getUser(
+ 'google',
+ 'an-id-not-linked'
+ )
+ } catch (err) {
+ error = err
+ }
+
+ expect(error).to.be.instanceOf(ThirdPartyUserNotFoundError)
})
})
describe('link', function () {
diff --git a/services/web/types/compile.ts b/services/web/types/compile.ts
index 11767f36ef..541d03149c 100644
--- a/services/web/types/compile.ts
+++ b/services/web/types/compile.ts
@@ -1,6 +1,7 @@
export type CompileOutputFile = {
path: string
url: string
+ downloadURL?: string
type: string
build: string
ranges?: {
diff --git a/services/web/types/project/dashboard/subscription.ts b/services/web/types/project/dashboard/subscription.ts
index 3501ad8c38..e8b595c49f 100644
--- a/services/web/types/project/dashboard/subscription.ts
+++ b/services/web/types/project/dashboard/subscription.ts
@@ -1,3 +1,5 @@
+import { SubscriptionState } from '../../subscription/dashboard/subscription'
+
type SubscriptionBase = {
featuresPageURL: string
}
@@ -9,7 +11,7 @@ export type FreePlanSubscription = {
type FreeSubscription = FreePlanSubscription
type RecurlyStatus = {
- state: 'active' | 'canceled' | 'expired' | 'paused'
+ state: SubscriptionState
}
type PaidSubscriptionBase = {
diff --git a/services/web/types/subscription/dashboard/modal-ids.ts b/services/web/types/subscription/dashboard/modal-ids.ts
index bbc13614c2..d878c356d7 100644
--- a/services/web/types/subscription/dashboard/modal-ids.ts
+++ b/services/web/types/subscription/dashboard/modal-ids.ts
@@ -5,5 +5,6 @@ export type SubscriptionDashModalIds =
| 'leave-group'
| 'change-plan'
| 'cancel-ai-add-on'
+ | 'manage-on-writefull'
| 'pause-subscription'
| 'unpause-subscription'
diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts
index c67f249c4c..1272f33eb0 100644
--- a/services/web/types/subscription/dashboard/subscription.ts
+++ b/services/web/types/subscription/dashboard/subscription.ts
@@ -1,55 +1,51 @@
import { CurrencyCode } from '../currency'
import { Nullable } from '../../utils'
-import { Plan, AddOn, RecurlyAddOn } from '../plan'
+import {
+ Plan,
+ AddOn,
+ PaymentProviderAddOn,
+ PendingPaymentProviderPlan,
+} from '../plan'
import { User } from '../../user'
-type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
+export type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
// when puchasing a new add-on in recurly, we only need to provide the code
export type PurchasingAddOnCode = {
code: string
}
-type Recurly = {
- tax: number
+type PaymentProviderCoupon = {
+ code: string
+ name: string
+ description: string
+}
+
+type PaymentProviderRecord = {
taxRate: number
billingDetailsLink: string
accountManagementLink: string
additionalLicenses: number
- addOns: RecurlyAddOn[]
+ addOns: PaymentProviderAddOn[]
totalLicenses: number
nextPaymentDueAt: string
nextPaymentDueDate: string
currency: CurrencyCode
state?: SubscriptionState
trialEndsAtFormatted: Nullable
- trial_ends_at: Nullable
- activeCoupons: any[] // TODO: confirm type in array
- account: {
- email: string
- created_at: string
- // data via Recurly API
- has_canceled_subscription: {
- _: 'false' | 'true'
- $: {
- type: 'boolean'
- }
- }
- has_past_due_invoice: {
- _: 'false' | 'true'
- $: {
- type: 'boolean'
- }
- }
- }
+ trialEndsAt: Nullable
+ activeCoupons: PaymentProviderCoupon[]
+ accountEmail: string
+ hasPastDueInvoice: boolean
displayPrice: string
planOnlyDisplayPrice: string
addOnDisplayPricesWithoutAdditionalLicense: Record
- currentPlanDisplayPrice?: string
pendingAdditionalLicenses?: number
pendingTotalLicenses?: number
pausedAt?: Nullable
remainingPauseCycles?: Nullable
+ isEligibleForPause: boolean
+ isEligibleForGroupPlan: boolean
}
export type GroupPolicy = {
@@ -69,19 +65,19 @@ export type Subscription = {
planCode: string
recurlySubscription_id: string
plan: Plan
- pendingPlan?: Plan
+ pendingPlan?: PendingPaymentProviderPlan
addOns?: AddOn[]
}
-export type RecurlySubscription = Subscription & {
- recurly: Recurly
+export type PaidSubscription = Subscription & {
+ payment: PaymentProviderRecord
}
export type CustomSubscription = Subscription & {
customAccount: boolean
}
-export type GroupSubscription = RecurlySubscription & {
+export type GroupSubscription = PaidSubscription & {
teamName: string
teamNotice?: string
}
@@ -105,3 +101,13 @@ export type MemberGroupSubscription = Omit & {
planLevelName: string
admin_id: User
}
+
+type PaymentProviderService = 'stripe' | 'recurly'
+
+export type PaymentProvider = {
+ service: PaymentProviderService
+ subscriptionId: string
+ state: SubscriptionState
+ trialStartedAt?: Nullable
+ trialEndsAt?: Nullable
+}
diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts
index 8747663575..a1b0f7b5d6 100644
--- a/services/web/types/subscription/plan.ts
+++ b/services/web/types/subscription/plan.ts
@@ -22,19 +22,21 @@ export type AddOn = {
unitAmountInCents: number
}
-// add-ons directly accessed through recurly
-export type RecurlyAddOn = {
- add_on_code: string
+// add-ons directly accessed through payment
+export type PaymentProviderAddOn = {
+ code: string
+ name: string
quantity: number
- unit_amount_in_cents: number
- displayPrice: string
+ unitPrice: number
+ amount?: number
+ displayPrice?: string
}
-export type PendingRecurlyPlan = {
+export type PendingPaymentProviderPlan = {
annual?: boolean
displayPrice?: string
featureDescription?: Record[]
- addOns?: RecurlyAddOn[]
+ addOns?: PaymentProviderAddOn[]
features?: Features
groupPlan?: boolean
hideFromUsers?: boolean
diff --git a/services/web/types/subscription/subscription-change-preview.ts b/services/web/types/subscription/subscription-change-preview.ts
index 6476ebd7de..096820a2f6 100644
--- a/services/web/types/subscription/subscription-change-preview.ts
+++ b/services/web/types/subscription/subscription-change-preview.ts
@@ -1,7 +1,7 @@
export type SubscriptionChangePreview = {
change: SubscriptionChangeDescription
currency: string
- paymentMethod: string
+ paymentMethod: string | undefined
nextPlan: {
annual: boolean
}
diff --git a/services/web/types/user.ts b/services/web/types/user.ts
index d87eb6ab80..0c6c45facf 100644
--- a/services/web/types/user.ts
+++ b/services/web/types/user.ts
@@ -45,7 +45,6 @@ export type User = {
alphaProgram?: boolean
betaProgram?: boolean
labsProgram?: boolean
- isLatexBeginner?: boolean
signUpDate?: string // date string
features?: Features
refProviders?: RefProviders
@@ -59,6 +58,8 @@ export type User = {
}
featureUsage?: FeatureUsage
planCode?: string
+ planName?: string
+ isAnnualPlan?: boolean
isMemberOfGroupSubscription?: boolean
hasInstitutionLicence?: boolean
}
diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js
index deb480ca36..4b8ec18113 100644
--- a/services/web/webpack.config.js
+++ b/services/web/webpack.config.js
@@ -380,12 +380,22 @@ module.exports = {
context: `${dictionariesDir}/dictionaries`,
},
// Copy CMap files (used to provide support for non-Latin characters),
- // fonts and images from pdfjs-dist package to build output.
+ // wasm, ICC profiles, fonts and images from pdfjs-dist package to build output.
{
from: 'cmaps',
to: 'js/pdfjs-dist/cmaps',
context: pdfjsDir,
},
+ {
+ from: 'iccs',
+ to: 'js/pdfjs-dist/iccs',
+ context: pdfjsDir,
+ },
+ {
+ from: 'wasm',
+ to: 'js/pdfjs-dist/wasm',
+ context: pdfjsDir,
+ },
{
from: 'standard_fonts',
to: 'fonts/pdfjs-dist',