Kaynağa Gözat

Standardise scope/context usage in Storybook stories (#7842)

GitOrigin-RevId: 109a4357fc3b083ffbd3af5b8c98acf0f655f297
Alf Eaton 4 yıl önce
ebeveyn
işleme
d91ee50762
35 değiştirilmiş dosya ile 643 ekleme ve 537 silme
  1. 2 2
      services/web/frontend/js/features/file-view/components/file-view.js
  2. 1 1
      services/web/frontend/js/infrastructure/error-reporter.js
  3. 28 47
      services/web/frontend/stories/chat.stories.js
  4. 5 6
      services/web/frontend/stories/clone-project-modal.stories.js
  5. 7 5
      services/web/frontend/stories/contact-us-modal.stories.js
  6. 193 0
      services/web/frontend/stories/decorators/scope.tsx
  7. 3 7
      services/web/frontend/stories/editor-switch.stories.js
  8. 25 8
      services/web/frontend/stories/file-tree.stories.js
  9. 2 8
      services/web/frontend/stories/file-view.stories.js
  10. 34 0
      services/web/frontend/stories/fixtures/chat-messages.js
  11. 4 3
      services/web/frontend/stories/fixtures/compile.js
  12. 41 0
      services/web/frontend/stories/fixtures/contacts.js
  13. 0 71
      services/web/frontend/stories/fixtures/context.js
  14. 48 0
      services/web/frontend/stories/fixtures/project.ts
  15. 6 12
      services/web/frontend/stories/git-bridge-modal.stories.js
  16. 0 22
      services/web/frontend/stories/hooks/use-fetch-mock.js
  17. 16 0
      services/web/frontend/stories/hooks/use-fetch-mock.tsx
  18. 8 0
      services/web/frontend/stories/hooks/use-meta.tsx
  19. 16 0
      services/web/frontend/stories/hooks/use-scope.tsx
  20. 4 42
      services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js
  21. 18 18
      services/web/frontend/stories/modals/create-file/create-file-modal.stories.js
  22. 0 2
      services/web/frontend/stories/modals/create-file/error-message.stories.js
  23. 2 8
      services/web/frontend/stories/outline.stories.js
  24. 5 4
      services/web/frontend/stories/pdf-preview-error-boundary.stories.js
  25. 13 45
      services/web/frontend/stories/pdf-preview.stories.js
  26. 12 26
      services/web/frontend/stories/pdf-viewer.stories.js
  27. 19 9
      services/web/frontend/stories/settings/linking.stories.js
  28. 73 144
      services/web/frontend/stories/share-project-modal.stories.js
  29. 0 38
      services/web/frontend/stories/utils/with-context-root.js
  30. 8 9
      services/web/frontend/stories/word-count-modal.stories.js
  31. 2 0
      services/web/tsconfig.json
  32. 7 0
      services/web/types/folder.ts
  33. 30 0
      services/web/types/project.ts
  34. 8 0
      services/web/types/user.ts
  35. 3 0
      services/web/types/window.ts

+ 2 - 2
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 imageExtensions = ['png', 'jpg', 'jpeg', 'gif']
 
 
-const textExtensions = window.ExposedSettings.textExtensions
-
 export default function FileView({ file, storeReferencesKeys }) {
 export default function FileView({ file, storeReferencesKeys }) {
   const [contentLoading, setContentLoading] = useState(true)
   const [contentLoading, setContentLoading] = useState(true)
   const [hasError, setHasError] = useState(false)
   const [hasError, setHasError] = useState(false)
 
 
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
+  const { textExtensions } = window.ExposedSettings
+
   const extension = file.name.split('.').pop().toLowerCase()
   const extension = file.name.split('.').pop().toLowerCase()
   const isUnpreviewableFile =
   const isUnpreviewableFile =
     !imageExtensions.includes(extension) && !textExtensions.includes(extension)
     !imageExtensions.includes(extension) && !textExtensions.includes(extension)

+ 1 - 1
services/web/frontend/js/infrastructure/error-reporter.js

@@ -1,7 +1,7 @@
 // Conditionally enable Sentry based on whether the DSN token is set
 // Conditionally enable Sentry based on whether the DSN token is set
 import getMeta from '../utils/meta'
 import getMeta from '../utils/meta'
 
 
-const reporterPromise = window.ExposedSettings.sentryDsn
+const reporterPromise = window.ExposedSettings?.sentryDsn
   ? sentryReporter()
   ? sentryReporter()
   : nullReporter()
   : nullReporter()
 
 

+ 28 - 47
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 ChatPane from '../js/features/chat/components/chat-pane'
-import { stubMathJax } from '../../test/frontend/features/chat/components/stubs'
 import useFetchMock from './hooks/use-fetch-mock'
 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 => {
 export const Conversation = args => {
   useFetchMock(fetchMock => {
   useFetchMock(fetchMock => {
@@ -66,6 +30,14 @@ export const Loading = args => {
   return <ChatPane {...args} />
   return <ChatPane {...args} />
 }
 }
 
 
+export const LoadingError = args => {
+  useFetchMock(fetchMock => {
+    fetchMock.get(/messages/, 500)
+  })
+
+  return <ChatPane {...args} />
+}
+
 export default {
 export default {
   title: 'Editor / Chat',
   title: 'Editor / Chat',
   component: ChatPane,
   component: ChatPane,
@@ -76,13 +48,22 @@ export default {
     resetUnreadMessages: () => {},
     resetUnreadMessages: () => {},
   },
   },
   decorators: [
   decorators: [
-    Story => (
-      <>
-        <style>{'html, body, .chat { height: 100%; width: 100%; }'}</style>
-        <ContextRoot ide={window._ide} settings={{}}>
-          <Story />
-        </ContextRoot>
-      </>
-    ),
+    ScopeDecorator,
+    Story => {
+      useEffect(() => {
+        window.MathJax = {
+          Hub: {
+            Queue: () => {},
+            config: { tex2jax: { inlineMath: [['$', '$']] } },
+          },
+        }
+
+        return () => {
+          delete window.MathJax
+        }
+      }, [])
+
+      return <Story />
+    },
   ],
   ],
 }
 }

+ 5 - 6
services/web/frontend/stories/clone-project-modal.stories.js

@@ -1,8 +1,6 @@
 import useFetchMock from './hooks/use-fetch-mock'
 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'
 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 => {
 export const Success = args => {
   useFetchMock(fetchMock => {
   useFetchMock(fetchMock => {
@@ -13,7 +11,7 @@ export const Success = args => {
     )
     )
   })
   })
 
 
-  return withContextRoot(<CloneProjectModal {...args} />, { project })
+  return <CloneProjectModal {...args} />
 }
 }
 
 
 export const GenericErrorResponse = args => {
 export const GenericErrorResponse = args => {
@@ -25,7 +23,7 @@ export const GenericErrorResponse = args => {
     )
     )
   })
   })
 
 
-  return withContextRoot(<CloneProjectModal {...args} />, { project })
+  return <CloneProjectModal {...args} />
 }
 }
 
 
 export const SpecificErrorResponse = args => {
 export const SpecificErrorResponse = args => {
@@ -37,7 +35,7 @@ export const SpecificErrorResponse = args => {
     )
     )
   })
   })
 
 
