Move project context out of scope value store (#26615)

* Refactor project context to not use scope store

* Fix Cypress tests for project context changes

* Fix frontend React Testing Library tests for project context changes

* Remove redundant code

* Fix some project types in tests

* Remove unused import and fix a type

* Throw an error if updating the project in the project context before joining the project

* Fix some review panel tests

* Remove unused imports

GitOrigin-RevId: 2f0c928b651f387aa980c29aef7d1ba0649790a7
This commit is contained in:
Tim Down
2025-07-10 08:06:31 +00:00
committed by Copybot
parent 9237d8227b
commit 905cc5d45f
79 changed files with 806 additions and 703 deletions
@@ -4,11 +4,16 @@ import {
OverallThemeMeta,
SpellCheckLanguage,
} from '../../../../types/project-settings'
import { EditorProviders } from '../../helpers/editor-providers'
import {
EditorProviders,
makeProjectProvider,
} from '../../helpers/editor-providers'
import { mockScope } from './scope'
import { Folder } from '../../../../types/folder'
import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
import getMeta from '@/utils/meta'
import { mockProject } from '../../features/source-editor/helpers/mock-project'
import { UserId } from '../../../../types/user'
describe('<EditorLeftMenu />', function () {
beforeEach(function () {
@@ -57,9 +62,14 @@ describe('<EditorLeftMenu />', function () {
it('render full menu', function () {
const scope = mockScope()
const project = mockProject()
cy.mount(
<EditorProviders scope={scope} layoutContext={{ leftMenuShown: true }}>
<EditorProviders
scope={scope}
layoutContext={{ leftMenuShown: true }}
providers={{ ProjectProvider: makeProjectProvider(project) }}
>
<EditorLeftMenu />
</EditorProviders>
)
@@ -209,12 +219,6 @@ describe('<EditorLeftMenu />', function () {
})
const scope = mockScope({
project: {
members: [],
owner: {
_id: '123',
},
},
user: {
features: {
dropbox: false,
@@ -226,6 +230,14 @@ describe('<EditorLeftMenu />', function () {
<EditorProviders
scope={scope}
layoutContext={{ leftMenuShown: true }}
projectOwner={{
_id: '123' as UserId,
email: 'owner@example.com',
first_name: 'Test',
last_name: 'Owner',
privileges: 'owner',
signUpDate: new Date('2025-07-07').toISOString(),
}}
>
<EditorLeftMenu />
</EditorProviders>
@@ -236,21 +248,25 @@ describe('<EditorLeftMenu />', function () {
})
it('shows git modal correctly', function () {
const scope = mockScope({
project: {
owner: {
_id: '123',
},
features: {
gitBridge: true,
},
},
})
const scope = mockScope()
cy.mount(
<EditorProviders
scope={scope}
layoutContext={{ leftMenuShown: true }}
projectOwner={{
_id: '123' as UserId,
email: 'owner@example.com',
first_name: 'Test',
last_name: 'Owner',
privileges: 'owner',
signUpDate: new Date('2025-07-07').toISOString(),
}}
projectFeatures={
{
gitBridge: true,
} as any
}
>
<EditorLeftMenu />
</EditorProviders>
@@ -262,21 +278,25 @@ describe('<EditorLeftMenu />', function () {
})
it('shows git modal paywall correctly', function () {
const scope = mockScope({
project: {
owner: {
_id: '123',
},
features: {
gitBridge: false,
},
},
})
const scope = mockScope()
cy.mount(
<EditorProviders
scope={scope}
layoutContext={{ leftMenuShown: true }}
projectOwner={{
_id: '123' as UserId,
email: 'owner@example.com',
first_name: 'Test',
last_name: 'Owner',
privileges: 'owner',
signUpDate: new Date('2025-07-07').toISOString(),
}}
projectFeatures={
{
gitBridge: false,
} as any
}
>
<EditorLeftMenu />
</EditorProviders>
@@ -9,6 +9,7 @@ import {
} from '../../../../frontend/js/shared/context/layout-context'
import { FC, PropsWithChildren, useEffect } from 'react'
import { useLocalCompileContext } from '@/shared/context/local-compile-context'
import { ProjectCompiler } from '../../../../types/project-settings'
const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
localStorage.setItem(key, value)
@@ -215,7 +216,7 @@ describe('<PdfPreview/>', function () {
cached: false,
setup: () => {},
props: {
compiler: 'lualatex',
compiler: 'lualatex' as ProjectCompiler,
},
},
'ignores the compile from cache when draft mode changed': {
@@ -15,9 +15,9 @@ describe('<EditorCloneProjectModalWrapper />', function () {
fetchMock.removeRoutes().clearHistory()
})
const project = {
_id: 'project-1',
name: 'Test Project',
const contextProps = {
projectId: 'project-1',
projectName: 'Test Project',
}
it('renders the translated modal title', async function () {
@@ -30,7 +30,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
openProject={openProject}
show
/>,
{ scope: { project } }
contextProps
)
await screen.findByText('Copy project')
@@ -55,7 +55,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
openProject={openProject}
show
/>,
{ scope: { project } }
contextProps
)
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
@@ -123,7 +123,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
openProject={openProject}
show
/>,
{ scope: { project } }
contextProps
)
const button = await screen.findByRole('button', { name: 'Copy' })
@@ -160,7 +160,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
openProject={openProject}
show
/>,
{ scope: { project } }
contextProps
)
const button = await screen.findByRole('button', { name: 'Copy' })
@@ -1,12 +1,14 @@
import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
import {
EditorProviders,
makeProjectProvider,
USER_EMAIL,
USER_ID,
} from '../../helpers/editor-providers'
import { mockScope } from '../source-editor/helpers/mock-scope'
import { TestContainer } from '../source-editor/helpers/test-container'
import { docId } from '../source-editor/helpers/mock-doc'
import { mockProject } from '../source-editor/helpers/mock-project'
const userData = {
avatar_text: 'User',
@@ -181,14 +183,22 @@ describe('<ReviewPanel />', function () {
removeChangeIds,
},
},
projectFeatures: { trackChangesVisible: true },
})
const project = mockProject({
projectOwner: {
_id: USER_ID,
},
projectFeatures: { trackChanges: false, trackChangesVisible: true },
})
cy.wrap(scope).as('scope')
cy.mount(
<TestContainer className="rp-size-expanded">
<EditorProviders scope={scope}>
<EditorProviders
scope={scope}
providers={{ ProjectProvider: makeProjectProvider(project) }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
@@ -653,6 +663,10 @@ describe('<ReviewPanel /> in mini mode', function () {
projectFeatures: { trackChangesVisible: true },
})
const project = mockProject({
projectFeatures: { trackChangesVisible: true },
})
cy.intercept('GET', '/project/*/ranges', [
{
id: docId,
@@ -672,7 +686,10 @@ describe('<ReviewPanel /> in mini mode', function () {
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<EditorProviders
scope={scope}
providers={{ ProjectProvider: makeProjectProvider(project) }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
@@ -717,7 +734,7 @@ describe('<ReviewPanel /> in mini mode', function () {
cy.get('.review-panel-mini').should('not.exist')
})
it("doesn't render mini when a resolved comments is present in document", function () {
it("doesn't render mini when a resolved comment is present in document", function () {
render({
comments: [
{
@@ -747,7 +764,7 @@ describe('<ReviewPanel /> in mini mode', function () {
cy.get('.review-panel-mini').should('not.exist')
})
it('renders mini when a unresolved comments is present in document', function () {
it('renders mini when an unresolved comment is present in document', function () {
render({
comments: [
{
@@ -796,6 +813,8 @@ describe('<ReviewPanel /> for free users', function () {
function mountEditor(ownerId = USER_ID) {
const scope = mockScope(undefined, {
permissions: { write: true, trackedWrite: false, comment: true },
})
const project = mockProject({
projectFeatures: { trackChanges: false, trackChangesVisible: true },
projectOwner: {
_id: ownerId,
@@ -806,7 +825,10 @@ describe('<ReviewPanel /> for free users', function () {
cy.mount(
<TestContainer className="rp-size-expanded">
<EditorProviders scope={scope}>
<EditorProviders
scope={scope}
providers={{ ProjectProvider: makeProjectProvider(project) }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
@@ -1,18 +1,19 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { screen, fireEvent, render, waitFor } from '@testing-library/react'
import { screen, fireEvent, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import userEvent from '@testing-library/user-event'
import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import {
EditorProviders,
makeProjectProvider,
projectDefaults,
USER_EMAIL,
USER_ID,
} from '../../../helpers/editor-providers'
import { location } from '@/shared/components/location'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { useProjectContext } from '@/shared/context/project-context'
async function changePrivilegeLevel(screen, { current, next }) {
const select = screen.getByDisplayValue(current)
@@ -23,22 +24,25 @@ async function changePrivilegeLevel(screen, { current, next }) {
fireEvent.click(option)
}
describe('<ShareProjectModal/>', function () {
const project = {
_id: 'test-project',
name: 'Test Project',
features: {
collaborators: 10,
compileGroup: 'standard',
},
owner: {
_id: USER_ID,
email: USER_EMAIL,
},
members: [],
invites: [],
}
const shareModalProjectDefaults = Object.assign({}, projectDefaults, {
_id: 'test-project',
name: 'Test Project',
features: {
collaborators: 10,
compileGroup: 'standard',
},
owner: {
_id: USER_ID,
email: USER_EMAIL,
},
})
function createContextProps(projectOverrides) {
const project = Object.assign({}, shareModalProjectDefaults, projectOverrides)
return { providers: { ProjectProvider: makeProjectProvider(project) } }
}
describe('<ShareProjectModal/>', function () {
const contacts = [
// user with edited name
{
@@ -101,9 +105,10 @@ describe('<ShareProjectModal/>', function () {
})
it('renders the modal', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project },
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps()
)
await screen.findByText('Share Project')
})
@@ -113,7 +118,7 @@ describe('<ShareProjectModal/>', function () {
renderWithEditorContext(
<ShareProjectModal {...modalProps} handleHide={handleHide} />,
{ scope: { project } }
createContextProps()
)
const [headerCloseButton, footerCloseButton] = await screen.findAllByRole(
@@ -128,9 +133,10 @@ describe('<ShareProjectModal/>', function () {
})
it('handles access level "private"', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project: { ...project, publicAccesLevel: 'private' } },
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'private' })
)
await screen.findByText('Link sharing is off')
await screen.findByRole('button', { name: 'Turn on link sharing' })
@@ -149,10 +155,11 @@ describe('<ShareProjectModal/>', function () {
readAndWriteHashPrefix: 'taEVki',
readOnlyHashPrefix: 'j2xYbL',
}
fetchMock.get(`/project/${project._id}/tokens`, tokens)
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, tokens)
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased' })
)
await screen.findByText('Link sharing is on')
await screen.findByRole('button', { name: 'Turn off link sharing' })
@@ -171,9 +178,10 @@ describe('<ShareProjectModal/>', function () {
})
it('handles legacy access level "readAndWrite"', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project: { ...project, publicAccesLevel: 'readAndWrite' } },
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'readAndWrite' })
)
await screen.findByText(
'This project is public and can be edited by anyone with the URL.'
@@ -182,9 +190,10 @@ describe('<ShareProjectModal/>', function () {
})
it('handles legacy access level "readOnly"', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project: { ...project, publicAccesLevel: 'readOnly' } },
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'readOnly' })
)
await screen.findByText(
'This project is public and can be viewed but not edited by anyone with the URL'
@@ -193,7 +202,7 @@ describe('<ShareProjectModal/>', function () {
})
it('displays actions for project-owners', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
const invites = [
{
@@ -204,18 +213,9 @@ describe('<ShareProjectModal/>', function () {
]
// render as project owner: actions should be present
render(
<EditorProviders
scope={{
project: {
...project,
invites,
publicAccesLevel: 'tokenBased',
},
}}
>
<ShareProjectModal {...modalProps} />
</EditorProviders>
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased', invites })
)
await screen.findByRole('button', { name: 'Turn off link sharing' })
@@ -231,23 +231,13 @@ describe('<ShareProjectModal/>', function () {
},
]
render(
<EditorProviders
scope={{
project: {
...project,
invites,
publicAccesLevel: 'tokenBased',
},
}}
user={{
id: 'non-project-owner',
email: 'non-project-owner@example.com',
}}
>
<ShareProjectModal {...modalProps} />
</EditorProviders>
)
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
...createContextProps({ publicAccessLevel: 'tokenBased', invites }),
user: {
id: 'non-project-owner',
email: 'non-project-owner@example.com',
},
})
await screen.findByText(
'To change access permissions, please ask the project owner'
@@ -269,23 +259,13 @@ describe('<ShareProjectModal/>', function () {
},
]
render(
<EditorProviders
scope={{
project: {
...project,
invites,
publicAccesLevel: 'private',
},
}}
user={{
id: 'non-project-owner',
email: 'non-project-owner@example.com',
}}
>
<ShareProjectModal {...modalProps} />
</EditorProviders>
)
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
...createContextProps({ publicAccessLevel: 'private', invites }),
user: {
id: 'non-project-owner',
email: 'non-project-owner@example.com',
},
})
await screen.findByText(
'To add more collaborators or turn on link sharing, please ask the project owner'
@@ -300,11 +280,11 @@ describe('<ShareProjectModal/>', function () {
it('only shows read-only token link to restricted token members', async function () {
window.metaAttributesCache.set('ol-isRestrictedTokenMember', true)
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
...createContextProps({ publicAccessLevel: 'private' }),
isRestrictedTokenMember: true,
scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
})
// no buttons
@@ -346,18 +326,12 @@ describe('<ShareProjectModal/>', function () {
},
]
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
members,
invites,
publicAccesLevel: 'tokenBased',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased', members, invites })
)
const projectOwnerEmail = USER_EMAIL
@@ -381,7 +355,7 @@ describe('<ShareProjectModal/>', function () {
})
it('resends an invite', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
fetchMock.postOnce(
'express:/project/:projectId/invite/:inviteId/resend',
204
@@ -395,15 +369,10 @@ describe('<ShareProjectModal/>', function () {
},
]
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
invites,
publicAccesLevel: 'tokenBased',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased', invites })
)
const [, closeButton] = screen.getAllByRole('button', {
name: 'Close',
@@ -419,7 +388,7 @@ describe('<ShareProjectModal/>', function () {
})
it('revokes an invite', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204)
const invites = [
@@ -430,15 +399,10 @@ describe('<ShareProjectModal/>', function () {
},
]
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
invites,
publicAccesLevel: 'tokenBased',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased', invites })
)
const [, closeButton] = screen.getAllByRole('button', {
name: 'Close',
@@ -453,7 +417,7 @@ describe('<ShareProjectModal/>', function () {
})
it('changes member privileges to read + write', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
fetchMock.putOnce('express:/project/:projectId/users/:userId', 204)
const members = [
@@ -464,17 +428,12 @@ describe('<ShareProjectModal/>', function () {
},
]
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
members,
publicAccesLevel: 'tokenBased',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased', members })
)
const [, closeButton] = await screen.getAllByRole('button', {
const [, closeButton] = screen.getAllByRole('button', {
name: 'Close',
})
@@ -494,7 +453,7 @@ describe('<ShareProjectModal/>', function () {
})
it('removes a member from the project', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204)
const members = [
@@ -505,15 +464,10 @@ describe('<ShareProjectModal/>', function () {
},
]
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
members,
publicAccesLevel: 'tokenBased',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased', members })
)
expect(
await screen.findAllByText('member-viewer@example.com')
@@ -537,7 +491,7 @@ describe('<ShareProjectModal/>', function () {
})
it('changes member privileges to owner with confirmation', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
const members = [
@@ -548,15 +502,10 @@ describe('<ShareProjectModal/>', function () {
},
]
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
members,
publicAccesLevel: 'tokenBased',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased', members })
)
await screen.findByText('member-viewer@example.com')
expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
@@ -586,16 +535,12 @@ describe('<ShareProjectModal/>', function () {
})
it('sends invites to input email addresses', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
publicAccesLevel: 'tokenBased',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased' })
)
const [inputElement] = await screen.findAllByLabelText('Add people')
@@ -680,26 +625,24 @@ describe('<ShareProjectModal/>', function () {
})
it('displays a message when the collaborator limit is reached', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
fetchMock.post(
'/event/paywall-prompt',
{},
{ body: { 'paywall-type': 'project-sharing' } }
)
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
publicAccesLevel: 'tokenBased',
features: {
collaborators: 0,
compileGroup: 'standard',
trackChangesVisible: true,
},
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({
publicAccessLevel: 'tokenBased',
features: {
collaborators: 0,
compileGroup: 'standard',
trackChangesVisible: true,
},
},
})
})
)
await screen.findByText('Add more collaborators')
@@ -719,24 +662,22 @@ describe('<ShareProjectModal/>', function () {
})
it('counts reviewers towards the collaborator limit', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
features: {
collaborators: 1,
trackChangesVisible: true,
},
members: [
{
_id: 'reviewer-id',
email: 'reviewer@example.com',
privileges: 'review',
},
],
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({
features: {
collaborators: 1,
trackChangesVisible: true,
},
},
})
members: [
{
_id: 'reviewer-id',
email: 'reviewer@example.com',
privileges: 'review',
},
],
})
)
await screen.findByText('Add more collaborators')
@@ -757,16 +698,14 @@ describe('<ShareProjectModal/>', function () {
})
it('handles server error responses', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: {
project: {
...project,
publicAccesLevel: 'tokenBased',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({
publicAccessLevel: 'tokenBased',
})
)
// loading contacts
await waitFor(() => {
@@ -818,21 +757,25 @@ describe('<ShareProjectModal/>', function () {
})
it('handles switching between access levels', async function () {
fetchMock.get(`/project/${project._id}/tokens`, {})
fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
fetchMock.post('express:/project/:projectId/settings/admin', 204)
let setPublicAccessLevel = function () {}
function WrappedModal() {
setPublicAccessLevel = useScopeValue('project.publicAccesLevel')[1]
const { updateProject } = useProjectContext()
setPublicAccessLevel = publicAccessLevel => {
updateProject({ publicAccessLevel })
}
return <ShareProjectModal {...modalProps} />
}
renderWithEditorContext(<WrappedModal />, {
scope: {
project: { ...project, publicAccesLevel: 'private' },
},
})
renderWithEditorContext(
<WrappedModal />,
createContextProps({
publicAccessLevel: 'private',
})
)
await screen.findByText('Link sharing is off')
@@ -848,8 +791,8 @@ describe('<ShareProjectModal/>', function () {
})
// NOTE: the project data is usually updated via the websocket connection
// but we can't do that so we're doing it via the scope value store (this
// will be via the project context when this value has been migrated)
// but we can't do that so we're doing it via the project context, which is
// exposed in a hacky way here
setPublicAccessLevel('tokenBased')
await screen.findByText('Link sharing is on')
@@ -870,9 +813,10 @@ describe('<ShareProjectModal/>', function () {
})
it('avoids selecting unmatched contact', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project },
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps()
)
const [inputElement] = await screen.findAllByLabelText('Add people')
@@ -924,9 +868,10 @@ describe('<ShareProjectModal/>', function () {
})
it('selects contact by typing the entire email and blurring the input', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project },
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps()
)
const [inputElement] = await screen.findAllByLabelText('Add people')
@@ -959,9 +904,10 @@ describe('<ShareProjectModal/>', function () {
})
it('selects contact by typing a partial email and selecting the suggestion', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project },
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps()
)
const [inputElement] = await screen.findAllByLabelText('Add people')
@@ -993,9 +939,10 @@ describe('<ShareProjectModal/>', function () {
})
it('allows an email address to be selected, removed, then re-added', async function () {
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
scope: { project },
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps()
)
const [inputElement] = await screen.findAllByLabelText('Add people')
@@ -64,7 +64,6 @@ describe('autocomplete', { scrollBehavior: false }, function () {
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
@@ -446,7 +445,6 @@ describe('autocomplete', { scrollBehavior: false }, function () {
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
@@ -910,7 +908,6 @@ describe('autocomplete', { scrollBehavior: false }, function () {
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
@@ -2,12 +2,15 @@ import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/
import {
EditorProviders,
makeEditorPropertiesProvider,
makeProjectProvider,
USER_ID,
} from '../../../helpers/editor-providers'
import { mockScope, rootFolderId } from '../helpers/mock-scope'
import { FC } from 'react'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { TestContainer } from '../helpers/test-container'
import getMeta from '@/utils/meta'
import { mockProject } from '../helpers/mock-project'
const clickToolbarButton = (text: string) => {
cy.findByLabelText(text).click()
@@ -44,6 +47,11 @@ describe('<FigureModal />', function () {
function mount() {
const content = ''
const scope = mockScope(content)
const project = mockProject({
projectOwner: {
_id: USER_ID,
},
})
const FileTreePathProvider: FC<React.PropsWithChildren> = ({
children,
@@ -69,6 +77,7 @@ describe('<FigureModal />', function () {
scope={scope}
providers={{
FileTreePathProvider,
ProjectProvider: makeProjectProvider(project),
EditorPropertiesProvider: makeEditorPropertiesProvider({
showVisual: true,
showSymbolPalette: false,
@@ -1,9 +1,13 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import {
EditorProviders,
makeProjectProvider,
} from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { TestContainer } from '../helpers/test-container'
import forEach from 'mocha-each'
import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
import { mockProject } from '../helpers/mock-project'
const languages = [
{ code: 'af', dic: 'af_ZA', name: 'Afrikaans' },
@@ -125,11 +129,14 @@ forEach(Object.keys(suggestions)).describe(
cy.interceptEvents()
const scope = mockScope(content)
scope.project.spellCheckLanguage = spellCheckLanguage
const project = mockProject({ spellCheckLanguage })
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<EditorProviders
scope={scope}
providers={{ ProjectProvider: makeProjectProvider(project) }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
@@ -1,19 +1,24 @@
import {
EditorProviders,
makeEditorPropertiesProvider,
makeProjectProvider,
} from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { mockProject } from '../helpers/mock-project'
const mountEditor = (content: string) => {
const scope = mockScope(content)
const project = mockProject()
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{
ProjectProvider: makeProjectProvider(project),
EditorPropertiesProvider: makeEditorPropertiesProvider({
showVisual: true,
showSymbolPalette: false,
@@ -1,20 +1,24 @@
import {
EditorProviders,
makeEditorPropertiesProvider,
makeProjectProvider,
} from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { isMac } from '@/shared/utils/os'
import { mockProject } from '../helpers/mock-project'
const mountEditor = (content: string) => {
const scope = mockScope(content)
const project = mockProject()
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{
ProjectProvider: makeProjectProvider(project),
EditorPropertiesProvider: makeEditorPropertiesProvider({
showVisual: true,
showSymbolPalette: false,
@@ -0,0 +1,78 @@
import { docId } from './mock-doc'
import { Folder } from '../../../../../types/folder'
import { UserId } from '../../../../../types/user'
import { ProjectCompiler } from '../../../../../types/project-settings'
export const rootFolderId = '012345678901234567890123'
export const figuresFolderId = '123456789012345678901234'
export const figureId = '234567890123456789012345'
export const mockProject = ({
projectFeatures = {},
projectOwner = undefined,
spellCheckLanguage = 'en',
rootFolder = null,
}: any = {}) => {
return {
_id: 'test-project',
name: 'Test Project',
spellCheckLanguage,
rootDocId: '_root_doc_id',
rootFolder:
rootFolder ||
([
{
_id: rootFolderId,
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'test.tex',
},
],
folders: [
{
_id: figuresFolderId,
name: 'figures',
docs: [
{
_id: 'fake-nested-doc-id',
name: 'foo.tex',
},
],
folders: [],
fileRefs: [
{
_id: figureId,
name: 'frog.jpg',
hash: '42',
},
{
_id: 'fake-figure-id',
name: 'unicorn.png',
hash: '43',
},
],
},
],
fileRefs: [],
},
] as Folder[]),
features: {
trackChanges: true,
...projectFeatures,
},
compiler: 'pdflatex' as ProjectCompiler,
imageName: 'texlive-full:2024.1',
trackChangesState: false,
invites: [],
members: [],
owner: projectOwner || {
_id: '124abd' as UserId,
email: 'owner@example.com',
first_name: 'Test',
last_name: 'Owner',
privileges: 'owner',
signUpDate: new Date('2025-07-07').toISOString(),
},
}
}
@@ -1,18 +1,10 @@
import { docId, mockDoc } from './mock-doc'
import { sleep } from '../../../helpers/sleep'
import { Folder } from '../../../../../types/folder'
export const rootFolderId = '012345678901234567890123'
export const figuresFolderId = '123456789012345678901234'
export const figureId = '234567890123456789012345'
export const mockScope = (
content?: string,
{
docOptions = {},
projectFeatures = {},
permissions = {},
projectOwner = undefined,
}: any = {}
{ docOptions = {}, permissions = {} }: any = {}
) => {
return {
editor: {
@@ -21,55 +13,8 @@ export const mockScope = (
currentDocumentId: docId,
wantTrackChanges: false,
},
project: {
_id: 'test-project',
name: 'Test Project',
spellCheckLanguage: 'en',
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'test.tex',
},
],
folders: [
{
_id: figuresFolderId,
name: 'figures',
docs: [
{
_id: 'fake-nested-doc-id',
name: 'foo.tex',
},
],
folders: [],
fileRefs: [
{
_id: figureId,
name: 'frog.jpg',
hash: '42',
},
{
_id: 'fake-figure-id',
name: 'unicorn.png',
hash: '43',
},
],
},
],
fileRefs: [],
},
] as Folder[],
features: {
trackChanges: true,
...projectFeatures,
},
trackChangesState: {},
members: [],
owner: projectOwner,
pdf: {
logEntryAnnotations: {},
},
permissions: {
comment: true,
@@ -23,10 +23,12 @@ import {
EditorOpenDocContext,
type EditorOpenDocContextState,
} from '@/features/ide-react/context/editor-open-doc-context'
import { ProjectContext } from '@/shared/context/project-context'
import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
import useEventListener from '@/shared/hooks/use-event-listener'
import useDetachLayout from '@/shared/hooks/use-detach-layout'
import useExposedState from '@/shared/hooks/use-exposed-state'
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
import {
EditorPropertiesContext,
EditorPropertiesContextValue,
@@ -42,6 +44,12 @@ import type { PermissionsLevel } from '@/features/ide-react/types/permissions'
import type { Folder } from '../../../types/folder'
import type { SocketDebuggingInfo } from '@/features/ide-react/connection/types/connection-state'
import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
import {
ProjectMetadata,
ProjectUpdate,
} from '@/shared/context/types/project-metadata'
import { UserId } from '../../../types/user'
import { ProjectCompiler } from '../../../types/project-settings'
// these constants can be imported in tests instead of
// using magic strings
@@ -68,10 +76,11 @@ const defaultUserSettings = {
export type EditorProvidersProps = {
user?: { id: string; email: string }
projectId?: string
projectOwner?: { _id: string; email: string }
projectName?: string
projectOwner?: ProjectMetadata['owner']
rootDocId?: string
imageName?: string
compiler?: string
compiler?: ProjectCompiler
socket?: Socket
isRestrictedTokenMember?: boolean
scope?: Record<string, any>
@@ -85,6 +94,46 @@ export type EditorProvidersProps = {
providers?: Record<string, React.FC<React.PropsWithChildren<any>>>
}
export const projectDefaults = {
_id: PROJECT_ID,
name: PROJECT_NAME,
owner: {
_id: '124abd' as UserId,
email: 'owner@example.com',
first_name: 'Test',
last_name: 'Owner',
privileges: 'owner',
signUpDate: new Date('2025-07-07').toISOString(),
},
features: {
referencesSearch: true,
gitBridge: false,
},
rootDocId: '_root_doc_id',
rootFolder: [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: '_root_doc_id',
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
],
imageName: 'texlive-full:2024.1',
compiler: 'pdflatex' as ProjectCompiler,
members: [],
invites: [],
}
/**
* @typedef {import('@/shared/context/layout-context').LayoutContextValue} LayoutContextValue
* @type Partial<LayoutContextValue>
*/
const layoutContextDefault = {
view: 'editor',
openFile: null,
@@ -99,37 +148,23 @@ const layoutContextDefault = {
export function EditorProviders({
user = { id: USER_ID, email: USER_EMAIL },
projectId = PROJECT_ID,
projectOwner = {
_id: '124abd',
email: 'owner@example.com',
},
rootDocId = '_root_doc_id',
imageName = 'texlive-full:2024.1',
compiler = 'pdflatex',
projectId = projectDefaults._id,
projectName = projectDefaults.name,
projectOwner = projectDefaults.owner,
rootDocId = projectDefaults.rootDocId,
imageName = projectDefaults.imageName,
compiler = projectDefaults.compiler,
socket = new SocketIOMock() as any as Socket,
isRestrictedTokenMember = false,
scope: defaultScope = {},
features = {
referencesSearch: true,
gitBridge: false,
},
projectFeatures = features,
permissionsLevel = 'owner',
children,
rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: '_root_doc_id',
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
],
rootFolder = projectDefaults.rootFolder,
/** @type {Partial<LayoutContext>} */
layoutContext = layoutContextDefault,
userSettings = {},
@@ -166,21 +201,26 @@ export function EditorProviders({
currentDocumentId: null,
wantTrackChanges: false,
},
project: {
_id: projectId,
name: PROJECT_NAME,
owner: projectOwner,
features: projectFeatures,
rootDocId,
rootFolder,
imageName,
compiler,
},
permissionsLevel,
},
defaultScope
)
const project = {
_id: projectId,
name: projectName,
owner: projectOwner,
features: projectFeatures,
rootDocId,
rootFolder,
imageName,
compiler,
members: [],
invites: [],
trackChangesState: false,
spellCheckLanguage: 'en',
}
// Add details for useUserContext
window.metaAttributesCache.set('ol-user', { ...user, features })
window.metaAttributesCache.set('ol-project_id', projectId)
@@ -199,6 +239,7 @@ export function EditorProviders({
wantTrackChanges: scope.editor.wantTrackChanges,
}),
LayoutProvider: makeLayoutProvider(layoutContext),
ProjectProvider: makeProjectProvider(project),
...providers,
}}
>
@@ -503,3 +544,35 @@ export function makeEditorPropertiesProvider(
return EditorPropertiesProvider
}
export function makeProjectProvider(initialProject: ProjectMetadata) {
const ProjectProvider: FC<PropsWithChildren> = ({ children }) => {
const [project, setProject] = useState(initialProject)
const updateProject = useCallback((projectUpdateData: ProjectUpdate) => {
setProject(projectData =>
Object.assign({}, projectData, projectUpdateData)
)
}, [])
const value = {
projectId: project._id,
project,
joinProject: () => {},
updateProject,
joinedOnce: true,
projectSnapshot: new ProjectSnapshot(project._id),
tags: [],
features: project.features,
name: project.name,
}
return (
<ProjectContext.Provider value={value}>
{children}
</ProjectContext.Provider>
)
}
return ProjectProvider
}