Kaynağa Gözat

Add IdeProvider (#4161)

GitOrigin-RevId: cab09354cf4b325a1ea3814a8c4c49fac7c831be
Alf Eaton 5 yıl önce
ebeveyn
işleme
ad3c66b36e

+ 1 - 5
services/web/frontend/js/features/share-project-modal/components/share-project-modal.js

@@ -84,12 +84,11 @@ export default function ShareProjectModal({
   show,
   show,
   animation = true,
   animation = true,
   isAdmin,
   isAdmin,
-  ide,
 }) {
 }) {
   const [inFlight, setInFlight] = useState(false)
   const [inFlight, setInFlight] = useState(false)
   const [error, setError] = useState()
   const [error, setError] = useState()
 
 
-  const [project, setProject] = useScopeValue('project', ide.$scope, true)
+  const [project, setProject] = useScopeValue('project', true)
 
 
   // reset error when the modal is opened
   // reset error when the modal is opened
   useEffect(() => {
   useEffect(() => {
@@ -167,8 +166,5 @@ ShareProjectModal.propTypes = {
   animation: PropTypes.bool,
   animation: PropTypes.bool,
   handleHide: PropTypes.func.isRequired,
   handleHide: PropTypes.func.isRequired,
   isAdmin: PropTypes.bool.isRequired,
   isAdmin: PropTypes.bool.isRequired,
-  ide: PropTypes.shape({
-    $scope: PropTypes.object.isRequired,
-  }).isRequired,
   show: PropTypes.bool.isRequired,
   show: PropTypes.bool.isRequired,
 }
 }

+ 1 - 2
services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js

@@ -9,8 +9,7 @@ App.component(
   'shareProjectModal',
   'shareProjectModal',
   react2angular(
   react2angular(
     rootContext.use(ShareProjectModal),
     rootContext.use(ShareProjectModal),
-    Object.keys(ShareProjectModal.propTypes),
-    ['ide']
+    Object.keys(ShareProjectModal.propTypes)
   )
   )
 )
 )
 
 

+ 6 - 11
services/web/frontend/js/shared/context/compile-context.js

@@ -13,11 +13,11 @@ CompileContext.Provider.propTypes = {
   }),
   }),
 }
 }
 
 
-export function CompileProvider({ children, $scope }) {
-  const [pdfUrl] = useScopeValue('pdf.url', $scope)
-  const [pdfDownloadUrl] = useScopeValue('pdf.downloadUrl', $scope)
-  const [logEntries] = useScopeValue('pdf.logEntries', $scope)
-  const [uncompiled] = useScopeValue('pdf.uncompiled', $scope)
+export function CompileProvider({ children }) {
+  const [pdfUrl] = useScopeValue('pdf.url')
+  const [pdfDownloadUrl] = useScopeValue('pdf.downloadUrl')
+  const [logEntries] = useScopeValue('pdf.logEntries')
+  const [uncompiled] = useScopeValue('pdf.uncompiled')
 
 
   const value = {
   const value = {
     pdfUrl,
     pdfUrl,
@@ -27,17 +27,12 @@ export function CompileProvider({ children, $scope }) {
   }
   }
 
 
   return (
   return (
-    <>
-      <CompileContext.Provider value={value}>
-        {children}
-      </CompileContext.Provider>
-    </>
+    <CompileContext.Provider value={value}>{children}</CompileContext.Provider>
   )
   )
 }
 }
 
 
 CompileProvider.propTypes = {
 CompileProvider.propTypes = {
   children: PropTypes.any,
   children: PropTypes.any,
-  $scope: PropTypes.any.isRequired,
 }
 }
 
 
 export function useCompileContext(propTypes) {
 export function useCompileContext(propTypes) {

+ 22 - 30
services/web/frontend/js/shared/context/editor-context.js

@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect } from 'react'
 import PropTypes from 'prop-types'
 import PropTypes from 'prop-types'
 import useScopeValue from './util/scope-value-hook'
 import useScopeValue from './util/scope-value-hook'
 import useBrowserWindow from '../hooks/use-browser-window'
 import useBrowserWindow from '../hooks/use-browser-window'
+import { useIdeContext } from './ide-context'
 
 
 export const EditorContext = createContext()
 export const EditorContext = createContext()
 
 
@@ -30,7 +31,9 @@ EditorContext.Provider.propTypes = {
   }),
   }),
 }
 }
 
 