-  return withContextRoot(<CloneProjectModal {...args} />, { project })
+  return <CloneProjectModal {...args} />
 }
 }
 
 
 export default {
 export default {
@@ -50,4 +48,5 @@ export default {
     handleHide: { action: 'close modal' },
     handleHide: { action: 'close modal' },
     openProject: { action: 'open project' },
     openProject: { action: 'open project' },
   },
   },
+  decorators: [ScopeDecorator],
 }
 }

+ 7 - 5
services/web/frontend/stories/contact-us-modal.stories.js

@@ -1,25 +1,26 @@
 import { useState } from 'react'
 import { useState } from 'react'
 import useFetchMock from './hooks/use-fetch-mock'
 import useFetchMock from './hooks/use-fetch-mock'
 import ContactUsModal from '../../modules/support/frontend/js/components/contact-us-modal'
 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 = () => {
 export const Generic = () => {
   const [show, setShow] = useState(true)
   const [show, setShow] = useState(true)
+
   const handleHide = () => setShow(false)
   const handleHide = () => setShow(false)
 
 
   useFetchMock(fetchMock => {
   useFetchMock(fetchMock => {
-    fetchMock.post('express:/support', { status: 200 }, { delay: 1000 })
+    fetchMock.post('/support', { status: 200 }, { delay: 1000 })
   })
   })
 
 
-  return withContextRoot(<ContactUsModal show={show} handleHide={handleHide} />)
+  return <ContactUsModal show={show} handleHide={handleHide} />
 }
 }
 
 
 export const RequestError = args => {
 export const RequestError = args => {
   useFetchMock(fetchMock => {
   useFetchMock(fetchMock => {
-    fetchMock.post('express:/support', { status: 404 }, { delay: 250 })
+    fetchMock.post('/support', { status: 404 }, { delay: 250 })
   })
   })
 
 
-  return withContextRoot(<ContactUsModal {...args} />)
+  return <ContactUsModal {...args} />
 }
 }
 
 
 export default {
 export default {
@@ -32,4 +33,5 @@ export default {
   argTypes: {
   argTypes: {
     handleHide: { action: 'close modal' },
     handleHide: { action: 'close modal' },
   },
   },
+  decorators: [ScopeDecorator],
 }
 }

+ 193 - 0
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 (
+    <ContextRoot ide={ide} settings={{}}>
+      <Story />
+    </ContextRoot>
+  )
+}

+ 3 - 7
services/web/frontend/stories/editor-switch.stories.js

@@ -1,16 +1,12 @@
 import EditorSwitch from '../js/features/source-editor/components/editor-switch'
 import EditorSwitch from '../js/features/source-editor/components/editor-switch'
-import { withContextRoot } from './utils/with-context-root'
+import { ScopeDecorator } from './decorators/scope'
 
 
 export default {
 export default {
   title: 'Editor / Switch',
   title: 'Editor / Switch',
   component: EditorSwitch,
   component: EditorSwitch,
+  decorators: [ScopeDecorator],
 }
 }
 
 
 export const Switcher = () => {
 export const Switcher = () => {
-  return withContextRoot(<EditorSwitch />, {
-    editor: {
-      richText: false,
-      newSourceEditor: false,
-    },
-  })
+  return <EditorSwitch />
 }
 }

