overleaf-cep/services/web/test/frontend/helpers/editor-providers.tsx
Mathias Jakobsen 4ab4fbdf51 Merge pull request #26951 from overleaf/mj-layout-broken
[web] Add restoreView to LayoutContext

GitOrigin-RevId: 3a50c1e215c99236f503285fee1c924df57e07e4
2025-07-11 08:05:06 +00:00

576 lines
16 KiB
TypeScript

// Disable prop type checks for test harnesses
/* eslint-disable react/prop-types */
import { merge } from 'lodash'
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
import { IdeContext } from '@/shared/context/ide-context'
import React, {
useCallback,
useEffect,
useState,
useMemo,
type FC,
type PropsWithChildren,
} from 'react'
import { IdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
import { ConnectionContext } from '@/features/ide-react/context/connection-context'
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,
} from '@/features/ide-react/context/editor-properties-context'
import {
type IdeLayout,
type IdeView,
LayoutContext,
type LayoutContextValue,
} from '@/shared/context/layout-context'
import type { Socket } from '@/features/ide-react/connection/types/socket'
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
export const PROJECT_ID = 'project123'
export const PROJECT_NAME = 'project-name'
export const USER_ID = '123abd'
export const USER_EMAIL = 'testuser@example.com'
const defaultUserSettings = {
pdfViewer: 'pdfjs',
fontSize: 12,
fontFamily: 'monaco',
lineHeight: 'normal',
editorTheme: 'textmate',
overallTheme: '',
mode: 'default',
autoComplete: true,
autoPairDelimiters: true,
trackChanges: true,
syntaxValidation: false,
mathPreview: true,
}
export type EditorProvidersProps = {
user?: { id: string; email: string }
projectId?: string
projectName?: string
projectOwner?: ProjectMetadata['owner']
rootDocId?: string
imageName?: string
compiler?: ProjectCompiler
socket?: Socket
isRestrictedTokenMember?: boolean
scope?: Record<string, any>
features?: Record<string, boolean>
projectFeatures?: Record<string, boolean>
permissionsLevel?: PermissionsLevel
children?: React.ReactNode
rootFolder?: Folder[]
layoutContext?: Partial<LayoutContextValue>
userSettings?: Record<string, any>
providers?: Record<string, React.FC<React.PropsWithChildren<any>>>
}
export const projectDefaults = {
_id: PROJECT_ID,
name: PROJECT_NAME,
owner: {
_id: '124abd' as UserId,
email: 'owner@example.com',
first_name: 'Test',
last_name: 'Owner',
privileges: 'owner',
signUpDate: new Date('2025-07-07').toISOString(),
},
features: {
referencesSearch: true,
gitBridge: false,
},
rootDocId: '_root_doc_id',
rootFolder: [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: '_root_doc_id',
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
],
imageName: 'texlive-full:2024.1',
compiler: 'pdflatex' as ProjectCompiler,
members: [],
invites: [],
}
/**
* @typedef {import('@/shared/context/layout-context').LayoutContextValue} LayoutContextValue
* @type Partial<LayoutContextValue>
*/
const layoutContextDefault = {
view: 'editor',
openFile: null,
chatIsOpen: true, // false in the application, true in tests
reviewPanelOpen: false,
miniReviewPanelVisible: false,
leftMenuShown: false,
projectSearchIsOpen: false,
pdfLayout: 'sideBySide',
loadingStyleSheet: false,
} satisfies Partial<LayoutContextValue>
export function EditorProviders({
user = { id: USER_ID, email: USER_EMAIL },
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 = projectDefaults.rootFolder,
/** @type {Partial<LayoutContext>} */
layoutContext = layoutContextDefault,
userSettings = {},
providers = {},
}: EditorProvidersProps) {
window.metaAttributesCache.set(
'ol-gitBridgePublicBaseUrl',
'https://git.overleaf.test'
)
window.metaAttributesCache.set(
'ol-isRestrictedTokenMember',
isRestrictedTokenMember
)
window.metaAttributesCache.set(
'ol-userSettings',
merge({}, defaultUserSettings, userSettings)
)
window.metaAttributesCache.set('ol-capabilities', ['chat', 'dropbox'])
const scope = merge(
{
user,
editor: {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
hasBufferedOps: () => false,
on: () => {},
off: () => {},
leaveAndCleanUpPromise: async () => {},
} as any as DocumentContainer,
openDocName: null,
currentDocumentId: null,
wantTrackChanges: false,
},
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)
return (
<ReactContextRoot
providers={{
ConnectionProvider: makeConnectionProvider(socket),
IdeReactProvider: makeIdeReactProvider(scope, socket),
EditorOpenDocProvider: makeEditorOpenDocProvider({
currentDocumentId: scope.editor.currentDocumentId,
openDocName: scope.editor.openDocName,
currentDocument: scope.editor.sharejs_doc,
}),
EditorPropertiesProvider: makeEditorPropertiesProvider({
wantTrackChanges: scope.editor.wantTrackChanges,
}),
LayoutProvider: makeLayoutProvider(layoutContext),
ProjectProvider: makeProjectProvider(project),
...providers,
}}
>
{children}
</ReactContextRoot>
)
}
const makeConnectionProvider = (socket: Socket) => {
const ConnectionProvider: FC<PropsWithChildren> = ({ children }) => {
const [value] = useState(() => ({
socket,
connectionState: {
readyState: WebSocket.OPEN,
forceDisconnected: false,
inactiveDisconnect: false,
reconnectAt: null,
forcedDisconnectDelay: 0,
lastConnectionAttempt: 0,
error: '' as const,
},
isConnected: true,
isStillReconnecting: false,
secondsUntilReconnect: () => 0,
tryReconnectNow: () => {},
registerUserActivity: () => {},
disconnect: () => {},
closeConnection: () => {},
getSocketDebuggingInfo: () => ({}) as SocketDebuggingInfo,
}))
return (
<ConnectionContext.Provider value={value}>
{children}
</ConnectionContext.Provider>
)
}
return ConnectionProvider
}
const makeIdeReactProvider = (
scope: Record<string, unknown>,
socket: Socket
) => {
const IdeReactProvider: FC<PropsWithChildren> = ({ children }) => {
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
const [ideReactContextValue] = useState(() => ({
projectId: PROJECT_ID,
eventEmitter: new IdeEventEmitter(),
startedFreeTrial,
setStartedFreeTrial,
reportError: () => {},
projectJoined: true,
permissionsLevel: scope.permissionsLevel as PermissionsLevel,
setPermissionsLevel: () => {},
setOutOfSync: () => {},
}))
const [ideContextValue] = useState(() => {
const scopeEventEmitter = new ReactScopeEventEmitter(
new IdeEventEmitter()
)
const unstableStore = new ReactScopeValueStore()
return {
socket,
scopeEventEmitter,
unstableStore,
}
})
useEffect(() => {
window.overleaf = {
...window.overleaf,
unstable: {
...window.overleaf?.unstable,
store: ideContextValue.unstableStore,
},
}
}, [ideContextValue.unstableStore])
return (
<IdeReactContext.Provider value={ideReactContextValue}>
<IdeContext.Provider value={ideContextValue}>
{children}
</IdeContext.Provider>
</IdeReactContext.Provider>
)
}
return IdeReactProvider
}
export function makeEditorOpenDocProvider(
initialValues: EditorOpenDocContextState
) {
const {
currentDocumentId: initialCurrentDocumentId,
openDocName: initialOpenDocName,
currentDocument: initialCurrentDocument,
} = initialValues
const EditorOpenDocProvider: FC<PropsWithChildren> = ({ children }) => {
const [currentDocumentId, setCurrentDocumentId] = useExposedState(
initialCurrentDocumentId,
'editor.open_doc_id'
)
const [openDocName, setOpenDocName] = useExposedState(
initialOpenDocName,
'editor.open_doc_name'
)
const [currentDocument, setCurrentDocument] = useState(
initialCurrentDocument
)
const value = {
currentDocumentId,
setCurrentDocumentId,
openDocName,
setOpenDocName,
currentDocument,
setCurrentDocument,
}
return (
<EditorOpenDocContext.Provider value={value}>
{children}
</EditorOpenDocContext.Provider>
)
}
return EditorOpenDocProvider
}
const makeLayoutProvider = (
layoutContextOverrides?: Partial<LayoutContextValue>
) => {
const layout = {
...layoutContextDefault,
...layoutContextOverrides,
}
const LayoutProvider: FC<PropsWithChildren> = ({ children }) => {
const [view, setView] = useState<IdeView | null>(layout.view)
const [openFile, setOpenFile] = useState(layout.openFile)
const [chatIsOpen, setChatIsOpen] = useState(layout.chatIsOpen)
const [reviewPanelOpen, setReviewPanelOpen] = useState(
layout.reviewPanelOpen
)
const [miniReviewPanelVisible, setMiniReviewPanelVisible] = useState(
layout.miniReviewPanelVisible
)
const [leftMenuShown, setLeftMenuShown] = useState(layout.leftMenuShown)
const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(
layout.projectSearchIsOpen
)
const [pdfLayout, setPdfLayout] = useState(layout.pdfLayout)
const [loadingStyleSheet, setLoadingStyleSheet] = useState(
layout.loadingStyleSheet
)
useEventListener(
'ui.toggle-review-panel',
useCallback(() => {
setReviewPanelOpen(open => !open)
}, [setReviewPanelOpen])
)
const changeLayout = useCallback(
(newLayout: IdeLayout, newView: IdeView = 'editor') => {
setPdfLayout(newLayout)
setView(newLayout === 'sideBySide' ? 'editor' : newView)
},
[setPdfLayout, setView]
)
const restoreView = useCallback(() => {
setView('editor')
}, [])
const {
reattach,
detach,
isLinked: detachIsLinked,
role: detachRole,
} = useDetachLayout()
const pdfPreviewOpen =
pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
const value = useMemo(
() => ({
reattach,
detach,
detachIsLinked,
detachRole,
changeLayout,
chatIsOpen,
leftMenuShown,
openFile,
pdfLayout,
pdfPreviewOpen,
projectSearchIsOpen,
setProjectSearchIsOpen,
reviewPanelOpen,
miniReviewPanelVisible,
loadingStyleSheet,
setChatIsOpen,
setLeftMenuShown,
setOpenFile,
setPdfLayout,
setReviewPanelOpen,
setMiniReviewPanelVisible,
setLoadingStyleSheet,
setView,
view,
restoreView,
}),
[
reattach,
detach,
detachIsLinked,
detachRole,
changeLayout,
chatIsOpen,
leftMenuShown,
openFile,
pdfLayout,
pdfPreviewOpen,
projectSearchIsOpen,
setProjectSearchIsOpen,
reviewPanelOpen,
miniReviewPanelVisible,
loadingStyleSheet,
setChatIsOpen,
setLeftMenuShown,
setOpenFile,
setPdfLayout,
setReviewPanelOpen,
setMiniReviewPanelVisible,
setLoadingStyleSheet,
setView,
view,
restoreView,
]
)
return (
<LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
)
}
return LayoutProvider
}
export function makeEditorPropertiesProvider(
initialValues: Partial<
Pick<
EditorPropertiesContextValue,
'showVisual' | 'showSymbolPalette' | 'wantTrackChanges'
>
>
) {
const EditorPropertiesProvider: FC<PropsWithChildren> = ({ children }) => {
const {
showVisual: initialShowVisual,
showSymbolPalette: initialShowSymbolPalette,
wantTrackChanges: initialWantTrackChanges,
} = initialValues
const [showVisual, setShowVisual] = useState(initialShowVisual || false)
const [showSymbolPalette, setShowSymbolPalette] = useState(
initialShowSymbolPalette || false
)
function toggleSymbolPalette() {
setShowSymbolPalette(show => !show)
}
const [opening, setOpening] = useState(true)
const [trackChanges, setTrackChanges] = useState(false)
const [wantTrackChanges, setWantTrackChanges] = useState(
initialWantTrackChanges || false
)
const [errorState, setErrorState] = useState(false)
const value = {
showVisual,
setShowVisual,
showSymbolPalette,
setShowSymbolPalette,
toggleSymbolPalette,
opening,
setOpening,
trackChanges,
setTrackChanges,
wantTrackChanges,
setWantTrackChanges,
errorState,
setErrorState,
}
return (
<EditorPropertiesContext.Provider value={value}>
{children}
</EditorPropertiesContext.Provider>
)
}
return EditorPropertiesProvider
}
export function makeProjectProvider(initialProject: ProjectMetadata) {
const ProjectProvider: FC<PropsWithChildren> = ({ children }) => {
const [project, setProject] = useState(initialProject)
const updateProject = useCallback((projectUpdateData: ProjectUpdate) => {
setProject(projectData =>
Object.assign({}, projectData, projectUpdateData)
)
}, [])
const value = {
projectId: project._id,
project,
joinProject: () => {},
updateProject,
joinedOnce: true,
projectSnapshot: new ProjectSnapshot(project._id),
tags: [],
features: project.features,
name: project.name,
}
return (
<ProjectContext.Provider value={value}>
{children}
</ProjectContext.Provider>
)
}
return ProjectProvider
}