-export function EditorProvider({ children, ide, settings }) {
+export function EditorProvider({ children, settings }) {
+  const ide = useIdeContext()
+
   const cobranding = window.brandVariation
   const cobranding = window.brandVariation
     ? {
     ? {
         logoImgUrl: window.brandVariation.logo_url,
         logoImgUrl: window.brandVariation.logo_url,
@@ -45,26 +48,12 @@ export function EditorProvider({ children, ide, settings }) {
       }
       }
     : undefined
     : undefined
 
 
-  const ownerId =
-    ide.$scope.project && ide.$scope.project.owner
-      ? ide.$scope.project.owner._id
-      : null
-
-  const [loading] = useScopeValue('state.loading', ide.$scope)
-
-  const [projectRootDocId] = useScopeValue('project.rootDoc_id', ide.$scope)
-
-  const [projectName, setProjectName] = useScopeValue(
-    'project.name',
-    ide.$scope
-  )
-
-  const [compileGroup] = useScopeValue(
-    'project.features.compileGroup',
-    ide.$scope
-  )
-
-  const [rootFolder] = useScopeValue('rootFolder', ide.$scope)
+  const [loading] = useScopeValue('state.loading')
+  const [projectRootDocId] = useScopeValue('project.rootDoc_id')
+  const [projectName, setProjectName] = useScopeValue('project.name')
+  const [compileGroup] = useScopeValue('project.features.compileGroup')
+  const [rootFolder] = useScopeValue('rootFolder')
+  const [ownerId] = useScopeValue('project.owner.id')
 
 
   const renameProject = useCallback(
   const renameProject = useCallback(
     newName => {
     newName => {
@@ -112,22 +101,25 @@ export function EditorProvider({ children, ide, settings }) {
   }
   }
 
 
   return (
   return (
-    <>
-      <EditorContext.Provider value={editorContextValue}>
-        {children}
-      </EditorContext.Provider>
-    </>
+    <EditorContext.Provider value={editorContextValue}>
+      {children}
+    </EditorContext.Provider>
   )
   )
 }
 }
 
 
 EditorProvider.propTypes = {
 EditorProvider.propTypes = {
   children: PropTypes.any,
   children: PropTypes.any,
-  ide: PropTypes.any.isRequired,
   settings: PropTypes.any.isRequired,
   settings: PropTypes.any.isRequired,
 }
 }
 
 
 export function useEditorContext(propTypes) {
 export function useEditorContext(propTypes) {
-  const data = useContext(EditorContext)
-  PropTypes.checkPropTypes(propTypes, data, 'data', 'EditorContext.Provider')
-  return data
+  const context = useContext(EditorContext)
+
+  if (!context) {
+    throw new Error('useEditorContext is only available inside EditorProvider')
+  }
+
+  PropTypes.checkPropTypes(propTypes, context, 'data', 'EditorContext.Provider')
+
+  return context
 }
 }

+ 30 - 0
services/web/frontend/js/shared/context/ide-context.js

@@ -0,0 +1,30 @@
+import React, { createContext, useContext } from 'react'
+import PropTypes from 'prop-types'
+
+const IdeContext = createContext()
+
+IdeContext.Provider.propTypes = {
+  value: PropTypes.shape({
+    $scope: PropTypes.object.isRequired,
+  }),
+}
+
+export function useIdeContext() {
+  const context = useContext(IdeContext)
+
+  if (!context) {
+    throw new Error('useIdeContext is only available inside IdeProvider')
+  }
+
+  return context
+}
+
+export function IdeProvider({ ide, children }) {
+  return <IdeContext.Provider value={ide}>{children}</IdeContext.Provider>
+}
+IdeProvider.propTypes = {
+  children: PropTypes.any.isRequired,
+  ide: PropTypes.shape({
+    $scope: PropTypes.object.isRequired,
+  }).isRequired,
+}

+ 12 - 18
services/web/frontend/js/shared/context/layout-context.js

@@ -1,6 +1,7 @@
 import React, { createContext, useContext, useCallback } from 'react'
 import React, { createContext, useContext, useCallback } from 'react'
 import PropTypes from 'prop-types'
 import PropTypes from 'prop-types'
 import useScopeValue from './util/scope-value-hook'
 import useScopeValue from './util/scope-value-hook'
+import { useIdeContext } from './ide-context'
 
 
 export const LayoutContext = createContext()
 export const LayoutContext = createContext()
 
 
@@ -18,8 +19,11 @@ LayoutContext.Provider.propTypes = {
   }).isRequired,
   }).isRequired,
 }
 }
 
 