+ 25 - 8
services/web/frontend/stories/file-tree.stories.js

@@ -1,11 +1,12 @@
 import MockedSocket from 'socket.io-mock'
 import MockedSocket from 'socket.io-mock'
 
 
-import { withContextRoot } from './utils/with-context-root'
 import { rootFolderBase } from './fixtures/file-tree-base'
 import { rootFolderBase } from './fixtures/file-tree-base'
 import { rootFolderLimit } from './fixtures/file-tree-limit'
 import { rootFolderLimit } from './fixtures/file-tree-limit'
 import FileTreeRoot from '../js/features/file-tree/components/file-tree-root'
 import FileTreeRoot from '../js/features/file-tree/components/file-tree-root'
 import FileTreeError from '../js/features/file-tree/components/file-tree-error'
 import FileTreeError from '../js/features/file-tree/components/file-tree-error'
 import useFetchMock from './hooks/use-fetch-mock'
 import useFetchMock from './hooks/use-fetch-mock'
+import { ScopeDecorator } from './decorators/scope'
+import { useScope } from './hooks/use-scope'
 
 
 const MOCK_DELAY = 2000
 const MOCK_DELAY = 2000
 
 
@@ -87,24 +88,30 @@ function defaultSetupMocks(fetchMock) {
 export const FullTree = args => {
 export const FullTree = args => {
   useFetchMock(defaultSetupMocks)
   useFetchMock(defaultSetupMocks)
 
 
-  return withContextRoot(<FileTreeRoot {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     project: DEFAULT_PROJECT,
     permissionsLevel: 'owner',
     permissionsLevel: 'owner',
   })
   })
+
+  return <FileTreeRoot {...args} />
 }
 }
 
 
 export const ReadOnly = args => {
 export const ReadOnly = args => {
-  return withContextRoot(<FileTreeRoot {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     project: DEFAULT_PROJECT,
     permissionsLevel: 'readOnly',
     permissionsLevel: 'readOnly',
   })
   })
+
+  return <FileTreeRoot {...args} />
 }
 }
 
 
 export const Disconnected = args => {
 export const Disconnected = args => {
-  return withContextRoot(<FileTreeRoot {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     project: DEFAULT_PROJECT,
     permissionsLevel: 'owner',
     permissionsLevel: 'owner',
   })
   })
+
+  return <FileTreeRoot {...args} />
 }
 }
 Disconnected.args = { isConnected: false }
 Disconnected.args = { isConnected: false }
 
 
@@ -125,25 +132,34 @@ export const NetworkErrors = args => {
       })
       })
   })
   })
 
 
-  return withContextRoot(<FileTreeRoot {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     project: DEFAULT_PROJECT,
     permissionsLevel: 'owner',
     permissionsLevel: 'owner',
   })
   })
+
+  return <FileTreeRoot {...args} />
 }
 }
 
 
 export const FallbackError = args => {
 export const FallbackError = args => {
-  return withContextRoot(<FileTreeError {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     project: DEFAULT_PROJECT,
   })
   })
+
+  return <FileTreeError {...args} />
 }
 }
 
 
 export const FilesLimit = args => {
 export const FilesLimit = args => {
   useFetchMock(defaultSetupMocks)
   useFetchMock(defaultSetupMocks)
 
 
-  return withContextRoot(<FileTreeRoot {...args} />, {
-    project: { ...DEFAULT_PROJECT, rootFolder: rootFolderLimit },
+  useScope({
+    project: {
+      ...DEFAULT_PROJECT,
+      rootFolder: rootFolderLimit,
+    },
     permissionsLevel: 'owner',
     permissionsLevel: 'owner',
   })
   })
+
+  return <FileTreeRoot {...args} />
 }
 }
 
 
 export default {
 export default {
@@ -167,6 +183,7 @@ export default {
     onSelect: { action: 'onSelect' },
     onSelect: { action: 'onSelect' },
   },
   },
   decorators: [
   decorators: [
+    ScopeDecorator,
     Story => (
     Story => (
       <>
       <>
         <style>{'html, body, .file-tree { height: 100%; width: 100%; }'}</style>
         <style>{'html, body, .file-tree { height: 100%; width: 100%; }'}</style>

+ 2 - 8
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 FileView from '../js/features/file-view/components/file-view'
 import useFetchMock from './hooks/use-fetch-mock'
 import useFetchMock from './hooks/use-fetch-mock'
+import { ScopeDecorator } from './decorators/scope'
 
 
 const bodies = {
 const bodies = {
   latex: `\\documentclass{article}
   latex: `\\documentclass{article}
@@ -252,11 +252,5 @@ export default {
   argTypes: {
   argTypes: {
     storeReferencesKeys: { action: 'store references keys' },
     storeReferencesKeys: { action: 'store references keys' },
   },
   },
-  decorators: [
-    Story => (
-      <ContextRoot ide={window._ide} settings={{}}>
-        <Story />
-      </ContextRoot>
-    ),
-  ],
+  decorators: [ScopeDecorator],
 }
 }

+ 34 - 0
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
+}

+ 4 - 3
services/web/frontend/stories/fixtures/compile.js

@@ -58,7 +58,7 @@ export const mockCompile = (fetchMock, delay = 1000) =>
         outputFiles: cloneDeep(outputFiles),
         outputFiles: cloneDeep(outputFiles),
       },
       },
     },
     },
-    { delay }
+    { delay, overwriteRoutes: true }
   )
   )
 
 
 export const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
 export const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
