diff --git a/services/web/frontend/js/features/file-view/components/file-view.js b/services/web/frontend/js/features/file-view/components/file-view.js index 443cc04e99..d5e9ff1e79 100644 --- a/services/web/frontend/js/features/file-view/components/file-view.js +++ b/services/web/frontend/js/features/file-view/components/file-view.js @@ -9,14 +9,14 @@ import Icon from '../../../shared/components/icon' const imageExtensions = ['png', 'jpg', 'jpeg', 'gif'] -const textExtensions = window.ExposedSettings.textExtensions - export default function FileView({ file, storeReferencesKeys }) { const [contentLoading, setContentLoading] = useState(true) const [hasError, setHasError] = useState(false) const { t } = useTranslation() + const { textExtensions } = window.ExposedSettings + const extension = file.name.split('.').pop().toLowerCase() const isUnpreviewableFile = !imageExtensions.includes(extension) && !textExtensions.includes(extension) diff --git a/services/web/frontend/js/infrastructure/error-reporter.js b/services/web/frontend/js/infrastructure/error-reporter.js index 078b582fd7..110a195e57 100644 --- a/services/web/frontend/js/infrastructure/error-reporter.js +++ b/services/web/frontend/js/infrastructure/error-reporter.js @@ -1,7 +1,7 @@ // Conditionally enable Sentry based on whether the DSN token is set import getMeta from '../utils/meta' -const reporterPromise = window.ExposedSettings.sentryDsn +const reporterPromise = window.ExposedSettings?.sentryDsn ? sentryReporter() : nullReporter() diff --git a/services/web/frontend/stories/chat.stories.js b/services/web/frontend/stories/chat.stories.js index 3d58cd537e..82dc4f4322 100644 --- a/services/web/frontend/stories/chat.stories.js +++ b/services/web/frontend/stories/chat.stories.js @@ -1,44 +1,8 @@ -import { v4 as uuid } from 'uuid' - -import { ContextRoot } from '../js/shared/context/root-context' +import { useEffect } from 'react' import ChatPane from '../js/features/chat/components/chat-pane' -import { stubMathJax } from '../../test/frontend/features/chat/components/stubs' import useFetchMock from './hooks/use-fetch-mock' - -const ONE_MINUTE = 60 * 1000 - -const user = { - id: 'fake_user', - first_name: 'mortimer', - email: 'fake@example.com', -} - -const user2 = { - id: 'another_fake_user', - first_name: 'leopold', - email: 'another_fake@example.com', -} - -function generateMessages(count) { - const messages = [] - let timestamp = new Date().getTime() // newest message goes first - for (let i = 0; i <= count; i++) { - const author = Math.random() > 0.5 ? user : user2 - // modify the timestamp so the previous message has 70% chances to be within 5 minutes from - // the current one, for grouping purposes - timestamp -= (4.3 + Math.random()) * ONE_MINUTE - - messages.push({ - id: uuid(), - content: `message #${i}`, - user: author, - timestamp, - }) - } - return messages -} - -stubMathJax() +import { generateMessages } from './fixtures/chat-messages' +import { ScopeDecorator } from './decorators/scope' export const Conversation = args => { useFetchMock(fetchMock => { @@ -66,6 +30,14 @@ export const Loading = args => { return } +export const LoadingError = args => { + useFetchMock(fetchMock => { + fetchMock.get(/messages/, 500) + }) + + return +} + export default { title: 'Editor / Chat', component: ChatPane, @@ -76,13 +48,22 @@ export default { resetUnreadMessages: () => {}, }, decorators: [ - Story => ( - <> - - - - - - ), + ScopeDecorator, + Story => { + useEffect(() => { + window.MathJax = { + Hub: { + Queue: () => {}, + config: { tex2jax: { inlineMath: [['$', '$']] } }, + }, + } + + return () => { + delete window.MathJax + } + }, []) + + return + }, ], } diff --git a/services/web/frontend/stories/clone-project-modal.stories.js b/services/web/frontend/stories/clone-project-modal.stories.js index f560df8bf5..7d3084ac5d 100644 --- a/services/web/frontend/stories/clone-project-modal.stories.js +++ b/services/web/frontend/stories/clone-project-modal.stories.js @@ -1,8 +1,6 @@ import useFetchMock from './hooks/use-fetch-mock' -import { withContextRoot } from './utils/with-context-root' import CloneProjectModal from '../js/features/clone-project-modal/components/clone-project-modal' - -const project = { _id: 'original-project', name: 'Project Title' } +import { ScopeDecorator } from './decorators/scope' export const Success = args => { useFetchMock(fetchMock => { @@ -13,7 +11,7 @@ export const Success = args => { ) }) - return withContextRoot(, { project }) + return } export const GenericErrorResponse = args => { @@ -25,7 +23,7 @@ export const GenericErrorResponse = args => { ) }) - return withContextRoot(, { project }) + return } export const SpecificErrorResponse = args => { @@ -37,7 +35,7 @@ export const SpecificErrorResponse = args => { ) }) - return withContextRoot(, { project }) + return } export default { @@ -50,4 +48,5 @@ export default { handleHide: { action: 'close modal' }, openProject: { action: 'open project' }, }, + decorators: [ScopeDecorator], } diff --git a/services/web/frontend/stories/contact-us-modal.stories.js b/services/web/frontend/stories/contact-us-modal.stories.js index 1a6736bd47..cb06c90e78 100644 --- a/services/web/frontend/stories/contact-us-modal.stories.js +++ b/services/web/frontend/stories/contact-us-modal.stories.js @@ -1,25 +1,26 @@ import { useState } from 'react' import useFetchMock from './hooks/use-fetch-mock' import ContactUsModal from '../../modules/support/frontend/js/components/contact-us-modal' -import { withContextRoot } from './utils/with-context-root' +import { ScopeDecorator } from './decorators/scope' export const Generic = () => { const [show, setShow] = useState(true) + const handleHide = () => setShow(false) useFetchMock(fetchMock => { - fetchMock.post('express:/support', { status: 200 }, { delay: 1000 }) + fetchMock.post('/support', { status: 200 }, { delay: 1000 }) }) - return withContextRoot() + return } export const RequestError = args => { useFetchMock(fetchMock => { - fetchMock.post('express:/support', { status: 404 }, { delay: 250 }) + fetchMock.post('/support', { status: 404 }, { delay: 250 }) }) - return withContextRoot() + return } export default { @@ -32,4 +33,5 @@ export default { argTypes: { handleHide: { action: 'close modal' }, }, + decorators: [ScopeDecorator], } diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx new file mode 100644 index 0000000000..3b885ffaa5 --- /dev/null +++ b/services/web/frontend/stories/decorators/scope.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo } from 'react' +import { get } from 'lodash' +import { ContextRoot } from '../../js/shared/context/root-context' +import { User } from '../../../types/user' +import { Project } from '../../../types/project' +import { + mockBuildFile, + mockCompile, + mockCompileError, +} from '../fixtures/compile' +import useFetchMock from '../hooks/use-fetch-mock' + +const scopeWatchers = [] + +const initialize = () => { + const user: User = { + id: 'story-user', + email: 'story-user@example.com', + allowedFreeTrial: true, + features: { dropbox: true, symbolPalette: true }, + } + + const project: Project = { + _id: 'a-project', + name: 'A Project', + features: { mendeley: true, zotero: true }, + tokens: {}, + owner: { + _id: 'a-user', + email: 'stories@overleaf.com', + }, + members: [], + invites: [], + rootDocId: '5e74f1a7ce17ae0041dfd056', + rootFolder: [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [], + fileRefs: [], + folders: [], + }, + ], + } + + const scope = { + user, + project, + $watch: (key, callback) => { + scopeWatchers.push([key, callback]) + }, + $applyAsync: callback => { + window.setTimeout(() => { + callback() + for (const [key, watcher] of scopeWatchers) { + watcher(get(ide.$scope, key)) + } + }, 0) + }, + $on: (eventName, callback) => { + // + }, + $broadcast: () => {}, + $root: { + _references: { + keys: [], + }, + }, + ui: { + chatOpen: true, + pdfLayout: 'flat', + }, + settings: { + pdfViewer: 'js', + syntaxValidation: true, + }, + toggleHistory: () => {}, + editor: { + richText: false, + newSourceEditor: false, + sharejs_doc: { + doc_id: 'test-doc', + getSnapshot: () => 'some doc content', + }, + }, + hasLintingError: false, + permissionsLevel: 'owner', + } + + const ide = { + $scope: scope, + socket: { + on: () => {}, + removeListener: () => {}, + }, + fileTreeManager: { + findEntityById: () => null, + findEntityByPath: () => null, + getEntityPath: () => null, + getRootDocDirname: () => undefined, + }, + editorManager: { + getCurrentDocId: () => 'foo', + openDoc: (id, options) => { + console.log('open doc', id, options) + }, + }, + metadataManager: { + metadata: { + state: { + documents: {}, + }, + }, + }, + } + + window.user = user + + window.ExposedSettings = { + appName: 'Overleaf', + maxEntitiesPerProject: 10, + maxUploadSize: 5 * 1024 * 1024, + enableSubscriptions: true, + textExtensions: [ + 'tex', + 'latex', + 'sty', + 'cls', + 'bst', + 'bib', + 'bibtex', + 'txt', + 'tikz', + 'mtx', + 'rtex', + 'md', + 'asy', + 'latexmkrc', + 'lbx', + 'bbx', + 'cbx', + 'm', + 'lco', + 'dtx', + 'ins', + 'ist', + 'def', + 'clo', + 'ldf', + 'rmd', + 'lua', + 'gv', + 'mf', + ], + } + + window.project_id = project._id + + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', user) + + window.gitBridgePublicBaseUrl = 'https://git.stories.com' + + window._ide = ide + + return ide +} + +export const ScopeDecorator = Story => { + // mock compile on load + useFetchMock(fetchMock => { + mockCompile(fetchMock) + mockCompileError(fetchMock) + mockBuildFile(fetchMock) + }) + + // clear scopeWatchers on unmount + useEffect(() => { + return () => { + scopeWatchers.length = 0 + } + }, []) + + const ide = useMemo(() => { + return initialize() + }, []) + + return ( + + + + ) +} diff --git a/services/web/frontend/stories/editor-switch.stories.js b/services/web/frontend/stories/editor-switch.stories.js index 94f68c690d..2db4255768 100644 --- a/services/web/frontend/stories/editor-switch.stories.js +++ b/services/web/frontend/stories/editor-switch.stories.js @@ -1,16 +1,12 @@ import EditorSwitch from '../js/features/source-editor/components/editor-switch' -import { withContextRoot } from './utils/with-context-root' +import { ScopeDecorator } from './decorators/scope' export default { title: 'Editor / Switch', component: EditorSwitch, + decorators: [ScopeDecorator], } export const Switcher = () => { - return withContextRoot(, { - editor: { - richText: false, - newSourceEditor: false, - }, - }) + return } diff --git a/services/web/frontend/stories/file-tree.stories.js b/services/web/frontend/stories/file-tree.stories.js index 66e0a60c29..d7373094d0 100644 --- a/services/web/frontend/stories/file-tree.stories.js +++ b/services/web/frontend/stories/file-tree.stories.js @@ -1,11 +1,12 @@ import MockedSocket from 'socket.io-mock' -import { withContextRoot } from './utils/with-context-root' import { rootFolderBase } from './fixtures/file-tree-base' import { rootFolderLimit } from './fixtures/file-tree-limit' import FileTreeRoot from '../js/features/file-tree/components/file-tree-root' import FileTreeError from '../js/features/file-tree/components/file-tree-error' import useFetchMock from './hooks/use-fetch-mock' +import { ScopeDecorator } from './decorators/scope' +import { useScope } from './hooks/use-scope' const MOCK_DELAY = 2000 @@ -87,24 +88,30 @@ function defaultSetupMocks(fetchMock) { export const FullTree = args => { useFetchMock(defaultSetupMocks) - return withContextRoot(, { + useScope({ project: DEFAULT_PROJECT, permissionsLevel: 'owner', }) + + return } export const ReadOnly = args => { - return withContextRoot(, { + useScope({ project: DEFAULT_PROJECT, permissionsLevel: 'readOnly', }) + + return } export const Disconnected = args => { - return withContextRoot(, { + useScope({ project: DEFAULT_PROJECT, permissionsLevel: 'owner', }) + + return } Disconnected.args = { isConnected: false } @@ -125,25 +132,34 @@ export const NetworkErrors = args => { }) }) - return withContextRoot(, { + useScope({ project: DEFAULT_PROJECT, permissionsLevel: 'owner', }) + + return } export const FallbackError = args => { - return withContextRoot(, { + useScope({ project: DEFAULT_PROJECT, }) + + return } export const FilesLimit = args => { useFetchMock(defaultSetupMocks) - return withContextRoot(, { - project: { ...DEFAULT_PROJECT, rootFolder: rootFolderLimit }, + useScope({ + project: { + ...DEFAULT_PROJECT, + rootFolder: rootFolderLimit, + }, permissionsLevel: 'owner', }) + + return } export default { @@ -167,6 +183,7 @@ export default { onSelect: { action: 'onSelect' }, }, decorators: [ + ScopeDecorator, Story => ( <> diff --git a/services/web/frontend/stories/file-view.stories.js b/services/web/frontend/stories/file-view.stories.js index ad08dab13b..e79350cb7a 100644 --- a/services/web/frontend/stories/file-view.stories.js +++ b/services/web/frontend/stories/file-view.stories.js @@ -1,6 +1,6 @@ -import { ContextRoot } from '../js/shared/context/root-context' import FileView from '../js/features/file-view/components/file-view' import useFetchMock from './hooks/use-fetch-mock' +import { ScopeDecorator } from './decorators/scope' const bodies = { latex: `\\documentclass{article} @@ -252,11 +252,5 @@ export default { argTypes: { storeReferencesKeys: { action: 'store references keys' }, }, - decorators: [ - Story => ( - - - - ), - ], + decorators: [ScopeDecorator], } diff --git a/services/web/frontend/stories/fixtures/chat-messages.js b/services/web/frontend/stories/fixtures/chat-messages.js new file mode 100644 index 0000000000..f9fa2180b0 --- /dev/null +++ b/services/web/frontend/stories/fixtures/chat-messages.js @@ -0,0 +1,34 @@ +import { v4 as uuid } from 'uuid' + +const ONE_MINUTE = 60 * 1000 + +const user = { + id: 'fake_user', + first_name: 'mortimer', + email: 'fake@example.com', +} + +const user2 = { + id: 'another_fake_user', + first_name: 'leopold', + email: 'another_fake@example.com', +} + +export function generateMessages(count) { + const messages = [] + let timestamp = new Date().getTime() // newest message goes first + for (let i = 0; i <= count; i++) { + const author = Math.random() > 0.5 ? user : user2 + // modify the timestamp so the previous message has 70% chances to be within 5 minutes from + // the current one, for grouping purposes + timestamp -= (4.3 + Math.random()) * ONE_MINUTE + + messages.push({ + id: uuid(), + content: `message #${i}`, + user: author, + timestamp, + }) + } + return messages +} diff --git a/services/web/frontend/stories/fixtures/compile.js b/services/web/frontend/stories/fixtures/compile.js index 12a6e2fab0..595998d3da 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 } + { delay, overwriteRoutes: true } ) export const mockCompileError = (fetchMock, status = 'success', delay = 1000) => @@ -97,6 +97,7 @@ export const mockCompileValidationIssues = ( export const mockClearCache = fetchMock => fetchMock.delete('express:/project/:projectId/output', 204, { delay: 1000, + overwriteRoutes: true, }) export const mockBuildFile = fetchMock => @@ -156,7 +157,7 @@ LaTeX Font Info: External font \`cmex10' loaded for size return 404 } }, - { sendAsJson: false } + { sendAsJson: false, overwriteRoutes: true } ) const mockHighlights = [ @@ -215,7 +216,7 @@ export const mockValidPdf = fetchMock => xhr.send() }) }, - { sendAsJson: false } + { sendAsJson: false, overwriteRoutes: true } ) export const mockSynctex = fetchMock => diff --git a/services/web/frontend/stories/fixtures/contacts.js b/services/web/frontend/stories/fixtures/contacts.js new file mode 100644 index 0000000000..10c0348ed0 --- /dev/null +++ b/services/web/frontend/stories/fixtures/contacts.js @@ -0,0 +1,41 @@ +export const contacts = [ + // user with edited name + { + type: 'user', + email: 'test-user@example.com', + first_name: 'Test', + last_name: 'User', + name: 'Test User', + }, + // user with default name (email prefix) + { + type: 'user', + email: 'test@example.com', + first_name: 'test', + }, + // no last name + { + type: 'user', + first_name: 'Eratosthenes', + email: 'eratosthenes@example.com', + }, + // more users + { + type: 'user', + first_name: 'Claudius', + last_name: 'Ptolemy', + email: 'ptolemy@example.com', + }, + { + type: 'user', + first_name: 'Abd al-Rahman', + last_name: 'Al-Sufi', + email: 'al-sufi@example.com', + }, + { + type: 'user', + first_name: 'Nicolaus', + last_name: 'Copernicus', + email: 'copernicus@example.com', + }, +] diff --git a/services/web/frontend/stories/fixtures/context.js b/services/web/frontend/stories/fixtures/context.js deleted file mode 100644 index 2e015e8301..0000000000 --- a/services/web/frontend/stories/fixtures/context.js +++ /dev/null @@ -1,71 +0,0 @@ -import sinon from 'sinon' - -export function setupContext() { - window.project_id = '1234' - window.user = { - id: 'fake_user', - allowedFreeTrial: true, - } - const $scope = { - ...window._ide?.$scope, - user: window.user, - project: { - _id: window.project_id, - name: 'Project Fake Name', - features: {}, - rootFolder: [ - { - _id: 'root-folder-id', - name: 'rootFolder', - docs: [], - folders: [], - fileRefs: [], - }, - ], - }, - $watch: () => {}, - $applyAsync: () => {}, - $broadcast: () => {}, - ui: { - chatOpen: true, - pdfLayout: 'flat', - }, - settings: { - pdfViewer: 'js', - }, - toggleHistory: () => {}, - } - window._ide = { - ...window._ide, - $scope, - socket: { - on: sinon.stub(), - removeListener: sinon.stub(), - }, - fileTreeManager: { - findEntityById: () => null, - findEntityByPath: () => null, - getEntityPath: () => null, - getRootDocDirname: () => undefined, - }, - editorManager: { - getCurrentDocId: () => 'foo', - openDoc: (id, options) => { - console.log('open doc', id, options) - }, - }, - metadataManager: { - metadata: { - state: { - documents: {}, - }, - }, - }, - } - window.ExposedSettings = window.ExposedSettings || {} - window.ExposedSettings.appName = 'Overleaf' - window.gitBridgePublicBaseUrl = 'https://git.stories.com' - - window.metaAttributesCache = window.metaAttributesCache || new Map() - window.metaAttributesCache.set('ol-user', window.user) -} diff --git a/services/web/frontend/stories/fixtures/project.ts b/services/web/frontend/stories/fixtures/project.ts new file mode 100644 index 0000000000..27acaea2e2 --- /dev/null +++ b/services/web/frontend/stories/fixtures/project.ts @@ -0,0 +1,48 @@ +import { Project } from '../../../types/project' + +export const project: Project = { + _id: 'a-project', + name: 'A Project', + features: { + collaborators: -1, // unlimited + }, + publicAccesLevel: 'private', + tokens: { + readOnly: 'ro-token', + readAndWrite: 'rw-token', + }, + owner: { + _id: 'project-owner', + email: 'stories@overleaf.com', + }, + members: [ + { + _id: 'viewer-member', + type: 'user', + privileges: 'readOnly', + name: 'Viewer User', + email: 'viewer@example.com', + }, + { + _id: 'author-member', + type: 'user', + privileges: 'readAndWrite', + name: 'Author User', + email: 'author@example.com', + }, + ], + invites: [ + { + _id: 'test-invite-1', + privileges: 'readOnly', + name: 'Invited Viewer', + email: 'invited-viewer@example.com', + }, + { + _id: 'test-invite-2', + privileges: 'readAndWrite', + name: 'Invited Author', + email: 'invited-author@example.com', + }, + ], +} diff --git a/services/web/frontend/stories/git-bridge-modal.stories.js b/services/web/frontend/stories/git-bridge-modal.stories.js index ca8b28e048..247605d743 100644 --- a/services/web/frontend/stories/git-bridge-modal.stories.js +++ b/services/web/frontend/stories/git-bridge-modal.stories.js @@ -1,6 +1,5 @@ -import { ContextRoot } from '../js/shared/context/root-context' import importOverleafModules from '../macros/import-overleaf-module.macro' -import useFetchMock from './hooks/use-fetch-mock' +import { ScopeDecorator } from './decorators/scope' const [ { @@ -19,7 +18,10 @@ CollaboratorModal.args = { } export const TeaserModal = args => { - useFetchMock(fetchMock => fetchMock.post('express:/event/:key', 202)) + // TODO: mock navigator.sendBeacon? + // useFetchMock(fetchMock => { + // fetchMock.post('express:/event/:key', 202) + // }) return } @@ -36,13 +38,5 @@ export default { argTypes: { handleHide: { action: 'handleHide' }, }, - decorators: [ - Story => ( - <> - - - - - ), - ], + decorators: [ScopeDecorator], } diff --git a/services/web/frontend/stories/hooks/use-fetch-mock.js b/services/web/frontend/stories/hooks/use-fetch-mock.js deleted file mode 100644 index 8dba7df560..0000000000 --- a/services/web/frontend/stories/hooks/use-fetch-mock.js +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect } from 'react' -import fetchMock from 'fetch-mock' -fetchMock.config.fallbackToNetwork = true - -/** - * Run callback to mock fetch routes, call restore() when unmounted - */ -export default function useFetchMock(callback) { - useEffect(() => { - return () => { - fetchMock.restore() - } - }, []) - - // Running fetchMock.restore() here as well, - // in case there was an error before the component was unmounted. - fetchMock.restore() - - // The callback has to be run here, rather than in useEffect, - // so it's run before the component is rendered. - callback(fetchMock) -} diff --git a/services/web/frontend/stories/hooks/use-fetch-mock.tsx b/services/web/frontend/stories/hooks/use-fetch-mock.tsx new file mode 100644 index 0000000000..dd2fcd68c3 --- /dev/null +++ b/services/web/frontend/stories/hooks/use-fetch-mock.tsx @@ -0,0 +1,16 @@ +import { useLayoutEffect } from 'react' +import fetchMock from 'fetch-mock' +fetchMock.config.fallbackToNetwork = true + +/** + * Run callback to mock fetch routes, call restore() when unmounted + */ +export default function useFetchMock(callback) { + useLayoutEffect(() => { + callback(fetchMock) + + return () => { + fetchMock.restore() + } + }, [callback]) +} diff --git a/services/web/frontend/stories/hooks/use-meta.tsx b/services/web/frontend/stories/hooks/use-meta.tsx new file mode 100644 index 0000000000..8c075f84dd --- /dev/null +++ b/services/web/frontend/stories/hooks/use-meta.tsx @@ -0,0 +1,8 @@ +/** + * Set values on window.metaAttributesCache, for use in Storybook stories + */ +export const useMeta = (meta: Record) => { + for (const [key, value] of Object.entries(meta)) { + window.metaAttributesCache.set(key, value) + } +} diff --git a/services/web/frontend/stories/hooks/use-scope.tsx b/services/web/frontend/stories/hooks/use-scope.tsx new file mode 100644 index 0000000000..7c609fbc55 --- /dev/null +++ b/services/web/frontend/stories/hooks/use-scope.tsx @@ -0,0 +1,16 @@ +import { merge } from 'lodash' +import { useLayoutEffect, useRef } from 'react' + +/** + * Merge properties with the scope object, for use in Storybook stories + */ +export const useScope = (scope: Record) => { + const scopeRef = useRef(null) + if (scopeRef.current === null) { + scopeRef.current = scope + } + + useLayoutEffect(() => { + merge(window._ide.$scope, scopeRef.current) + }, []) +} diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js b/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js index ec4df863bd..bb1ea8dc7d 100644 --- a/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js +++ b/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js @@ -1,27 +1,10 @@ import { useEffect } from 'react' -import { withContextRoot } from './../../utils/with-context-root' import FileTreeContext from '../../../js/features/file-tree/components/file-tree-context' import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name' import FileTreeCreateFormProvider from '../../../js/features/file-tree/contexts/file-tree-create-form' import { useFileTreeActionable } from '../../../js/features/file-tree/contexts/file-tree-actionable' import PropTypes from 'prop-types' -export const DEFAULT_PROJECT = { - _id: '123abc', - name: 'Some Project', - rootDocId: '5e74f1a7ce17ae0041dfd056', - rootFolder: [ - { - _id: 'root-folder-id', - name: 'rootFolder', - docs: [], - folders: [], - fileRefs: [], - }, - ], - features: { mendeley: true, zotero: true }, -} - const defaultFileTreeContextProps = { refProviders: { mendeley: false, zotero: false }, reindexReferences: () => { @@ -87,19 +70,6 @@ export const mockCreateFileModalFetch = fetchMock => }, ], }) - .post('express:/project/:projectId/compile', { - status: 'success', - outputFiles: [ - { - build: 'foo', - path: 'baz.jpg', - }, - { - build: 'foo', - path: 'ball.jpg', - }, - ], - }) .post('express:/project/:projectId/doc', (path, req) => { console.log({ path, req }) return 204 @@ -114,14 +84,10 @@ export const mockCreateFileModalFetch = fetchMock => }) export const createFileModalDecorator = - ( - fileTreeContextProps = {}, - projectProps = {}, - createMode = 'doc' - // eslint-disable-next-line react/display-name - ) => + (fileTreeContextProps = {}, createMode = 'doc') => + // eslint-disable-next-line react/display-name Story => { - return withContextRoot( + return ( - , - { - project: { ...DEFAULT_PROJECT, ...projectProps }, - permissionsLevel: 'owner', - } + ) } diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal.stories.js b/services/web/frontend/stories/modals/create-file/create-file-modal.stories.js index b4c7fb5315..3e2a10dd4d 100644 --- a/services/web/frontend/stories/modals/create-file/create-file-modal.stories.js +++ b/services/web/frontend/stories/modals/create-file/create-file-modal.stories.js @@ -5,6 +5,8 @@ import { } from './create-file-modal-decorator' import FileTreeModalCreateFile from '../../../js/features/file-tree/components/modals/file-tree-modal-create-file' import useFetchMock from '../../hooks/use-fetch-mock' +import { ScopeDecorator } from '../../decorators/scope' +import { useScope } from '../../hooks/use-scope' export const MinimalFeatures = args => { useFetchMock(mockCreateFileModalFetch) @@ -75,28 +77,26 @@ ErrorImportingFileFromReferenceProvider.decorators = [ export const FileLimitReached = args => { useFetchMock(mockCreateFileModalFetch) + useScope({ + project: { + rootFolder: { + _id: 'root-folder-id', + name: 'rootFolder', + docs: Array.from({ length: 10 }, (_, index) => ({ + _id: `entity-${index}`, + })), + fileRefs: [], + folders: [], + }, + }, + }) + return } -FileLimitReached.decorators = [ - createFileModalDecorator( - {}, - { - rootFolder: [ - { - _id: 'root-folder-id', - name: 'rootFolder', - docs: Array.from({ length: 10 }, (_, index) => ({ - _id: `entity-${index}`, - })), - fileRefs: [], - folders: [], - }, - ], - } - ), -] +FileLimitReached.decorators = [createFileModalDecorator()] export default { title: 'Editor / Modals / Create File', component: FileTreeModalCreateFile, + decorators: [ScopeDecorator], } diff --git a/services/web/frontend/stories/modals/create-file/error-message.stories.js b/services/web/frontend/stories/modals/create-file/error-message.stories.js index c436aac351..867a2b9e74 100644 --- a/services/web/frontend/stories/modals/create-file/error-message.stories.js +++ b/services/web/frontend/stories/modals/create-file/error-message.stories.js @@ -1,5 +1,4 @@ import ErrorMessage from '../../../js/features/file-tree/components/file-tree-create/error-message' -import { createFileModalDecorator } from './create-file-modal-decorator' import { FetchError } from '../../../js/infrastructure/fetch-json' import { BlockedFilenameError, @@ -19,7 +18,6 @@ export const KeyedErrors = () => { ) } -KeyedErrors.decorators = [createFileModalDecorator()] export const FetchStatusErrors = () => { return ( diff --git a/services/web/frontend/stories/outline.stories.js b/services/web/frontend/stories/outline.stories.js index 654a0ee69c..3eb3c705e6 100644 --- a/services/web/frontend/stories/outline.stories.js +++ b/services/web/frontend/stories/outline.stories.js @@ -1,5 +1,5 @@ import OutlinePane from '../js/features/outline/components/outline-pane' -import { ContextRoot } from '../js/shared/context/root-context' +import { ScopeDecorator } from './decorators/scope' export const Basic = args => Basic.args = { @@ -52,11 +52,5 @@ export default { jumpToLine: () => {}, onToggle: () => {}, }, - decorators: [ - Story => ( - - - - ), - ], + decorators: [ScopeDecorator], } diff --git a/services/web/frontend/stories/pdf-preview-error-boundary.stories.js b/services/web/frontend/stories/pdf-preview-error-boundary.stories.js index 21b6d59a4a..353234d372 100644 --- a/services/web/frontend/stories/pdf-preview-error-boundary.stories.js +++ b/services/web/frontend/stories/pdf-preview-error-boundary.stories.js @@ -1,19 +1,20 @@ import ErrorBoundaryFallback from '../js/features/pdf-preview/components/error-boundary-fallback' -import { withContextRoot } from './utils/with-context-root' +import { ScopeDecorator } from './decorators/scope' export default { title: 'Editor / PDF Preview / Error Boundary', component: ErrorBoundaryFallback, + decorators: [ScopeDecorator], } export const PreviewErrorBoundary = () => { - return withContextRoot() + return } export const PdfErrorBoundary = () => { - return withContextRoot() + return } export const LogsErrorBoundary = () => { - return withContextRoot() + return } diff --git a/services/web/frontend/stories/pdf-preview.stories.js b/services/web/frontend/stories/pdf-preview.stories.js index 13f522ad68..37768196b6 100644 --- a/services/web/frontend/stories/pdf-preview.stories.js +++ b/services/web/frontend/stories/pdf-preview.stories.js @@ -1,4 +1,3 @@ -import { withContextRoot } from './utils/with-context-root' import { useCallback, useMemo, useState } from 'react' import useFetchMock from './hooks/use-fetch-mock' import { Button } from 'react-bootstrap' @@ -21,6 +20,7 @@ import { outputFiles, } from './fixtures/compile' import { cloneDeep } from 'lodash' +import { ScopeDecorator } from './decorators/scope' export default { title: 'Editor / PDF Preview', @@ -30,34 +30,7 @@ export default { PdfFileList, PdfPreviewError, }, -} - -const project = { - _id: 'a-project', - name: 'A Project', - features: {}, - tokens: {}, - owner: { - _id: 'a-user', - email: 'stories@overleaf.com', - }, - members: [], - invites: [], -} - -const scope = { - project, - settings: { - syntaxValidation: true, - }, - hasLintingError: false, - $applyAsync: () => {}, - editor: { - sharejs_doc: { - doc_id: 'test-doc', - getSnapshot: () => 'some doc content', - }, - }, + decorators: [ScopeDecorator], } export const Interactive = () => { @@ -205,12 +178,11 @@ export const Interactive = () => { ) } - return withContextRoot( + return (
-
, - scope + ) } @@ -276,12 +248,11 @@ export const CompileError = () => { ) } - return withContextRoot( + return ( <> - , - scope + ) } @@ -309,7 +280,7 @@ export const DisplayError = () => { mockCompile(fetchMock) }) - return withContextRoot( + return ( <> {compileErrors.map(error => (
{
))} - , - scope + ) } @@ -332,11 +302,10 @@ export const HybridToolbar = () => { mockEventTracking(fetchMock) }) - return withContextRoot( + return (
-
, - scope + ) } @@ -361,11 +330,10 @@ export const Logs = () => { mockClearCache(fetchMock) }) - return withContextRoot( + return (
-
, - scope + ) } @@ -393,5 +361,5 @@ export const ValidationIssues = () => { mockBuildFile(fetchMock) }) - return withContextRoot(, scope) + return } diff --git a/services/web/frontend/stories/pdf-viewer.stories.js b/services/web/frontend/stories/pdf-viewer.stories.js index 40dad362ed..5f217e9c0a 100644 --- a/services/web/frontend/stories/pdf-viewer.stories.js +++ b/services/web/frontend/stories/pdf-viewer.stories.js @@ -1,6 +1,4 @@ -import { useEffect, Suspense } from 'react' import useFetchMock from './hooks/use-fetch-mock' -import { withContextRoot } from './utils/with-context-root' import PdfSynctexControls from '../js/features/pdf-preview/components/pdf-synctex-controls' import PdfViewer from '../js/features/pdf-preview/components/pdf-viewer' import { @@ -9,24 +7,13 @@ import { mockSynctex, mockValidPdf, } from './fixtures/compile' +import { useEffect, Suspense } from 'react' +import { ScopeDecorator } from './decorators/scope' export default { title: 'Editor / PDF Viewer', component: PdfViewer, -} - -const project = { - _id: 'story-project', -} - -const scope = { - project, - editor: { - sharejs_doc: { - doc_id: 'test-doc', - getSnapshot: () => 'some doc content', - }, - }, + decorators: [ScopeDecorator], } export const Interactive = () => { @@ -45,17 +32,16 @@ export const Interactive = () => { ) }, []) - return withContextRoot( - -
-
+ return ( +
+
+ -
-
- -
+
- , - scope +
+ +
+
) } diff --git a/services/web/frontend/stories/settings/linking.stories.js b/services/web/frontend/stories/settings/linking.stories.js index 8de85654d9..353c6867e8 100644 --- a/services/web/frontend/stories/settings/linking.stories.js +++ b/services/web/frontend/stories/settings/linking.stories.js @@ -3,6 +3,9 @@ import LinkingSection from '../../js/features/settings/components/linking-sectio import { setDefaultMeta, defaultSetupMocks } from './helpers/linking' import { UserProvider } from '../../js/shared/context/user-context' import { SSOProvider } from '../../js/features/settings/context/sso-context' +import { ScopeDecorator } from '../decorators/scope' +import { useEffect } from 'react' +import { useMeta } from '../hooks/use-meta' const MOCK_DELAY = 1000 @@ -21,17 +24,23 @@ export const Section = args => { export const SectionAllUnlinked = args => { useFetchMock(defaultSetupMocks) - setDefaultMeta() - window.metaAttributesCache.set('ol-thirdPartyIds', {}) - window.metaAttributesCache.set('ol-user', { - features: { github: true, dropbox: true, mendeley: true, zotero: true }, - refProviders: { - mendeley: false, - zotero: false, + + useMeta({ + 'ol-thirdPartyIds': {}, + 'ol-user': { + features: { github: true, dropbox: true, mendeley: true, zotero: true }, + refProviders: { + mendeley: false, + zotero: false, + }, }, + 'ol-github': { enabled: false }, + 'ol-dropbox': { registered: false }, }) - window.metaAttributesCache.set('ol-github', { enabled: false }) - window.metaAttributesCache.set('ol-dropbox', { registered: false }) + + useEffect(() => { + setDefaultMeta() + }, []) return ( @@ -84,4 +93,5 @@ export const SectionProjetSyncSuccess = args => { export default { title: 'Account Settings / Linking', component: LinkingSection, + decorators: [ScopeDecorator], } diff --git a/services/web/frontend/stories/share-project-modal.stories.js b/services/web/frontend/stories/share-project-modal.stories.js index 9c9daacf56..07615320ad 100644 --- a/services/web/frontend/stories/share-project-modal.stories.js +++ b/services/web/frontend/stories/share-project-modal.stories.js @@ -1,62 +1,71 @@ import { useEffect } from 'react' import ShareProjectModal from '../js/features/share-project-modal/components/share-project-modal' import useFetchMock from './hooks/use-fetch-mock' -import { withContextRoot } from './utils/with-context-root' +import { useScope } from './hooks/use-scope' +import { ScopeDecorator } from './decorators/scope' +import { contacts } from './fixtures/contacts' +import { project } from './fixtures/project' export const LinkSharingOff = args => { useFetchMock(setupFetchMock) - const project = { - ...args.project, - publicAccesLevel: 'private', - } + useScope({ + project: { + ...args.project, + publicAccesLevel: 'private', + }, + }) - return withContextRoot(, { project }) + return } export const LinkSharingOn = args => { useFetchMock(setupFetchMock) - const project = { - ...args.project, - publicAccesLevel: 'tokenBased', - } + useScope({ + project: { + ...args.project, + publicAccesLevel: 'tokenBased', + }, + }) - return withContextRoot(, { project }) + return } export const LinkSharingLoading = args => { useFetchMock(setupFetchMock) - const project = { - ...args.project, - publicAccesLevel: 'tokenBased', - tokens: undefined, - } + useScope({ + project: { + ...args.project, + publicAccesLevel: 'tokenBased', + tokens: undefined, + }, + }) - return withContextRoot(, { project }) + return } export const NonAdminLinkSharingOff = args => { - const project = { - ...args.project, - publicAccesLevel: 'private', - } - - return withContextRoot(, { - project, + useScope({ + project: { + ...args.project, + publicAccesLevel: 'private', + }, }) + + return } export const NonAdminLinkSharingOn = args => { - const project = { - ...args.project, - publicAccesLevel: 'tokenBased', - } - - return withContextRoot(, { - project, + useScope({ + project: { + ...args.project, + publicAccesLevel: 'tokenBased', + }, }) + + return } export const RestrictedTokenMember = args => { @@ -64,103 +73,64 @@ export const RestrictedTokenMember = args => { // original value on unmount // Currently this is necessary because the context value is set from window, // however in the future we should change this to set via props - const originalIsRestrictedTokenMember = window.isRestrictedTokenMember - window.isRestrictedTokenMember = true useEffect(() => { + const originalIsRestrictedTokenMember = window.isRestrictedTokenMember + window.isRestrictedTokenMember = true return () => { window.isRestrictedTokenMember = originalIsRestrictedTokenMember } }) - const project = { - ...args.project, - publicAccesLevel: 'tokenBased', - } + useScope({ + project: { + ...args.project, + publicAccesLevel: 'tokenBased', + }, + }) - return withContextRoot(, { project }) + return } export const LegacyLinkSharingReadAndWrite = args => { useFetchMock(setupFetchMock) - const project = { - ...args.project, - publicAccesLevel: 'readAndWrite', - } + useScope({ + project: { + ...args.project, + publicAccesLevel: 'readAndWrite', + }, + }) - return withContextRoot(, { project }) + return } export const LegacyLinkSharingReadOnly = args => { useFetchMock(setupFetchMock) - const project = { - ...args.project, - publicAccesLevel: 'readOnly', - } + useScope({ + project: { + ...args.project, + publicAccesLevel: 'readOnly', + }, + }) - return withContextRoot(, { project }) + return } export const LimitedCollaborators = args => { useFetchMock(setupFetchMock) - const project = { - ...args.project, - features: { - ...args.project.features, - collaborators: 3, + useScope({ + project: { + ...args.project, + features: { + ...args.project.features, + collaborators: 3, + }, }, - } + }) - return withContextRoot(, { project }) -} - -const project = { - _id: 'a-project', - name: 'A Project', - features: { - collaborators: -1, // unlimited - }, - publicAccesLevel: 'private', - tokens: { - readOnly: 'ro-token', - readAndWrite: 'rw-token', - }, - owner: { - _id: 'fakeOwnerId', - email: 'stories@overleaf.com', - }, - members: [ - { - _id: 'viewer-member', - type: 'user', - privileges: 'readOnly', - name: 'Viewer User', - email: 'viewer@example.com', - }, - { - _id: 'author-member', - type: 'user', - privileges: 'readAndWrite', - name: 'Author User', - email: 'author@example.com', - }, - ], - invites: [ - { - _id: 'test-invite-1', - privileges: 'readOnly', - name: 'Invited Viewer', - email: 'invited-viewer@example.com', - }, - { - _id: 'test-invite-2', - privileges: 'readAndWrite', - name: 'Invited Author', - email: 'invited-author@example.com', - }, - ], + return } export default { @@ -176,50 +146,9 @@ export default { argTypes: { handleHide: { action: 'hide' }, }, + decorators: [ScopeDecorator], } -const contacts = [ - // user with edited name - { - type: 'user', - email: 'test-user@example.com', - first_name: 'Test', - last_name: 'User', - name: 'Test User', - }, - // user with default name (email prefix) - { - type: 'user', - email: 'test@example.com', - first_name: 'test', - }, - // no last name - { - type: 'user', - first_name: 'Eratosthenes', - email: 'eratosthenes@example.com', - }, - // more users - { - type: 'user', - first_name: 'Claudius', - last_name: 'Ptolemy', - email: 'ptolemy@example.com', - }, - { - type: 'user', - first_name: 'Abd al-Rahman', - last_name: 'Al-Sufi', - email: 'al-sufi@example.com', - }, - { - type: 'user', - first_name: 'Nicolaus', - last_name: 'Copernicus', - email: 'copernicus@example.com', - }, -] - function setupFetchMock(fetchMock) { const delay = 1000 diff --git a/services/web/frontend/stories/utils/with-context-root.js b/services/web/frontend/stories/utils/with-context-root.js deleted file mode 100644 index 18020ab5d9..0000000000 --- a/services/web/frontend/stories/utils/with-context-root.js +++ /dev/null @@ -1,38 +0,0 @@ -import { ContextRoot } from '../../js/shared/context/root-context' -import _ from 'lodash' - -// Unfortunately, we cannot currently use decorators here, since we need to -// set a value on window, before the contexts are rendered. -// When using decorators, the contexts are rendered before the story, so we -// don't have the opportunity to set the window value first. -export function withContextRoot(Story, scope) { - const scopeWatchers = [] - - const ide = { - ...window._ide, - $scope: { - ...window._ide.$scope, - ...scope, - $watch: (key, callback) => { - scopeWatchers.push([key, callback]) - }, - $applyAsync: callback => { - window.setTimeout(() => { - callback() - for (const [key, watcher] of scopeWatchers) { - watcher(_.get(ide.$scope, key)) - } - }, 0) - }, - $on: (eventName, callback) => { - // - }, - }, - } - - return ( - - {Story} - - ) -} diff --git a/services/web/frontend/stories/word-count-modal.stories.js b/services/web/frontend/stories/word-count-modal.stories.js index 79f0ca7e16..7742815ea6 100644 --- a/services/web/frontend/stories/word-count-modal.stories.js +++ b/services/web/frontend/stories/word-count-modal.stories.js @@ -1,6 +1,6 @@ import useFetchMock from './hooks/use-fetch-mock' -import { withContextRoot } from './utils/with-context-root' import WordCountModal from '../js/features/word-count-modal/components/word-count-modal' +import { ScopeDecorator } from './decorators/scope' const counts = { headers: 4, @@ -14,11 +14,6 @@ const messages = [ 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', ].join('\n') -const project = { - _id: 'project-id', - name: 'A Project', -} - export const WordCount = args => { useFetchMock(fetchMock => { fetchMock.get( @@ -28,7 +23,7 @@ export const WordCount = args => { ) }) - return withContextRoot(, { project }) + return } export const WordCountWithMessages = args => { @@ -40,7 +35,7 @@ export const WordCountWithMessages = args => { ) }) - return withContextRoot(, { project }) + return } export const ErrorResponse = args => { @@ -52,7 +47,7 @@ export const ErrorResponse = args => { ) }) - return withContextRoot(, { project }) + return } export default { @@ -61,4 +56,8 @@ export default { args: { show: true, }, + argTypes: { + handleHide: { action: 'close modal' }, + }, + decorators: [ScopeDecorator], } diff --git a/services/web/tsconfig.json b/services/web/tsconfig.json index 5b033da62b..a0b5e28ffd 100644 --- a/services/web/tsconfig.json +++ b/services/web/tsconfig.json @@ -18,6 +18,8 @@ "modules/**/frontend/js/**/*.*", "test/frontend/**/*.*", "modules/**/test/frontend/**/*.*", + "frontend/stories/**/*.*", + "modules/**/stories/**/*.*", "cypress", "types" ] diff --git a/services/web/types/folder.ts b/services/web/types/folder.ts new file mode 100644 index 0000000000..ee12a87a55 --- /dev/null +++ b/services/web/types/folder.ts @@ -0,0 +1,7 @@ +export type Folder = { + _id: string + name: string + docs: [] + folders: [] + fileRefs: [] +} diff --git a/services/web/types/project.ts b/services/web/types/project.ts new file mode 100644 index 0000000000..c6b01a6e53 --- /dev/null +++ b/services/web/types/project.ts @@ -0,0 +1,30 @@ +import { MongoUser } from './user' +import { Folder } from './folder' + +type ProjectMember = { + _id: string + type: 'user' + privileges: 'readOnly' | 'readAndWrite' + name: string + email: string +} + +type ProjectInvite = { + _id: string + privileges: 'readOnly' | 'readAndWrite' + name: string + email: string +} + +export type Project = { + _id: string + name: string + features: Record + publicAccesLevel?: string + tokens: Record + owner: MongoUser + members: ProjectMember[] + invites: ProjectInvite[] + rootDocId?: string + rootFolder?: Folder[] +} diff --git a/services/web/types/user.ts b/services/web/types/user.ts new file mode 100644 index 0000000000..f657355480 --- /dev/null +++ b/services/web/types/user.ts @@ -0,0 +1,8 @@ +export type User = { + id: string + email: string + allowedFreeTrial?: boolean + features?: Record +} + +export type MongoUser = Pick> & { _id: string } diff --git a/services/web/types/window.ts b/services/web/types/window.ts index de5401b495..55e6eacba4 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -15,5 +15,8 @@ declare global { currentLangCode: string } ExposedSettings: ExposedSettings + project_id: string + gitBridgePublicBaseUrl: string + _ide: Record } }