-export function LayoutProvider({ children, $scope }) {
-  const [view, _setView] = useScopeValue('ui.view', $scope)
+export function LayoutProvider({ children }) {
+  const { $scope } = useIdeContext()
+
+  const [view, _setView] = useScopeValue('ui.view')
+
   const setView = useCallback(
   const setView = useCallback(
     value => {
     value => {
       _setView(value)
       _setView(value)
@@ -30,19 +34,14 @@ export function LayoutProvider({ children, $scope }) {
     [$scope, _setView]
     [$scope, _setView]
   )
   )
 
 
-  const [chatIsOpen, setChatIsOpen] = useScopeValue('ui.chatOpen', $scope)
+  const [chatIsOpen, setChatIsOpen] = useScopeValue('ui.chatOpen')
   const [reviewPanelOpen, setReviewPanelOpen] = useScopeValue(
   const [reviewPanelOpen, setReviewPanelOpen] = useScopeValue(
-    'ui.reviewPanelOpen',
-    $scope
-  )
-  const [leftMenuShown, setLeftMenuShown] = useScopeValue(
-    'ui.leftMenuShown',
-    $scope
+    'ui.reviewPanelOpen'
   )
   )
+  const [leftMenuShown, setLeftMenuShown] = useScopeValue('ui.leftMenuShown')
+  const [pdfLayout] = useScopeValue('ui.pdfLayout')
 
 
-  const [pdfLayout] = useScopeValue('ui.pdfLayout', $scope)
-
-  const layoutContextValue = {
+  const value = {
     view,
     view,
     setView,
     setView,
     chatIsOpen,
     chatIsOpen,
@@ -55,17 +54,12 @@ export function LayoutProvider({ children, $scope }) {
   }
   }
 
 
   return (
   return (
-    <LayoutContext.Provider value={layoutContextValue}>
-      {children}
-    </LayoutContext.Provider>
+    <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
   )
   )
 }
 }
 
 
 LayoutProvider.propTypes = {
 LayoutProvider.propTypes = {
   children: PropTypes.any,
   children: PropTypes.any,
-  $scope: PropTypes.shape({
-    toggleHistory: PropTypes.func.isRequired,
-  }).isRequired,
 }
 }
 
 
 export function useLayoutContext(propTypes) {
 export function useLayoutContext(propTypes) {

+ 14 - 11
services/web/frontend/js/shared/context/root-context.js

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
 import createSharedContext from 'react2angular-shared-context'
 import createSharedContext from 'react2angular-shared-context'
 
 
 import { ApplicationProvider } from './application-context'
 import { ApplicationProvider } from './application-context'
+import { IdeProvider } from './ide-context'
 import { EditorProvider } from './editor-context'
 import { EditorProvider } from './editor-context'
 import { CompileProvider } from './compile-context'
 import { CompileProvider } from './compile-context'
 import { LayoutProvider } from './layout-context'
 import { LayoutProvider } from './layout-context'
@@ -13,17 +14,19 @@ export function ContextRoot({ children, ide, settings }) {
 
 
   return (
   return (
     <ApplicationProvider>
     <ApplicationProvider>
-      <EditorProvider ide={ide} settings={settings}>
-        <CompileProvider $scope={ide.$scope}>
-          <LayoutProvider $scope={ide.$scope}>
-            {isAnonymousUser ? (
-              children
-            ) : (
-              <ChatProvider>{children}</ChatProvider>
-            )}
-          </LayoutProvider>
-        </CompileProvider>
-      </EditorProvider>
+      <IdeProvider ide={ide}>
+        <EditorProvider settings={settings}>
+          <CompileProvider>
+            <LayoutProvider>
+              {isAnonymousUser ? (
+                children
+              ) : (
+                <ChatProvider>{children}</ChatProvider>
+              )}
+            </LayoutProvider>
+          </CompileProvider>
+        </EditorProvider>
+      </IdeProvider>
     </ApplicationProvider>
     </ApplicationProvider>
   )
   )
 }
 }

+ 8 - 3
services/web/frontend/js/shared/context/util/scope-value-hook.js

@@ -1,17 +1,22 @@
 import { useCallback, useEffect, useState } from 'react'
 import { useCallback, useEffect, useState } from 'react'
+import PropTypes from 'prop-types'
 import _ from 'lodash'
 import _ from 'lodash'
+import { useIdeContext } from '../ide-context'
 
 
 /**
 /**
  * Binds a property in an Angular scope making it accessible in a React
  * Binds a property in an Angular scope making it accessible in a React
  * component. The interface is compatible with React.useState(), including
  * component. The interface is compatible with React.useState(), including
  * the option of passing a function to the setter.
  * the option of passing a function to the setter.
  *
  *
- * @param {string} path - dot '.' path of a property in `sourceScope`.
- * @param {object} $scope - Angular $scope containing the value to bind.
+ * @param {string} path - dot '.' path of a property in the Angular scope.
  * @param {boolean} deep
  * @param {boolean} deep
  * @returns {[any, function]} - Binded value and setter function tuple.
  * @returns {[any, function]} - Binded value and setter function tuple.
  */
  */
-export default function useScopeValue(path, $scope, deep = false) {
+export default function useScopeValue(path, deep = false) {
+  const { $scope } = useIdeContext({
+    $scope: PropTypes.object.isRequired,
+  })
+
   const [value, setValue] = useState(() => _.get($scope, path))
   const [value, setValue] = useState(() => _.get($scope, path))
 
 
   useEffect(() => {
   useEffect(() => {

+ 11 - 6
services/web/frontend/stories/preview-logs-pane.stories.js

@@ -3,6 +3,7 @@ import PreviewLogsPane from '../js/features/preview/components/preview-logs-pane
 import { EditorProvider } from '../js/shared/context/editor-context'
 import { EditorProvider } from '../js/shared/context/editor-context'
 import { ApplicationProvider } from '../js/shared/context/application-context'
 import { ApplicationProvider } from '../js/shared/context/application-context'
 import useFetchMock from './hooks/use-fetch-mock'
 import useFetchMock from './hooks/use-fetch-mock'
+import { IdeProvider } from '../js/shared/context/ide-context'
 
 
 export const TimedOutError = args => {
 export const TimedOutError = args => {
   useFetchMock(fetchMock => {
   useFetchMock(fetchMock => {
@@ -25,9 +26,11 @@ export const TimedOutError = args => {
 
 
   return (
   return (
     <ApplicationProvider>
     <ApplicationProvider>
-      <EditorProvider ide={ide} settings={{}}>
-        <PreviewLogsPane {...args} />
-      </EditorProvider>
+      <IdeProvider ide={ide}>
+        <EditorProvider settings={{}}>
+          <PreviewLogsPane {...args} />
+        </EditorProvider>
+      </IdeProvider>
     </ApplicationProvider>
     </ApplicationProvider>
   )
   )
 }
 }
@@ -58,9 +61,11 @@ export const TimedOutErrorWithPriorityCompile = args => {
 
 
   return (
   return (
     <ApplicationProvider>
     <ApplicationProvider>
-      <EditorProvider ide={ide} settings={{}}>
-        <PreviewLogsPane {...args} />
-      </EditorProvider>
+      <IdeProvider ide={ide}>
+        <EditorProvider settings={{}}>
+          <PreviewLogsPane {...args} />
+        </EditorProvider>
+      </IdeProvider>
     </ApplicationProvider>
     </ApplicationProvider>
   )
   )
 }
 }

+ 14 - 58
services/web/frontend/stories/share-project-modal.stories.js

@@ -1,7 +1,7 @@
 import React, { useEffect } from 'react'
 import React, { 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 { ContextRoot } from '../js/shared/context/root-context'
 import useFetchMock from './hooks/use-fetch-mock'
 import useFetchMock from './hooks/use-fetch-mock'
+import { withContextRoot } from './utils/with-context-root'
 
 
 export const LinkSharingOff = args => {
 export const LinkSharingOff = args => {
   useFetchMock(setupFetchMock)
   useFetchMock(setupFetchMock)
@@ -11,9 +11,7 @@ export const LinkSharingOff = args => {
     publicAccesLevel: 'private',
     publicAccesLevel: 'private',
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal {...args} ide={ideWithProject(project)} />
-  )
+  return withContextRoot(<ShareProjectModal {...args} />, { project })
 }
 }
 
 
 export const LinkSharingOn = args => {
 export const LinkSharingOn = args => {
@@ -24,9 +22,7 @@ export const LinkSharingOn = args => {
     publicAccesLevel: 'tokenBased',
     publicAccesLevel: 'tokenBased',
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal {...args} ide={ideWithProject(project)} />
-  )
+  return withContextRoot(<ShareProjectModal {...args} />, { project })
 }
 }
 
 
 export const LinkSharingLoading = args => {
 export const LinkSharingLoading = args => {
@@ -38,9 +34,7 @@ export const LinkSharingLoading = args => {
     tokens: undefined,
     tokens: undefined,
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal {...args} ide={ideWithProject(project)} />
-  )
+  return withContextRoot(<ShareProjectModal {...args} />, { project })
 }
 }
 
 
 export const NonAdminLinkSharingOff = args => {
 export const NonAdminLinkSharingOff = args => {
@@ -49,13 +43,9 @@ export const NonAdminLinkSharingOff = args => {
     publicAccesLevel: 'private',
     publicAccesLevel: 'private',
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal
-      {...args}
-      isAdmin={false}
-      ide={ideWithProject(project)}
-    />
-  )
+  return withContextRoot(<ShareProjectModal {...args} isAdmin={false} />, {
+    project,
+  })
 }
 }
 
 
 export const NonAdminLinkSharingOn = args => {
 export const NonAdminLinkSharingOn = args => {
@@ -64,13 +54,9 @@ export const NonAdminLinkSharingOn = args => {
     publicAccesLevel: 'tokenBased',
     publicAccesLevel: 'tokenBased',
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal
-      {...args}
-      isAdmin={false}
-      ide={ideWithProject(project)}
-    />
-  )
+  return withContextRoot(<ShareProjectModal {...args} isAdmin={false} />, {
+    project,
+  })
 }
 }
 
 
 export const RestrictedTokenMember = args => {
 export const RestrictedTokenMember = args => {
@@ -91,9 +77,7 @@ export const RestrictedTokenMember = args => {
     publicAccesLevel: 'tokenBased',
     publicAccesLevel: 'tokenBased',
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal {...args} ide={ideWithProject(project)} />
-  )
+  return withContextRoot(<ShareProjectModal {...args} />, { project })
 }
 }
 
 
 export const LegacyLinkSharingReadAndWrite = args => {
 export const LegacyLinkSharingReadAndWrite = args => {
@@ -104,9 +88,7 @@ export const LegacyLinkSharingReadAndWrite = args => {
     publicAccesLevel: 'readAndWrite',
     publicAccesLevel: 'readAndWrite',
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal {...args} ide={ideWithProject(project)} />
-  )
+  return withContextRoot(<ShareProjectModal {...args} />, { project })
 }
 }
 
 
 export const LegacyLinkSharingReadOnly = args => {
 export const LegacyLinkSharingReadOnly = args => {
@@ -117,9 +99,7 @@ export const LegacyLinkSharingReadOnly = args => {
     publicAccesLevel: 'readOnly',
     publicAccesLevel: 'readOnly',
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal {...args} ide={ideWithProject(project)} />
-  )
+  return withContextRoot(<ShareProjectModal {...args} />, { project })
 }
 }
 
 
 export const LimitedCollaborators = args => {
 export const LimitedCollaborators = args => {
@@ -133,9 +113,7 @@ export const LimitedCollaborators = args => {
     },
     },
   }
   }
 
 
-  return renderWithContext(
-    <ShareProjectModal {...args} ide={ideWithProject(project)} />
-  )
+  return withContextRoot(<ShareProjectModal {...args} />, { project })
 }
 }
 
 
 const project = {
 const project = {
@@ -199,18 +177,6 @@ export default {
   },
   },
 }
 }
 
 
-// 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.
-function renderWithContext(Story) {
-  return (
-    <ContextRoot ide={window._ide} settings={{}}>
-      {Story}
-    </ContextRoot>
-  )
-}
-
 const contacts = [
 const contacts = [
   // user with edited name
   // user with edited name
   {
   {
@@ -282,13 +248,3 @@ function setupFetchMock(fetchMock) {
     // send analytics event
     // send analytics event
     .post('express:/event/:key', 200)
     .post('express:/event/:key', 200)
 }
 }
-
-function ideWithProject(project) {
-  return {
-    $scope: {
-      $watch: () => () => {},
-      $applyAsync: () => {},
-      project,
-    },
-  }
-}

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

@@ -0,0 +1,22 @@
+import React from 'react'
+import { ContextRoot } from '../../js/shared/context/root-context'
+
+// 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 ide = {
+    ...window._ide,
+    $scope: {
+      ...window._ide.$scope,
+      ...scope,
+    },
+  }
+
+  return (
+    <ContextRoot ide={ide} settings={{}}>
+      {Story}
+    </ContextRoot>
+  )
+}

+ 125 - 161
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js

@@ -4,16 +4,17 @@ import React from 'react'
 import {
 import {
   screen,
   screen,
   fireEvent,
   fireEvent,
+  render,
   waitFor,
   waitFor,
   waitForElementToBeRemoved,
   waitForElementToBeRemoved,
 } from '@testing-library/react'
 } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import fetchMock from 'fetch-mock'
-import { get } from 'lodash'
 
 
 import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
 import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
 import {
 import {
   renderWithEditorContext,
   renderWithEditorContext,
   cleanUpContext,
   cleanUpContext,
+  EditorProviders,
 } from '../../../helpers/render-with-context'
 } from '../../../helpers/render-with-context'
 import * as locationModule from '../../../../../frontend/js/features/share-project-modal/utils/location'
 import * as locationModule from '../../../../../frontend/js/features/share-project-modal/utils/location'
 
 
@@ -73,23 +74,7 @@ describe('<ShareProjectModal/>', function () {
     },
     },
   ]
   ]
 
 
-  const ideWithProject = project => {
-    const scope = { project }
-
-    return {
-      $scope: {
-        $watch: (path, callback) => {
-          callback(get(scope, path))
-          return () => null
-        },
-        $applyAsync: () => {},
-        ...scope,
-      },
-    }
-  }
-
   const modalProps = {
   const modalProps = {
-    ide: ideWithProject(project),
     show: true,
     show: true,
     isAdmin: true,
     isAdmin: true,
     handleHide: sinon.stub(),
     handleHide: sinon.stub(),
@@ -105,7 +90,9 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('renders the modal', async function () {
   it('renders the modal', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />)
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: { project },
+    })
 
 
     await screen.findByText('Share Project')
     await screen.findByText('Share Project')
   })
   })
@@ -114,7 +101,8 @@ describe('<ShareProjectModal/>', function () {
     const handleHide = sinon.stub()
     const handleHide = sinon.stub()
 
 
     renderWithEditorContext(
     renderWithEditorContext(
-      <ShareProjectModal {...modalProps} handleHide={handleHide} />
+      <ShareProjectModal {...modalProps} handleHide={handleHide} />,
+      { scope: { project } }
     )
     )
 
 
     const [
     const [
@@ -129,12 +117,9 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles access level "private"', async function () {
   it('handles access level "private"', async function () {
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({ ...project, publicAccesLevel: 'private' })}
-      />
-    )
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: { project: { ...project, publicAccesLevel: 'private' } },
+    })
 
 
     await screen.findByText(
     await screen.findByText(
       'Link sharing is off, only invited users can view this project.'
       'Link sharing is off, only invited users can view this project.'
@@ -148,12 +133,9 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles access level "tokenBased"', async function () {
   it('handles access level "tokenBased"', async function () {
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({ ...project, publicAccesLevel: 'tokenBased' })}
-      />
-    )
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
+    })
 
 
     await screen.findByText('Link sharing is on')
     await screen.findByText('Link sharing is on')
     await screen.findByRole('button', { name: 'Turn off link sharing' })
     await screen.findByRole('button', { name: 'Turn off link sharing' })
@@ -165,12 +147,9 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles legacy access level "readAndWrite"', async function () {
   it('handles legacy access level "readAndWrite"', async function () {
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({ ...project, publicAccesLevel: 'readAndWrite' })}
-      />
-    )
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: { project: { ...project, publicAccesLevel: 'readAndWrite' } },
+    })
 
 
     await screen.findByText(
     await screen.findByText(
       'This project is public and can be edited by anyone with the URL.'
       'This project is public and can be edited by anyone with the URL.'
@@ -179,12 +158,9 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles legacy access level "readOnly"', async function () {
   it('handles legacy access level "readOnly"', async function () {
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({ ...project, publicAccesLevel: 'readOnly' })}
-      />
-    )
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: { project: { ...project, publicAccesLevel: 'readOnly' } },
+    })
 
 
     await screen.findByText(
     await screen.findByText(
       'This project is public and can be viewed but not edited by anyone with the URL'
       'This project is public and can be viewed but not edited by anyone with the URL'
@@ -202,16 +178,18 @@ describe('<ShareProjectModal/>', function () {
     ]
     ]
 
 
     // render as admin: actions should be present
     // render as admin: actions should be present
-    const { rerender } = renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
-          ...project,
-          invites,
-          publicAccesLevel: 'tokenBased',
-        })}
-        isAdmin
-      />
+    const { rerender } = render(
+      <EditorProviders
+        scope={{
+          project: {
+            ...project,
+            invites,
+            publicAccesLevel: 'tokenBased',
+          },
+        }}
+      >
+        <ShareProjectModal {...modalProps} isAdmin />
+      </EditorProviders>
     )
     )
 
 
     await screen.findByRole('button', { name: 'Turn off link sharing' })
     await screen.findByRole('button', { name: 'Turn off link sharing' })
@@ -219,15 +197,17 @@ describe('<ShareProjectModal/>', function () {
 
 
     // render as non-admin (non-owner), link sharing on: actions should be missing and message should be present
     // render as non-admin (non-owner), link sharing on: actions should be missing and message should be present
     rerender(
     rerender(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
-          ...project,
-          invites,
-          publicAccesLevel: 'tokenBased',
-        })}
-        isAdmin={false}
-      />
+      <EditorProviders
+        scope={{
+          project: {
+            ...project,
+            invites,
+            publicAccesLevel: 'tokenBased',
+          },
+        }}
+      >
+        <ShareProjectModal {...modalProps} isAdmin={false} />
+      </EditorProviders>
     )
     )
 
 
     await screen.findByText(
     await screen.findByText(
@@ -242,15 +222,17 @@ describe('<ShareProjectModal/>', function () {
 
 
     // render as non-admin (non-owner), link sharing off: actions should be missing and message should be present
     // render as non-admin (non-owner), link sharing off: actions should be missing and message should be present
     rerender(
     rerender(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
-          ...project,
-          invites,
-          publicAccesLevel: 'private',
-        })}
-        isAdmin={false}
-      />
+      <EditorProviders
+        scope={{
+          project: {
+            ...project,
+            invites,
+            publicAccesLevel: 'private',
+          },
+        }}
+      >
+        <ShareProjectModal {...modalProps} isAdmin={false} />
+      </EditorProviders>
     )
     )
 
 
     await screen.findByText(
     await screen.findByText(
@@ -265,13 +247,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('only shows read-only token link to restricted token members', async function () {
   it('only shows read-only token link to restricted token members', async function () {
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({ ...project, publicAccesLevel: 'tokenBased' })}
-      />,
-      { isRestrictedTokenMember: true }
-    )
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      isRestrictedTokenMember: true,
+      scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
+    })
 
 
     // no buttons
     // no buttons
     expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
     expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
@@ -312,17 +291,16 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: {
+        project: {
           ...project,
           ...project,
           members,
           members,
           invites,
           invites,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
-        })}
-      />
-    )
+        },
+      },
+    })
 
 
     expect(screen.queryAllByText('project-owner@example.com')).to.have.length(1)
     expect(screen.queryAllByText('project-owner@example.com')).to.have.length(1)
     expect(screen.queryAllByText('member-author@example.com')).to.have.length(1)
     expect(screen.queryAllByText('member-author@example.com')).to.have.length(1)
@@ -356,16 +334,15 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: {
+        project: {
           ...project,
           ...project,
           invites,
           invites,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
-        })}
-      />
-    )
+        },
+      },
+    })
 
 
     const [, closeButton] = screen.getAllByRole('button', {
     const [, closeButton] = screen.getAllByRole('button', {
       name: 'Close',
       name: 'Close',
@@ -391,16 +368,15 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: {
+        project: {
           ...project,
           ...project,
           invites,
           invites,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
-        })}
-      />
-    )
+        },
+      },
+    })
 
 
     const [, closeButton] = screen.getAllByRole('button', {
     const [, closeButton] = screen.getAllByRole('button', {
       name: 'Close',
       name: 'Close',
@@ -425,16 +401,15 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: {
+        project: {
           ...project,
           ...project,
           members,
           members,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
-        })}
-      />
-    )
+        },
+      },
+    })
 
 
     const [, closeButton] = await screen.getAllByRole('button', {
     const [, closeButton] = await screen.getAllByRole('button', {
       name: 'Close',
       name: 'Close',
@@ -468,16 +443,15 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: {
+        project: {
           ...project,
           ...project,
           members,
           members,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
-        })}
-      />
-    )
+        },
+      },
+    })
 
 
     expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
     expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
 
 