@@ -97,6 +97,7 @@ export const mockCompileValidationIssues = (
 export const mockClearCache = fetchMock =>
 export const mockClearCache = fetchMock =>
   fetchMock.delete('express:/project/:projectId/output', 204, {
   fetchMock.delete('express:/project/:projectId/output', 204, {
     delay: 1000,
     delay: 1000,
+    overwriteRoutes: true,
   })
   })
 
 
 export const mockBuildFile = fetchMock =>
 export const mockBuildFile = fetchMock =>
@@ -156,7 +157,7 @@ LaTeX Font Info:    External font \`cmex10' loaded for size
           return 404
           return 404
       }
       }
     },
     },
-    { sendAsJson: false }
+    { sendAsJson: false, overwriteRoutes: true }
   )
   )
 
 
 const mockHighlights = [
 const mockHighlights = [
@@ -215,7 +216,7 @@ export const mockValidPdf = fetchMock =>
         xhr.send()
         xhr.send()
       })
       })
     },
     },
-    { sendAsJson: false }
+    { sendAsJson: false, overwriteRoutes: true }
   )
   )
 
 
 export const mockSynctex = fetchMock =>
 export const mockSynctex = fetchMock =>

+ 41 - 0
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',
+  },
+]

+ 0 - 71
services/web/frontend/stories/fixtures/context.js

@@ -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)
-}

+ 48 - 0
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',
+    },
+  ],
+}

+ 6 - 12
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 importOverleafModules from '../macros/import-overleaf-module.macro'
-import useFetchMock from './hooks/use-fetch-mock'
+import { ScopeDecorator } from './decorators/scope'
 
 
 const [
 const [
   {
   {
@@ -19,7 +18,10 @@ CollaboratorModal.args = {
 }
 }
 
 
 export const TeaserModal = 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 <GitBridgeModal {...args} />
   return <GitBridgeModal {...args} />
 }
 }
@@ -36,13 +38,5 @@ export default {
   argTypes: {
   argTypes: {
     handleHide: { action: 'handleHide' },
     handleHide: { action: 'handleHide' },
   },
   },
-  decorators: [
-    Story => (
-      <>
-        <ContextRoot ide={window._ide} settings={{}}>
-          <Story />
-        </ContextRoot>
-      </>
-    ),
-  ],
+  decorators: [ScopeDecorator],
 }
 }

+ 0 - 22
services/web/frontend/stories/hooks/use-fetch-mock.js

@@ -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)
-}

+ 16 - 0
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])
+}

+ 8 - 0
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<string, unknown>) => {
+  for (const [key, value] of Object.entries(meta)) {
+    window.metaAttributesCache.set(key, value)
+  }
+}

+ 16 - 0
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<string, unknown>) => {
+  const scopeRef = useRef(null)
+  if (scopeRef.current === null) {
+    scopeRef.current = scope
+  }
+
+  useLayoutEffect(() => {
+    merge(window._ide.$scope, scopeRef.current)
+  }, [])
+}

+ 4 - 42
services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js

@@ -1,27 +1,10 @@
 import { useEffect } from 'react'
 import { useEffect } from 'react'
-import { withContextRoot } from './../../utils/with-context-root'
 import FileTreeContext from '../../../js/features/file-tree/components/file-tree-context'
 import FileTreeContext from '../../../js/features/file-tree/components/file-tree-context'
 import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name'
 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 FileTreeCreateFormProvider from '../../../js/features/file-tree/contexts/file-tree-create-form'
 import { useFileTreeActionable } from '../../../js/features/file-tree/contexts/file-tree-actionable'
 import { useFileTreeActionable } from '../../../js/features/file-tree/contexts/file-tree-actionable'
 import PropTypes from 'prop-types'
 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 = {
 const defaultFileTreeContextProps = {
   refProviders: { mendeley: false, zotero: false },
   refProviders: { mendeley: false, zotero: false },
   reindexReferences: () => {
   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) => {
     .post('express:/project/:projectId/doc', (path, req) => {
       console.log({ path, req })
       console.log({ path, req })
       return 204
       return 204
@@ -114,14 +84,10 @@ export const mockCreateFileModalFetch = fetchMock =>
     })
     })
 
 
 export const createFileModalDecorator =
 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 => {
   Story => {
-    return withContextRoot(
+    return (
       <FileTreeContext
       <FileTreeContext
         {...defaultFileTreeContextProps}
         {...defaultFileTreeContextProps}
         {...fileTreeContextProps}
         {...fileTreeContextProps}
@@ -133,11 +99,7 @@ export const createFileModalDecorator =
             </OpenCreateFileModal>
             </OpenCreateFileModal>
           </FileTreeCreateFormProvider>
           </FileTreeCreateFormProvider>
         </FileTreeCreateNameProvider>
         </FileTreeCreateNameProvider>
-      </FileTreeContext>,
-      {
-        project: { ...DEFAULT_PROJECT, ...projectProps },
-        permissionsLevel: 'owner',
-      }
+      </FileTreeContext>
     )
     )
   }
   }
 
 

+ 18 - 18
services/web/frontend/stories/modals/create-file/create-file-modal.stories.js

@@ -5,6 +5,8 @@ import {
 } from './create-file-modal-decorator'
 } from './create-file-modal-decorator'
 import FileTreeModalCreateFile from '../../../js/features/file-tree/components/modals/file-tree-modal-create-file'
 import FileTreeModalCreateFile from '../../../js/features/file-tree/components/modals/file-tree-modal-create-file'
 import useFetchMock from '../../hooks/use-fetch-mock'
 import useFetchMock from '../../hooks/use-fetch-mock'
+import { ScopeDecorator } from '../../decorators/scope'
+import { useScope } from '../../hooks/use-scope'
 
 
 export const MinimalFeatures = args => {
 export const MinimalFeatures = args => {
   useFetchMock(mockCreateFileModalFetch)
   useFetchMock(mockCreateFileModalFetch)
@@ -75,28 +77,26 @@ ErrorImportingFileFromReferenceProvider.decorators = [
 export const FileLimitReached = args => {
 export const FileLimitReached = args => {
   useFetchMock(mockCreateFileModalFetch)
   useFetchMock(mockCreateFileModalFetch)
 
 
+  useScope({
+    project: {
+      rootFolder: {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: Array.from({ length: 10 }, (_, index) => ({
+          _id: `entity-${index}`,
+        })),
+        fileRefs: [],
+        folders: [],
+      },
+    },
+  })
+
   return <FileTreeModalCreateFile {...args} />
   return <FileTreeModalCreateFile {...args} />
 }
 }
-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 {
 export default {
   title: 'Editor / Modals / Create File',
   title: 'Editor / Modals / Create File',
   component: FileTreeModalCreateFile,
   component: FileTreeModalCreateFile,
+  decorators: [ScopeDecorator],
 }
 }

+ 0 - 2
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 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 { FetchError } from '../../../js/infrastructure/fetch-json'
 import {
 import {
   BlockedFilenameError,
   BlockedFilenameError,
@@ -19,7 +18,6 @@ export const KeyedErrors = () => {
     </>
     </>
   )
   )
 }
 }
-KeyedErrors.decorators = [createFileModalDecorator()]
 
 
 export const FetchStatusErrors = () => {
 export const FetchStatusErrors = () => {
   return (
   return (

+ 2 - 8
services/web/frontend/stories/outline.stories.js

@@ -1,5 +1,5 @@
 import OutlinePane from '../js/features/outline/components/outline-pane'
 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 => <OutlinePane {...args} />
 export const Basic = args => <OutlinePane {...args} />
 Basic.args = {
 Basic.args = {
@@ -52,11 +52,5 @@ export default {
     jumpToLine: () => {},
     jumpToLine: () => {},
     onToggle: () => {},
     onToggle: () => {},
   },
   },
-  decorators: [
-    Story => (
-      <ContextRoot ide={window._ide} settings={{}}>
-        <Story />
-      </ContextRoot>
-    ),
-  ],
+  decorators: [ScopeDecorator],
 }
 }

+ 5 - 4
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 ErrorBoundaryFallback from '../js/features/pdf-preview/components/error-boundary-fallback'
-import { withContextRoot } from './utils/with-context-root'
+import { ScopeDecorator } from './decorators/scope'
 
 
 export default {
 export default {
   title: 'Editor / PDF Preview / Error Boundary',
   title: 'Editor / PDF Preview / Error Boundary',
   component: ErrorBoundaryFallback,
   component: ErrorBoundaryFallback,
+  decorators: [ScopeDecorator],
 }
 }
 
 
 export const PreviewErrorBoundary = () => {
 export const PreviewErrorBoundary = () => {
-  return withContextRoot(<ErrorBoundaryFallback type="preview" />)
+  return <ErrorBoundaryFallback type="preview" />
 }
 }
 
 
 export const PdfErrorBoundary = () => {
 export const PdfErrorBoundary = () => {
-  return withContextRoot(<ErrorBoundaryFallback type="pdf" />)
+  return <ErrorBoundaryFallback type="pdf" />
 }
 }
 
 
 export const LogsErrorBoundary = () => {
 export const LogsErrorBoundary = () => {
-  return withContextRoot(<ErrorBoundaryFallback type="logs" />)
+  return <ErrorBoundaryFallback type="logs" />
 }
 }

+ 13 - 45
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 { useCallback, useMemo, useState } from 'react'
 import useFetchMock from './hooks/use-fetch-mock'
 import useFetchMock from './hooks/use-fetch-mock'
 import { Button } from 'react-bootstrap'
 import { Button } from 'react-bootstrap'
@@ -21,6 +20,7 @@ import {
   outputFiles,
   outputFiles,
 } from './fixtures/compile'
 } from './fixtures/compile'
 import { cloneDeep } from 'lodash'
 import { cloneDeep } from 'lodash'
+import { ScopeDecorator } from './decorators/scope'
 
 
 export default {
 export default {
   title: 'Editor / PDF Preview',
   title: 'Editor / PDF Preview',
@@ -30,34 +30,7 @@ export default {
     PdfFileList,
     PdfFileList,
     PdfPreviewError,
     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 = () => {
 export const Interactive = () => {
@@ -205,12 +178,11 @@ export const Interactive = () => {
     )
     )
   }
   }
 
 
-  return withContextRoot(
+  return (
     <div className="pdf-viewer">
     <div className="pdf-viewer">
       <PdfPreviewPane />
       <PdfPreviewPane />
       <Inner />
       <Inner />
-    </div>,
-    scope
+    </div>
   )
   )
 }
 }
 
 
@@ -276,12 +248,11 @@ export const CompileError = () => {
     )
     )
   }
   }
 
 
-  return withContextRoot(
+  return (
     <>
     <>
       <PdfPreviewPane />
       <PdfPreviewPane />
       <Inner />
       <Inner />
-    </>,
-    scope
+    </>
   )
   )
 }
 }
 
 
@@ -309,7 +280,7 @@ export const DisplayError = () => {
     mockCompile(fetchMock)
     mockCompile(fetchMock)
   })
   })
 
 
-  return withContextRoot(
+  return (
     <>
     <>
       {compileErrors.map(error => (
       {compileErrors.map(error => (
         <div
         <div
@@ -320,8 +291,7 @@ export const DisplayError = () => {
           <PdfPreviewError error={error} />
           <PdfPreviewError error={error} />
         </div>
         </div>
       ))}
       ))}
-    </>,
-    scope
+    </>
   )
   )
 }
 }
 
 
@@ -332,11 +302,10 @@ export const HybridToolbar = () => {
     mockEventTracking(fetchMock)
     mockEventTracking(fetchMock)
   })
   })
 
 
-  return withContextRoot(
+  return (
     <div className="pdf">
     <div className="pdf">
       <PdfPreviewHybridToolbar />
       <PdfPreviewHybridToolbar />
-    </div>,
-    scope
+    </div>
   )
   )
 }
 }
 
 
@@ -361,11 +330,10 @@ export const Logs = () => {
     mockClearCache(fetchMock)
     mockClearCache(fetchMock)
   })
   })
 
 
-  return withContextRoot(
+  return (
     <div className="pdf">
     <div className="pdf">
       <PdfLogsViewer />
       <PdfLogsViewer />
-    </div>,
-    scope
+    </div>
   )
   )
 }
 }
 
 
@@ -393,5 +361,5 @@ export const ValidationIssues = () => {
     mockBuildFile(fetchMock)
     mockBuildFile(fetchMock)
   })
   })
 
 
-  return withContextRoot(<PdfPreviewPane />, scope)
+  return <PdfPreviewPane />
 }
 }

+ 12 - 26
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 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 PdfSynctexControls from '../js/features/pdf-preview/components/pdf-synctex-controls'
 import PdfViewer from '../js/features/pdf-preview/components/pdf-viewer'
 import PdfViewer from '../js/features/pdf-preview/components/pdf-viewer'
 import {
 import {
@@ -9,24 +7,13 @@ import {
   mockSynctex,
   mockSynctex,
   mockValidPdf,
   mockValidPdf,
 } from './fixtures/compile'
 } from './fixtures/compile'
+import { useEffect, Suspense } from 'react'
+import { ScopeDecorator } from './decorators/scope'
 
 
 export default {
 export default {
   title: 'Editor / PDF Viewer',
   title: 'Editor / PDF Viewer',
   component: PdfViewer,
   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 = () => {
 export const Interactive = () => {
@@ -45,17 +32,16 @@ export const Interactive = () => {
     )
     )
   }, [])
   }, [])
 
 
-  return withContextRoot(
-    <Suspense fallback="Loading">
-      <div>
-        <div className="pdf-viewer">
+  return (
+    <div>
+      <div className="pdf-viewer">
+        <Suspense fallback={null}>
           <PdfViewer />
           <PdfViewer />
-        </div>
-        <div style={{ position: 'absolute', top: 150, left: 50 }}>
-          <PdfSynctexControls />
-        </div>
+        </Suspense>
+      </div>
+      <div style={{ position: 'absolute', top: 150, left: 50 }}>
+        <PdfSynctexControls />
       </div>
       </div>
-    </Suspense>,
-    scope
+    </div>
   )
   )
 }
 }

+ 19 - 9
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 { setDefaultMeta, defaultSetupMocks } from './helpers/linking'
 import { UserProvider } from '../../js/shared/context/user-context'
 import { UserProvider } from '../../js/shared/context/user-context'
 import { SSOProvider } from '../../js/features/settings/context/sso-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
 const MOCK_DELAY = 1000
 
 
@@ -21,17 +24,23 @@ export const Section = args => {
 
 
 export const SectionAllUnlinked = args => {
 export const SectionAllUnlinked = args => {
   useFetchMock(defaultSetupMocks)
   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 (
   return (
     <UserProvider>
     <UserProvider>
@@ -84,4 +93,5 @@ export const SectionProjetSyncSuccess = args => {
 export default {
 export default {
   title: 'Account Settings / Linking',
   title: 'Account Settings / Linking',
   component: LinkingSection,
   component: LinkingSection,
+  decorators: [ScopeDecorator],
 }
 }

+ 73 - 144
services/web/frontend/stories/share-project-modal.stories.js

@@ -1,62 +1,71 @@
 import { useEffect } from 'react'
 import { useEffect } from 'react'
 import ShareProjectModal from '../js/features/share-project-modal/components/share-project-modal'
 import ShareProjectModal from '../js/features/share-project-modal/components/share-project-modal'
 import useFetchMock from './hooks/use-fetch-mock'
 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 => {
 export const LinkSharingOff = args => {
   useFetchMock(setupFetchMock)
   useFetchMock(setupFetchMock)
 
 
-  const project = {
-    ...args.project,
-    publicAccesLevel: 'private',
-  }
+  useScope({
+    project: {
+      ...args.project,
+      publicAccesLevel: 'private',
+    },
+  })
 
 
-  return withContextRoot(<ShareProjectModal {...args} />, { project })
+  return <ShareProjectModal {...args} />
 }
 }
 
 
 export const LinkSharingOn = args => {
 export const LinkSharingOn = args => {
   useFetchMock(setupFetchMock)
   useFetchMock(setupFetchMock)
 
 
-  const project = {
-    ...args.project,
-    publicAccesLevel: 'tokenBased',
-  }
+  useScope({
+    project: {
+      ...args.project,
+      publicAccesLevel: 'tokenBased',
+    },
+  })
 
 
-  return withContextRoot(<ShareProjectModal {...args} />, { project })
+  return <ShareProjectModal {...args} />
 }
 }
 
 
 export const LinkSharingLoading = args => {
 export const LinkSharingLoading = args => {
   useFetchMock(setupFetchMock)
   useFetchMock(setupFetchMock)
 
 
-  const project = {
-    ...args.project,
-    publicAccesLevel: 'tokenBased',
-    tokens: undefined,
-  }
+  useScope({
+    project: {
+      ...args.project,
+      publicAccesLevel: 'tokenBased',
+      tokens: undefined,
+    },
+  })
 
 
-  return withContextRoot(<ShareProjectModal {...args} />, { project })
+  return <ShareProjectModal {...args} />
 }
 }
 
 
 export const NonAdminLinkSharingOff = args => {
 export const NonAdminLinkSharingOff = args => {
-  const project = {
-    ...args.project,
-    publicAccesLevel: 'private',
-  }
-
-  return withContextRoot(<ShareProjectModal {...args} isAdmin={false} />, {
-    project,
+  useScope({
+    project: {
+      ...args.project,
+      publicAccesLevel: 'private',
+    },
   })
   })
+
+  return <ShareProjectModal {...args} isAdmin={false} />
 }
 }
 
 
 export const NonAdminLinkSharingOn = args => {
 export const NonAdminLinkSharingOn = args => {
-  const project = {
-    ...args.project,
-    publicAccesLevel: 'tokenBased',
-  }
-
-  return withContextRoot(<ShareProjectModal {...args} isAdmin={false} />, {
-    project,
+  useScope({
+    project: {
+      ...args.project,
+      publicAccesLevel: 'tokenBased',
+    },
   })
   })
+
+  return <ShareProjectModal {...args} isAdmin={false} />
 }
 }
 
 
 export const RestrictedTokenMember = args => {
 export const RestrictedTokenMember = args => {
@@ -64,103 +73,64 @@ export const RestrictedTokenMember = args => {
   // original value on unmount
   // original value on unmount
   // Currently this is necessary because the context value is set from window,
   // Currently this is necessary because the context value is set from window,
   // however in the future we should change this to set via props
   // however in the future we should change this to set via props
-  const originalIsRestrictedTokenMember = window.isRestrictedTokenMember
-  window.isRestrictedTokenMember = true
   useEffect(() => {
   useEffect(() => {
+    const originalIsRestrictedTokenMember = window.isRestrictedTokenMember
+    window.isRestrictedTokenMember = true
     return () => {
     return () => {
       window.isRestrictedTokenMember = originalIsRestrictedTokenMember
       window.isRestrictedTokenMember = originalIsRestrictedTokenMember
     }
     }
   })
   })
 
 
-  const project = {
-    ...args.project,
-    publicAccesLevel: 'tokenBased',
-  }
+  useScope({
+    project: {
+      ...args.project,
+      publicAccesLevel: 'tokenBased',
+    },
+  })
 
 
-  return withContextRoot(<ShareProjectModal {...args} />, { project })
+  return <ShareProjectModal {...args} />
 }
 }
 
 
 export const LegacyLinkSharingReadAndWrite = args => {
 export const LegacyLinkSharingReadAndWrite = args => {
   useFetchMock(setupFetchMock)
   useFetchMock(setupFetchMock)
 
 
-  const project = {
-    ...args.project,
-    publicAccesLevel: 'readAndWrite',
-  }
+  useScope({
+    project: {
+      ...args.project,
+      publicAccesLevel: 'readAndWrite',
+    },
+  })
 
 
-  return withContextRoot(<ShareProjectModal {...args} />, { project })
+  return <ShareProjectModal {...args} />
 }
 }
 
 
 export const LegacyLinkSharingReadOnly = args => {
 export const LegacyLinkSharingReadOnly = args => {
   useFetchMock(setupFetchMock)
   useFetchMock(setupFetchMock)
 
 
-  const project = {
-    ...args.project,
-    publicAccesLevel: 'readOnly',
-  }
+  useScope({
+    project: {
+      ...args.project,
+      publicAccesLevel: 'readOnly',
+    },
+  })
 
 
-  return withContextRoot(<ShareProjectModal {...args} />, { project })
+  return <ShareProjectModal {...args} />
 }
 }
 
 
 export const LimitedCollaborators = args => {
 export const LimitedCollaborators = args => {
   useFetchMock(setupFetchMock)
   useFetchMock(setupFetchMock)
 
 
-  const project = {
-    ...args.project,
-    features: {
-      ...args.project.features,
-      collaborators: 3,
+  useScope({
+    project: {
+      ...args.project,
+      features: {
+        ...args.project.features,
+        collaborators: 3,
+      },
     },
     },
-  }
-
-  return withContextRoot(<ShareProjectModal {...args} />, { 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 <ShareProjectModal {...args} />
 }
 }
 
 
 export default {
 export default {
@@ -176,50 +146,9 @@ export default {
   argTypes: {
   argTypes: {
     handleHide: { action: 'hide' },
     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) {
 function setupFetchMock(fetchMock) {
   const delay = 1000
   const delay = 1000
 
 

+ 0 - 38
services/web/frontend/stories/utils/with-context-root.js

@@ -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 (
-    <ContextRoot ide={ide} settings={{}}>
-      {Story}
-    </ContextRoot>
-  )
-}

+ 8 - 9
services/web/frontend/stories/word-count-modal.stories.js

@@ -1,6 +1,6 @@
 import useFetchMock from './hooks/use-fetch-mock'
 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 WordCountModal from '../js/features/word-count-modal/components/word-count-modal'
+import { ScopeDecorator } from './decorators/scope'
 
 
 const counts = {
 const counts = {
   headers: 4,
   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.',
   'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
 ].join('\n')
 ].join('\n')
 
 
-const project = {
-  _id: 'project-id',
-  name: 'A Project',
-}
-
 export const WordCount = args => {
 export const WordCount = args => {
   useFetchMock(fetchMock => {
   useFetchMock(fetchMock => {
     fetchMock.get(
     fetchMock.get(
@@ -28,7 +23,7 @@ export const WordCount = args => {
     )
     )
   })
   })
 
 
-  return withContextRoot(<WordCountModal {...args} />, { project })
+  return <WordCountModal {...args} />
 }
 }
 
 
 export const WordCountWithMessages = args => {
 export const WordCountWithMessages = args => {
@@ -40,7 +35,7 @@ export const WordCountWithMessages = args => {
     )
     )
   })
   })
 
 
-  return withContextRoot(<WordCountModal {...args} />, { project })
+  return <WordCountModal {...args} />
 }
 }
 
 
 export const ErrorResponse = args => {
 export const ErrorResponse = args => {
@@ -52,7 +47,7 @@ export const ErrorResponse = args => {
     )
     )
   })
   })
 
 
-  return withContextRoot(<WordCountModal {...args} />, { project })
+  return <WordCountModal {...args} />
 }
 }
 
 
 export default {
 export default {
@@ -61,4 +56,8 @@ export default {
   args: {
   args: {
     show: true,
     show: true,
   },
   },
+  argTypes: {
+    handleHide: { action: 'close modal' },
+  },
+  decorators: [ScopeDecorator],
 }
 }

+ 2 - 0
services/web/tsconfig.json

@@ -18,6 +18,8 @@
     "modules/**/frontend/js/**/*.*",
     "modules/**/frontend/js/**/*.*",
     "test/frontend/**/*.*",
     "test/frontend/**/*.*",
     "modules/**/test/frontend/**/*.*",
     "modules/**/test/frontend/**/*.*",
+    "frontend/stories/**/*.*",
+    "modules/**/stories/**/*.*",
     "cypress",
     "cypress",
     "types"
     "types"
   ]
   ]

+ 7 - 0
services/web/types/folder.ts

@@ -0,0 +1,7 @@
+export type Folder = {
+  _id: string
+  name: string
+  docs: []
+  folders: []
+  fileRefs: []
+}

+ 30 - 0
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<string, unknown>
+  publicAccesLevel?: string
+  tokens: Record<string, unknown>
+  owner: MongoUser
+  members: ProjectMember[]
+  invites: ProjectInvite[]
+  rootDocId?: string
+  rootFolder?: Folder[]
+}

+ 8 - 0
services/web/types/user.ts

@@ -0,0 +1,8 @@
+export type User = {
+  id: string
+  email: string
+  allowedFreeTrial?: boolean
+  features?: Record<string, boolean>
+}
+
+export type MongoUser = Pick<User, Exclude<keyof User, 'id'>> & { _id: string }

+ 3 - 0
services/web/types/window.ts

@@ -15,5 +15,8 @@ declare global {
       currentLangCode: string
       currentLangCode: string
     }
     }
     ExposedSettings: ExposedSettings
     ExposedSettings: ExposedSettings
+    project_id: string
+    gitBridgePublicBaseUrl: string
+    _ide: Record<string, unknown>
   }
   }
 }
 }