From 905cc5d45f0e87fc3ea4e68b9b12e005f1149178 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:20:47 +0100 Subject: [PATCH] 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 --- .../js/features/chat/context/chat-context.tsx | 2 +- .../editor-clone-project-modal-wrapper.tsx | 12 +- .../components/download-pdf.tsx | 2 +- .../components/download-source.tsx | 2 +- ...-project-wide-settings-socket-listener.tsx | 18 +- .../hooks/use-project-wide-settings.tsx | 4 +- .../hooks/use-root-doc-id.tsx | 6 +- .../hooks/use-save-project-settings.tsx | 16 +- .../hooks/use-set-spell-check-language.tsx | 13 +- .../js/features/editor-left-menu/utils/api.ts | 2 +- .../file-tree-upload-conflicts.tsx | 2 +- .../modes/file-tree-import-from-project.tsx | 2 +- .../modes/file-tree-upload-doc.tsx | 2 +- .../file-tree-create/redirect-to-login.tsx | 2 +- .../file-tree-item-menu-items.tsx | 7 +- .../file-tree/components/file-tree-root.tsx | 2 +- .../contexts/file-tree-actionable.tsx | 2 +- .../contexts/file-tree-selectable.tsx | 3 +- .../file-view/components/file-view-header.tsx | 2 +- .../file-view/components/file-view-image.tsx | 2 +- .../components/file-view-refresh-button.tsx | 2 +- .../file-view/components/file-view-text.tsx | 2 +- .../history/context/history-context.tsx | 9 +- .../context/file-tree-open-context.tsx | 10 +- .../ide-react/context/ide-react-context.tsx | 41 +- .../ide-react/context/outline-context.tsx | 2 +- .../ide-react/context/permissions-context.tsx | 6 +- .../ide-react/context/react-context-root.tsx | 16 +- .../ide-react/context/snapshot-context.tsx | 2 +- .../ide-react/hooks/use-socket-listeners.ts | 44 +- .../components/toolbar/download-project.tsx | 4 +- .../components/pdf-hybrid-download-button.tsx | 2 +- .../pdf-preview/components/pdf-js-viewer.tsx | 2 +- .../features/pdf-preview/hooks/use-synctex.ts | 3 +- .../components/review-mode-switcher.tsx | 8 +- .../upgrade-track-changes-modal.tsx | 6 +- .../context/changes-users-context.tsx | 6 +- .../context/threads-context.tsx | 2 +- .../context/track-changes-state-context.tsx | 16 +- .../hooks/use-overview-file-collapsed.ts | 2 +- .../hooks/use-project-ranges.ts | 2 +- .../components/add-collaborators.tsx | 19 +- .../components/edit-member.tsx | 21 +- .../components/editor-over-limit-modal.tsx | 10 +- .../share-project-modal/components/invite.tsx | 20 +- .../components/link-sharing.tsx | 7 +- .../components/member-privileges.tsx | 4 +- .../components/owner-info.tsx | 4 +- .../components/send-invites-notice.tsx | 3 +- .../components/share-modal-body.tsx | 29 +- .../components/share-project-modal.tsx | 21 +- .../components/transfer-ownership-modal.tsx | 6 +- .../components/view-member.tsx | 8 +- .../components/view-only-access-modal.tsx | 3 +- .../figure-modal-other-project-source.tsx | 2 +- .../figure-modal-upload-source.tsx | 2 +- .../file-sources/figure-modal-url-source.tsx | 2 +- .../hooks/use-codemirror-scope.ts | 10 +- .../components/word-count-client.tsx | 3 +- .../components/word-count-server.tsx | 2 +- .../js/shared/context/editor-context.tsx | 62 ++- .../shared/context/file-tree-data-context.tsx | 7 +- .../shared/context/local-compile-context.tsx | 9 +- .../js/shared/context/project-context.tsx | 123 +++--- ...oject-context.tsx => project-metadata.tsx} | 22 +- .../shared/hooks/use-stop-on-first-error.ts | 2 +- .../editor-left-menu.spec.tsx | 76 ++-- .../pdf-preview/pdf-preview.spec.tsx | 3 +- .../components/clone-project-modal.test.jsx | 14 +- .../review-panel/review-panel.spec.tsx | 34 +- .../components/share-project-modal.test.jsx | 377 ++++++++---------- .../codemirror-editor-autocomplete.spec.tsx | 3 - .../codemirror-editor-figure-modal.spec.tsx | 9 + ...mirror-editor-spellchecker-client.spec.tsx | 13 +- ...ror-editor-visual-command-tooltip.spec.tsx | 5 + .../codemirror-editor-visual-list.spec.tsx | 4 + .../source-editor/helpers/mock-project.ts | 78 ++++ .../source-editor/helpers/mock-scope.ts | 61 +-- .../frontend/helpers/editor-providers.tsx | 141 +++++-- 79 files changed, 806 insertions(+), 703 deletions(-) rename services/web/frontend/js/shared/context/types/{project-context.tsx => project-metadata.tsx} (65%) create mode 100644 services/web/test/frontend/features/source-editor/helpers/mock-project.ts diff --git a/services/web/frontend/js/features/chat/context/chat-context.tsx b/services/web/frontend/js/features/chat/context/chat-context.tsx index b7a8a2c80d..2ef6fc9af3 100644 --- a/services/web/frontend/js/features/chat/context/chat-context.tsx +++ b/services/web/frontend/js/features/chat/context/chat-context.tsx @@ -200,7 +200,7 @@ export const ChatProvider: FC = ({ children }) => { clientId.current = chatClientIdGenerator.generate() } const user = useUserContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { chatIsOpen: chatIsOpenOldEditor } = useLayoutContext() const { selectedTab: selectedRailTab, isOpen: railIsOpen } = useRailContext() diff --git a/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx b/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx index aba9a87fbc..cae44dc1e0 100644 --- a/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx +++ b/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx @@ -13,13 +13,9 @@ const EditorCloneProjectModalWrapper = React.memo( handleHide: () => void openProject: ({ project_id }: { project_id: string }) => void }) { - const { - _id: projectId, - name: projectName, - tags: projectTags, - } = useProjectContext() + const { project, tags: projectTags } = useProjectContext() - if (!projectName) { + if (!project) { // wait for useProjectContext return null } else { @@ -28,8 +24,8 @@ const EditorCloneProjectModalWrapper = React.memo( handleHide={handleHide} show={show} handleAfterCloned={openProject} - projectId={projectId} - projectName={projectName} + projectId={project._id} + projectName={project.name} projectTags={projectTags} /> ) diff --git a/services/web/frontend/js/features/editor-left-menu/components/download-pdf.tsx b/services/web/frontend/js/features/editor-left-menu/components/download-pdf.tsx index 31e9073743..406c2b4cc5 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/download-pdf.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/download-pdf.tsx @@ -9,7 +9,7 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip' export default function DownloadPDF() { const { t } = useTranslation() const { pdfDownloadUrl, pdfUrl } = useCompileContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() function sendDownloadEvent() { eventTracking.sendMB('download-pdf-button-click', { diff --git a/services/web/frontend/js/features/editor-left-menu/components/download-source.tsx b/services/web/frontend/js/features/editor-left-menu/components/download-source.tsx index 6b25b2a28d..9c9e4b2471 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/download-source.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/download-source.tsx @@ -6,7 +6,7 @@ import MaterialIcon from '@/shared/components/material-icon' export default function DownloadSource() { const { t } = useTranslation() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() function sendDownloadEvent() { eventTracking.sendMB('download-zip-button-click', { diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings-socket-listener.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings-socket-listener.tsx index fde26a19d9..953084bd25 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings-socket-listener.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings-socket-listener.tsx @@ -1,40 +1,38 @@ import { useCallback, useEffect } from 'react' import { useIdeContext } from '../../../shared/context/ide-context' -import useScopeValue from '../../../shared/hooks/use-scope-value' +import { useProjectContext } from '@/shared/context/project-context' import type { ProjectSettings } from '../utils/api' export default function useProjectWideSettingsSocketListener() { const { socket } = useIdeContext() - const [project, setProject] = useScopeValue( - 'project' - ) + const { project, updateProject } = useProjectContext() const setCompiler = useCallback( (compiler: ProjectSettings['compiler']) => { if (project) { - setProject({ ...project, compiler }) + updateProject({ compiler }) } }, - [project, setProject] + [project, updateProject] ) const setImageName = useCallback( (imageName: ProjectSettings['imageName']) => { if (project) { - setProject({ ...project, imageName }) + updateProject({ imageName }) } }, - [project, setProject] + [project, updateProject] ) const setSpellCheckLanguage = useCallback( (spellCheckLanguage: ProjectSettings['spellCheckLanguage']) => { if (project) { - setProject({ ...project, spellCheckLanguage }) + updateProject({ spellCheckLanguage }) } }, - [project, setProject] + [project, updateProject] ) useEffect(() => { diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings.tsx index 3e569f2757..0226e87678 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings.tsx @@ -1,14 +1,14 @@ import { useCallback } from 'react' -import useScopeValue from '../../../shared/hooks/use-scope-value' import type { ProjectSettings } from '../utils/api' import useRootDocId from './use-root-doc-id' import useSaveProjectSettings from './use-save-project-settings' import useSetSpellCheckLanguage from './use-set-spell-check-language' import { debugConsole } from '@/utils/debugging' +import { useProjectContext } from '@/shared/context/project-context' export default function useProjectWideSettings() { // The value will be undefined on mount - const [project] = useScopeValue('project') + const { project } = useProjectContext() const saveProjectSettings = useSaveProjectSettings() const setCompiler = useCallback( diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-root-doc-id.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-root-doc-id.tsx index 2d62431b77..632a3b3ce7 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-root-doc-id.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-root-doc-id.tsx @@ -1,12 +1,12 @@ import { useCallback } from 'react' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' -import useScopeValue from '../../../shared/hooks/use-scope-value' +import { useProjectContext } from '@/shared/context/project-context' import type { ProjectSettings } from '../utils/api' import useSaveProjectSettings from './use-save-project-settings' export default function useRootDocId() { - const [rootDocId] = - useScopeValue('project.rootDocId') + const { project } = useProjectContext() + const rootDocId = project?.rootDocId const { permissionsLevel } = useIdeReactContext() const saveProjectSettings = useSaveProjectSettings() diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-save-project-settings.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-save-project-settings.tsx index f34c506708..425cfee74b 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-save-project-settings.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-save-project-settings.tsx @@ -1,25 +1,21 @@ import { type ProjectSettings, saveProjectSettings } from '../utils/api' -import { useProjectContext } from '../../../shared/context/project-context' -import useScopeValue from '../../../shared/hooks/use-scope-value' +import { useProjectContext } from '@/shared/context/project-context' export default function useSaveProjectSettings() { - // projectSettings value will be undefined on mount - const [projectSettings, setProjectSettings] = useScopeValue< - ProjectSettings | undefined - >('project') - const { _id: projectId } = useProjectContext() + const { projectId, project, updateProject } = useProjectContext() return async ( key: keyof ProjectSettings, newSetting: ProjectSettings[keyof ProjectSettings] ) => { - if (projectSettings) { - const currentSetting = projectSettings[key] + if (project) { + const currentSetting = project[key] if (currentSetting !== newSetting) { await saveProjectSettings(projectId, { [key]: newSetting, }) - setProjectSettings({ ...projectSettings, [key]: newSetting }) + + updateProject({ [key]: newSetting }) } } } diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-set-spell-check-language.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-set-spell-check-language.tsx index 075b2106f3..51ea3673e3 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-set-spell-check-language.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-set-spell-check-language.tsx @@ -1,12 +1,11 @@ import { useCallback } from 'react' -import useScopeValue from '../../../shared/hooks/use-scope-value' +import { useProjectContext } from '@/shared/context/project-context' import { type ProjectSettings, saveUserSettings } from '../utils/api' import useSaveProjectSettings from './use-save-project-settings' export default function useSetSpellCheckLanguage() { - const [spellCheckLanguage, setSpellCheckLanguage] = useScopeValue< - ProjectSettings['spellCheckLanguage'] - >('project.spellCheckLanguage') + const { project } = useProjectContext() + const spellCheckLanguage = project?.spellCheckLanguage const saveProjectSettings = useSaveProjectSettings() return useCallback( @@ -16,10 +15,8 @@ export default function useSetSpellCheckLanguage() { newSpellCheckLanguage !== spellCheckLanguage if (allowUpdate) { - setSpellCheckLanguage(newSpellCheckLanguage) - // Save project settings is created from hooks because it will save the value on - // both server-side and client-side (angular scope) + // both server-side and client-side (project context) saveProjectSettings('spellCheckLanguage', newSpellCheckLanguage) // For user settings, we only need to save it on server-side, @@ -27,6 +24,6 @@ export default function useSetSpellCheckLanguage() { saveUserSettings('spellCheckLanguage', newSpellCheckLanguage) } }, - [setSpellCheckLanguage, spellCheckLanguage, saveProjectSettings] + [spellCheckLanguage, saveProjectSettings] ) } diff --git a/services/web/frontend/js/features/editor-left-menu/utils/api.ts b/services/web/frontend/js/features/editor-left-menu/utils/api.ts index 3954f7ea5c..122df91f8c 100644 --- a/services/web/frontend/js/features/editor-left-menu/utils/api.ts +++ b/services/web/frontend/js/features/editor-left-menu/utils/api.ts @@ -4,7 +4,7 @@ import { postJSON } from '../../../infrastructure/fetch-json' import { debugConsole } from '@/utils/debugging' import { UserSettings } from '../../../../../types/user-settings' -export type ProjectSettings = { +export interface ProjectSettings { compiler: ProjectCompiler imageName: string rootDocId: string diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx index 9d8a66122c..c582edef23 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx @@ -96,7 +96,7 @@ export function FolderUploadConflicts({ setError: (error: string) => void }) { const { t } = useTranslation() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() // Don't allow overwriting files with a folder const hasFileConflict = conflicts.some(conflict => conflict.type === 'file') diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx index 7e02183641..8084043af8 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx @@ -191,7 +191,7 @@ function SelectProject({ setSelectedProject, }: SelectProjectProps) { const { t } = useTranslation() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { data, error, loading } = useUserProjects() diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx index 909e1a1962..afd0bf3671 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx @@ -28,7 +28,7 @@ export default function FileTreeUploadDoc() { const { parentFolderId, cancel, droppedFiles, setDroppedFiles } = useFileTreeActionable() const { fileTreeData } = useFileTreeData() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [error, setError] = useState() diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx index ab32fcecdb..13ba08b86f 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx @@ -6,7 +6,7 @@ import { useLocation } from '../../../../shared/hooks/use-location' // handle "not-logged-in" errors by redirecting to the login page export default function RedirectToLogin() { const { t } = useTranslation() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [secondsToRedirect, setSecondsToRedirect] = useState(10) const location = useLocation() diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx index 28f4a47c9b..571156a0a4 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx @@ -25,14 +25,15 @@ function FileTreeItemMenuItems() { selectedFileName, } = useFileTreeActionable() - const { owner } = useProjectContext() + const { project } = useProjectContext() + const projectOwner = project?.owner?._id const downloadWithAnalytics = useCallback(() => { // we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations if (selectedFileName?.endsWith('.bib')) { - eventTracking.sendMB('download-bib-file', { projectOwner: owner._id }) + eventTracking.sendMB('download-bib-file', { projectOwner }) } - }, [selectedFileName, owner]) + }, [selectedFileName, projectOwner]) const createWithAnalytics = useCallback(() => { eventTracking.sendMB('new-file-click', { location: 'file-menu' }) diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx index a6cde94e73..17ca5bf504 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx @@ -40,7 +40,7 @@ const FileTreeRoot = React.memo<{ }) { const [fileTreeContainer, setFileTreeContainer] = useState(null) - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { fileTreeData } = useFileTreeData() const isReady = Boolean(projectId && fileTreeData) const newEditor = useIsNewEditorEnabled() diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx index 54723e3e0d..1c144bcf80 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx @@ -222,7 +222,7 @@ function fileTreeActionableReducer(state: State, action: Action) { export const FileTreeActionableProvider: FC = ({ children, }) => { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { fileTreeReadOnly } = useFileTreeData() const { indexAllReferences } = useReferencesContext() const { write } = usePermissionsContext() diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx index edd1334169..9e0bfc71df 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx @@ -128,7 +128,8 @@ export const FileTreeSelectableProvider: FC< onSelect: (value: FindResult[]) => void }> > = ({ onSelect, children }) => { - const { _id: projectId, rootDocId } = useProjectContext() + const { projectId, project } = useProjectContext() + const rootDocId = project?.rootDocId const [initialSelectedEntityId] = usePersistedState( `doc.open_id.${projectId}`, diff --git a/services/web/frontend/js/features/file-view/components/file-view-header.tsx b/services/web/frontend/js/features/file-view/components/file-view-header.tsx index ca228a9304..0c9a218bc8 100644 --- a/services/web/frontend/js/features/file-view/components/file-view-header.tsx +++ b/services/web/frontend/js/features/file-view/components/file-view-header.tsx @@ -49,7 +49,7 @@ type FileViewHeaderProps = { } export default function FileViewHeader({ file }: FileViewHeaderProps) { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { fileTreeReadOnly } = useFileTreeData() const { t } = useTranslation() diff --git a/services/web/frontend/js/features/file-view/components/file-view-image.tsx b/services/web/frontend/js/features/file-view/components/file-view-image.tsx index 62a1fd0159..dfa117841d 100644 --- a/services/web/frontend/js/features/file-view/components/file-view-image.tsx +++ b/services/web/frontend/js/features/file-view/components/file-view-image.tsx @@ -11,7 +11,7 @@ export default function FileViewImage({ onLoad: () => void onError: () => void }) { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() return ( void onError: () => void }) { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [textPreview, setTextPreview] = useState('') const [shouldShowDots, setShouldShowDots] = useState(false) diff --git a/services/web/frontend/js/features/history/context/history-context.tsx b/services/web/frontend/js/features/history/context/history-context.tsx index 4fa7fabea6..a7a0746e3c 100644 --- a/services/web/frontend/js/features/history/context/history-context.tsx +++ b/services/web/frontend/js/features/history/context/history-context.tsx @@ -78,13 +78,10 @@ const updatesInfoInitialState: HistoryContextValue['updatesInfo'] = { function useHistory() { const { view } = useLayoutContext() const user = useUserContext() - const project = useProjectContext() + const { projectId, project, features } = useProjectContext() const userId = user.id - const projectId = project._id - const projectOwnerId = project.owner?._id - const userHasFullFeature = Boolean( - project.features?.versioning || user.isAdmin - ) + const projectOwnerId = project?.owner?._id + const userHasFullFeature = Boolean(features.versioning || user.isAdmin) const currentUserIsOwner = projectOwnerId === userId const [selection, setSelection] = useState(selectionInitialState) diff --git a/services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx b/services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx index d24187abd0..8c9078514c 100644 --- a/services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx @@ -39,7 +39,9 @@ const FileTreeOpenContext = createContext< export const FileTreeOpenProvider: FC = ({ children, }) => { - const { rootDocId, owner } = useProjectContext() + const { project } = useProjectContext() + const rootDocId = project?.rootDocId + const projectOwner = project?.owner?._id const { eventEmitter, projectJoined } = useIdeReactContext() const { openDocWithId, openInitialDoc } = useEditorManagerContext() const { currentDocumentId } = useEditorOpenDocContext() @@ -81,7 +83,7 @@ export const FileTreeOpenProvider: FC = ({ openDocWithId(selected.entity._id, { keepCurrentView: true }) if (selected.entity.name.endsWith('.bib')) { sendMB('open-bib-file', { - projectOwner: owner._id, + projectOwner, isSampleFile: selected.entity.name === 'sample.bib', linkedFileProvider: null, }) @@ -96,7 +98,7 @@ export const FileTreeOpenProvider: FC = ({ if (openFile) { if (selected?.entity?.name?.endsWith('.bib')) { sendMB('open-bib-file', { - projectOwner: owner._id, + projectOwner, isSampleFile: false, linkedFileProvider: (selected.entity as FileRef).linkedFileData ?.provider, @@ -105,7 +107,7 @@ export const FileTreeOpenProvider: FC = ({ window.dispatchEvent(new CustomEvent('file-view:file-opened')) } }, - [fileTreeReady, setOpenFile, openDocWithId, owner] + [fileTreeReady, setOpenFile, openDocWithId, projectOwner] ) const handleFileTreeDelete = useCallback( diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx index 147f683174..a55fc3236b 100644 --- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx @@ -19,6 +19,8 @@ import { postJSON } from '@/infrastructure/fetch-json' import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter' import getMeta from '@/utils/meta' import { type PermissionsLevel } from '@/features/ide-react/types/permissions' +import { useProjectContext } from '@/shared/context/project-context' +import { ProjectMetadata } from '@/shared/context/types/project-metadata' const LOADED_AT = new Date() @@ -44,10 +46,6 @@ function populateIdeReactScope(store: ReactScopeValueStore) { store.set('settings', {}) } -function populateProjectScope(store: ReactScopeValueStore) { - store.allowNonExistentPath('project', true) -} - function populatePdfScope(store: ReactScopeValueStore) { store.allowNonExistentPath('pdf', true) } @@ -61,7 +59,6 @@ export function createReactScopeValueStore() { // initialization code together with the context and would only populate // necessary values in the store, but this is simpler for now populateIdeReactScope(scopeStore) - populateProjectScope(scopeStore) populatePdfScope(scopeStore) scopeStore.allowNonExistentPath('hasLintingError') @@ -94,6 +91,8 @@ export const IdeReactProvider: FC = ({ children }) => { const [projectJoined, setProjectJoined] = useState(false) const { socket, getSocketDebuggingInfo } = useConnectionContext() + const { joinProject, project } = useProjectContext() + const spellCheckLanguage = project?.spellCheckLanguage const reportError = useCallback( (error: any, meta?: Record) => { @@ -105,7 +104,7 @@ export const IdeReactProvider: FC = ({ children }) => { performance_now: performance.now(), release, client_load: LOADED_AT, - spellCheckLanguage: scopeStore.get('project.spellCheckLanguage'), + spellCheckLanguage, ...getSocketDebuggingInfo(), } @@ -124,17 +123,28 @@ export const IdeReactProvider: FC = ({ children }) => { }, }) }, - [release, projectId, getSocketDebuggingInfo, scopeStore] + [release, projectId, getSocketDebuggingInfo, spellCheckLanguage] ) // Populate scope values when joining project, then fire project:joined event useEffect(() => { function handleJoinProjectResponse({ - project: { rootDoc_id: rootDocId, ..._project }, + project: { + rootDoc_id: rootDocId, + publicAccesLevel: publicAccessLevel, + ..._project + }, permissionsLevel, }: JoinProjectPayload) { - const project = { ..._project, rootDocId } - scopeStore.set('project', project) + const project = { ..._project, rootDocId, publicAccessLevel } + + // Cast the project from the payload as ProjectMetadata to ensure it has + // the correct type for the context. It must be close enough because the + // data structure hasn't changed and it worked previously. This type + // coercion was previously sidestepped by adding the project to the scope + // store, which does not enforce types. + joinProject(project as unknown as ProjectMetadata) + setPermissionsLevel(permissionsLevel) // Make watchers update immediately scopeStore.flushUpdates() @@ -142,21 +152,12 @@ export const IdeReactProvider: FC = ({ children }) => { setProjectJoined(true) } - function handleMainBibliographyDocUpdated(payload: string) { - scopeStore.set('project.mainBibliographyDoc_id', payload) - } - socket.on('joinProjectResponse', handleJoinProjectResponse) - socket.on('mainBibliographyDocUpdated', handleMainBibliographyDocUpdated) return () => { socket.removeListener('joinProjectResponse', handleJoinProjectResponse) - socket.removeListener( - 'mainBibliographyDocUpdated', - handleMainBibliographyDocUpdated - ) } - }, [socket, eventEmitter, scopeStore]) + }, [socket, eventEmitter, scopeStore, joinProject]) const ide = useMemo(() => { return { diff --git a/services/web/frontend/js/features/ide-react/context/outline-context.tsx b/services/web/frontend/js/features/ide-react/context/outline-context.tsx index ea3c38d105..40c2cb1928 100644 --- a/services/web/frontend/js/features/ide-react/context/outline-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/outline-context.tsx @@ -124,7 +124,7 @@ export const OutlineProvider: FC = ({ children }) => { [openDocName] ) - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const storageKey = `file_outline.expanded.${projectId}` const [outlineExpanded, setOutlineExpanded] = useState( diff --git a/services/web/frontend/js/features/ide-react/context/permissions-context.tsx b/services/web/frontend/js/features/ide-react/context/permissions-context.tsx index bdb101d3f5..c3621aac23 100644 --- a/services/web/frontend/js/features/ide-react/context/permissions-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/permissions-context.tsx @@ -98,7 +98,7 @@ export const PermissionsProvider: React.FC = ({ const { permissionsLevel } = useIdeReactContext() const hasViewerPermissions = useViewerPermissions() const anonymous = getMeta('ol-anonymous') - const project = useProjectContext() + const { features } = useProjectContext() useEffect(() => { let activePermissionsMap @@ -106,7 +106,7 @@ export const PermissionsProvider: React.FC = ({ activePermissionsMap = linkSharingWarningPermissionsMap } else if (anonymous) { activePermissionsMap = anonymousPermissionsMap - } else if (!project.features.trackChanges) { + } else if (!features.trackChanges) { activePermissionsMap = noTrackChangesPermissionsMap } else { activePermissionsMap = permissionsMap @@ -117,7 +117,7 @@ export const PermissionsProvider: React.FC = ({ permissionsLevel, setPermissions, hasViewerPermissions, - project.features.trackChanges, + features.trackChanges, ]) useEffect(() => { diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index f85e2c40fa..6236f2bfe2 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx @@ -75,10 +75,10 @@ export const ReactContextRoot: FC< - - - - + + + + @@ -128,10 +128,10 @@ export const ReactContextRoot: FC< - - - - + + + + diff --git a/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx b/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx index 817e03fe86..34b782b5bf 100644 --- a/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx @@ -55,7 +55,7 @@ export const SnapshotContext = createContext< >(undefined) export const SnapshotProvider: FC = ({ children }) => { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [snapshotLoadingState, setSnapshotLoadingState] = useState('') const [snapshotUpdater] = useState(() => new SnapshotUpdater(projectId)) diff --git a/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts b/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts index 388fa0299b..55d9f01ba9 100644 --- a/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts +++ b/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts @@ -4,7 +4,7 @@ import { listProjectInvites, listProjectMembers, } from '@/features/share-project-modal/utils/api' -import useScopeValue from '@/shared/hooks/use-scope-value' +import { useProjectContext } from '@/shared/context/project-context' import { useConnectionContext } from '@/features/ide-react/context/connection-context' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' import { useModalsContext } from '@/features/ide-react/context/modals-context' @@ -16,11 +16,9 @@ import { useLocation } from '@/shared/hooks/use-location' function useSocketListeners() { const { t } = useTranslation() const { socket } = useConnectionContext() - const { permissionsLevel, projectId } = useIdeReactContext() const { showGenericMessageModal } = useModalsContext() - const [, setPublicAccessLevel] = useScopeValue('project.publicAccesLevel') - const [, setProjectMembers] = useScopeValue('project.members') - const [, setProjectInvites] = useScopeValue('project.invites') + const { permissionsLevel } = useIdeReactContext() + const { projectId, updateProject } = useProjectContext() const location = useLocation() useSocketListener( @@ -51,10 +49,10 @@ function useSocketListeners() { useCallback( (data: { newAccessLevel?: PublicAccessLevel }) => { if (data.newAccessLevel) { - setPublicAccessLevel(data.newAccessLevel) + updateProject({ publicAccessLevel: data.newAccessLevel }) } }, - [setPublicAccessLevel] + [updateProject] ) ) @@ -65,13 +63,13 @@ function useSocketListeners() { listProjectMembers(projectId) .then(({ members }) => { if (members) { - setProjectMembers(members) + updateProject({ members }) } }) .catch(err => { debugConsole.error('Error fetching members for project', err) }) - }, [projectId, setProjectMembers]) + }, [projectId, updateProject]) ) useSocketListener( @@ -83,7 +81,7 @@ function useSocketListeners() { listProjectMembers(projectId) .then(({ members }) => { if (members) { - setProjectMembers(members) + updateProject({ members }) } }) .catch(err => { @@ -95,7 +93,7 @@ function useSocketListeners() { listProjectInvites(projectId) .then(({ invites }) => { if (invites) { - setProjectInvites(invites) + updateProject({ invites }) } }) .catch(err => { @@ -103,7 +101,29 @@ function useSocketListeners() { }) } }, - [projectId, setProjectInvites, setProjectMembers, permissionsLevel] + [projectId, updateProject, permissionsLevel] + ) + ) + + useSocketListener( + socket, + 'mainBibliographyDocUpdated', + useCallback( + (payload: string) => { + updateProject({ mainBibliographyDocId: payload }) + }, + [updateProject] + ) + ) + + useSocketListener( + socket, + 'projectNameUpdated', + useCallback( + (payload: string) => { + updateProject({ name: payload }) + }, + [updateProject] ) ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx index 712c874309..9be57c130e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next' export const DownloadProjectZip = () => { const { t } = useTranslation() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const sendDownloadEvent = useCallback(() => { sendMB('download-zip-button-click', { projectId, @@ -44,7 +44,7 @@ export const DownloadProjectZip = () => { export const DownloadProjectPDF = () => { const { t } = useTranslation() const { pdfDownloadUrl, pdfUrl } = useCompileContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const sendDownloadEvent = useCallback(() => { sendMB('download-pdf-button-click', { projectId, diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx index bf0b0378d2..4a6991f30b 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx @@ -9,7 +9,7 @@ import MaterialIcon from '@/shared/components/material-icon' function PdfHybridDownloadButton() { const { pdfDownloadUrl } = useCompileContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { t } = useTranslation() const description = pdfDownloadUrl diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx index 4a098e67da..e0ed7fdb78 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx @@ -23,7 +23,7 @@ type PdfJsViewerProps = { } function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { setError, firstRenderDone, highlights, position, setPosition } = useCompileContext() diff --git a/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts b/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts index 667374bb66..51c8eefc7c 100644 --- a/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts +++ b/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts @@ -28,7 +28,8 @@ export default function useSynctex(): { syncToCodeInFlight: boolean canSyncToPdf: boolean } { - const { _id: projectId, rootDocId } = useProjectContext() + const { projectId, project } = useProjectContext() + const rootDocId = project?.rootDocId const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } = useCompileContext() diff --git a/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx b/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx index 74c865632c..b95b1a11b4 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx @@ -55,7 +55,7 @@ function ReviewModeSwitcher() { const mode = useCurrentMode() const { permissionsLevel } = useIdeReactContext() const { write, trackedWrite } = usePermissionsContext() - const project = useProjectContext() + const { features } = useProjectContext() const [showUpgradeModal, setShowUpgradeModal] = useState(false) const showViewOption = permissionsLevel === 'readOnly' const view = useCodeMirrorViewContext() @@ -100,7 +100,7 @@ function ReviewModeSwitcher() { view.focus() return } - if (!project.features.trackChanges) { + if (!features.trackChanges) { setShowUpgradeModal(true) } else { sendMB('editing-mode-change', { @@ -212,7 +212,7 @@ const ModeSwitcherToggleButtonContent = forwardRef< }) const user = useUserContext() - const project = useProjectContext() + const { features } = useProjectContext() const { reviewPanelOpen } = useLayoutContext() const { inactiveTutorials } = useEditorContext() @@ -222,7 +222,7 @@ const ModeSwitcherToggleButtonContent = forwardRef< const canShowReviewModePromo = reviewPanelOpen && currentMode !== 'review' && - project.features.trackChanges && + features.trackChanges && user.signUpDate && user.signUpDate < '2025-03-15' && !hasCompletedReviewModeTutorial diff --git a/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx b/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx index 4948a00ce3..4611060438 100644 --- a/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx @@ -26,7 +26,7 @@ function UpgradeTrackChangesModal({ setShow, }: UpgradeTrackChangesModalProps) { const { t } = useTranslation() - const project = useProjectContext() + const { project } = useProjectContext() const user = useUserContext() return ( @@ -62,9 +62,9 @@ function UpgradeTrackChangesModal({ - {project.owner && ( + {Boolean(project?.owner) && (
- {project.owner._id === user.id ? ( + {project?.owner._id === user.id ? ( user.allowedFreeTrial ? ( ( export const ChangesUsersProvider: FC = ({ children, }) => { - const { _id: projectId, members, owner } = useProjectContext() + const { projectId, project } = useProjectContext() + const { members, owner } = project || {} const { isRestrictedTokenMember } = useEditorContext() const [changesUsers, setChangesUsers] = useState() @@ -49,6 +50,9 @@ export const ChangesUsersProvider: FC = ({ // add the project owner and members to the changes users data const value = useMemo(() => { + if (!owner || !members) { + return + } const value: ChangesUsers = new Map(changesUsers) value.set(owner._id, { ...owner, id: owner._id }) for (const member of members) { diff --git a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx index 5ad69245c7..6b73073bfc 100644 --- a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx @@ -49,7 +49,7 @@ const ThreadsActionsContext = createContext( ) export const ThreadsProvider: FC = ({ children }) => { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { currentDocument } = useEditorOpenDocContext() const { isRestrictedTokenMember } = useEditorContext() diff --git a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx index 453fec51b7..e68ef1fec1 100644 --- a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx @@ -15,7 +15,7 @@ import { useEditorPropertiesContext } from '@/features/ide-react/context/editor- import { useUserContext } from '@/shared/context/user-context' import { postJSON } from '@/infrastructure/fetch-json' import useEventListener from '@/shared/hooks/use-event-listener' -import { ProjectContextValue } from '@/shared/context/types/project-context' +import { ProjectMetadata } from '@/shared/context/types/project-metadata' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' export type TrackChangesState = { @@ -48,14 +48,14 @@ export const TrackChangesStateProvider: FC = ({ }) => { const permissions = usePermissionsContext() const { socket } = useConnectionContext() - const project = useProjectContext() + const { projectId, project, features } = useProjectContext() const user = useUserContext() const { setWantTrackChanges } = useEditorPropertiesContext() // TODO: update project.trackChangesState instead? const [trackChangesValue, setTrackChangesValue] = useState< - ProjectContextValue['trackChangesState'] - >(project.trackChangesState ?? false) + ProjectMetadata['trackChangesState'] + >(project?.trackChangesState ?? false) useSocketListener(socket, 'toggle-track-changes', setTrackChangesValue) @@ -88,11 +88,11 @@ export const TrackChangesStateProvider: FC = ({ const saveTrackChanges = useCallback( async (trackChangesBody: SaveTrackChangesRequestBody) => { - postJSON(`/project/${project._id}/track_changes`, { + postJSON(`/project/${projectId}/track_changes`, { body: trackChangesBody, }) }, - [project._id] + [projectId] ) const saveTrackChangesForCurrentUser = useCallback( @@ -122,7 +122,7 @@ export const TrackChangesStateProvider: FC = ({ useCallback(() => { if ( user.id && - project.features.trackChanges && + features.trackChanges && permissions.write && !onForEveryone ) { @@ -139,7 +139,7 @@ export const TrackChangesStateProvider: FC = ({ onForMembers, onForEveryone, permissions.write, - project.features.trackChanges, + features.trackChanges, user.id, ]) ) diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts index 47bce33e9f..f2f12ec664 100644 --- a/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts @@ -23,7 +23,7 @@ const safeParse = (value: string) => { } export default function useOverviewFileCollapsed(docId: DocId) { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [collapsedDocs, setCollapsedDocs] = usePersistedState< Record, string diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts index 95e854d9ca..23ff05479e 100644 --- a/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts @@ -6,7 +6,7 @@ import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' import { useConnectionContext } from '@/features/ide-react/context/connection-context' export default function useProjectRanges() { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [error, setError] = useState() const [projectRanges, setProjectRanges] = useState>() const [loading, setLoading] = useState(true) diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx index e80fb03711..a224d704f8 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx @@ -23,9 +23,10 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { const { t } = useTranslation() - const { updateProject, setInFlight, setError } = useShareProjectContext() + const { setInFlight, setError } = useShareProjectContext() - const { _id: projectId, members, invites, features } = useProjectContext() + const { projectId, project, features, updateProject } = useProjectContext() + const { members, invites } = project || {} const currentMemberEmails = useMemo( () => (members || []).map(member => member.email).sort(), @@ -82,9 +83,7 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { let data try { - const invite = (invites || []).find( - invite => invite.email === normalisedEmail - ) + const invite = invites?.find(invite => invite.email === normalisedEmail) if (invite) { data = await resendInvite(projectId, invite) @@ -109,8 +108,8 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null successful_invite: !!data.invite, users_updated: !!(data.users || data.user), - current_collaborators_amount: members.length, - current_invites_amount: invites.length, + current_collaborators_amount: members?.length || 0, + current_invites_amount: invites?.length || 0, role, previousEditorsAmount, previousReviewersAmount, @@ -144,15 +143,15 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { setInFlight(false) } else if (data.invite) { updateProject({ - invites: invites.concat(data.invite), + invites: invites?.concat(data.invite) || [data.invite], }) } else if (data.users) { updateProject({ - members: members.concat(data.users), + members: members?.concat(data.users) || data.users, }) } else if (data.user) { updateProject({ - members: members.concat(data.user), + members: members?.concat(data.user) || [data.user], }) } diff --git a/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx b/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx index 9f24cddc4a..c8a7216e2c 100644 --- a/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx @@ -6,7 +6,7 @@ import { removeMemberFromProject, updateMember } from '../utils/api' import { useProjectContext } from '@/shared/context/project-context' import { sendMB } from '@/infrastructure/event-tracking' import { Select } from '@/shared/components/select' -import type { ProjectContextMember } from '@/shared/context/types/project-context' +import type { ProjectMember } from '@/shared/context/types/project-metadata' import { PermissionsLevel } from '@/features/ide-react/types/permissions' import { linkSharingEnforcementDate } from '../utils/link-sharing' import OLButton from '@/features/ui/components/ol/ol-button' @@ -19,7 +19,7 @@ import { upgradePlan } from '@/main/account-upgrade' type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded' type EditMemberProps = { - member: ProjectContextMember + member: ProjectMember hasExceededCollaboratorLimit: boolean hasBeenDowngraded: boolean canAddCollaborators: boolean @@ -51,8 +51,9 @@ export default function EditMember({ setPrivileges(member.privileges) }, [member.privileges]) - const { updateProject, monitorRequest } = useShareProjectContext() - const { _id: projectId, members, invites } = useProjectContext() + const { monitorRequest } = useShareProjectContext() + const { projectId, project, updateProject } = useProjectContext() + const { members, invites } = project || {} const user = useUserContext() // Immediately commit this change if it's lower impact (eg. editor > viewer) @@ -88,14 +89,15 @@ export default function EditMember({ } else if (newPrivileges === 'removeAccess') { monitorRequest(() => removeMemberFromProject(projectId, member)).then( () => { - const updatedMembers = members.filter(existing => existing !== member) + const updatedMembers = + members?.filter(existing => existing !== member) || [] updateProject({ members: updatedMembers, }) sendMB('collaborator-removed', { project_id: projectId, current_collaborators_amount: updatedMembers.length, - current_invites_amount: invites.length, + current_invites_amount: invites?.length || 0, }) } ) @@ -110,9 +112,10 @@ export default function EditMember({ }) ).then(() => { updateProject({ - members: members.map(item => - item._id === member._id ? { ...item, newPrivileges } : item - ), + members: + members?.map(item => + item._id === member._id ? { ...item, newPrivileges } : item + ) || [], }) }) } diff --git a/services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal.tsx b/services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal.tsx index 8914d8ba3b..acfdc0a3e0 100644 --- a/services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal.tsx @@ -12,7 +12,8 @@ const EditorOverLimitModal = () => { const { isProjectOwner } = useEditorContext() const { permissionsLevel } = useIdeReactContext() - const { members, features, _id: projectId } = useProjectContext() + const { project, features, projectId } = useProjectContext() + const members = project?.members const handleHide = () => { setShow(false) @@ -24,7 +25,12 @@ const EditorOverLimitModal = () => { useEffect(() => { const showModalCooldownHours = 24 const hasExceededCollaboratorLimit = () => { - if (isProjectOwner || !features || permissionsLevel === 'readOnly') { + if ( + isProjectOwner || + !features || + !members || + permissionsLevel === 'readOnly' + ) { return false } diff --git a/services/web/frontend/js/features/share-project-modal/components/invite.tsx b/services/web/frontend/js/features/share-project-modal/components/invite.tsx index e9d761e4ee..391a6f69dd 100644 --- a/services/web/frontend/js/features/share-project-modal/components/invite.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/invite.tsx @@ -10,13 +10,13 @@ import OLCol from '@/features/ui/components/ol/ol-col' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import OLButton from '@/features/ui/components/ol/ol-button' import MaterialIcon from '@/shared/components/material-icon' -import { ProjectContextMember } from '@/shared/context/types/project-context' +import { ProjectMember } from '@/shared/context/types/project-metadata' export default function Invite({ invite, isProjectOwner, }: { - invite: ProjectContextMember + invite: ProjectMember isProjectOwner: boolean }) { const { t } = useTranslation() @@ -44,10 +44,10 @@ export default function Invite({ ) } -function ResendInvite({ invite }: { invite: ProjectContextMember }) { +function ResendInvite({ invite }: { invite: ProjectMember }) { const { t } = useTranslation() const { monitorRequest, setError, inFlight } = useShareProjectContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() // const buttonRef = useRef(null) // @@ -87,23 +87,25 @@ function ResendInvite({ invite }: { invite: ProjectContextMember }) { ) } -function RevokeInvite({ invite }: { invite: ProjectContextMember }) { +function RevokeInvite({ invite }: { invite: ProjectMember }) { const { t } = useTranslation() - const { updateProject, monitorRequest } = useShareProjectContext() - const { _id: projectId, invites, members } = useProjectContext() + const { monitorRequest } = useShareProjectContext() + const { projectId, project, updateProject } = useProjectContext() + const { invites, members } = project || {} function handleClick(event: React.MouseEvent) { event.preventDefault() monitorRequest(() => revokeInvite(projectId, invite)).then(() => { - const updatedInvites = invites.filter(existing => existing !== invite) + const updatedInvites = + invites?.filter(existing => existing !== invite) || [] updateProject({ invites: updatedInvites, }) sendMB('collaborator-invite-revoked', { project_id: projectId, current_invites_amount: updatedInvites.length, - current_collaborators_amount: members.length, + current_collaborators_amount: members?.length || 0, }) }) } diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx index a2d17734b0..e4db7e9a68 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx @@ -33,7 +33,8 @@ export default function LinkSharing() { const { monitorRequest } = useShareProjectContext() - const { _id: projectId, publicAccessLevel } = useProjectContext() + const { projectId, project } = useProjectContext() + const { publicAccessLevel } = project || {} // set the access level of a project const setAccessLevel = useCallback( @@ -145,7 +146,7 @@ function TokenBasedSharing({ showLinks: boolean }) { const { t } = useTranslation() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [tokens, setTokens] = useState(null) @@ -244,7 +245,7 @@ function LegacySharing({ export function ReadOnlyTokenLink() { const { t } = useTranslation() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [tokens, setTokens] = useState(null) diff --git a/services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx b/services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx index c2b7bf98ef..0ed0d9bb8a 100644 --- a/services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx @@ -1,10 +1,10 @@ -import { ProjectContextMember } from '@/shared/context/types/project-context' +import { ProjectMember } from '@/shared/context/types/project-metadata' import { useTranslation } from 'react-i18next' export default function MemberPrivileges({ privileges, }: { - privileges: ProjectContextMember['privileges'] + privileges: ProjectMember['privileges'] }) { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/share-project-modal/components/owner-info.tsx b/services/web/frontend/js/features/share-project-modal/components/owner-info.tsx index c289c41568..2f30dd2f20 100644 --- a/services/web/frontend/js/features/share-project-modal/components/owner-info.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/owner-info.tsx @@ -6,14 +6,14 @@ import MaterialIcon from '@/shared/components/material-icon' export default function OwnerInfo() { const { t } = useTranslation() - const { owner } = useProjectContext() + const { project } = useProjectContext() return (
-
{owner?.email}
+
{project?.owner.email}
diff --git a/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx b/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx index c7417db158..155f908ed4 100644 --- a/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx @@ -7,7 +7,8 @@ import OLRow from '@/features/ui/components/ol/ol-row' import OLCol from '@/features/ui/components/ol/ol-col' export default function SendInvitesNotice() { - const { publicAccessLevel } = useProjectContext() + const { project } = useProjectContext() + const { publicAccessLevel } = project || {} const { isPendingEditor } = useEditorContext() const { t } = useTranslation() diff --git a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx index 886e877f55..c12ad1d2a5 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx @@ -12,7 +12,8 @@ import RecaptchaConditions from '@/shared/components/recaptcha-conditions' import getMeta from '@/utils/meta' export default function ShareModalBody() { - const { members, invites, features } = useProjectContext() + const { project, features } = useProjectContext() + const { members, invites } = project || {} const { isProjectOwner } = useEditorContext() // whether the project has not reached the collaborator limit @@ -26,12 +27,12 @@ export default function ShareModalBody() { return true } - const editorInvites = invites.filter( - invite => invite.privileges !== 'readOnly' - ).length + const editorInvites = + invites?.filter(invite => invite.privileges !== 'readOnly').length || 0 return ( - members.filter(member => member.privileges !== 'readOnly').length + + (members?.filter(member => member.privileges !== 'readOnly').length || + 0) + editorInvites < (features.collaborators ?? 1) ) @@ -40,11 +41,11 @@ export default function ShareModalBody() { // determine if some but not all pending editors' permissions have been resolved, // for moving between warning and info notification states etc. const somePendingEditorsResolved = useMemo(() => { - return ( - members.some(member => + return Boolean( + members?.some(member => ['readAndWrite', 'review'].includes(member.privileges) ) && - members.some(member => member.pendingEditor || member.pendingReviewer) + members?.some(member => member.pendingEditor || member.pendingReviewer) ) }, [members]) @@ -56,13 +57,14 @@ export default function ShareModalBody() { if (features.collaborators === -1) { return false } - return members.some( - member => member.pendingEditor || member.pendingReviewer + return ( + members?.some(member => member.pendingEditor || member.pendingReviewer) || + false ) }, [features, isProjectOwner, members]) const hasExceededCollaboratorLimit = useMemo(() => { - if (!isProjectOwner || !features) { + if (!isProjectOwner || !features || !members) { return false } @@ -77,6 +79,9 @@ export default function ShareModalBody() { }, [features, isProjectOwner, members]) const sortedMembers = useMemo(() => { + if (!members) { + return [] + } return [ ...members.filter(member => member.privileges === 'readAndWrite'), ...members.filter(member => member.pendingEditor), @@ -126,7 +131,7 @@ export default function ShareModalBody() { ) )} - {invites.map(invite => ( + {(invites || []).map(invite => ( void monitorRequest: >(request: () => T) => T inFlight: boolean setInFlight: React.Dispatch< @@ -61,7 +59,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ useState(false) const [error, setError] = useState() - const project = useProjectContext() + const { project, projectId } = useProjectContext() const { isProjectOwner } = useEditorContext() const { splitTestVariants } = useSplitTestContext() @@ -70,7 +68,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ // is over collaborator limit or has pending editors (once every 24 hours) useEffect(() => { const hasExceededCollaboratorLimit = () => { - if (!isProjectOwner || !project.features) { + if (!isProjectOwner || !project || !project.features) { return false } @@ -88,7 +86,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ } if (hasExceededCollaboratorLimit()) { - const localStorageKey = `last-shown-share-modal.${project._id}` + const localStorageKey = `last-shown-share-modal.${projectId}` const lastShownShareModalTime = customLocalStorage.getItem(localStorageKey) if ( @@ -99,17 +97,17 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ customLocalStorage.setItem(localStorageKey, Date.now()) } } - }, [project, isProjectOwner, handleOpen]) + }, [project, isProjectOwner, handleOpen, projectId]) // send tracking event when the modal is opened useEffect(() => { if (show) { sendMB('share-modal-opened', { splitTestVariant: splitTestVariants['null-test-share-modal'], - project_id: project._id, + project_id: projectId, }) } - }, [splitTestVariants, project._id, show]) + }, [splitTestVariants, projectId, show]) // reset error when the modal is opened useEffect(() => { @@ -147,12 +145,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ return promise }, []) - // merge the new data with the old project data - const updateProject = useCallback( - (data: ProjectContextUpdateValue) => Object.assign(project, data), - [project] - ) - if (!project) { return null } @@ -160,7 +152,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ return ( void }) { const { t } = useTranslation() @@ -27,7 +27,7 @@ export default function TransferOwnershipModal({ const [error, setError] = useState(false) const location = useLocation() - const { _id: projectId, name: projectName } = useProjectContext() + const { projectId, name: projectName } = useProjectContext() function confirm() { setError(false) diff --git a/services/web/frontend/js/features/share-project-modal/components/view-member.tsx b/services/web/frontend/js/features/share-project-modal/components/view-member.tsx index d4cf2e9333..1822af9ce3 100644 --- a/services/web/frontend/js/features/share-project-modal/components/view-member.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/view-member.tsx @@ -2,13 +2,9 @@ import MemberPrivileges from './member-privileges' import OLRow from '@/features/ui/components/ol/ol-row' import OLCol from '@/features/ui/components/ol/ol-col' import MaterialIcon from '@/shared/components/material-icon' -import { ProjectContextMember } from '@/shared/context/types/project-context' +import { ProjectMember } from '@/shared/context/types/project-metadata' -export default function ViewMember({ - member, -}: { - member: ProjectContextMember -}) { +export default function ViewMember({ member }: { member: ProjectMember }) { return ( diff --git a/services/web/frontend/js/features/share-project-modal/components/view-only-access-modal.tsx b/services/web/frontend/js/features/share-project-modal/components/view-only-access-modal.tsx index d474e681c1..daae76bd7a 100644 --- a/services/web/frontend/js/features/share-project-modal/components/view-only-access-modal.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/view-only-access-modal.tsx @@ -12,7 +12,8 @@ const ViewOnlyAccessModal = () => { const { isProjectOwner, isPendingEditor } = useEditorContext() const { permissionsLevel } = useIdeReactContext() - const { members, features, _id: projectId } = useProjectContext() + const { features, projectId, project } = useProjectContext() + const members = project?.members const handleHide = () => { setShow(false) diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx index 6ab6acb6a0..12c6ba0e7a 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx @@ -34,7 +34,7 @@ export const FigureModalOtherProjectSource: FC = () => { const { t } = useTranslation() const view = useCodeMirrorViewContext() const { dispatch } = useFigureModalContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { loading: projectsLoading, data: projects, error } = useUserProjects() const [selectedProject, setSelectedProject] = useState(null) const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } = diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx index 94877fb233..b92398ef4b 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx @@ -34,7 +34,7 @@ export const FigureModalUploadFileSource: FC = () => { const { t } = useTranslation() const view = useCodeMirrorViewContext() const { dispatch, pastedImageData } = useFigureModalContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { rootFile } = useCurrentProjectFolders() const [folder, setFolder] = useState(null) const [nameDirty, setNameDirty] = useState(false) diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx index 2db449e172..c93ebd5c94 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx @@ -46,7 +46,7 @@ export const FigureModalUrlSource: FC = () => { const [url, setUrl] = useState('') const [nameDirty, setNameDirty] = useState(false) const [name, setName] = useState('') - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { rootFile } = useCurrentProjectFolders() const [folder, setFolder] = useState(rootFile) diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index f894758de9..8e46bdd7c5 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef } from 'react' import { EditorState } from '@codemirror/state' -import useScopeValue from '../../../shared/hooks/use-scope-value' import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter' import useEventListener from '../../../shared/hooks/use-event-listener' import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener' @@ -55,6 +54,7 @@ import { GotoOffsetOptions } from '@/features/ide-react/context/editor-manager-c import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' +import { useProjectContext } from '@/shared/context/project-context' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import { SearchQuery } from '@codemirror/search' @@ -92,17 +92,13 @@ function useCodeMirrorScope(view: EditorView) { const { onlineUserCursorHighlights } = useOnlineUsersContext() - let [spellCheckLanguage] = useScopeValue('project.spellCheckLanguage') + const { project, features: projectFeatures } = useProjectContext() + let spellCheckLanguage = project?.spellCheckLanguage || '' // spell check is off when read-only if (!permissions.write && !permissions.trackedWrite) { spellCheckLanguage = '' } - const [projectFeatures] = - useScopeValue>( - 'project.features' - ) - const hunspellManager = useHunspell(spellCheckLanguage) const { showVisual: visual, trackChanges } = useEditorPropertiesContext() diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx index 4dd58958ac..f5208166c4 100644 --- a/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx +++ b/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx @@ -19,7 +19,8 @@ export const WordCountClient: FC = () => { const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [data, setData] = useState(null) - const { projectSnapshot, rootDocId } = useProjectContext() + const { projectSnapshot, project } = useProjectContext() + const rootDocId = project?.rootDocId const { spellCheckLanguage } = useProjectSettingsContext() const { openDocs } = useEditorManagerContext() const { currentDocument } = useEditorOpenDocContext() diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx index a34807864e..4435b48500 100644 --- a/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx +++ b/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx @@ -10,7 +10,7 @@ import { debugConsole } from '@/utils/debugging' import { WordCounts } from '@/features/word-count-modal/components/word-counts' export const WordCountServer: FC = () => { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { clsiServerId } = useLocalCompileContext() const [loading, setLoading] = useState(true) diff --git a/services/web/frontend/js/shared/context/editor-context.tsx b/services/web/frontend/js/shared/context/editor-context.tsx index 945c6b63af..d3d56fe64a 100644 --- a/services/web/frontend/js/shared/context/editor-context.tsx +++ b/services/web/frontend/js/shared/context/editor-context.tsx @@ -9,9 +9,7 @@ import { useMemo, useState, } from 'react' -import useScopeValue from '../hooks/use-scope-value' import useBrowserWindow from '../hooks/use-browser-window' -import { useIdeContext } from './ide-context' import { useProjectContext } from './project-context' import { useDetachContext } from './detach-context' import getMeta from '../../utils/meta' @@ -46,12 +44,18 @@ export const EditorContext = createContext< >(undefined) export const EditorProvider: FC = ({ children }) => { - const { socket } = useIdeContext() const { id: userId, featureUsage } = useUserContext() const { role } = useDetachContext() const { showGenericMessageModal } = useModalsContext() - const { owner, features, _id: projectId, members } = useProjectContext() + const { + features, + projectId, + project, + name: projectName, + updateProject, + } = useProjectContext() + const { owner, members } = project || {} const cobranding = useMemo(() => { const brandVariation = getMeta('ol-brandVariation') @@ -71,8 +75,6 @@ export const EditorProvider: FC = ({ children }) => { ) }, []) - const [projectName, setProjectName] = useScopeValue('project.name') - const [inactiveTutorials, setInactiveTutorials] = useState( () => getMeta('ol-inactiveTutorials') || [] ) @@ -95,10 +97,12 @@ export const EditorProvider: FC = ({ children }) => { const isPendingEditor = useMemo( () => - members?.some( - member => - member._id === userId && - (member.pendingEditor || member.pendingReviewer) + Boolean( + members?.some( + member => + member._id === userId && + (member.pendingEditor || member.pendingReviewer) + ) ), [members, userId] ) @@ -110,33 +114,25 @@ export const EditorProvider: FC = ({ children }) => { [inactiveTutorials] ) - useEffect(() => { - if (socket) { - socket.on('projectNameUpdated', setProjectName) - return () => socket.removeListener('projectNameUpdated', setProjectName) - } - }, [socket, setProjectName]) - const renameProject = useCallback( (newName: string) => { - setProjectName((oldName: string) => { - if (oldName !== newName) { - saveProjectSettings(projectId, { name: newName }).catch( - (response: any) => { - setProjectName(oldName) - const { data, status } = response + const oldName = projectName + if (newName !== oldName) { + updateProject({ name: newName }) + saveProjectSettings(projectId, { name: newName }).catch( + (response: any) => { + updateProject({ name: oldName }) + const { data, status } = response - showGenericMessageModal( - 'Error renaming project', - status === 400 ? data : 'Please try again in a moment' - ) - } - ) - } - return newName - }) + showGenericMessageModal( + 'Error renaming project', + status === 400 ? data : 'Please try again in a moment' + ) + } + ) + } }, - [setProjectName, projectId, showGenericMessageModal] + [projectName, updateProject, projectId, showGenericMessageModal] ) const { setTitle } = useBrowserWindow() diff --git a/services/web/frontend/js/shared/context/file-tree-data-context.tsx b/services/web/frontend/js/shared/context/file-tree-data-context.tsx index 889c865e91..be48f52776 100644 --- a/services/web/frontend/js/shared/context/file-tree-data-context.tsx +++ b/services/web/frontend/js/shared/context/file-tree-data-context.tsx @@ -8,7 +8,6 @@ import { FC, useEffect, } from 'react' -import useScopeValue from '../hooks/use-scope-value' import { renameInTree, deleteInTree, @@ -20,7 +19,6 @@ import useDeepCompareEffect from '../../shared/hooks/use-deep-compare-effect' import { docsInFolder } from '@/features/file-tree/util/docs-in-folder' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' import { Folder } from '../../../../types/folder' -import { Project } from '../../../../types/project' import { MainDocument } from '../../../../types/project-settings' import { FindResult } from '@/features/file-tree/util/path' import { @@ -28,6 +26,7 @@ import { useSnapshotContext, } from '@/features/ide-react/context/snapshot-context' import importOverleafModules from '../../../macros/import-overleaf-module.macro' +import { useProjectContext } from '@/shared/context/project-context' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' const { buildFileTree, createFolder } = @@ -182,7 +181,7 @@ export function useFileTreeData() { export const FileTreeDataProvider: FC = ({ children, }) => { - const [project] = useScopeValue('project') + const { project } = useProjectContext() const { currentDocumentId, setOpenDocName } = useEditorOpenDocContext() const { permissionsLevel } = useIdeReactContext() const { fileTreeFromHistory, snapshot, snapshotVersion } = @@ -195,7 +194,7 @@ export const FileTreeDataProvider: FC = ({ useEffect(() => { if (fileTreeFromHistory) return setRootFolder(project?.rootFolder) - }, [project, fileTreeFromHistory]) + }, [project?.rootFolder, fileTreeFromHistory]) useEffect(() => { if (!fileTreeFromHistory) return diff --git a/services/web/frontend/js/shared/context/local-compile-context.tsx b/services/web/frontend/js/shared/context/local-compile-context.tsx index 827688c2b2..0e938aa881 100644 --- a/services/web/frontend/js/shared/context/local-compile-context.tsx +++ b/services/web/frontend/js/shared/context/local-compile-context.tsx @@ -131,13 +131,8 @@ export const LocalCompileProvider: FC = ({ const { currentDocument } = useEditorOpenDocContext() const { role } = useDetachContext() - const { - _id: projectId, - rootDocId, - joinedOnce, - imageName, - compiler: compilerName, - } = useProjectContext() + const { projectId, joinedOnce, project } = useProjectContext() + const { rootDocId, imageName, compiler: compilerName } = project || {} const { pdfPreviewOpen } = useLayoutContext() diff --git a/services/web/frontend/js/shared/context/project-context.tsx b/services/web/frontend/js/shared/context/project-context.tsx index f294a88021..8861aba1a5 100644 --- a/services/web/frontend/js/shared/context/project-context.tsx +++ b/services/web/frontend/js/shared/context/project-context.tsx @@ -1,10 +1,31 @@ -import { FC, createContext, useContext, useMemo, useState } from 'react' -import useScopeValue from '../hooks/use-scope-value' +import { + FC, + createContext, + useContext, + useMemo, + useState, + useCallback, +} from 'react' import getMeta from '@/utils/meta' -import { ProjectContextValue } from './types/project-context' +import { ProjectUpdate, ProjectMetadata } from './types/project-metadata' import { ProjectSnapshot } from '@/infrastructure/project-snapshot' +import { Tag } from '../../../../app/src/Features/Tags/types' -const ProjectContext = createContext(undefined) +type ProjectContextValue = { + projectId: ProjectMetadata['_id'] + project: ProjectMetadata | null + joinProject: (project: ProjectMetadata) => void + updateProject: (projectUpdate: ProjectUpdate) => void + joinedOnce: boolean + projectSnapshot: ProjectSnapshot + tags: Tag[] + features: ProjectMetadata['features'] + name: ProjectMetadata['name'] +} + +export const ProjectContext = createContext( + undefined +) export function useProjectContext() { const context = useContext(ProjectContext) @@ -18,35 +39,33 @@ export function useProjectContext() { return context } -// when the provider is created the project is still not added to the Angular -// scope. A few props are populated to prevent errors in existing React -// components -const projectFallback = { - _id: getMeta('ol-project_id'), - name: '', - features: {}, -} - export const ProjectProvider: FC = ({ children }) => { - const [project] = useScopeValue('project') - const joinedOnce = !!project + const [joinedOnce, setJoinedOnce] = useState(false) + const [project, setProject] = useState(null) - const { - _id, - compiler, - imageName, - name, - rootDocId, - members, - invites, - features, - publicAccesLevel: publicAccessLevel, - owner, - trackChangesState, - mainBibliographyDoc_id: mainBibliographyDocId, - } = project || projectFallback + // Expose some project properties with fallbacks for convenience + const projectId = project ? project._id : getMeta('ol-project_id') + const name = project ? project.name : '' + const features = project ? project.features : {} - const [projectSnapshot] = useState(() => new ProjectSnapshot(_id)) + const joinProject = useCallback((projectData: ProjectMetadata) => { + setProject(projectData) + setJoinedOnce(true) + }, []) + + const updateProject = useCallback((projectUpdateData: ProjectUpdate) => { + setProject(projectData => { + // Only perform the update if `project` is already set, otherwise we could + // end up with an incomplete project object + if (!projectData) { + throw new Error('Project not initialized. Use joinProject first.') + } + + return Object.assign({}, projectData, projectUpdateData) + }) + }, []) + + const [projectSnapshot] = useState(() => new ProjectSnapshot(projectId)) const tags = useMemo( () => @@ -56,41 +75,17 @@ export const ProjectProvider: FC = ({ children }) => { [] ) - const value = useMemo(() => { - return { - _id, - compiler, - imageName, - name, - rootDocId, - members, - invites, - features, - publicAccessLevel, - owner, - tags, - trackChangesState, - mainBibliographyDocId, - projectSnapshot, - joinedOnce, - } - }, [ - _id, - compiler, - imageName, - name, - rootDocId, - members, - invites, - features, - publicAccessLevel, - owner, - tags, - trackChangesState, - mainBibliographyDocId, - projectSnapshot, + const value = { + projectId, + project, + joinProject, + updateProject, joinedOnce, - ]) + projectSnapshot, + tags, + features, + name, + } return ( {children} diff --git a/services/web/frontend/js/shared/context/types/project-context.tsx b/services/web/frontend/js/shared/context/types/project-metadata.tsx similarity index 65% rename from services/web/frontend/js/shared/context/types/project-context.tsx rename to services/web/frontend/js/shared/context/types/project-metadata.tsx index 4e1abdc420..b0847dfda7 100644 --- a/services/web/frontend/js/shared/context/types/project-context.tsx +++ b/services/web/frontend/js/shared/context/types/project-metadata.tsx @@ -1,9 +1,9 @@ import { UserId } from '../../../../../types/user' import { PublicAccessLevel } from '../../../../../types/public-access-level' -import { ProjectSnapshot } from '@/infrastructure/project-snapshot' -import { Tag } from '../../../../../app/src/Features/Tags/types' +import { ProjectSettings } from '@/features/editor-left-menu/utils/api' +import { Folder } from '../../../../../types/folder' -export type ProjectContextMember = { +export type ProjectMember = { _id: UserId privileges: 'readOnly' | 'readAndWrite' | 'review' email: string @@ -13,15 +13,11 @@ export type ProjectContextMember = { pendingReviewer?: boolean } -export type ProjectContextValue = { +export interface ProjectMetadata extends ProjectSettings { _id: string - name: string - rootDocId?: string mainBibliographyDocId?: string - compiler: string - imageName: string - members: ProjectContextMember[] - invites: ProjectContextMember[] + members: ProjectMember[] + invites: ProjectMember[] features: { collaborators?: number compileGroup?: 'alpha' | 'standard' | 'priority' @@ -44,10 +40,8 @@ export type ProjectContextValue = { privileges: string signUpDate: string } - tags: Tag[] + rootFolder?: Folder[] trackChangesState: boolean | Record - projectSnapshot: ProjectSnapshot - joinedOnce: boolean } -export type ProjectContextUpdateValue = Partial +export type ProjectUpdate = Partial diff --git a/services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts b/services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts index 05dc7e36e6..b85c7a0e7d 100644 --- a/services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts +++ b/services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts @@ -10,7 +10,7 @@ type UseStopOnFirstErrorProps = { export function useStopOnFirstError(opts: UseStopOnFirstErrorProps = {}) { const { eventSource } = opts const { stopOnFirstError, setStopOnFirstError } = useCompileContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() type Opts = { projectId: string diff --git a/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx b/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx index 72955f38e6..d65669f720 100644 --- a/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx +++ b/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx @@ -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('', function () { beforeEach(function () { @@ -57,9 +62,14 @@ describe('', function () { it('render full menu', function () { const scope = mockScope() + const project = mockProject() cy.mount( - + ) @@ -209,12 +219,6 @@ describe('', function () { }) const scope = mockScope({ - project: { - members: [], - owner: { - _id: '123', - }, - }, user: { features: { dropbox: false, @@ -226,6 +230,14 @@ describe('', function () { @@ -236,21 +248,25 @@ describe('', function () { }) it('shows git modal correctly', function () { - const scope = mockScope({ - project: { - owner: { - _id: '123', - }, - features: { - gitBridge: true, - }, - }, - }) + const scope = mockScope() cy.mount( @@ -262,21 +278,25 @@ describe('', function () { }) it('shows git modal paywall correctly', function () { - const scope = mockScope({ - project: { - owner: { - _id: '123', - }, - features: { - gitBridge: false, - }, - }, - }) + const scope = mockScope() cy.mount( diff --git a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx index 47f7e1024d..34a3893ab5 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx @@ -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('', function () { cached: false, setup: () => {}, props: { - compiler: 'lualatex', + compiler: 'lualatex' as ProjectCompiler, }, }, 'ignores the compile from cache when draft mode changed': { diff --git a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx index 6a837ed81f..0f523349e6 100644 --- a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx +++ b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx @@ -15,9 +15,9 @@ describe('', 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('', function () { openProject={openProject} show />, - { scope: { project } } + contextProps ) await screen.findByText('Copy project') @@ -55,7 +55,7 @@ describe('', function () { openProject={openProject} show />, - { scope: { project } } + contextProps ) const cancelButton = await screen.findByRole('button', { name: 'Cancel' }) @@ -123,7 +123,7 @@ describe('', function () { openProject={openProject} show />, - { scope: { project } } + contextProps ) const button = await screen.findByRole('button', { name: 'Copy' }) @@ -160,7 +160,7 @@ describe('', function () { openProject={openProject} show />, - { scope: { project } } + contextProps ) const button = await screen.findByRole('button', { name: 'Copy' }) diff --git a/services/web/test/frontend/features/review-panel/review-panel.spec.tsx b/services/web/test/frontend/features/review-panel/review-panel.spec.tsx index 9cccd5093b..fcad15969b 100644 --- a/services/web/test/frontend/features/review-panel/review-panel.spec.tsx +++ b/services/web/test/frontend/features/review-panel/review-panel.spec.tsx @@ -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('', function () { removeChangeIds, }, }, - projectFeatures: { trackChangesVisible: true }, + }) + const project = mockProject({ + projectOwner: { + _id: USER_ID, + }, + projectFeatures: { trackChanges: false, trackChangesVisible: true }, }) cy.wrap(scope).as('scope') cy.mount( - + @@ -653,6 +663,10 @@ describe(' 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(' in mini mode', function () { cy.mount( - + @@ -717,7 +734,7 @@ describe(' 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(' 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(' 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(' for free users', function () { cy.mount( - + diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx index 793ff7b16d..548423e90f 100644 --- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx +++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx @@ -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('', 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('', function () { const contacts = [ // user with edited name { @@ -101,9 +105,10 @@ describe('', function () { }) it('renders the modal', async function () { - renderWithEditorContext(, { - scope: { project }, - }) + renderWithEditorContext( + , + createContextProps() + ) await screen.findByText('Share Project') }) @@ -113,7 +118,7 @@ describe('', function () { renderWithEditorContext( , - { scope: { project } } + createContextProps() ) const [headerCloseButton, footerCloseButton] = await screen.findAllByRole( @@ -128,9 +133,10 @@ describe('', function () { }) it('handles access level "private"', async function () { - renderWithEditorContext(, { - scope: { project: { ...project, publicAccesLevel: 'private' } }, - }) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'private' }) + ) await screen.findByText('Link sharing is off') await screen.findByRole('button', { name: 'Turn on link sharing' }) @@ -149,10 +155,11 @@ describe('', function () { readAndWriteHashPrefix: 'taEVki', readOnlyHashPrefix: 'j2xYbL', } - fetchMock.get(`/project/${project._id}/tokens`, tokens) - renderWithEditorContext(, { - scope: { project: { ...project, publicAccesLevel: 'tokenBased' } }, - }) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, tokens) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) await screen.findByText('Link sharing is on') await screen.findByRole('button', { name: 'Turn off link sharing' }) @@ -171,9 +178,10 @@ describe('', function () { }) it('handles legacy access level "readAndWrite"', async function () { - renderWithEditorContext(, { - scope: { project: { ...project, publicAccesLevel: 'readAndWrite' } }, - }) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'readAndWrite' }) + ) await screen.findByText( 'This project is public and can be edited by anyone with the URL.' @@ -182,9 +190,10 @@ describe('', function () { }) it('handles legacy access level "readOnly"', async function () { - renderWithEditorContext(, { - scope: { project: { ...project, publicAccesLevel: 'readOnly' } }, - }) + renderWithEditorContext( + , + 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('', 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('', function () { ] // render as project owner: actions should be present - render( - - - + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased', invites }) ) await screen.findByRole('button', { name: 'Turn off link sharing' }) @@ -231,23 +231,13 @@ describe('', function () { }, ] - render( - - - - ) + renderWithEditorContext(, { + ...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('', function () { }, ] - render( - - - - ) + renderWithEditorContext(, { + ...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('', 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(, { + ...createContextProps({ publicAccessLevel: 'private' }), isRestrictedTokenMember: true, - scope: { project: { ...project, publicAccesLevel: 'tokenBased' } }, }) // no buttons @@ -346,18 +326,12 @@ describe('', function () { }, ] - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) - renderWithEditorContext(, { - scope: { - project: { - ...project, - members, - invites, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased', members, invites }) + ) const projectOwnerEmail = USER_EMAIL @@ -381,7 +355,7 @@ describe('', 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('', function () { }, ] - renderWithEditorContext(, { - scope: { - project: { - ...project, - invites, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased', invites }) + ) const [, closeButton] = screen.getAllByRole('button', { name: 'Close', @@ -419,7 +388,7 @@ describe('', 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('', function () { }, ] - renderWithEditorContext(, { - scope: { - project: { - ...project, - invites, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased', invites }) + ) const [, closeButton] = screen.getAllByRole('button', { name: 'Close', @@ -453,7 +417,7 @@ describe('', 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('', function () { }, ] - renderWithEditorContext(, { - scope: { - project: { - ...project, - members, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased', members }) + ) - const [, closeButton] = await screen.getAllByRole('button', { + const [, closeButton] = screen.getAllByRole('button', { name: 'Close', }) @@ -494,7 +453,7 @@ describe('', 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('', function () { }, ] - renderWithEditorContext(, { - scope: { - project: { - ...project, - members, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased', members }) + ) expect( await screen.findAllByText('member-viewer@example.com') @@ -537,7 +491,7 @@ describe('', 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('', function () { }, ] - renderWithEditorContext(, { - scope: { - project: { - ...project, - members, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + , + 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('', function () { }) it('sends invites to input email addresses', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) - renderWithEditorContext(, { - scope: { - project: { - ...project, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) const [inputElement] = await screen.findAllByLabelText('Add people') @@ -680,26 +625,24 @@ describe('', 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(, { - scope: { - project: { - ...project, - publicAccesLevel: 'tokenBased', - features: { - collaborators: 0, - compileGroup: 'standard', - trackChangesVisible: true, - }, + renderWithEditorContext( + , + createContextProps({ + publicAccessLevel: 'tokenBased', + features: { + collaborators: 0, + compileGroup: 'standard', + trackChangesVisible: true, }, - }, - }) + }) + ) await screen.findByText('Add more collaborators') @@ -719,24 +662,22 @@ describe('', function () { }) it('counts reviewers towards the collaborator limit', async function () { - renderWithEditorContext(, { - scope: { - project: { - ...project, - features: { - collaborators: 1, - trackChangesVisible: true, - }, - members: [ - { - _id: 'reviewer-id', - email: 'reviewer@example.com', - privileges: 'review', - }, - ], + renderWithEditorContext( + , + 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('', function () { }) it('handles server error responses', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) - renderWithEditorContext(, { - scope: { - project: { - ...project, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + , + createContextProps({ + publicAccessLevel: 'tokenBased', + }) + ) // loading contacts await waitFor(() => { @@ -818,21 +757,25 @@ describe('', 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 } - renderWithEditorContext(, { - scope: { - project: { ...project, publicAccesLevel: 'private' }, - }, - }) + renderWithEditorContext( + , + createContextProps({ + publicAccessLevel: 'private', + }) + ) await screen.findByText('Link sharing is off') @@ -848,8 +791,8 @@ describe('', 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('', function () { }) it('avoids selecting unmatched contact', async function () { - renderWithEditorContext(, { - scope: { project }, - }) + renderWithEditorContext( + , + createContextProps() + ) const [inputElement] = await screen.findAllByLabelText('Add people') @@ -924,9 +868,10 @@ describe('', function () { }) it('selects contact by typing the entire email and blurring the input', async function () { - renderWithEditorContext(, { - scope: { project }, - }) + renderWithEditorContext( + , + createContextProps() + ) const [inputElement] = await screen.findAllByLabelText('Add people') @@ -959,9 +904,10 @@ describe('', function () { }) it('selects contact by typing a partial email and selecting the suggestion', async function () { - renderWithEditorContext(, { - scope: { project }, - }) + renderWithEditorContext( + , + createContextProps() + ) const [inputElement] = await screen.findAllByLabelText('Add people') @@ -993,9 +939,10 @@ describe('', function () { }) it('allows an email address to be selected, removed, then re-added', async function () { - renderWithEditorContext(, { - scope: { project }, - }) + renderWithEditorContext( + , + createContextProps() + ) const [inputElement] = await screen.findAllByLabelText('Add people') diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx index 932482205f..fc8831efd5 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx @@ -64,7 +64,6 @@ describe('autocomplete', { scrollBehavior: false }, function () { ] const scope = mockScope() - scope.project.rootFolder = rootFolder cy.mount( @@ -446,7 +445,6 @@ describe('autocomplete', { scrollBehavior: false }, function () { ] const scope = mockScope() - scope.project.rootFolder = rootFolder cy.mount( @@ -910,7 +908,6 @@ describe('autocomplete', { scrollBehavior: false }, function () { ] const scope = mockScope() - scope.project.rootFolder = rootFolder cy.mount( diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx index bd8eebfb18..e9896699c3 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx @@ -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('', function () { function mount() { const content = '' const scope = mockScope(content) + const project = mockProject({ + projectOwner: { + _id: USER_ID, + }, + }) const FileTreePathProvider: FC = ({ children, @@ -69,6 +77,7 @@ describe('', function () { scope={scope} providers={{ FileTreePathProvider, + ProjectProvider: makeProjectProvider(project), EditorPropertiesProvider: makeEditorPropertiesProvider({ showVisual: true, showSymbolPalette: false, diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx index ee96d76d53..3677884a0c 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx @@ -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( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx index 720335c035..7ffd4f0fcd 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx @@ -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( { const scope = mockScope(content) + const project = mockProject() cy.mount( { + 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(), + }, + } +} diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts index c01d20f164..830a9c97a2 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts @@ -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, diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx index a20c7f015f..a82adefdbd 100644 --- a/services/web/test/frontend/helpers/editor-providers.tsx +++ b/services/web/test/frontend/helpers/editor-providers.tsx @@ -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 @@ -85,6 +94,46 @@ export type EditorProvidersProps = { providers?: Record>> } +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 + */ 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 = 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 = ({ 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 ( + + {children} + + ) + } + + return ProjectProvider +}