@@ -508,16 +482,15 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: {
+        project: {
           ...project,
           ...project,
           members,
           members,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
-        })}
-      />
-    )
+        },
+      },
+    })
 
 
     expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
     expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
 
 
@@ -551,15 +524,14 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('sends invites to input email addresses', async function () {
   it('sends invites to input email addresses', async function () {
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: {
+        project: {
           ...project,
           ...project,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
-        })}
-      />
-    )
+        },
+      },
+    })
 
 
     const [inputElement] = await screen.findAllByLabelText(
     const [inputElement] = await screen.findAllByLabelText(
       'Share with your collaborators'
       'Share with your collaborators'
@@ -641,24 +613,21 @@ describe('<ShareProjectModal/>', function () {
   it('displays a message when the collaborator limit is reached', async function () {
   it('displays a message when the collaborator limit is reached', async function () {
     fetchMock.post('/event/project-sharing-paywall-prompt', {})
     fetchMock.post('/event/project-sharing-paywall-prompt', {})
 
 
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      user: {
+        id: '123abd',
+        allowedFreeTrial: true,
+      },
+      scope: {
+        project: {
           ...project,
           ...project,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
           features: {
           features: {
             collaborators: 0,
             collaborators: 0,
           },
           },
-        })}
-      />,
-      {
-        user: {
-          id: '123abd',
-          allowedFreeTrial: true,
         },
         },
-      }
-    )
+      },
+    })
 
 
     expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
     expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
 
 
@@ -668,15 +637,14 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles server error responses', async function () {
   it('handles server error responses', async function () {
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: {
+        project: {
           ...project,
           ...project,
           publicAccesLevel: 'tokenBased',
           publicAccesLevel: 'tokenBased',
-        })}
-      />
-    )
+        },
+      },
+    })
 
 
     // loading contacts
     // loading contacts
     await waitFor(() => {
     await waitFor(() => {
@@ -738,25 +706,19 @@ describe('<ShareProjectModal/>', function () {
 
 
     const watchCallbacks = {}
     const watchCallbacks = {}
 
 
-    const ideWithProject = project => {
+    const scopeWithProject = project => {
       return {
       return {
-        $scope: {
-          $watch: (path, callback, deep) => {
-            watchCallbacks[path] = callback
-            return () => {}
-          },
-          $applyAsync: () => {},
-          project,
+        $watch: (path, callback) => {
+          watchCallbacks[path] = callback
+          return () => {}
         },
         },
+        project,
       }
       }
     }
     }
 
 
-    renderWithEditorContext(
-      <ShareProjectModal
-        {...modalProps}
-        ide={ideWithProject({ ...project, publicAccesLevel: 'private' })}
-      />
-    )
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: scopeWithProject({ ...project, publicAccesLevel: 'private' }),
+    })
 
 
     await screen.findByText(
     await screen.findByText(
       'Link sharing is off, only invited users can view this project.'
       'Link sharing is off, only invited users can view this project.'
@@ -799,7 +761,9 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('avoids selecting unmatched contact', async function () {
   it('avoids selecting unmatched contact', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />)
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      scope: { project },
+    })
 
 
     const [inputElement] = await screen.findAllByLabelText(
     const [inputElement] = await screen.findAllByLabelText(
       'Share with your collaborators'
       'Share with your collaborators'

+ 36 - 28
services/web/test/frontend/helpers/render-with-context.js

@@ -8,6 +8,8 @@ import { ApplicationProvider } from '../../../frontend/js/shared/context/applica
 import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
 import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
 import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context'
 import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context'
 import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-context'
 import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-context'
+import { IdeProvider } from '../../../frontend/js/shared/context/ide-context'
+import { get } from 'lodash'
 
 
 export function EditorProviders({
 export function EditorProviders({
   user = { id: '123abd' },
   user = { id: '123abd' },
@@ -17,6 +19,7 @@ export function EditorProviders({
     removeListener: sinon.stub(),
     removeListener: sinon.stub(),
   },
   },
   isRestrictedTokenMember = false,
   isRestrictedTokenMember = false,
+  scope,
   children,
   children,
 }) {
 }) {
   window.user = user || window.user
   window.user = user || window.user
@@ -24,38 +27,44 @@ export function EditorProviders({
   window.project_id = projectId != null ? projectId : window.project_id
   window.project_id = projectId != null ? projectId : window.project_id
   window.isRestrictedTokenMember = isRestrictedTokenMember
   window.isRestrictedTokenMember = isRestrictedTokenMember
 
 
-  window._ide = {
-    $scope: {
-      project: {
-        owner: {
-          _id: '124abd',
-        },
+  const $scope = {
+    project: {
+      owner: {
+        _id: '124abd',
       },
       },
-      ui: {
-        chatOpen: true,
-        pdfLayout: 'flat',
-      },
-      $watch: () => {},
-      toggleHistory: () => {},
     },
     },
-    socket,
+    ui: {
+      chatOpen: true,
+      pdfLayout: 'flat',
+    },
+    $watch: (path, callback) => {
+      callback(get($scope, path))
+      return () => null
+    },
+    $applyAsync: () => {},
+    toggleHistory: () => {},
+    ...scope,
   }
   }
+
+  window._ide = { $scope, socket }
+
   return (
   return (
     <ApplicationProvider>
     <ApplicationProvider>
-      <EditorProvider ide={window._ide} settings={{}}>
-        <LayoutProvider $scope={window._ide.$scope}>{children}</LayoutProvider>
-      </EditorProvider>
+      <IdeProvider ide={window._ide}>
+        <EditorProvider settings={{}}>
+          <LayoutProvider>{children}</LayoutProvider>
+        </EditorProvider>
+      </IdeProvider>
     </ApplicationProvider>
     </ApplicationProvider>
   )
   )
 }
 }
 
 
 export function renderWithEditorContext(component, contextProps) {
 export function renderWithEditorContext(component, contextProps) {
-  return render(component, {
-    // eslint-disable-next-line react/display-name
-    wrapper: ({ children }) => (
-      <EditorProviders {...contextProps}>{children}</EditorProviders>
-    ),
-  })
+  const EditorProvidersWrapper = ({ children }) => (
+    <EditorProviders {...contextProps}>{children}</EditorProviders>
+  )
+
+  return render(component, { wrapper: EditorProvidersWrapper })
 }
 }
 
 
 export function ChatProviders({ children, ...props }) {
 export function ChatProviders({ children, ...props }) {
@@ -67,12 +76,11 @@ export function ChatProviders({ children, ...props }) {
 }
 }
 
 
 export function renderWithChatContext(component, props) {
 export function renderWithChatContext(component, props) {
-  return render(component, {
-    // eslint-disable-next-line react/display-name
-    wrapper: ({ children }) => (
-      <ChatProviders {...props}>{children}</ChatProviders>
-    ),
-  })
+  const ChatProvidersWrapper = ({ children }) => (
+    <ChatProviders {...props}>{children}</ChatProviders>
+  )
+
+  return render(component, { wrapper: ChatProvidersWrapper })
 }
 }
 
 
 export function cleanUpContext() {
 export function cleanUpContext() {