Bladeren bron

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

GitOrigin-RevId: 109a4357fc3b083ffbd3af5b8c98acf0f655f297
Alf Eaton 4 jaren geleden
bovenliggende
commit
d91ee50762
35 gewijzigde bestanden met toevoegingen van 643 en 537 verwijderingen
  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 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)

+ 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
 import getMeta from '../utils/meta'
 
-const reporterPromise = window.ExposedSettings.sentryDsn
+const reporterPromise = window.ExposedSettings?.sentryDsn
   ? sentryReporter()
   : 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 { 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 <ChatPane {...args} />
 }
 
+export const LoadingError = args => {
+  useFetchMock(fetchMock => {
+    fetchMock.get(/messages/, 500)
+  })
+
+  return <ChatPane {...args} />
+}
+
 export default {
   title: 'Editor / Chat',
   component: ChatPane,
@@ -76,13 +48,22 @@ export default {
     resetUnreadMessages: () => {},
   },
   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 { 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(<CloneProjectModal {...args} />, { project })
+  return <CloneProjectModal {...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 => {
@@ -37,7 +35,7 @@ export const SpecificErrorResponse = args => {
     )
   })
 
-  return withContextRoot(<CloneProjectModal {...args} />, { project })
+  return <CloneProjectModal {...args} />
 }
 
 export default {
@@ -50,4 +48,5 @@ export default {
     handleHide: { action: 'close modal' },
     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 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(<ContactUsModal show={show} handleHide={handleHide} />)
+  return <ContactUsModal show={show} handleHide={handleHide} />
 }
 
 export const RequestError = args => {
   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 {
@@ -32,4 +33,5 @@ export default {
   argTypes: {
     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 { 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(<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 { 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(<FileTreeRoot {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     permissionsLevel: 'owner',
   })
+
+  return <FileTreeRoot {...args} />
 }
 
 export const ReadOnly = args => {
-  return withContextRoot(<FileTreeRoot {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     permissionsLevel: 'readOnly',
   })
+
+  return <FileTreeRoot {...args} />
 }
 
 export const Disconnected = args => {
-  return withContextRoot(<FileTreeRoot {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     permissionsLevel: 'owner',
   })
+
+  return <FileTreeRoot {...args} />
 }
 Disconnected.args = { isConnected: false }
 
@@ -125,25 +132,34 @@ export const NetworkErrors = args => {
       })
   })
 
-  return withContextRoot(<FileTreeRoot {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
     permissionsLevel: 'owner',
   })
+
+  return <FileTreeRoot {...args} />
 }
 
 export const FallbackError = args => {
-  return withContextRoot(<FileTreeError {...args} />, {
+  useScope({
     project: DEFAULT_PROJECT,
   })
+
+  return <FileTreeError {...args} />
 }
 
 export const FilesLimit = args => {
   useFetchMock(defaultSetupMocks)
 
-  return withContextRoot(<FileTreeRoot {...args} />, {
-    project: { ...DEFAULT_PROJECT, rootFolder: rootFolderLimit },
+  useScope({
+    project: {
+      ...DEFAULT_PROJECT,
+      rootFolder: rootFolderLimit,
+    },
     permissionsLevel: 'owner',
   })
+
+  return <FileTreeRoot {...args} />
 }
 
 export default {
@@ -167,6 +183,7 @@ export default {
     onSelect: { action: 'onSelect' },
   },
   decorators: [
+    ScopeDecorator,
     Story => (
       <>
         <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 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 => (
-      <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),
       },
     },
-    { 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 =>

+ 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 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 <GitBridgeModal {...args} />
 }
@@ -36,13 +38,5 @@ export default {
   argTypes: {
     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 { 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 (
       <FileTreeContext
         {...defaultFileTreeContextProps}
         {...fileTreeContextProps}
@@ -133,11 +99,7 @@ export const createFileModalDecorator =
             </OpenCreateFileModal>
           </FileTreeCreateFormProvider>
         </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'
 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 <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 {
   title: 'Editor / Modals / Create File',
   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 { 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 (

+ 2 - 8
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 => <OutlinePane {...args} />
 Basic.args = {
@@ -52,11 +52,5 @@ export default {
     jumpToLine: () => {},
     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 { 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(<ErrorBoundaryFallback type="preview" />)
+  return <ErrorBoundaryFallback type="preview" />
 }
 
 export const PdfErrorBoundary = () => {
-  return withContextRoot(<ErrorBoundaryFallback type="pdf" />)
+  return <ErrorBoundaryFallback type="pdf" />
 }
 
 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 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 (
     <div className="pdf-viewer">
       <PdfPreviewPane />
       <Inner />
-    </div>,
-    scope
+    </div>
   )
 }
 
@@ -276,12 +248,11 @@ export const CompileError = () => {
     )
   }
 
-  return withContextRoot(
+  return (
     <>
       <PdfPreviewPane />
       <Inner />
-    </>,
-    scope
+    </>
   )
 }
 
@@ -309,7 +280,7 @@ export const DisplayError = () => {
     mockCompile(fetchMock)
   })
 
-  return withContextRoot(
+  return (
     <>
       {compileErrors.map(error => (
         <div
@@ -320,8 +291,7 @@ export const DisplayError = () => {
           <PdfPreviewError error={error} />
         </div>
       ))}
-    </>,
-    scope
+    </>
   )
 }
 
@@ -332,11 +302,10 @@ export const HybridToolbar = () => {
     mockEventTracking(fetchMock)
   })
 
-  return withContextRoot(
+  return (
     <div className="pdf">
       <PdfPreviewHybridToolbar />
-    </div>,
-    scope
+    </div>
   )
 }
 
@@ -361,11 +330,10 @@ export const Logs = () => {
     mockClearCache(fetchMock)
   })
 
-  return withContextRoot(
+  return (
     <div className="pdf">
       <PdfLogsViewer />
-    </div>,
-    scope
+    </div>
   )
 }
 
@@ -393,5 +361,5 @@ export const ValidationIssues = () => {
     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 { 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(
-    <Suspense fallback="Loading">
-      <div>
-        <div className="pdf-viewer">
+  return (
+    <div>
+      <div className="pdf-viewer">
+        <Suspense fallback={null}>
           <PdfViewer />
-        </div>
-        <div style={{ position: 'absolute', top: 150, left: 50 }}>
-          <PdfSynctexControls />
-        </div>
+        </Suspense>
+      </div>
+      <div style={{ position: 'absolute', top: 150, left: 50 }}>
+        <PdfSynctexControls />
       </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 { 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 (
     <UserProvider>
@@ -84,4 +93,5 @@ export const SectionProjetSyncSuccess = args => {
 export default {
   title: 'Account Settings / Linking',
   component: LinkingSection,
+  decorators: [ScopeDecorator],
 }

+ 73 - 144
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(<ShareProjectModal {...args} />, { project })
+  return <ShareProjectModal {...args} />
 }
 
 export const LinkSharingOn = args => {
   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 => {
   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 => {
-  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 => {
-  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 => {
@@ -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(<ShareProjectModal {...args} />, { project })
+  return <ShareProjectModal {...args} />
 }
 
 export const LegacyLinkSharingReadAndWrite = args => {
   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 => {
   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 => {
   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 {
@@ -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
 

+ 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 { 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(<WordCountModal {...args} />, { project })
+  return <WordCountModal {...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 => {
@@ -52,7 +47,7 @@ export const ErrorResponse = args => {
     )
   })
 
-  return withContextRoot(<WordCountModal {...args} />, { project })
+  return <WordCountModal {...args} />
 }
 
 export default {
@@ -61,4 +56,8 @@ export default {
   args: {
     show: true,
   },
+  argTypes: {
+    handleHide: { action: 'close modal' },
+  },
+  decorators: [ScopeDecorator],
 }

+ 2 - 0
services/web/tsconfig.json

@@ -18,6 +18,8 @@
     "modules/**/frontend/js/**/*.*",
     "test/frontend/**/*.*",
     "modules/**/test/frontend/**/*.*",
+    "frontend/stories/**/*.*",
+    "modules/**/stories/**/*.*",
     "cypress",
     "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
     }
     ExposedSettings: ExposedSettings
+    project_id: string
+    gitBridgePublicBaseUrl: string
+    _ide: Record<string, unknown>
   }
 }