Merge pull request #14709 from overleaf/ae-context-typescript

Convert React context providers to TypeScript [don't squash!]

GitOrigin-RevId: d92a91798286978410956ab791d73c17c5086d86
This commit is contained in:
Alf Eaton 2024-01-26 09:23:48 +00:00 committed by Copybot
parent fdf8ebe001
commit 0cde5be165
60 changed files with 1146 additions and 1080 deletions

View file

@ -1,5 +1,4 @@
import React, { lazy, Suspense, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import MessageInput from './message-input'
@ -19,9 +18,7 @@ const ChatPane = React.memo(function ChatPane() {
const { t } = useTranslation()
const { chatIsOpen } = useLayoutContext()
const user = useUserContext({
id: PropTypes.string.isRequired,
})
const user = useUserContext()
const {
status,

View file

@ -6,8 +6,8 @@ import {
useReducer,
useMemo,
useRef,
FC,
} from 'react'
import PropTypes from 'prop-types'
import { v4 as uuid } from 'uuid'
import { useUserContext } from '../../../shared/context/user-context'
@ -20,7 +20,55 @@ import { useIdeContext } from '@/shared/context/ide-context'
const PAGE_SIZE = 50
export function chatReducer(state, action) {
export type Message = {
id: string
timestamp: number
contents: string
}
type State = {
status: 'idle' | 'pending' | 'error'
messages: Message[]
initialMessagesLoaded: boolean
lastTimestamp: number | null
atEnd: boolean
unreadMessageCount: number
error?: Error | null
uniqueMessageIds: string[]
}
type Action =
| {
type: 'INITIAL_FETCH_MESSAGES'
}
| {
type: 'FETCH_MESSAGES'
}
| {
type: 'FETCH_MESSAGES_SUCCESS'
messages: Message[]
}
| {
type: 'SEND_MESSAGE'
user: any
content: any
}
| {
type: 'RECEIVE_MESSAGE'
message: any
}
| {
type: 'MARK_MESSAGES_AS_READ'
}
| {
type: 'CLEAR'
}
| {
type: 'ERROR'
error: any
}
function chatReducer(state: State, action: Action): State {
switch (action.type) {
case 'INITIAL_FETCH_MESSAGES':
return {
@ -99,7 +147,7 @@ export function chatReducer(state, action) {
}
}
const initialState = {
const initialState: State = {
status: 'idle',
messages: [],
initialMessagesLoaded: false,
@ -110,32 +158,27 @@ const initialState = {
uniqueMessageIds: [],
}
export const ChatContext = createContext()
export const ChatContext = createContext<
| {
status: 'idle' | 'pending' | 'error'
messages: Message[]
initialMessagesLoaded: boolean
atEnd: boolean
unreadMessageCount: number
loadInitialMessages: () => void
loadMoreMessages: () => void
sendMessage: (message: any) => void
markMessagesAsRead: () => void
reset: () => void
error?: Error | null
}
| undefined
>(undefined)
ChatContext.Provider.propTypes = {
value: PropTypes.shape({
status: PropTypes.string.isRequired,
messages: PropTypes.array.isRequired,
initialMessagesLoaded: PropTypes.bool.isRequired,
atEnd: PropTypes.bool.isRequired,
unreadMessageCount: PropTypes.number.isRequired,
loadInitialMessages: PropTypes.func.isRequired,
loadMoreMessages: PropTypes.func.isRequired,
sendMessage: PropTypes.func.isRequired,
markMessagesAsRead: PropTypes.func.isRequired,
reset: PropTypes.func.isRequired,
error: PropTypes.object,
}).isRequired,
}
export function ChatProvider({ children }) {
export const ChatProvider: FC = ({ children }) => {
const clientId = useRef(uuid())
const user = useUserContext({
id: PropTypes.string.isRequired,
})
const { _id: projectId } = useProjectContext({
_id: PropTypes.string.isRequired,
})
const user = useUserContext()
const { _id: projectId } = useProjectContext()
const { chatIsOpen } = useLayoutContext()
@ -151,10 +194,12 @@ export function ChatProvider({ children }) {
function fetchMessages() {
if (state.atEnd) return
const query = { limit: PAGE_SIZE }
const query: Record<string, string> = {
limit: String(PAGE_SIZE),
}
if (state.lastTimestamp) {
query.before = state.lastTimestamp
query.before = String(state.lastTimestamp)
}
const queryString = new URLSearchParams(query)
@ -231,7 +276,7 @@ export function ChatProvider({ children }) {
useEffect(() => {
if (!socket) return
function receivedMessage(message) {
function receivedMessage(message: any) {
// If the message is from the current client id, then we are receiving the sent message back from the socket.
// Ignore it to prevent double message.
if (message.clientId === clientId.current) return
@ -299,12 +344,10 @@ export function ChatProvider({ children }) {
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
}
ChatProvider.propTypes = {
children: PropTypes.any,
}
export function useChatContext(propTypes) {
const data = useContext(ChatContext)
PropTypes.checkPropTypes(propTypes, data, 'data', 'ChatContext.Provider')
return data
export function useChatContext() {
const context = useContext(ChatContext)
if (!context) {
throw new Error('useChatContext is only available inside ChatProvider')
}
return context
}

View file

@ -6,12 +6,11 @@ import { useProjectSettingsContext } from '../../context/project-settings-contex
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { MainDocument } from '../../../../../../types/project-settings'
export default function SettingsDocument() {
const { t } = useTranslation()
const { permissionsLevel } = useEditorContext()
const docs: MainDocument[] = useFileTreeData().docs
const { docs } = useFileTreeData()
const { rootDocId, setRootDocId } = useProjectSettingsContext()
const validDocsOptions = useMemo(() => {

View file

@ -1,34 +1,13 @@
import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import ToolbarHeader from './toolbar-header'
import { useEditorContext } from '../../../shared/context/editor-context'
import { useChatContext } from '../../chat/context/chat-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useProjectContext } from '../../../shared/context/project-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { Doc } from '../../../../../types/doc'
const projectContextPropTypes = {
name: PropTypes.string.isRequired,
features: PropTypes.shape({
trackChangesVisible: PropTypes.bool,
}).isRequired,
}
const editorContextPropTypes = {
cobranding: PropTypes.object,
loading: PropTypes.bool,
isRestrictedTokenMember: PropTypes.bool,
renameProject: PropTypes.func.isRequired,
isProjectOwner: PropTypes.bool,
permissionsLevel: PropTypes.string,
}
const chatContextPropTypes = {
markMessagesAsRead: PropTypes.func.isRequired,
unreadMessageCount: PropTypes.number.isRequired,
}
function isOpentoString(open) {
function isOpentoString(open: boolean) {
return open ? 'open' : 'close'
}
@ -37,19 +16,22 @@ const EditorNavigationToolbarRoot = React.memo(
onlineUsersArray,
openDoc,
openShareProjectModal,
}: {
onlineUsersArray: any[]
openDoc: (doc: Doc, { gotoLine }: { gotoLine: number }) => void
openShareProjectModal: () => void
}) {
const {
name: projectName,
features: { trackChangesVisible },
} = useProjectContext(projectContextPropTypes)
} = useProjectContext()
const {
cobranding,
loading,
isRestrictedTokenMember,
renameProject,
permissionsLevel,
} = useEditorContext(editorContextPropTypes)
} = useEditorContext()
const {
chatIsOpen,
@ -61,8 +43,7 @@ const EditorNavigationToolbarRoot = React.memo(
setLeftMenuShown,
} = useLayoutContext()
const { markMessagesAsRead, unreadMessageCount } =
useChatContext(chatContextPropTypes)
const { markMessagesAsRead, unreadMessageCount } = useChatContext()
const toggleChatOpen = useCallback(() => {
if (!chatIsOpen) {
@ -110,11 +91,9 @@ const EditorNavigationToolbarRoot = React.memo(
[openDoc]
)
// using {display: 'none'} as 1:1 migration from Angular's ng-hide. Using
// `loading ? null : <ToolbarHeader/>` causes UI glitches
return (
<ToolbarHeader
style={loading ? { display: 'none' } : {}}
// @ts-ignore: TODO(convert ToolbarHeader to TSX)
cobranding={cobranding}
onShowLeftMenuClick={onShowLeftMenuClick}
chatIsOpen={chatIsOpen}
@ -140,10 +119,4 @@ const EditorNavigationToolbarRoot = React.memo(
}
)
EditorNavigationToolbarRoot.propTypes = {
onlineUsersArray: PropTypes.array.isRequired,
openDoc: PropTypes.func.isRequired,
openShareProjectModal: PropTypes.func.isRequired,
}
export default EditorNavigationToolbarRoot

View file

@ -1,14 +1,18 @@
import { memo, useCallback } from 'react'
import PropTypes from 'prop-types'
import { Dropdown, MenuItem } from 'react-bootstrap'
import { memo, ReactNode, useCallback } from 'react'
import { Dropdown, MenuItem, MenuItemProps } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import Tooltip from '../../../shared/components/tooltip'
import Icon from '../../../shared/components/icon'
import IconChecked from '../../../shared/components/icon-checked'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { useLayoutContext } from '../../../shared/context/layout-context'
import {
IdeLayout,
IdeView,
useLayoutContext,
} from '../../../shared/context/layout-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import useEventListener from '../../../shared/hooks/use-event-listener'
import { DetachRole } from '@/shared/context/detach-context'
function IconPlaceholder() {
return <Icon type="" fw />
@ -38,7 +42,17 @@ function IconPdfOnly() {
return <Icon type="file-pdf-o" fw />
}
function IconCheckmark({ iconFor, pdfLayout, view, detachRole }) {
function IconCheckmark({
iconFor,
pdfLayout,
view,
detachRole,
}: {
iconFor: string
pdfLayout: IdeLayout
view: IdeView | null
detachRole?: DetachRole
}) {
if (detachRole === 'detacher' || view === 'history') {
return <IconPlaceholder />
}
@ -57,7 +71,16 @@ function IconCheckmark({ iconFor, pdfLayout, view, detachRole }) {
return <IconPlaceholder />
}
function LayoutMenuItem({ checkmark, icon, text, ...props }) {
function LayoutMenuItem({
checkmark,
icon,
text,
...props
}: {
checkmark: ReactNode
icon: ReactNode
text: string | ReactNode
} & MenuItemProps) {
return (
<MenuItem {...props}>
<div className="layout-menu-item">
@ -70,12 +93,6 @@ function LayoutMenuItem({ checkmark, icon, text, ...props }) {
</MenuItem>
)
}
LayoutMenuItem.propTypes = {
checkmark: PropTypes.node.isRequired,
icon: PropTypes.node.isRequired,
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
onSelect: PropTypes.func,
}
function DetachDisabled() {
const { t } = useTranslation()
@ -126,7 +143,7 @@ function LayoutDropdownButton() {
useEventListener('ui:pdf-open', handleReattach)
const handleChangeLayout = useCallback(
(newLayout, newView) => {
(newLayout: IdeLayout, newView?: IdeView) => {
handleReattach()
changeLayout(newLayout, newView)
eventTracking.sendMB('project-layout-change', {
@ -243,10 +260,3 @@ function LayoutDropdownButton() {
}
export default memo(LayoutDropdownButton)
IconCheckmark.propTypes = {
iconFor: PropTypes.string.isRequired,
pdfLayout: PropTypes.string.isRequired,
view: PropTypes.string,
detachRole: PropTypes.string,
}

View file

@ -1,7 +1,5 @@
import React from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
import { Dropdown } from 'react-bootstrap'
import { useEditorContext } from '../../../shared/context/editor-context'
import { useFileTreeMainContext } from '../contexts/file-tree-main'
@ -9,7 +7,7 @@ import { useFileTreeMainContext } from '../contexts/file-tree-main'
import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
function FileTreeContextMenu() {
const { permissionsLevel } = useEditorContext(editorContextPropTypes)
const { permissionsLevel } = useEditorContext()
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
if (permissionsLevel === 'readOnly' || !contextMenuCoords) return null
@ -19,7 +17,7 @@ function FileTreeContextMenu() {
setContextMenuCoords(null)
}
function handleToggle(wantOpen) {
function handleToggle(wantOpen: boolean) {
if (!wantOpen) close()
}
@ -39,19 +37,17 @@ function FileTreeContextMenu() {
<FileTreeItemMenuItems />
</Dropdown.Menu>
</Dropdown>,
document.querySelector('body')
document.body
)
}
const editorContextPropTypes = {
permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}
// fake component required as Dropdowns require a Toggle, even tho we don't want
// one for the context menu
const FakeDropDownToggle = React.forwardRef((props, ref) => {
return null
})
const FakeDropDownToggle = React.forwardRef<undefined, { bsRole: string }>(
({ bsRole }, ref) => {
return null
}
)
FakeDropDownToggle.displayName = 'FakeDropDownToggle'

View file

@ -1,23 +1,28 @@
import PropTypes from 'prop-types'
import { FileTreeMainProvider } from '../contexts/file-tree-main'
import { FileTreeActionableProvider } from '../contexts/file-tree-actionable'
import { FileTreeSelectableProvider } from '../contexts/file-tree-selectable'
import { FileTreeDraggableProvider } from '../contexts/file-tree-draggable'
import { FC } from 'react'
// renders all the contexts needed for the file tree:
// FileTreeMain: generic store
// FileTreeActionable: global UI state for actions (rename, delete, etc.)
// FileTreeMutable: provides entities mutation operations
// FileTreeSelectable: handles selection and multi-selection
function FileTreeContext({
const FileTreeContext: FC<{
reindexReferences: () => void
refProviders: Record<string, boolean>
setRefProviderEnabled: (provider: string, value: boolean) => void
setStartedFreeTrial: (value: boolean) => void
onSelect: () => void
}> = ({
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
onSelect,
children,
}) {
}) => {
return (
<FileTreeMainProvider
refProviders={refProviders}
@ -34,16 +39,4 @@ function FileTreeContext({
)
}
FileTreeContext.propTypes = {
reindexReferences: PropTypes.func.isRequired,
refProviders: PropTypes.object.isRequired,
setRefProviderEnabled: PropTypes.func.isRequired,
setStartedFreeTrial: PropTypes.func.isRequired,
onSelect: PropTypes.func.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
}
export default FileTreeContext

View file

@ -1,11 +1,16 @@
import { useState, useCallback, useEffect, useMemo } from 'react'
import {
useState,
useCallback,
useEffect,
useMemo,
FormEventHandler,
} from 'react'
import { Button, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
import Icon from '../../../../../shared/components/icon'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import { useUserProjects } from '../../../hooks/use-user-projects'
import { useProjectEntities } from '../../../hooks/use-project-entities'
import { Entity, useProjectEntities } from '../../../hooks/use-project-entities'
import { useProjectOutputFiles } from '../../../hooks/use-project-output-files'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
@ -13,6 +18,8 @@ import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
import { useProjectContext } from '../../../../../shared/context/project-context'
import ErrorMessage from '../error-message'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import { File } from '@/features/source-editor/utils/file'
import { Project } from '../../../../../../../types/project'
export default function FileTreeImportFromProject() {
const { t } = useTranslation()
@ -26,9 +33,11 @@ export default function FileTreeImportFromProject() {
const { setValid } = useFileTreeCreateForm()
const { error, finishCreatingLinkedFile } = useFileTreeActionable()
const [selectedProject, setSelectedProject] = useState()
const [selectedProjectEntity, setSelectedProjectEntity] = useState()
const [selectedProjectOutputFile, setSelectedProjectOutputFile] = useState()
const [selectedProject, setSelectedProject] = useState<Project>()
const [selectedProjectEntity, setSelectedProjectEntity] = useState<Entity>()
const [selectedProjectOutputFile, setSelectedProjectOutputFile] = useState<
File & { build: string; clsiServerId: string }
>()
const [isOutputFilesMode, setOutputFilesMode] = useState(
// default to project file mode, unless the feature is not enabled
!hasLinkedProjectFileFeature
@ -51,10 +60,10 @@ export default function FileTreeImportFromProject() {
if (selectedProjectOutputFile) {
if (
selectedProjectOutputFile.path === 'output.pdf' &&
selectedProject.name
selectedProject!.name
) {
// if the output PDF is selected, use the project's name as the filename
setName(`${selectedProject.name}.pdf`)
setName(`${selectedProject!.name}.pdf`)
} else {
setNameFromPath(selectedProjectOutputFile.path)
}
@ -84,7 +93,7 @@ export default function FileTreeImportFromProject() {
])
// form submission: create a linked file with this name, from this entity or output file
const handleSubmit = event => {
const handleSubmit: FormEventHandler = event => {
event.preventDefault()
eventTracking.sendMB('new-file-created', { method: 'project' })
@ -93,10 +102,10 @@ export default function FileTreeImportFromProject() {
name,
provider: 'project_output_file',
data: {
source_project_id: selectedProject._id,
source_output_file_path: selectedProjectOutputFile.path,
build_id: selectedProjectOutputFile.build,
clsiServerId: selectedProjectOutputFile.clsiServerId,
source_project_id: selectedProject!._id,
source_output_file_path: selectedProjectOutputFile!.path,
build_id: selectedProjectOutputFile!.build,
clsiServerId: selectedProjectOutputFile!.clsiServerId,
},
})
} else {
@ -104,8 +113,8 @@ export default function FileTreeImportFromProject() {
name,
provider: 'project_file',
data: {
source_project_id: selectedProject._id,
source_entity_path: selectedProjectEntity.path,
source_project_id: selectedProject!._id,
source_entity_path: selectedProjectEntity!.path,
},
})
}
@ -163,9 +172,17 @@ export default function FileTreeImportFromProject() {
)
}
function SelectProject({ selectedProject, setSelectedProject }) {
type SelectProjectProps = {
selectedProject?: any
setSelectedProject(project: any): void
}
function SelectProject({
selectedProject,
setSelectedProject,
}: SelectProjectProps) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext(projectContextPropTypes)
const { _id: projectId } = useProjectContext()
const { data, error, loading } = useUserProjects()
@ -197,8 +214,8 @@ function SelectProject({ selectedProject, setSelectedProject }) {
disabled={!data}
value={selectedProject ? selectedProject._id : ''}
onChange={event => {
const projectId = event.target.value
const project = data.find(item => item._id === projectId)
const projectId = (event.target as HTMLSelectElement).value
const project = data!.find(item => item._id === projectId)
setSelectedProject(project)
}}
>
@ -220,20 +237,18 @@ function SelectProject({ selectedProject, setSelectedProject }) {
</FormGroup>
)
}
SelectProject.propTypes = {
selectedProject: PropTypes.object,
setSelectedProject: PropTypes.func.isRequired,
}
const projectContextPropTypes = {
_id: PropTypes.string.isRequired,
type SelectProjectOutputFileProps = {
selectedProjectId?: string
selectedProjectOutputFile?: any
setSelectedProjectOutputFile(file: any): void
}
function SelectProjectOutputFile({
selectedProjectId,
selectedProjectOutputFile,
setSelectedProjectOutputFile,
}) {
}: SelectProjectOutputFileProps) {
const { t } = useTranslation()
const { data, error, loading } = useProjectOutputFiles(selectedProjectId)
@ -261,8 +276,8 @@ function SelectProjectOutputFile({
disabled={!data}
value={selectedProjectOutputFile?.path || ''}
onChange={event => {
const path = event.target.value
const file = data.find(item => item.path === path)
const path = (event.target as HTMLSelectElement).value
const file = data?.find(item => item.path === path)
setSelectedProjectOutputFile(file)
}}
>
@ -280,17 +295,18 @@ function SelectProjectOutputFile({
</FormGroup>
)
}
SelectProjectOutputFile.propTypes = {
selectedProjectId: PropTypes.string,
selectedProjectOutputFile: PropTypes.object,
setSelectedProjectOutputFile: PropTypes.func.isRequired,
type SelectProjectEntityProps = {
selectedProjectId?: string
selectedProjectEntity?: any
setSelectedProjectEntity(entity: any): void
}
function SelectProjectEntity({
selectedProjectId,
selectedProjectEntity,
setSelectedProjectEntity,
}) {
}: SelectProjectEntityProps) {
const { t } = useTranslation()
const { data, error, loading } = useProjectEntities(selectedProjectId)
@ -318,8 +334,8 @@ function SelectProjectEntity({
disabled={!data}
value={selectedProjectEntity?.path || ''}
onChange={event => {
const path = event.target.value
const entity = data.find(item => item.path === path)
const path = (event.target as HTMLSelectElement).value
const entity = data!.find(item => item.path === path)
setSelectedProjectEntity(entity)
}}
>
@ -337,8 +353,3 @@ function SelectProjectEntity({
</FormGroup>
)
}
SelectProjectEntity.propTypes = {
selectedProjectId: PropTypes.string,
selectedProjectEntity: PropTypes.object,
setSelectedProjectEntity: PropTypes.func.isRequired,
}

View file

@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Button } from 'react-bootstrap'
import { useCallback, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import Uppy from '@uppy/core'
import XHRUpload from '@uppy/xhr-upload'
import { Dashboard } from '@uppy/react'
@ -18,23 +17,23 @@ import { debugConsole } from '@/utils/debugging'
export default function FileTreeUploadDoc() {
const { parentFolderId, cancel, isDuplicate, droppedFiles, setDroppedFiles } =
useFileTreeActionable()
const { _id: projectId } = useProjectContext(projectContextPropTypes)
const { _id: projectId } = useProjectContext()
const [error, setError] = useState()
const [error, setError] = useState<string>()
const [conflicts, setConflicts] = useState([])
const [conflicts, setConflicts] = useState<any[]>([])
const [overwrite, setOverwrite] = useState(false)
const maxNumberOfFiles = 40
const maxFileSize = window.ExposedSettings.maxUploadSize
// calculate conflicts
const buildConflicts = files =>
const buildConflicts = (files: Record<string, any>) =>
Object.values(files).filter(file =>
isDuplicate(file.meta.targetFolderId ?? parentFolderId, file.meta.name)
)
const buildEndpoint = (projectId, targetFolderId) => {
const buildEndpoint = (projectId: string, targetFolderId: string) => {
let endpoint = `/project/${projectId}/upload`
if (targetFolderId) {
@ -142,6 +141,7 @@ export default function FileTreeUploadDoc() {
const uppyFile = uppy.getFile(fileId)
uppy.setFileState(fileId, {
xhrUpload: {
// @ts-ignore
...uppyFile.xhrUpload,
endpoint: buildEndpoint(projectId, droppedFiles.targetFolderId),
},
@ -204,28 +204,39 @@ export default function FileTreeUploadDoc() {
)
}
const projectContextPropTypes = {
_id: PropTypes.string.isRequired,
}
function UploadErrorMessage({ error, maxNumberOfFiles }) {
function UploadErrorMessage({
error,
maxNumberOfFiles,
}: {
error: string
maxNumberOfFiles: number
}) {
const { t } = useTranslation()
switch (error) {
case 'too-many-files':
return t('maximum_files_uploaded_together', {
max: maxNumberOfFiles,
})
return (
<>
{t('maximum_files_uploaded_together', {
max: maxNumberOfFiles,
})}
</>
)
default:
return <ErrorMessage error={error} />
}
}
UploadErrorMessage.propTypes = {
error: PropTypes.string.isRequired,
maxNumberOfFiles: PropTypes.number.isRequired,
}
function UploadConflicts({ cancel, conflicts, handleOverwrite }) {
function UploadConflicts({
cancel,
conflicts,
handleOverwrite,
}: {
cancel: () => void
conflicts: any[]
handleOverwrite: () => void
}) {
const { t } = useTranslation()
return (
@ -258,8 +269,3 @@ function UploadConflicts({ cancel, conflicts, handleOverwrite }) {
</div>
)
}
UploadConflicts.propTypes = {
cancel: PropTypes.func.isRequired,
conflicts: PropTypes.array.isRequired,
handleOverwrite: PropTypes.func.isRequired,
}

View file

@ -1,5 +1,4 @@
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '../../../../shared/context/project-context'
import { useLocation } from '../../../../shared/hooks/use-location'
@ -7,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(projectContextPropTypes)
const { _id: projectId } = useProjectContext()
const [secondsToRedirect, setSecondsToRedirect] = useState(10)
const location = useLocation()
@ -35,7 +34,3 @@ export default function RedirectToLogin() {
seconds: secondsToRedirect,
})
}
const projectContextPropTypes = {
_id: PropTypes.string.isRequired,
}

View file

@ -1,11 +1,7 @@
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
import { useCallback } from 'react'
import { FC, useCallback } from 'react'
type FileTreeInnerProps = {
children: React.ReactNode
}
function FileTreeInner({ children }: FileTreeInnerProps) {
const FileTreeInner: FC = ({ children }) => {
const { setIsRootFolderSelected, selectedEntityIds, select } =
useFileTreeSelectable()

View file

@ -1,5 +1,4 @@
import { useEffect, useRef } from 'react'
import PropTypes from 'prop-types'
import { ReactNode, useEffect, useRef } from 'react'
import classNames from 'classnames'
import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
@ -13,8 +12,18 @@ import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useDragDropManager } from 'react-dnd'
function FileTreeItemInner({ id, name, isSelected, icons }) {
const { permissionsLevel } = useEditorContext(editorContextPropTypes)
function FileTreeItemInner({
id,
name,
isSelected,
icons,
}: {
id: string
name: string
isSelected: boolean
icons?: ReactNode
}) {
const { permissionsLevel } = useEditorContext()
const { setContextMenuCoords } = useFileTreeMainContext()
const { isRenaming } = useFileTreeActionable()
@ -29,7 +38,7 @@ function FileTreeItemInner({ id, name, isSelected, icons }) {
const dragDropItem = useDragDropManager().getMonitor().getItem()
const itemRef = useRef()
const itemRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const item = itemRef.current
@ -49,7 +58,7 @@ function FileTreeItemInner({ id, name, isSelected, icons }) {
}
}, [isSelected, itemRef])
function handleContextMenu(ev) {
function handleContextMenu(ev: React.MouseEvent<HTMLDivElement>) {
ev.preventDefault()
setContextMenuCoords({
@ -85,15 +94,4 @@ function FileTreeItemInner({ id, name, isSelected, icons }) {
)
}
FileTreeItemInner.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
icons: PropTypes.node,
}
const editorContextPropTypes = {
permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}
export default FileTreeItemInner

View file

@ -1,6 +1,4 @@
import React, { useEffect } from 'react'
import PropTypes from 'prop-types'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { useProjectContext } from '../../../shared/context/project-context'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
@ -13,16 +11,23 @@ import FileTreeModalCreateFolder from './modals/file-tree-modal-create-folder'
import FileTreeModalError from './modals/file-tree-modal-error'
import FileTreeContextMenu from './file-tree-context-menu'
import FileTreeError from './file-tree-error'
import { useDroppable } from '../contexts/file-tree-draggable'
import { useFileTreeSocketListener } from '../hooks/file-tree-socket-listener'
import FileTreeModalCreateFile from './modals/file-tree-modal-create-file'
import FileTreeInner from './file-tree-inner'
import { useDragLayer } from 'react-dnd'
import classnames from 'classnames'
const FileTreeRoot = React.memo(function FileTreeRoot({
const FileTreeRoot = React.memo<{
onSelect: () => void
onDelete: () => void
onInit: () => void
isConnected: boolean
setRefProviderEnabled: () => void
setStartedFreeTrial: () => void
reindexReferences: () => void
refProviders: Record<string, boolean>
}>(function FileTreeRoot({
refProviders,
reindexReferences,
setRefProviderEnabled,
@ -32,7 +37,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
onDelete,
isConnected,
}) {
const { _id: projectId } = useProjectContext(projectContextPropTypes)
const { _id: projectId } = useProjectContext()
const { fileTreeData } = useFileTreeData()
const isReady = Boolean(projectId && fileTreeData)
@ -63,7 +68,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
)
})
function FileTreeRootFolder({ onDelete }) {
function FileTreeRootFolder({ onDelete }: { onDelete: () => void }) {
useFileTreeSocketListener(onDelete)
const { fileTreeData } = useFileTreeData()
@ -87,30 +92,11 @@ function FileTreeRootFolder({ onDelete }) {
'file-tree-dragging': dragLayer.isDragging,
}),
}}
dropRef={dropRef}
dropRef={dropRef as any}
dataTestId="file-tree-list-root"
/>
</>
)
}
FileTreeRootFolder.propTypes = {
onDelete: PropTypes.func,
}
FileTreeRoot.propTypes = {
onSelect: PropTypes.func.isRequired,
onInit: PropTypes.func.isRequired,
onDelete: PropTypes.func,
isConnected: PropTypes.bool.isRequired,
setRefProviderEnabled: PropTypes.func.isRequired,
setStartedFreeTrial: PropTypes.func.isRequired,
reindexReferences: PropTypes.func.isRequired,
refProviders: PropTypes.object.isRequired,
}
const projectContextPropTypes = {
_id: PropTypes.string.isRequired,
}
export default withErrorBoundary(FileTreeRoot, FileTreeError)

View file

@ -1,4 +1,3 @@
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
@ -10,7 +9,7 @@ import { useEditorContext } from '../../../shared/context/editor-context'
import { useFileTreeActionable } from '../contexts/file-tree-actionable'
function FileTreeToolbar() {
const { permissionsLevel } = useEditorContext(editorContextPropTypes)
const { permissionsLevel } = useEditorContext()
if (permissionsLevel === 'readOnly') return null
@ -22,10 +21,6 @@ function FileTreeToolbar() {
)
}
const editorContextPropTypes = {
permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}
function FileTreeToolbarLeft() {
const { t } = useTranslation()
const {

View file

@ -6,8 +6,8 @@ import {
useContext,
useEffect,
useState,
FC,
} from 'react'
import PropTypes from 'prop-types'
import { mapSeries } from '../../../infrastructure/promise'
@ -32,24 +32,73 @@ import {
DuplicateFilenameError,
DuplicateFilenameMoveError,
} from '../errors'
import { Folder } from '../../../../../types/folder'
const FileTreeActionableContext = createContext()
const FileTreeActionableContext = createContext<
| {
isDeleting: boolean
isRenaming: boolean
isCreatingFile: boolean
isCreatingFolder: boolean
isMoving: boolean
inFlight: boolean
actionedEntities: any | null
newFileCreateMode: any | null
error: any | null
canDelete: boolean
canRename: boolean
canCreate: boolean
parentFolderId: string
isDuplicate: (parentFolderId: string, name: string) => boolean
startRenaming: any
finishRenaming: any
startDeleting: any
finishDeleting: any
finishMoving: any
startCreatingFile: any
startCreatingFolder: any
finishCreatingFolder: any
startCreatingDocOrFile: any
startUploadingDocOrFile: any
finishCreatingDoc: any
finishCreatingLinkedFile: any
cancel: () => void
droppedFiles: { files: any; targetFolderId: string } | null
setDroppedFiles: (files: any) => void
downloadPath?: string
}
| undefined
>(undefined)
const ACTION_TYPES = {
START_RENAME: 'START_RENAME',
START_DELETE: 'START_DELETE',
DELETING: 'DELETING',
START_CREATE_FILE: 'START_CREATE_FILE',
START_CREATE_FOLDER: 'START_CREATE_FOLDER',
CREATING_FILE: 'CREATING_FILE',
CREATING_FOLDER: 'CREATING_FOLDER',
MOVING: 'MOVING',
CANCEL: 'CANCEL',
CLEAR: 'CLEAR',
ERROR: 'ERROR',
/* eslint-disable no-unused-vars */
enum ACTION_TYPES {
START_RENAME = 'START_RENAME',
START_DELETE = 'START_DELETE',
DELETING = 'DELETING',
START_CREATE_FILE = 'START_CREATE_FILE',
START_CREATE_FOLDER = 'START_CREATE_FOLDER',
CREATING_FILE = 'CREATING_FILE',
CREATING_FOLDER = 'CREATING_FOLDER',
MOVING = 'MOVING',
CANCEL = 'CANCEL',
CLEAR = 'CLEAR',
ERROR = 'ERROR',
}
/* eslint-enable no-unused-vars */
type State = {
isDeleting: boolean
isRenaming: boolean
isCreatingFile: boolean
isCreatingFolder: boolean
isMoving: boolean
inFlight: boolean
actionedEntities: any | null
newFileCreateMode: any | null
error: unknown | null
}
const defaultState = {
const defaultState: State = {
isDeleting: false,
isRenaming: false,
isCreatingFile: false,
@ -61,11 +110,49 @@ const defaultState = {
error: null,
}
function fileTreeActionableReadOnlyReducer(state) {
function fileTreeActionableReadOnlyReducer(state: State) {
return state
}
function fileTreeActionableReducer(state, action) {
type Action =
| {
type: ACTION_TYPES.START_RENAME
}
| {
type: ACTION_TYPES.START_DELETE
actionedEntities: any | null
}
| {
type: ACTION_TYPES.START_CREATE_FILE
newFileCreateMode: any | null
}
| {
type: ACTION_TYPES.START_CREATE_FOLDER
}
| {
type: ACTION_TYPES.CREATING_FILE
}
| {
type: ACTION_TYPES.CREATING_FOLDER
}
| {
type: ACTION_TYPES.DELETING
}
| {
type: ACTION_TYPES.MOVING
}
| {
type: ACTION_TYPES.CLEAR
}
| {
type: ACTION_TYPES.CANCEL
}
| {
type: ACTION_TYPES.ERROR
error: unknown
}
function fileTreeActionableReducer(state: State, action: Action) {
switch (action.type) {
case ACTION_TYPES.START_RENAME:
return { ...defaultState, isRenaming: true }
@ -115,13 +202,15 @@ function fileTreeActionableReducer(state, action) {
case ACTION_TYPES.ERROR:
return { ...state, inFlight: false, error: action.error }
default:
throw new Error(`Unknown user action type: ${action.type}`)
throw new Error(`Unknown user action type: ${(action as Action).type}`)
}
}
export function FileTreeActionableProvider({ reindexReferences, children }) {
const { _id: projectId } = useProjectContext(projectContextPropTypes)
const { permissionsLevel } = useEditorContext(editorContextPropTypes)
export const FileTreeActionableProvider: FC<{
reindexReferences: () => void
}> = ({ reindexReferences, children }) => {
const { _id: projectId } = useProjectContext()
const { permissionsLevel } = useEditorContext()
const [state, dispatch] = useReducer(
permissionsLevel === 'readOnly'
@ -142,7 +231,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
// update the entity with the new name immediately in the tree, but revert to
// the old name if the sync fails
const finishRenaming = useCallback(
newName => {
(newName: string) => {
const selectedEntityId = Array.from(selectedEntityIds)[0]
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
const oldName = found.entity.name
@ -168,7 +257,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
)
const isDuplicate = useCallback(
(parentFolderId, name) => {
(parentFolderId: string, name: string) => {
return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
},
[fileTreeData]
@ -189,29 +278,32 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
dispatch({ type: ACTION_TYPES.DELETING })
let shouldReindexReferences = false
return mapSeries(Array.from(selectedEntityIds), id => {
const found = findInTreeOrThrow(fileTreeData, id)
shouldReindexReferences =
shouldReindexReferences || /\.bib$/.test(found.entity.name)
return syncDelete(projectId, found.type, found.entity._id).catch(
error => {
// throw unless 404
if (error.info.statusCode !== 404) {
throw error
return (
mapSeries(Array.from(selectedEntityIds), id => {
const found = findInTreeOrThrow(fileTreeData, id)
shouldReindexReferences =
shouldReindexReferences || /\.bib$/.test(found.entity.name)
return syncDelete(projectId, found.type, found.entity._id).catch(
error => {
// throw unless 404
if (error.info.statusCode !== 404) {
throw error
}
}
}
)
})
.then(() => {
if (shouldReindexReferences) {
reindexReferences()
}
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
// set an error and allow user to retry
dispatch({ type: ACTION_TYPES.ERROR, error })
)
})
// @ts-ignore (TODO: improve mapSeries types)
.then(() => {
if (shouldReindexReferences) {
reindexReferences()
}
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch((error: Error) => {
// set an error and allow user to retry
dispatch({ type: ACTION_TYPES.ERROR, error })
})
)
}, [fileTreeData, projectId, selectedEntityIds, reindexReferences])
// moves entities. Tree is updated immediately and data are sync'd after.
@ -242,7 +334,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
}
// keep track of old parent folder ids so we can revert entities if sync fails
const oldParentFolderIds = {}
const oldParentFolderIds: Record<string, string> = {}
let isMoveFailed = false
// dispatch moves immediately
@ -252,19 +344,23 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
})
// sync dispatched moves after
return mapSeries(founds, async found => {
try {
await syncMove(projectId, found.type, found.entity._id, toFolderId)
} catch (error) {
isMoveFailed = true
dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
dispatch({ type: ACTION_TYPES.ERROR, error })
}
}).then(() => {
if (!isMoveFailed) {
dispatch({ type: ACTION_TYPES.CLEAR })
}
})
return (
mapSeries(founds, async found => {
try {
await syncMove(projectId, found.type, found.entity._id, toFolderId)
} catch (error) {
isMoveFailed = true
dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
dispatch({ type: ACTION_TYPES.ERROR, error })
}
})
// @ts-ignore (TODO: improve mapSeries types)
.then(() => {
if (!isMoveFailed) {
dispatch({ type: ACTION_TYPES.CLEAR })
}
})
)
},
[dispatchMove, fileTreeData, projectId]
)
@ -356,10 +452,10 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
// listen for `file-tree.start-creating` events
useEffect(() => {
function handleEvent(event) {
function handleEvent(event: Event) {
dispatch({
type: ACTION_TYPES.START_CREATE_FILE,
newFileCreateMode: event.detail.mode,
newFileCreateMode: (event as CustomEvent<{ mode: string }>).detail.mode,
})
}
@ -381,6 +477,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
}
}, [fileTreeData, projectId, selectedEntityIds])
// TODO: wrap in useMemo
const value = {
canDelete: selectedEntityIds.size > 0 && !isRootFolderSelected,
canRename: selectedEntityIds.size === 1 && !isRootFolderSelected,
@ -413,22 +510,6 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
)
}
FileTreeActionableProvider.propTypes = {
reindexReferences: PropTypes.func.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
}
const projectContextPropTypes = {
_id: PropTypes.string.isRequired,
}
const editorContextPropTypes = {
permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}
export function useFileTreeActionable() {
const context = useContext(FileTreeActionableContext)
@ -442,9 +523,9 @@ export function useFileTreeActionable() {
}
function getSelectedParentFolderId(
fileTreeData,
selectedEntityIds,
isRootFolderSelected
fileTreeData: Folder,
selectedEntityIds: Set<string>,
isRootFolderSelected: boolean
) {
if (isRootFolderSelected) {
return fileTreeData._id
@ -467,7 +548,11 @@ function getSelectedParentFolderId(
return found.type === 'folder' ? found.entity._id : found.parentFolderId
}
function validateCreate(fileTreeData, parentFolderId, entity) {
function validateCreate(
fileTreeData: Folder,
parentFolderId: string,
entity: { name: string; endpoint: string }
) {
if (!isCleanFilename(entity.name)) {
return new InvalidFilenameError()
}
@ -484,7 +569,11 @@ function validateCreate(fileTreeData, parentFolderId, entity) {
}
}
function validateRename(fileTreeData, found, newName) {
function validateRename(
fileTreeData: Folder,
found: { parentFolderId: string; path: string; type: string },
newName: string
) {
if (!isCleanFilename(newName)) {
return new InvalidFilenameError()
}
@ -500,10 +589,16 @@ function validateRename(fileTreeData, found, newName) {
}
}
function validateMove(fileTreeData, toFolderId, found, isMoveToRoot) {
function validateMove(
fileTreeData: Folder,
toFolderId: string,
found: { entity: { name: string }; type: string },
isMoveToRoot: boolean
) {
if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
const error = new DuplicateFilenameMoveError()
error.entityName = found.entity.name
;(error as DuplicateFilenameMoveError & { entityName: string }).entityName =
found.entity.name
return error
}

View file

@ -1,7 +1,8 @@
import { createContext, useContext, useState } from 'react'
import PropTypes from 'prop-types'
import { createContext, FC, useContext, useState } from 'react'
const FileTreeCreateFormContext = createContext()
const FileTreeCreateFormContext = createContext<
{ valid: boolean; setValid: (value: boolean) => void } | undefined
>(undefined)
export const useFileTreeCreateForm = () => {
const context = useContext(FileTreeCreateFormContext)
@ -15,7 +16,7 @@ export const useFileTreeCreateForm = () => {
return context
}
export default function FileTreeCreateFormProvider({ children }) {
const FileTreeCreateFormProvider: FC = ({ children }) => {
// is the form valid
const [valid, setValid] = useState(false)
@ -26,9 +27,4 @@ export default function FileTreeCreateFormProvider({ children }) {
)
}
FileTreeCreateFormProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
}
export default FileTreeCreateFormProvider

View file

@ -1,8 +1,15 @@
import { createContext, useContext, useMemo, useReducer } from 'react'
import { createContext, FC, useContext, useMemo, useReducer } from 'react'
import { isCleanFilename } from '../util/safe-path'
import PropTypes from 'prop-types'
const FileTreeCreateNameContext = createContext()
const FileTreeCreateNameContext = createContext<
| {
name: string
touchedName: boolean
validName: boolean
setName: (name: string) => void
}
| undefined
>(undefined)
export const useFileTreeCreateName = () => {
const context = useContext(FileTreeCreateNameContext)
@ -16,12 +23,17 @@ export const useFileTreeCreateName = () => {
return context
}
export default function FileTreeCreateNameProvider({
type State = {
name: string
touchedName: boolean
}
const FileTreeCreateNameProvider: FC<{ initialName?: string }> = ({
children,
initialName = '',
}) {
}) => {
const [state, setName] = useReducer(
(state, name) => ({
(state: State, name: string) => ({
name, // the file name
touchedName: true, // whether the name has been edited
}),
@ -43,10 +55,4 @@ export default function FileTreeCreateNameProvider({
)
}
FileTreeCreateNameProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
initialName: PropTypes.string,
}
export default FileTreeCreateNameProvider

View file

@ -1,5 +1,4 @@
import { useRef, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { useRef, useEffect, useState, FC } from 'react'
import { useTranslation } from 'react-i18next'
import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles'
import { DndProvider, createDndContext, useDrag, useDrop } from 'react-dnd'
@ -25,12 +24,13 @@ import { useEditorContext } from '../../../shared/context/editor-context'
// particular in rich text.
// This is a hacky workaround to avoid calling the DnD listeners when the
// draggable or droppable element is not within a `dnd-container` element.
const ModifiedBackend = (...args) => {
function isDndChild(elt) {
const ModifiedBackend = (...args: any[]) => {
function isDndChild(elt: Element): boolean {
if (elt.getAttribute && elt.getAttribute('dnd-container')) return true
if (!elt.parentNode) return false
return isDndChild(elt.parentNode)
return isDndChild(elt.parentNode as Element)
}
// @ts-ignore
const instance = new HTML5Backend(...args)
const dragDropListeners = [
@ -48,8 +48,8 @@ const ModifiedBackend = (...args) => {
dragDropListeners.forEach(dragDropListener => {
const originalListener = instance[dragDropListener]
instance[dragDropListener] = (ev, ...extraArgs) => {
if (isDndChild(ev.target)) originalListener(ev, ...extraArgs)
instance[dragDropListener] = (ev: Event, ...extraArgs: any[]) => {
if (isDndChild(ev.target as Element)) originalListener(ev, ...extraArgs)
}
})
@ -59,28 +59,20 @@ const ModifiedBackend = (...args) => {
const DndContext = createDndContext(ModifiedBackend)
const DRAGGABLE_TYPE = 'ENTITY'
export function FileTreeDraggableProvider({ children }) {
export const FileTreeDraggableProvider: FC = ({ children }) => {
const DndManager = useRef(DndContext)
return (
<DndProvider manager={DndManager.current.dragDropManager}>
<DndProvider manager={DndManager.current.dragDropManager!}>
{children}
</DndProvider>
)
}
FileTreeDraggableProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
}
export function useDraggable(draggedEntityId) {
export function useDraggable(draggedEntityId: string) {
const { t } = useTranslation()
const { permissionsLevel } = useEditorContext(editorContextPropTypes)
const { permissionsLevel } = useEditorContext()
const { fileTreeData } = useFileTreeData()
const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
@ -101,6 +93,7 @@ export function useDraggable(draggedEntityId) {
},
collect: monitor => ({
isDragging: !!monitor.isDragging(),
draggedEntityIds: monitor.getItem()?.draggedEntityIds,
}),
canDrag: () => permissionsLevel !== 'readOnly' && isDraggable,
end: () => item,
@ -120,15 +113,11 @@ export function useDraggable(draggedEntityId) {
}
}
const editorContextPropTypes = {
permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}
export function useDroppable(droppedEntityId) {
export function useDroppable(droppedEntityId: string) {
const { finishMoving, setDroppedFiles, startUploadingDocOrFile } =
useFileTreeActionable()
const [{ isOver }, dropRef] = useDrop({
const [{ isOver }, dropRef] = useDrop<any, any, any>({
accept: [DRAGGABLE_TYPE, NativeTypes.FILE],
canDrop: (item, monitor) => {
const isOver = monitor.isOver({ shallow: true })
@ -166,7 +155,10 @@ export function useDroppable(droppedEntityId) {
// Get the list of dragged entity ids. If the dragged entity is one of the
// selected entities then all the selected entites are dragged entities,
// otherwise it's the dragged entity only.
function getDraggedEntityIds(selectedEntityIds, draggedEntityId) {
function getDraggedEntityIds(
selectedEntityIds: Set<string>,
draggedEntityId: string
) {
if (selectedEntityIds.size > 1 && selectedEntityIds.has(draggedEntityId)) {
// dragging the multi-selected entities
return new Set(selectedEntityIds)
@ -178,7 +170,10 @@ function getDraggedEntityIds(selectedEntityIds, draggedEntityId) {
// Get the draggable title. This is the name of the dragged entities if there's
// only one, otherwise it's the number of dragged entities.
function getDraggedTitle(draggedItems, t) {
function getDraggedTitle(
draggedItems: Set<any>,
t: (key: string, options: Record<string, any>) => void
) {
if (draggedItems.size === 1) {
const draggedItem = Array.from(draggedItems)[0]
return draggedItem.entity.name
@ -187,7 +182,7 @@ function getDraggedTitle(draggedItems, t) {
}
// Get all children folder ids of any of the dragged items.
function getForbiddenFolderIds(draggedItems) {
function getForbiddenFolderIds(draggedItems: Set<any>) {
const draggedFoldersArray = Array.from(draggedItems)
.filter(draggedItem => {
return draggedItem.type === 'folder'

View file

@ -1,52 +0,0 @@
import { createContext, useContext, useState } from 'react'
import PropTypes from 'prop-types'
const FileTreeMainContext = createContext()
export function useFileTreeMainContext() {
const context = useContext(FileTreeMainContext)
if (!context) {
throw new Error(
'useFileTreeMainContext is only available inside FileTreeMainProvider'
)
}
return context
}
export const FileTreeMainProvider = function ({
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
children,
}) {
const [contextMenuCoords, setContextMenuCoords] = useState()
return (
<FileTreeMainContext.Provider
value={{
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
contextMenuCoords,
setContextMenuCoords,
}}
>
{children}
</FileTreeMainContext.Provider>
)
}
FileTreeMainProvider.propTypes = {
reindexReferences: PropTypes.func.isRequired,
refProviders: PropTypes.object.isRequired,
setRefProviderEnabled: PropTypes.func.isRequired,
setStartedFreeTrial: PropTypes.func.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
}

View file

@ -0,0 +1,58 @@
import { createContext, FC, useContext, useState } from 'react'
type ContextMenuCoords = { top: number; left: number }
const FileTreeMainContext = createContext<
| {
refProviders: object
reindexReferences: () => void
setRefProviderEnabled: (provider: string, value: boolean) => void
setStartedFreeTrial: (value: boolean) => void
contextMenuCoords: ContextMenuCoords | null
setContextMenuCoords: (value: ContextMenuCoords | null) => void
}
| undefined
>(undefined)
export function useFileTreeMainContext() {
const context = useContext(FileTreeMainContext)
if (!context) {
throw new Error(
'useFileTreeMainContext is only available inside FileTreeMainProvider'
)
}
return context
}
export const FileTreeMainProvider: FC<{
reindexReferences: () => void
refProviders: object
setRefProviderEnabled: (provider: string, value: boolean) => void
setStartedFreeTrial: (value: boolean) => void
}> = ({
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
children,
}) => {
const [contextMenuCoords, setContextMenuCoords] =
useState<ContextMenuCoords | null>(null)
return (
<FileTreeMainContext.Provider
value={{
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
contextMenuCoords,
setContextMenuCoords,
}}
>
{children}
</FileTreeMainContext.Provider>
)
}

View file

@ -6,11 +6,10 @@ import {
useEffect,
useMemo,
useState,
FC,
} from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import _ from 'lodash'
import { findInTree } from '../util/find-in-tree'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import { useProjectContext } from '../../../shared/context/project-context'
@ -19,17 +18,53 @@ import { useLayoutContext } from '../../../shared/context/layout-context'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import usePreviousValue from '../../../shared/hooks/use-previous-value'
import { useFileTreeMainContext } from '@/features/file-tree/contexts/file-tree-main'
import { FindResult } from '@/features/file-tree/util/path'
import { fileCollator } from '@/features/file-tree/util/file-collator'
import { Folder } from '../../../../../types/folder'
import { FileTreeEntity } from '../../../../../types/file-tree-entity'
const FileTreeSelectableContext = createContext()
const FileTreeSelectableContext = createContext<
| {
selectedEntityIds: Set<string>
isRootFolderSelected: boolean
selectOrMultiSelectEntity: (
id: string | string[],
multiple?: boolean
) => void
setIsRootFolderSelected: (value: boolean) => void
selectedEntityParentIds: Set<string>
select: (id: string | string[]) => void
unselect: (id: string) => void
}
| undefined
>(undefined)
const ACTION_TYPES = {
SELECT: 'SELECT',
MULTI_SELECT: 'MULTI_SELECT',
UNSELECT: 'UNSELECT',
/* eslint-disable no-unused-vars */
enum ACTION_TYPES {
SELECT = 'SELECT',
MULTI_SELECT = 'MULTI_SELECT',
UNSELECT = 'UNSELECT',
}
/* eslint-enable no-unused-vars */
function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
type Action =
| {
type: ACTION_TYPES.SELECT
id: string
}
| {
type: ACTION_TYPES.MULTI_SELECT
id: string
}
| {
type: ACTION_TYPES.UNSELECT
id: string
}
function fileTreeSelectableReadWriteReducer(
selectedEntityIds: Set<string>,
action: Action
) {
switch (action.type) {
case ACTION_TYPES.SELECT: {
// reset selection
@ -59,11 +94,16 @@ function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
}
default:
throw new Error(`Unknown selectable action type: ${action.type}`)
throw new Error(
`Unknown selectable action type: ${(action as Action).type}`
)
}
}
function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) {
function fileTreeSelectableReadOnlyReducer(
selectedEntityIds: Set<string>,
action: Action
) {
switch (action.type) {
case ACTION_TYPES.SELECT:
return new Set([action.id])
@ -73,15 +113,17 @@ function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) {
return selectedEntityIds
default:
throw new Error(`Unknown selectable action type: ${action.type}`)
throw new Error(
`Unknown selectable action type: ${(action as Action).type}`
)
}
}
export function FileTreeSelectableProvider({ onSelect, children }) {
const { _id: projectId, rootDocId } = useProjectContext(
projectContextPropTypes
)
const { permissionsLevel } = useEditorContext(editorContextPropTypes)
export const FileTreeSelectableProvider: FC<{
onSelect: (value: FindResult[]) => void
}> = ({ onSelect, children }) => {
const { _id: projectId, rootDocId } = useProjectContext()
const { permissionsLevel } = useEditorContext()
const [initialSelectedEntityId] = usePersistedState(
`doc.open_id.${projectId}`,
@ -98,7 +140,7 @@ export function FileTreeSelectableProvider({ onSelect, children }) {
: fileTreeSelectableReadWriteReducer,
null,
() => {
if (!initialSelectedEntityId) return new Set()
if (!initialSelectedEntityId) return new Set<string>()
// the entity with id=initialSelectedEntityId might not exist in the tree
// anymore. This checks that it exists before initialising the reducer
@ -107,21 +149,21 @@ export function FileTreeSelectableProvider({ onSelect, children }) {
return new Set([initialSelectedEntityId])
// the entity doesn't exist anymore; don't select any files
return new Set()
return new Set<string>()
}
)
const [selectedEntityParentIds, setSelectedEntityParentIds] = useState(
new Set()
)
const [selectedEntityParentIds, setSelectedEntityParentIds] = useState<
Set<string>
>(new Set())
// fills `selectedEntityParentIds` set
useEffect(() => {
const ids = new Set()
const ids = new Set<string>()
selectedEntityIds.forEach(id => {
const found = findInTree(fileTreeData, id)
if (found) {
found.path.forEach(pathItem => ids.add(pathItem))
found.path.forEach((pathItem: any) => ids.add(pathItem))
}
})
setSelectedEntityParentIds(ids)
@ -148,7 +190,7 @@ export function FileTreeSelectableProvider({ onSelect, children }) {
useEffect(() => {
// listen for `editor.openDoc` and selected that doc
function handleOpenDoc(ev) {
function handleOpenDoc(ev: any) {
const found = findInTree(fileTreeData, ev.detail)
if (!found) return
@ -175,6 +217,7 @@ export function FileTreeSelectableProvider({ onSelect, children }) {
dispatch({ type: actionType, id })
}, [])
// TODO: wrap in useMemo
const value = {
selectedEntityIds,
selectedEntityParentIds,
@ -192,26 +235,9 @@ export function FileTreeSelectableProvider({ onSelect, children }) {
)
}
FileTreeSelectableProvider.propTypes = {
onSelect: PropTypes.func.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
}
const projectContextPropTypes = {
_id: PropTypes.string.isRequired,
rootDocId: PropTypes.string,
}
const editorContextPropTypes = {
permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}
const isMac = /Mac/.test(window.navigator?.platform)
export function useSelectableEntity(id, type) {
export function useSelectableEntity(id: string, type: string) {
const { view, setView } = useLayoutContext()
const { setContextMenuCoords } = useFileTreeMainContext()
const { fileTreeData } = useFileTreeData()
@ -220,7 +246,7 @@ export function useSelectableEntity(id, type) {
selectOrMultiSelectEntity,
isRootFolderSelected,
setIsRootFolderSelected,
} = useContext(FileTreeSelectableContext)
} = useFileTreeSelectable()
const isSelected = selectedEntityIds.has(id)
@ -369,9 +395,10 @@ export function useFileTreeSelectable() {
return context
}
const alphabetical = (a, b) => fileCollator.compare(a.name, b.name)
const alphabetical = (a: FileTreeEntity, b: FileTreeEntity) =>
fileCollator.compare(a.name, b.name)
function* sortedItems(folder) {
function* sortedItems(folder: Folder): Generator<string> {
yield folder._id
const folders = [...folder.folders].sort(alphabetical)

View file

@ -110,8 +110,14 @@ App.controller('ReactFileTreeController', [
App.component(
'fileTreeRoot',
react2angular(
rootContext.use(FileTreeRoot),
Object.keys(FileTreeRoot.propTypes)
)
react2angular(rootContext.use(FileTreeRoot), [
'onSelect',
'onDelete',
'onInit',
'isConnected',
'setRefProviderEnabled',
'setStartedFreeTrial',
'reindexReferences',
'refProviders',
])
)

View file

@ -1,5 +1,4 @@
import { useCallback, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useUserContext } from '../../../shared/context/user-context'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
@ -7,10 +6,8 @@ import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
import { useIdeContext } from '@/shared/context/ide-context'
export function useFileTreeSocketListener(onDelete) {
const user = useUserContext({
id: PropTypes.string.isRequired,
})
export function useFileTreeSocketListener(onDelete: (entity: any) => void) {
const user = useUserContext()
const {
dispatchRename,
dispatchDelete,
@ -41,7 +38,7 @@ export function useFileTreeSocketListener(onDelete) {
)
useEffect(() => {
function handleDispatchRename(entityId, name) {
function handleDispatchRename(entityId: string, name: string) {
dispatchRename(entityId, name)
}
if (socket) socket.on('reciveEntityRename', handleDispatchRename)
@ -52,7 +49,7 @@ export function useFileTreeSocketListener(onDelete) {
}, [socket, dispatchRename])
useEffect(() => {
function handleDispatchDelete(entityId) {
function handleDispatchDelete(entityId: string) {
const entity = findInTree(fileTreeData, entityId)
unselect(entityId)
if (selectedEntityParentIds.has(entityId)) {
@ -88,7 +85,7 @@ export function useFileTreeSocketListener(onDelete) {
])
useEffect(() => {
function handleDispatchMove(entityId, toFolderId) {
function handleDispatchMove(entityId: string, toFolderId: string) {
dispatchMove(entityId, toFolderId)
}
if (socket) socket.on('reciveEntityMove', handleDispatchMove)
@ -98,7 +95,7 @@ export function useFileTreeSocketListener(onDelete) {
}, [socket, dispatchMove])
useEffect(() => {
function handleDispatchCreateFolder(parentFolderId, folder, userId) {
function handleDispatchCreateFolder(parentFolderId: string, folder: any) {
dispatchCreateFolder(parentFolderId, folder)
}
if (socket) socket.on('reciveNewFolder', handleDispatchCreateFolder)
@ -109,7 +106,11 @@ export function useFileTreeSocketListener(onDelete) {
}, [socket, dispatchCreateFolder])
useEffect(() => {
function handleDispatchCreateDoc(parentFolderId, doc, _source, userId) {
function handleDispatchCreateDoc(
parentFolderId: string,
doc: any,
_source: unknown
) {
dispatchCreateDoc(parentFolderId, doc)
}
if (socket) socket.on('reciveNewDoc', handleDispatchCreateDoc)
@ -120,11 +121,11 @@ export function useFileTreeSocketListener(onDelete) {
useEffect(() => {
function handleDispatchCreateFile(
parentFolderId,
file,
_source,
linkedFileData,
userId
parentFolderId: string,
file: any,
_source: unknown,
linkedFileData: any,
userId: string
) {
dispatchCreateFile(parentFolderId, file)
if (linkedFileData) {

View file

@ -1,5 +1,4 @@
import { useState, type ElementType } from 'react'
import PropTypes from 'prop-types'
import { Trans, useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
@ -48,12 +47,8 @@ type FileViewHeaderProps = {
}
export default function FileViewHeader({ file }: FileViewHeaderProps) {
const { _id: projectId } = useProjectContext({
_id: PropTypes.string.isRequired,
})
const { permissionsLevel } = useEditorContext({
permissionsLevel: PropTypes.string,
})
const { _id: projectId } = useProjectContext()
const { permissionsLevel } = useEditorContext()
const { t } = useTranslation()
const [refreshError, setRefreshError] = useState<Nullable<string>>(null)
@ -150,7 +145,6 @@ function ProjectFilePathProvider({ file }: ProjectFilePathProviderProps) {
return (
<p>
<LinkedFileIcon />
&nbsp;
<Trans
i18nKey="imported_from_another_project_at_date"
components={

View file

@ -1,24 +0,0 @@
import PropTypes from 'prop-types'
import { useProjectContext } from '../../../shared/context/project-context'
export default function FileViewImage({ fileName, fileId, onLoad, onError }) {
const { _id: projectId } = useProjectContext({
_id: PropTypes.string.isRequired,
})
return (
<img
src={`/project/${projectId}/file/${fileId}`}
onLoad={onLoad}
onError={onError}
alt={fileName}
/>
)
}
FileViewImage.propTypes = {
fileName: PropTypes.string.isRequired,
fileId: PropTypes.string.isRequired,
onLoad: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
}

View file

@ -0,0 +1,24 @@
import { useProjectContext } from '../../../shared/context/project-context'
export default function FileViewImage({
fileName,
fileId,
onLoad,
onError,
}: {
fileName: string
fileId: string
onLoad: () => void
onError: () => void
}) {
const { _id: projectId } = useProjectContext()
return (
<img
src={`/project/${projectId}/file/${fileId}`}
onLoad={onLoad}
onError={onError}
alt={fileName}
/>
)
}

View file

@ -1,15 +1,20 @@
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useProjectContext } from '../../../shared/context/project-context'
import { debugConsole } from '@/utils/debugging'
import useAbortController from '../../../shared/hooks/use-abort-controller'
const MAX_FILE_SIZE = 2 * 1024 * 1024
export default function FileViewText({ file, onLoad, onError }) {
const { _id: projectId } = useProjectContext({
_id: PropTypes.string.isRequired,
})
export default function FileViewText({
file,
onLoad,
onError,
}: {
file: { id: string }
onLoad: () => void
onError: () => void
}) {
const { _id: projectId } = useProjectContext()
const [textPreview, setTextPreview] = useState('')
const [shouldShowDots, setShouldShowDots] = useState(false)
@ -27,7 +32,7 @@ export default function FileViewText({ file, onLoad, onError }) {
() => fetchContentLengthController.abort(),
10000
)
let fetchDataTimeout
let fetchDataTimeout: number | undefined
fetch(path, { method: 'HEAD', signal: fetchContentLengthController.signal })
.then(response => {
if (!response.ok) throw new Error('HTTP Error Code: ' + response.status)
@ -36,7 +41,7 @@ export default function FileViewText({ file, onLoad, onError }) {
.then(fileSize => {
let truncated = false
let maxSize = null
if (fileSize > MAX_FILE_SIZE) {
if (fileSize && Number(fileSize) > MAX_FILE_SIZE) {
truncated = true
maxSize = MAX_FILE_SIZE
}
@ -44,7 +49,10 @@ export default function FileViewText({ file, onLoad, onError }) {
if (maxSize != null) {
path += `?range=0-${maxSize}`
}
fetchDataTimeout = setTimeout(() => fetchDataController.abort(), 60000)
fetchDataTimeout = window.setTimeout(
() => fetchDataController.abort(),
60000
)
return fetch(path, { signal: fetchDataController.signal }).then(
response => {
return response.text().then(text => {
@ -90,9 +98,3 @@ export default function FileViewText({ file, onLoad, onError }) {
</div>
)
}
FileViewText.propTypes = {
file: PropTypes.shape({ id: PropTypes.string }).isRequired,
onLoad: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
}

View file

@ -16,11 +16,6 @@ import useAsync from '@/shared/hooks/use-async'
import { completeHistoryTutorial } from '../../services/api'
import { debugConsole } from '@/utils/debugging'
type EditorTutorials = {
inactiveTutorials: [string]
deactivateTutorial: (key: string) => void
}
function AllHistoryList() {
const { id: currentUserId } = useUserContext()
const {
@ -97,8 +92,7 @@ function AllHistoryList() {
}
}, [updatesLoadingState])
const { inactiveTutorials, deactivateTutorial }: EditorTutorials =
useEditorContext()
const { inactiveTutorials, deactivateTutorial } = useEditorContext()
const [showPopover, setShowPopover] = useState(() => {
// only show tutorial popover if they haven't dismissed ("completed") it yet

View file

@ -22,7 +22,6 @@ function EditorNavigationToolbar() {
return (
<>
<EditorNavigationToolbarRoot
// @ts-ignore
onlineUsersArray={onlineUsersArray}
openDoc={openDoc}
openShareProjectModal={handleOpenShareModal}

View file

@ -91,7 +91,7 @@ export const FileTreeOpenProvider: FC = ({ children }) => {
eventEmitter.emit('entity:deleted', entity)
// Select the root document if the current document was deleted
if (entity.entity._id === openDocId) {
openDocWithId(rootDocId)
openDocWithId(rootDocId!)
}
},
[eventEmitter, openDocId, openDocWithId, rootDocId]
@ -116,7 +116,12 @@ export const FileTreeOpenProvider: FC = ({ children }) => {
// Open a document once the file tree and project are ready
const initialOpenDoneRef = useRef(false)
useEffect(() => {
if (fileTreeReady && projectJoined && !initialOpenDoneRef.current) {
if (
rootDocId &&
fileTreeReady &&
projectJoined &&
!initialOpenDoneRef.current
) {
initialOpenDoneRef.current = true
openInitialDoc(rootDocId)
}

View file

@ -523,7 +523,8 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
if (!user) {
return 'anonymous'
}
if (project.owner === user.id) {
// FIXME: check this
if (project.owner._id === user.id) {
return 'member'
}
for (const member of project.members as any[]) {
@ -711,7 +712,7 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
)
const applyTrackChangesStateToClient = useCallback(
(state: boolean | Record<UserId, boolean>) => {
(state: boolean | ReviewPanel.Value<'trackChangesState'>) => {
if (typeof state === 'boolean') {
setEveryoneTCState(state)
setGuestsTCState(state)
@ -728,13 +729,13 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
newTrackChangesState = setUserTCState(
newTrackChangesState,
member._id,
state[member._id] ?? false
!!state[member._id]
)
}
newTrackChangesState = setUserTCState(
newTrackChangesState,
project.owner._id,
state[project.owner._id] ?? false
!!state[project.owner._id]
)
return newTrackChangesState
}
@ -750,7 +751,7 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
)
const setGuestFeatureBasedOnProjectAccessLevel = (
projectPublicAccessLevel: PublicAccessLevel
projectPublicAccessLevel?: PublicAccessLevel
) => {
setTrackChangesForGuestsAvailable(projectPublicAccessLevel === 'tokenBased')
}
@ -1169,7 +1170,7 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
}, [currentDocumentId, getDocEntries, trackChangesVisible])
useEffect(() => {
setMiniReviewPanelVisible(!reviewPanelOpen && hasEntries)
setMiniReviewPanelVisible(!reviewPanelOpen && !!hasEntries)
}, [reviewPanelOpen, hasEntries, setMiniReviewPanelVisible])
// listen for events from the CodeMirror 6 track changes extension

View file

@ -1,5 +1,4 @@
import React, { useEffect } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
@ -8,7 +7,18 @@ import Icon from '../../../shared/components/icon'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import Tooltip from '../../../shared/components/tooltip'
const OutlinePane = React.memo(function OutlinePane({
const OutlinePane = React.memo<{
isTexFile: boolean
outline: any[]
jumpToLine(line: number): void
onToggle(value: boolean): void
eventTracking: any
highlightedLine?: number
show: boolean
isPartial?: boolean
expanded?: boolean
toggleExpanded: () => void
}>(function OutlinePane({
isTexFile,
outline,
jumpToLine,
@ -20,7 +30,7 @@ const OutlinePane = React.memo(function OutlinePane({
}) {
const { t } = useTranslation()
const isOpen = isTexFile && expanded
const isOpen = Boolean(isTexFile && expanded)
useEffect(() => {
onToggle(isOpen)
@ -73,15 +83,4 @@ const OutlinePane = React.memo(function OutlinePane({
)
})
OutlinePane.propTypes = {
isTexFile: PropTypes.bool.isRequired,
outline: PropTypes.array.isRequired,
jumpToLine: PropTypes.func.isRequired,
onToggle: PropTypes.func.isRequired,
highlightedLine: PropTypes.number,
isPartial: PropTypes.bool,
expanded: PropTypes.bool,
toggleExpanded: PropTypes.func.isRequired,
}
export default withErrorBoundary(OutlinePane)

View file

@ -5,7 +5,7 @@ import { FC } from 'react'
export const CompileTimeoutWarning: FC<{
handleDismissWarning: () => void
showNewCompileTimeoutUI: string
showNewCompileTimeoutUI?: string
}> = ({ handleDismissWarning, showNewCompileTimeoutUI }) => {
const { t } = useTranslation()

View file

@ -26,8 +26,8 @@ function FasterCompilesFeedbackContent() {
true
)
const [sayThanks, setSayThanks] = useState(false)
const lastClsiServerId = useRef('')
const lastPdfUrl = useRef('')
const lastClsiServerId = useRef<string | undefined>(undefined)
const lastPdfUrl = useRef<string | undefined>(undefined)
useEffect(() => {
if (
@ -52,7 +52,7 @@ function FasterCompilesFeedbackContent() {
projectId,
server: clsiServerId?.includes('-c2d-') ? 'faster' : 'normal',
feedback,
pdfSize: pdfFile.size,
pdfSize: pdfFile?.size,
...deliveryLatencies,
})
setHasRatedProject(true)

View file

@ -136,7 +136,7 @@ type ReadOrWriteFormGroupProps = {
id: string
type: string
label: string
value: string
value?: string
handleChange: (event: any) => void
canEdit: boolean
required: boolean

View file

@ -1,7 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from 'react-bootstrap'
import PropTypes from 'prop-types'
import { useUserContext } from '../../../shared/context/user-context'
@ -13,14 +12,11 @@ import { useSplitTestContext } from '../../../shared/context/split-test-context'
export default function AddCollaboratorsUpgrade() {
const { t } = useTranslation()
const user = useUserContext({
allowedFreeTrial: PropTypes.bool,
})
const user = useUserContext()
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
const { splitTestVariants } = useSplitTestContext({
splitTestVariants: PropTypes.object,
})
const { splitTestVariants } = useSplitTestContext()
const variant = splitTestVariants['project-share-modal-paywall']
return (

View file

@ -10,13 +10,10 @@ import { useProjectContext } from '../../../shared/context/project-context'
import { useSplitTestContext } from '../../../shared/context/split-test-context'
import { useMemo } from 'react'
import { Row } from 'react-bootstrap'
import PropTypes from 'prop-types'
import RecaptchaConditions from '../../../shared/components/recaptcha-conditions'
export default function ShareModalBody() {
const { splitTestVariants } = useSplitTestContext({
splitTestVariants: PropTypes.object,
})
const { splitTestVariants } = useSplitTestContext()
const { members, invites, features } = useProjectContext()
const { isProjectOwner } = useEditorContext()
@ -32,7 +29,7 @@ export default function ShareModalBody() {
return true
}
return members.length + invites.length < features.collaborators
return members.length + invites.length < (features.collaborators ?? 1)
}, [members, invites, features, isProjectOwner])
switch (splitTestVariants['project-share-modal-paywall']) {

View file

@ -5,17 +5,14 @@ import React, {
useEffect,
useState,
} from 'react'
import PropTypes from 'prop-types'
import ShareProjectModalContent from './share-project-modal-content'
import {
useProjectContext,
projectShape,
} from '../../../shared/context/project-context'
import { useProjectContext } from '../../../shared/context/project-context'
import { useSplitTestContext } from '../../../shared/context/split-test-context'
import { sendMB } from '../../../infrastructure/event-tracking'
import { Project } from '../../../../../types/project'
type ShareProjectContextValue = {
updateProject: (data: unknown) => void
updateProject: (project: Project) => void
monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
inFlight: boolean
setInFlight: React.Dispatch<
@ -58,11 +55,9 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
useState<ShareProjectContextValue['inFlight']>(false)
const [error, setError] = useState<ShareProjectContextValue['error']>()
const project = useProjectContext(projectShape)
const project = useProjectContext()
const { splitTestVariants } = useSplitTestContext({
splitTestVariants: PropTypes.object,
})
const { splitTestVariants } = useSplitTestContext()
// send tracking event when the modal is opened
useEffect(() => {

View file

@ -6,12 +6,11 @@ import Icon from '../../../../shared/components/icon'
import OverviewFile from './overview-file'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { MainDocument } from '../../../../../../types/project-settings'
import { memo } from 'react'
function OverviewContainer() {
const { isOverviewLoading } = useReviewPanelValueContext()
const docs: MainDocument[] = useFileTreeData().docs
const { docs } = useFileTreeData()
return (
<Container>

View file

@ -13,10 +13,7 @@ import {
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
import { ReviewPanelResolvedCommentThread } from '../../../../../../../types/review-panel/comment-thread'
import {
DocId,
MainDocument,
} from '../../../../../../../types/project-settings'
import { DocId } from '../../../../../../../types/project-settings'
import { ReviewPanelEntry } from '../../../../../../../types/review-panel/entry'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
@ -35,7 +32,7 @@ function ResolvedCommentsDropdown() {
const [isLoading, setIsLoading] = useState(false)
const { commentThreads, resolvedComments, permissions } =
useReviewPanelValueContext()
const docs: MainDocument[] = useFileTreeData().docs
const { docs } = useFileTreeData()
const { refreshResolvedCommentsDropdown } = useReviewPanelUpdaterFnsContext()

View file

@ -9,7 +9,6 @@ import {
useRef,
useState,
} from 'react'
import PropTypes from 'prop-types'
import { useEditorContext } from '../../../../../shared/context/editor-context'
import { Button, Overlay, Popover } from 'react-bootstrap'
import Close from '../../../../../shared/components/close'
@ -19,7 +18,6 @@ import { useSplitTestContext } from '../../../../../shared/context/split-test-co
import { User } from '../../../../../../../types/user'
import { useUserContext } from '../../../../../shared/context/user-context'
import grammarlyExtensionPresent from '../../../../../shared/utils/grammarly'
import { EditorTutorials } from '../../../../../../../types/tutorial'
import { debugConsole } from '../../../../../utils/debugging'
const DELAY_BEFORE_SHOWING_PROMOTION = 1000
@ -27,19 +25,11 @@ const NEW_USER_CUTOFF_TIME = new Date(2023, 8, 20).getTime()
const NOW_TIME = new Date().getTime()
const GRAMMARLY_CUTOFF_TIME = new Date(2023, 9, 10).getTime()
const editorContextPropTypes = {
inactiveTutorials: PropTypes.arrayOf(PropTypes.string).isRequired,
deactivateTutorial: PropTypes.func.isRequired,
currentPopup: PropTypes.string,
setCurrentPopup: PropTypes.func.isRequired,
}
export const PromotionOverlay: FC = ({ children }) => {
const ref = useRef<HTMLSpanElement>(null)
const { inactiveTutorials, currentPopup, setCurrentPopup }: EditorTutorials =
useEditorContext(editorContextPropTypes)
const { inactiveTutorials, currentPopup, setCurrentPopup } =
useEditorContext()
const {
splitTestVariants,
}: { splitTestVariants: Record<string, string | undefined> } =
@ -88,9 +78,7 @@ const PromotionOverlayContent = memo(
_props,
ref: Ref<HTMLSpanElement>
) {
const { deactivateTutorial }: EditorTutorials = useEditorContext(
editorContextPropTypes
)
const { deactivateTutorial } = useEditorContext()
const [timeoutExpired, setTimeoutExpired] = useState(false)
const onClose = useCallback(() => {

View file

@ -1,18 +1,14 @@
import { createContext, useContext, useMemo } from 'react'
import PropTypes from 'prop-types'
import {
useLocalCompileContext,
CompileContextPropTypes,
} from './local-compile-context'
import { createContext, FC, useContext, useMemo } from 'react'
import { CompileContext, useLocalCompileContext } from './local-compile-context'
import useDetachStateWatcher from '../hooks/use-detach-state-watcher'
import useDetachAction from '../hooks/use-detach-action'
import useCompileTriggers from '../../features/pdf-preview/hooks/use-compile-triggers'
export const DetachCompileContext = createContext()
export const DetachCompileContext = createContext<CompileContext | undefined>(
undefined
)
DetachCompileContext.Provider.propTypes = CompileContextPropTypes
export function DetachCompileProvider({ children }) {
export const DetachCompileProvider: FC = ({ children }) => {
const localCompileContext = useLocalCompileContext()
if (!localCompileContext) {
throw new Error(
@ -435,6 +431,7 @@ export function DetachCompileProvider({ children }) {
validationIssues,
firstRenderDone,
setChangedAt,
setSavedAt,
cleanupCompileResult,
syncToEntry,
}),
@ -488,6 +485,7 @@ export function DetachCompileProvider({ children }) {
validationIssues,
firstRenderDone,
setChangedAt,
setSavedAt,
cleanupCompileResult,
syncToEntry,
]
@ -500,17 +498,12 @@ export function DetachCompileProvider({ children }) {
)
}
DetachCompileProvider.propTypes = {
children: PropTypes.any,
}
export function useDetachCompileContext(propTypes) {
const data = useContext(DetachCompileContext)
PropTypes.checkPropTypes(
propTypes,
data,
'data',
'DetachCompileContext.Provider'
)
return data
export function useDetachCompileContext() {
const context = useContext(DetachCompileContext)
if (!context) {
throw new Error(
'useDetachCompileContext is ony available inside DetachCompileProvider'
)
}
return context
}

View file

@ -5,25 +5,32 @@ import {
useMemo,
useEffect,
useState,
FC,
} from 'react'
import PropTypes from 'prop-types'
import getMeta from '../../utils/meta'
import { buildUrlWithDetachRole } from '../utils/url-helper'
import useCallbackHandlers from '../hooks/use-callback-handlers'
import { debugConsole } from '@/utils/debugging'
export const DetachContext = createContext()
export type DetachRole = 'detacher' | 'detached' | null
DetachContext.Provider.propTypes = {
value: PropTypes.shape({
role: PropTypes.oneOf(['detacher', 'detached', null]),
setRole: PropTypes.func.isRequired,
broadcastEvent: PropTypes.func.isRequired,
addEventHandler: PropTypes.func.isRequired,
deleteEventHandler: PropTypes.func.isRequired,
}).isRequired,
type Message = {
role: DetachRole
event: string
data?: any
}
export const DetachContext = createContext<
| {
role: DetachRole
setRole: (role: DetachRole) => void
broadcastEvent: (event: string, data?: any) => void
addEventHandler: (handler: (...args: any[]) => void) => void
deleteEventHandler: (handler: (...args: any[]) => void) => void
}
| undefined
>(undefined)
const debugPdfDetach = getMeta('ol-debugPdfDetach')
const projectId = getMeta('ol-project_id')
@ -33,8 +40,8 @@ export const detachChannel =
? new BroadcastChannel(detachChannelId)
: undefined
export function DetachProvider({ children }) {
const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState()
export const DetachProvider: FC = ({ children }) => {
const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState<Date>()
const [role, setRole] = useState(() => getMeta('ol-detachRole') || null)
const {
addHandler: addEventHandler,
@ -51,7 +58,7 @@ export function DetachProvider({ children }) {
useEffect(() => {
if (detachChannel) {
const listener = event => {
const listener = (event: MessageEvent) => {
if (debugPdfDetach) {
debugConsole.warn(`Receiving:`, event.data)
}
@ -67,7 +74,7 @@ export function DetachProvider({ children }) {
}, [callEventHandlers])
const broadcastEvent = useCallback(
(event, data) => {
(event: string, data?: any) => {
if (!role) {
if (debugPdfDetach) {
debugConsole.warn('Not Broadcasting (no role)', {
@ -85,10 +92,7 @@ export function DetachProvider({ children }) {
data,
})
}
const message = {
role,
event,
}
const message: Message = { role, event }
if (data) {
message.data = data
}
@ -109,7 +113,7 @@ export function DetachProvider({ children }) {
}, [broadcastEvent])
useEffect(() => {
const updateLastDetachedConnectedAt = message => {
const updateLastDetachedConnectedAt = (message: Message) => {
if (message.role === 'detached' && message.event === 'connected') {
setLastDetachedConnectedAt(new Date())
}
@ -142,15 +146,10 @@ export function DetachProvider({ children }) {
)
}
DetachProvider.propTypes = {
children: PropTypes.any,
}
export function useDetachContext(propTypes) {
export function useDetachContext() {
const data = useContext(DetachContext)
if (!data) {
throw new Error('useDetachContext is only available inside DetachProvider')
}
PropTypes.checkPropTypes(propTypes, data, 'data', 'DetachContext.Provider')
return data
}

View file

@ -1,12 +1,14 @@
import {
createContext,
Dispatch,
FC,
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import PropTypes from 'prop-types'
import useScopeValue from '../hooks/use-scope-value'
import useBrowserWindow from '../hooks/use-browser-window'
import { useIdeContext } from './ide-context'
@ -15,56 +17,49 @@ import { useDetachContext } from './detach-context'
import getMeta from '../../utils/meta'
import { useUserContext } from './user-context'
import { saveProjectSettings } from '@/features/editor-left-menu/utils/api'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
export const EditorContext = createContext()
export const EditorContext = createContext<
| {
cobranding?: {
logoImgUrl: string
brandVariationName: string
brandVariationId: number
brandId: number
brandVariationHomeUrl: string
publishGuideHtml?: string
partner?: string
brandedMenu?: boolean
submitBtnHtml?: string
}
hasPremiumCompile?: boolean
loading?: boolean
renameProject: (newName: string) => void
setPermissionsLevel: (permissionsLevel: PermissionsLevel) => void
showSymbolPalette?: boolean
toggleSymbolPalette?: () => void
insertSymbol?: (symbol: string) => void
isProjectOwner: boolean
isRestrictedTokenMember?: boolean
permissionsLevel: 'readOnly' | 'readAndWrite' | 'owner'
deactivateTutorial: (tutorial: string) => void
inactiveTutorials: [string]
currentPopup: string | null
setCurrentPopup: Dispatch<SetStateAction<string | null>>
}
| undefined
>(undefined)
EditorContext.Provider.propTypes = {
value: PropTypes.shape({
cobranding: PropTypes.shape({
logoImgUrl: PropTypes.string.isRequired,
brandVariationName: PropTypes.string.isRequired,
brandVariationId: PropTypes.number.isRequired,
brandId: PropTypes.number.isRequired,
brandVariationHomeUrl: PropTypes.string.isRequired,
publishGuideHtml: PropTypes.string,
partner: PropTypes.string,
brandedMenu: PropTypes.bool,
submitBtnHtml: PropTypes.string,
}),
hasPremiumCompile: PropTypes.bool,
loading: PropTypes.bool,
renameProject: PropTypes.func.isRequired,
showSymbolPalette: PropTypes.bool,
toggleSymbolPalette: PropTypes.func,
insertSymbol: PropTypes.func,
isProjectOwner: PropTypes.bool,
isRestrictedTokenMember: PropTypes.bool,
permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}),
}
export function EditorProvider({ children }) {
export const EditorProvider: FC = ({ children }) => {
const ide = useIdeContext()
const { id: userId } = useUserContext()
const { role } = useDetachContext()
const {
owner,
features,
_id: projectId,
} = useProjectContext({
owner: PropTypes.shape({
_id: PropTypes.string.isRequired,
}),
features: PropTypes.shape({
compileGroup: PropTypes.string,
}),
_id: PropTypes.string.isRequired,
})
const { owner, features, _id: projectId } = useProjectContext()
const cobranding = useMemo(() => {
if (window.brandVariation) {
return {
return (
window.brandVariation && {
logoImgUrl: window.brandVariation.logo_url,
brandVariationName: window.brandVariation.name,
brandVariationId: window.brandVariation.id,
@ -75,9 +70,7 @@ export function EditorProvider({ children }) {
brandedMenu: window.brandVariation.branded_menu,
submitBtnHtml: window.brandVariation.submit_button_html,
}
} else {
return undefined
}
)
}, [])
const [loading] = useScopeValue('state.loading')
@ -91,7 +84,7 @@ export function EditorProvider({ children }) {
getMeta('ol-inactiveTutorials', [])
)
const [currentPopup, setCurrentPopup] = useState(null)
const [currentPopup, setCurrentPopup] = useState<string | null>(null)
const deactivateTutorial = useCallback(
tutorialKey => {
@ -109,21 +102,26 @@ export function EditorProvider({ children }) {
}, [ide?.socket, setProjectName])
const renameProject = useCallback(
newName => {
setProjectName(oldName => {
(newName: string) => {
setProjectName((oldName: string) => {
if (oldName !== newName) {
saveProjectSettings(projectId, { name: newName }).catch(response => {
setProjectName(oldName)
const { data, status } = response
if (status === 400) {
return ide.showGenericMessageModal('Error renaming project', data)
} else {
return ide.showGenericMessageModal(
'Error renaming project',
'Please try again in a moment'
)
saveProjectSettings(projectId, { name: newName }).catch(
(response: any) => {
setProjectName(oldName)
const { data, status } = response
if (status === 400) {
return ide.showGenericMessageModal(
'Error renaming project',
data
)
} else {
return ide.showGenericMessageModal(
'Error renaming project',
'Please try again in a moment'
)
}
}
})
)
}
return newName
})
@ -152,7 +150,7 @@ export function EditorProvider({ children }) {
setTitle(title)
}, [projectName, setTitle, role])
const insertSymbol = useCallback(symbol => {
const insertSymbol = useCallback((symbol: string) => {
window.dispatchEvent(
new CustomEvent('editor:insert-symbol', {
detail: symbol,
@ -201,19 +199,12 @@ export function EditorProvider({ children }) {
<EditorContext.Provider value={value}>{children}</EditorContext.Provider>
)
}
EditorProvider.propTypes = {
children: PropTypes.any,
}
export function useEditorContext(propTypes) {
export function useEditorContext() {
const context = useContext(EditorContext)
if (!context) {
throw new Error('useEditorContext is only available inside EditorProvider')
}
PropTypes.checkPropTypes(propTypes, context, 'data', 'EditorContext.Provider')
return context
}

View file

@ -5,8 +5,8 @@ import {
useContext,
useMemo,
useState,
FC,
} from 'react'
import PropTypes from 'prop-types'
import useScopeValue from '../hooks/use-scope-value'
import {
renameInTree,
@ -18,35 +18,70 @@ import { countFiles } from '../../features/file-tree/util/count-in-tree'
import useDeepCompareEffect from '../../shared/hooks/use-deep-compare-effect'
import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only'
import { Folder } from '../../../../types/folder'
import { Project } from '../../../../types/project'
import { MainDocument } from '../../../../types/project-settings'
import { FindResult } from '@/features/file-tree/util/path'
const FileTreeDataContext = createContext()
const FileTreeDataContext = createContext<
| {
// fileTreeData is the up-to-date representation of the files list, updated
// by the file tree
fileTreeData: Folder
fileCount: { value: number; status: string; limit: number } | number
hasFolders: boolean
selectedEntities: FindResult[]
setSelectedEntities: (selectedEntities: FindResult[]) => void
dispatchRename: (id: string, name: string) => void
dispatchMove: (id: string, target: string) => void
dispatchDelete: (id: string) => void
dispatchCreateFolder: (name: string, folder: any) => void
dispatchCreateDoc: (name: string, doc: any) => void
dispatchCreateFile: (name: string, file: any) => void
docs?: MainDocument[]
}
| undefined
>(undefined)
const fileTreeDataPropType = PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
docs: PropTypes.array.isRequired,
fileRefs: PropTypes.array.isRequired,
folders: PropTypes.array.isRequired,
})
FileTreeDataContext.Provider.propTypes = {
value: PropTypes.shape({
// fileTreeData is the up-to-date representation of the files list, updated
// by the file tree
fileTreeData: fileTreeDataPropType,
hasFolders: PropTypes.bool,
}),
/* eslint-disable no-unused-vars */
enum ACTION_TYPES {
RENAME = 'RENAME',
RESET = 'RESET',
DELETE = 'DELETE',
MOVE = 'MOVE',
CREATE = 'CREATE',
}
/* eslint-enable no-unused-vars */
const ACTION_TYPES = {
RENAME: 'RENAME',
RESET: 'RESET',
DELETE: 'DELETE',
MOVE: 'MOVE',
CREATE: 'CREATE',
}
type Action =
| {
type: ACTION_TYPES.RESET
fileTreeData?: Folder
}
| {
type: ACTION_TYPES.RENAME
id: string
newName: string
}
| {
type: ACTION_TYPES.DELETE
id: string
}
| {
type: ACTION_TYPES.MOVE
entityId: string
toFolderId: string
}
| {
type: typeof ACTION_TYPES.CREATE
parentFolderId: string
entity: any // TODO
}
function fileTreeMutableReducer({ fileTreeData }, action) {
function fileTreeMutableReducer(
{ fileTreeData }: { fileTreeData: Folder },
action: Action
) {
switch (action.type) {
case ACTION_TYPES.RESET: {
const newFileTreeData = action.fileTreeData
@ -104,12 +139,14 @@ function fileTreeMutableReducer({ fileTreeData }, action) {
}
default: {
throw new Error(`Unknown mutable file tree action type: ${action.type}`)
throw new Error(
`Unknown mutable file tree action type: ${(action as Action).type}`
)
}
}
}
const initialState = rootFolder => {
const initialState = (rootFolder?: Folder[]) => {
const fileTreeData = rootFolder?.[0]
return {
fileTreeData,
@ -117,7 +154,7 @@ const initialState = rootFolder => {
}
}
export function useFileTreeData(propTypes) {
export function useFileTreeData() {
const context = useContext(FileTreeDataContext)
if (!context) {
@ -126,18 +163,11 @@ export function useFileTreeData(propTypes) {
)
}
PropTypes.checkPropTypes(
propTypes,
context,
'data',
'FileTreeDataContext.Provider'
)
return context
}
export function FileTreeDataProvider({ children }) {
const [project] = useScopeValue('project')
export const FileTreeDataProvider: FC = ({ children }) => {
const [project] = useScopeValue<Project>('project')
const [openDocId] = useScopeValue('editor.open_doc_id')
const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name')
@ -149,7 +179,7 @@ export function FileTreeDataProvider({ children }) {
initialState
)
const [selectedEntities, setSelectedEntities] = useState([])
const [selectedEntities, setSelectedEntities] = useState<FindResult[]>([])
const docs = useMemo(
() => (fileTreeData ? docsInFolder(fileTreeData) : undefined),
@ -172,26 +202,32 @@ export function FileTreeDataProvider({ children }) {
})
}, [])
const dispatchCreateDoc = useCallback((parentFolderId, entity) => {
entity.type = 'doc'
dispatch({
type: ACTION_TYPES.CREATE,
parentFolderId,
entity,
})
}, [])
const dispatchCreateDoc = useCallback(
(parentFolderId: string, entity: any) => {
entity.type = 'doc'
dispatch({
type: ACTION_TYPES.CREATE,
parentFolderId,
entity,
})
},
[]
)
const dispatchCreateFile = useCallback((parentFolderId, entity) => {
entity.type = 'fileRef'
dispatch({
type: ACTION_TYPES.CREATE,
parentFolderId,
entity,
})
}, [])
const dispatchCreateFile = useCallback(
(parentFolderId: string, entity: any) => {
entity.type = 'fileRef'
dispatch({
type: ACTION_TYPES.CREATE,
parentFolderId,
entity,
})
},
[]
)
const dispatchRename = useCallback(
(id, newName) => {
(id: string, newName: string) => {
dispatch({
type: ACTION_TYPES.RENAME,
newName,
@ -204,11 +240,11 @@ export function FileTreeDataProvider({ children }) {
[openDocId, setOpenDocName]
)
const dispatchDelete = useCallback(id => {
const dispatchDelete = useCallback((id: string) => {
dispatch({ type: ACTION_TYPES.DELETE, id })
}, [])
const dispatchMove = useCallback((entityId, toFolderId) => {
const dispatchMove = useCallback((entityId: string, toFolderId: string) => {
dispatch({ type: ACTION_TYPES.MOVE, entityId, toFolderId })
}, [])
@ -247,7 +283,3 @@ export function FileTreeDataProvider({ children }) {
</FileTreeDataContext.Provider>
)
}
FileTreeDataProvider.propTypes = {
children: PropTypes.any,
}

View file

@ -12,6 +12,7 @@ import useScopeValue from '../hooks/use-scope-value'
import useDetachLayout from '../hooks/use-detach-layout'
import localStorage from '../../infrastructure/local-storage'
import getMeta from '../../utils/meta'
import { DetachRole } from './detach-context'
import { debugConsole } from '@/utils/debugging'
import { BinaryFile } from '@/features/file-view/types/binary-file'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
@ -23,10 +24,10 @@ type LayoutContextValue = {
reattach: () => void
detach: () => void
detachIsLinked: boolean
detachRole: 'detacher' | 'detached' | null
detachRole: DetachRole
changeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
view: IdeView
setView: (view: IdeView) => void
view: IdeView | null
setView: (view: IdeView | null) => void
chatIsOpen: boolean
setChatIsOpen: Dispatch<SetStateAction<LayoutContextValue['chatIsOpen']>>
reviewPanelOpen: boolean
@ -64,12 +65,12 @@ function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
export const LayoutProvider: FC = ({ children }) => {
// what to show in the "flat" view (editor or pdf)
const [view, _setView] = useScopeValue<IdeView>('ui.view')
const [view, _setView] = useScopeValue<IdeView | null>('ui.view')
const [openFile] = useScopeValue<BinaryFile | null>('openFile')
const historyToggleEmitter = useScopeEventEmitter('history:toggle', true)
const setView = useCallback(
(value: IdeView) => {
(value: IdeView | null) => {
_setView(oldValue => {
// ensure that the "history:toggle" event is broadcast when switching in or out of history view
if (value === 'history' || oldValue === 'history') {

View file

@ -1,4 +1,5 @@
import {
FC,
createContext,
useCallback,
useContext,
@ -7,7 +8,6 @@ import {
useRef,
useState,
} from 'react'
import PropTypes from 'prop-types'
import useScopeValue from '../hooks/use-scope-value'
import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only'
import usePersistedState from '../hooks/use-persisted-state'
@ -36,57 +36,70 @@ import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { useSplitTestContext } from '@/shared/context/split-test-context'
export const LocalCompileContext = createContext()
type PdfFile = Record<string, any>
export const CompileContextPropTypes = {
value: PropTypes.shape({
autoCompile: PropTypes.bool.isRequired,
clearingCache: PropTypes.bool.isRequired,
clsiServerId: PropTypes.string,
codeCheckFailed: PropTypes.bool.isRequired,
compiling: PropTypes.bool.isRequired,
deliveryLatencies: PropTypes.object.isRequired,
draft: PropTypes.bool.isRequired,
error: PropTypes.string,
fileList: PropTypes.object,
hasChanges: PropTypes.bool.isRequired,
highlights: PropTypes.arrayOf(PropTypes.object),
logEntries: PropTypes.object,
logEntryAnnotations: PropTypes.object,
pdfDownloadUrl: PropTypes.string,
pdfFile: PropTypes.object,
pdfUrl: PropTypes.string,
pdfViewer: PropTypes.string,
position: PropTypes.object,
rawLog: PropTypes.string,
setAutoCompile: PropTypes.func.isRequired,
setDraft: PropTypes.func.isRequired,
setError: PropTypes.func.isRequired,
setHasLintingError: PropTypes.func.isRequired, // only for storybook
setHighlights: PropTypes.func.isRequired,
setPosition: PropTypes.func.isRequired,
setShowCompileTimeWarning: PropTypes.func.isRequired,
setShowLogs: PropTypes.func.isRequired,
toggleLogs: PropTypes.func.isRequired,
setStopOnFirstError: PropTypes.func.isRequired,
setStopOnValidationError: PropTypes.func.isRequired,
showCompileTimeWarning: PropTypes.bool.isRequired,
showLogs: PropTypes.bool.isRequired,
showNewCompileTimeoutUI: PropTypes.string,
showFasterCompilesFeedbackUI: PropTypes.bool.isRequired,
stopOnFirstError: PropTypes.bool.isRequired,
stopOnValidationError: PropTypes.bool.isRequired,
stoppedOnFirstError: PropTypes.bool.isRequired,
uncompiled: PropTypes.bool,
validationIssues: PropTypes.object,
firstRenderDone: PropTypes.func.isRequired,
cleanupCompileResult: PropTypes.func,
}),
export type CompileContext = {
autoCompile: boolean
clearingCache: boolean
clsiServerId?: string
codeCheckFailed: boolean
compiling: boolean
deliveryLatencies: Record<string, any>
draft: boolean
error?: string
fileList?: Record<string, any>
hasChanges: boolean
highlights?: Record<string, any>[]
isProjectOwner: boolean
logEntries?: Record<string, any>
logEntryAnnotations?: Record<string, any>
pdfDownloadUrl?: string
pdfFile?: PdfFile
pdfUrl?: string
pdfViewer?: string
position?: Record<string, any>
rawLog?: string
setAutoCompile: (value: boolean) => void
setDraft: (value: any) => void
setError: (value: any) => void
setHasLintingError: (value: any) => void // only for storybook
setHighlights: (value: any) => void
setPosition: (value: any) => void
setShowCompileTimeWarning: (value: any) => void
setShowLogs: (value: boolean) => void
toggleLogs: () => void
setStopOnFirstError: (value: boolean) => void
setStopOnValidationError: (value: boolean) => void
showCompileTimeWarning: boolean
showLogs: boolean
showNewCompileTimeoutUI?: string
showFasterCompilesFeedbackUI: boolean
stopOnFirstError: boolean
stopOnValidationError: boolean
stoppedOnFirstError: boolean
uncompiled?: boolean
validationIssues?: Record<string, any>
firstRenderDone: () => void
cleanupCompileResult?: () => void
animateCompileDropdownArrow: boolean
editedSinceCompileStarted: boolean
lastCompileOptions: any
setAnimateCompileDropdownArrow: (value: boolean) => void
recompileFromScratch: () => void
setCompiling: (value: boolean) => void
startCompile: (options?: any) => void
stopCompile: () => void
setChangedAt: (value: any) => void
setSavedAt: (value: any) => void
clearCache: () => void
syncToEntry: (value: any) => void
}
LocalCompileContext.Provider.propTypes = CompileContextPropTypes
export const LocalCompileContext = createContext<CompileContext | undefined>(
undefined
)
export function LocalCompileProvider({ children }) {
export const LocalCompileProvider: FC = ({ children }) => {
const ide = useIdeContext()
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
@ -123,13 +136,14 @@ export function LocalCompileProvider({ children }) {
const { pdfViewer, syntaxValidation } = userSettings
// the URL for downloading the PDF
const [, setPdfDownloadUrl] = useScopeValueSetterOnly('pdf.downloadUrl')
const [, setPdfDownloadUrl] =
useScopeValueSetterOnly<string>('pdf.downloadUrl')
// the URL for loading the PDF in the preview pane
const [, setPdfUrl] = useScopeValueSetterOnly('pdf.url')
const [, setPdfUrl] = useScopeValueSetterOnly<string>('pdf.url')
// low level details for metrics
const [pdfFile, setPdfFile] = useState()
const [pdfFile, setPdfFile] = useState<PdfFile | undefined>()
useEffect(() => {
setPdfDownloadUrl(pdfFile?.pdfDownloadUrl)
@ -147,7 +161,7 @@ export function LocalCompileProvider({ children }) {
const [clsiServerId, setClsiServerId] = useState()
// data received in response to a compile request
const [data, setData] = useState()
const [data, setData] = useState<Record<string, any>>()
// the rootDocId used in the most recent compile request, which may not be the
// same as the project rootDocId. This is used to calculate correct paths when
@ -187,13 +201,13 @@ export function LocalCompileProvider({ children }) {
}, [setShowLogs])
// an error that occurred
const [error, setError] = useState()
const [error, setError] = useState<string>()
// the list of files that can be downloaded
const [fileList, setFileList] = useState()
const [fileList, setFileList] = useState<Record<string, any[]>>()
// the raw contents of the log file
const [rawLog, setRawLog] = useState()
const [rawLog, setRawLog] = useState<string>()
// validation issues from CLSI
const [validationIssues, setValidationIssues] = useState()
@ -246,7 +260,7 @@ export function LocalCompileProvider({ children }) {
const { signal } = useAbortController()
const cleanupCompileResult = useCallback(() => {
setPdfFile(null)
setPdfFile(undefined)
setLogEntries(null)
setLogEntryAnnotations({})
}, [setPdfFile, setLogEntries, setLogEntryAnnotations])
@ -323,7 +337,8 @@ export function LocalCompileProvider({ children }) {
}, [compiledOnce, currentDoc, compiler])
useEffect(() => {
const compileTimeWarningEnabled = features?.compileTimeout <= 60
const compileTimeWarningEnabled =
features?.compileTimeout !== undefined && features.compileTimeout <= 60
if (compileTimeWarningEnabled && compiling && isProjectOwner) {
const timeout = window.setTimeout(() => {
@ -372,10 +387,10 @@ export function LocalCompileProvider({ children }) {
// asynchronous (TODO: cancel on new compile?)
setLogEntryAnnotations(null)
setLogEntries(null)
setRawLog(null)
setRawLog(undefined)
handleLogFiles(outputFiles, data, abortController.signal).then(
result => {
(result: Record<string, any>) => {
setRawLog(result.log)
setLogEntries(result.logEntries)
setLogEntryAnnotations(
@ -702,17 +717,12 @@ export function LocalCompileProvider({ children }) {
)
}
LocalCompileProvider.propTypes = {
children: PropTypes.any,
}
export function useLocalCompileContext(propTypes) {
const data = useContext(LocalCompileContext)
PropTypes.checkPropTypes(
propTypes,
data,
'data',
'LocalCompileContext.Provider'
)
return data
export function useLocalCompileContext() {
const context = useContext(LocalCompileContext)
if (!context) {
throw new Error(
'useLocalCompileContext is only available inside LocalCompileProvider'
)
}
return context
}

View file

@ -1,54 +1,46 @@
import { createContext, useContext, useMemo } from 'react'
import PropTypes from 'prop-types'
import { FC, createContext, useContext, useMemo } from 'react'
import useScopeValue from '../hooks/use-scope-value'
import getMeta from '@/utils/meta'
import { UserId } from '../../../../types/user'
import { PublicAccessLevel } from '../../../../types/public-access-level'
import * as ReviewPanel from '@/features/ide-react/context/review-panel/types/review-panel-state'
const ProjectContext = createContext()
const ProjectContext = createContext<
| {
_id: string
name: string
rootDocId?: string
members: { _id: UserId; email: string; privileges: string }[]
invites: { _id: UserId }[]
features: {
collaborators?: number
compileGroup?: 'alpha' | 'standard' | 'priority'
trackChanges?: boolean
trackChangesVisible?: boolean
references?: boolean
mendeley?: boolean
zotero?: boolean
versioning?: boolean
gitBridge?: boolean
referencesSearch?: boolean
}
publicAccessLevel?: PublicAccessLevel
owner: {
_id: UserId
email: string
}
showNewCompileTimeoutUI?: string
tags: {
_id: string
name: string
color?: string
}[]
trackChangesState: ReviewPanel.Value<'trackChangesState'>
}
| undefined
>(undefined)
export const projectShape = {
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
rootDocId: PropTypes.string,
members: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
})
),
invites: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
})
),
features: PropTypes.shape({
collaborators: PropTypes.number,
compileGroup: PropTypes.oneOf(['alpha', 'standard', 'priority']),
trackChangesVisible: PropTypes.bool,
references: PropTypes.bool,
mendeley: PropTypes.bool,
zotero: PropTypes.bool,
versioning: PropTypes.bool,
gitBridge: PropTypes.bool,
}),
publicAccessLevel: PropTypes.string,
owner: PropTypes.shape({
_id: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
}),
useNewCompileTimeoutUI: PropTypes.string,
tags: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string,
})
).isRequired,
}
ProjectContext.Provider.propTypes = {
value: PropTypes.shape(projectShape),
}
export function useProjectContext(propTypes) {
export function useProjectContext() {
const context = useContext(ProjectContext)
if (!context) {
@ -57,13 +49,6 @@ export function useProjectContext(propTypes) {
)
}
PropTypes.checkPropTypes(
propTypes,
context,
'data',
'ProjectContext.Provider'
)
return context
}
@ -76,7 +61,7 @@ const projectFallback = {
features: {},
}
export function ProjectProvider({ children }) {
export const ProjectProvider: FC = ({ children }) => {
const [project] = useScopeValue('project', true)
const {
@ -96,7 +81,7 @@ export function ProjectProvider({ children }) {
() =>
getMeta('ol-projectTags', [])
// `tag.name` data may be null for some old users
.map(tag => ({ ...tag, name: tag.name ?? '' })),
.map((tag: any) => ({ ...tag, name: tag.name ?? '' })),
[]
)
@ -145,7 +130,3 @@ export function ProjectProvider({ children }) {
<ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
)
}
ProjectProvider.propTypes = {
children: PropTypes.any,
}

View file

@ -1,6 +1,5 @@
import PropTypes from 'prop-types'
import { FC } from 'react'
import createSharedContext from 'react2angular-shared-context'
import { UserProvider } from './user-context'
import { IdeAngularProvider } from './ide-angular-provider'
import { EditorProvider } from './editor-context'
@ -16,8 +15,9 @@ import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/pro
import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
import { OutlineProvider } from '@/features/ide-react/context/outline-context'
import { Ide } from '@/shared/context/ide-context'
export function ContextRoot({ children, ide }) {
export const ContextRoot: FC<{ ide?: Ide }> = ({ children, ide }) => {
return (
<SplitTestProvider>
<IdeAngularProvider ide={ide}>
@ -51,9 +51,4 @@ export function ContextRoot({ children, ide }) {
)
}
ContextRoot.propTypes = {
children: PropTypes.any,
ide: PropTypes.object,
}
export const rootContext = createSharedContext(ContextRoot)

View file

@ -1,45 +0,0 @@
import { createContext, useContext, useMemo } from 'react'
import PropTypes from 'prop-types'
import getMeta from '../../utils/meta'
export const SplitTestContext = createContext()
SplitTestContext.Provider.propTypes = {
value: PropTypes.shape({
splitTestVariants: PropTypes.object.isRequired,
splitTestInfo: PropTypes.object.isRequired,
}),
}
export function SplitTestProvider({ children }) {
const value = useMemo(
() => ({
splitTestVariants: getMeta('ol-splitTestVariants') || {},
splitTestInfo: getMeta('ol-splitTestInfo') || {},
}),
[]
)
return (
<SplitTestContext.Provider value={value}>
{children}
</SplitTestContext.Provider>
)
}
SplitTestProvider.propTypes = {
children: PropTypes.any,
}
export function useSplitTestContext(propTypes) {
const context = useContext(SplitTestContext)
PropTypes.checkPropTypes(
propTypes,
context,
'data',
'SplitTestContext.Provider'
)
return context
}

View file

@ -0,0 +1,41 @@
import { createContext, FC, useContext, useMemo } from 'react'
import getMeta from '../../utils/meta'
type SplitTestVariants = Record<string, any>
type SplitTestInfo = Record<string, any>
export const SplitTestContext = createContext<
| {
splitTestVariants: SplitTestVariants
splitTestInfo: SplitTestInfo
}
| undefined
>(undefined)
export const SplitTestProvider: FC = ({ children }) => {
const value = useMemo(
() => ({
splitTestVariants: getMeta('ol-splitTestVariants') || {},
splitTestInfo: getMeta('ol-splitTestInfo') || {},
}),
[]
)
return (
<SplitTestContext.Provider value={value}>
{children}
</SplitTestContext.Provider>
)
}
export function useSplitTestContext() {
const context = useContext(SplitTestContext)
if (!context) {
throw new Error(
'useSplitTestContext is only available within SplitTestProvider'
)
}
return context
}

View file

@ -1,60 +0,0 @@
import { createContext, useContext } from 'react'
import PropTypes from 'prop-types'
import getMeta from '../../utils/meta'
export const UserContext = createContext()
UserContext.Provider.propTypes = {
value: PropTypes.shape({
user: PropTypes.shape({
id: PropTypes.string,
isAdmin: PropTypes.boolean,
email: PropTypes.string,
allowedFreeTrial: PropTypes.boolean,
first_name: PropTypes.string,
last_name: PropTypes.string,
alphaProgram: PropTypes.boolean,
betaProgram: PropTypes.boolean,
labsProgram: PropTypes.boolean,
signUpDate: PropTypes.string,
features: PropTypes.shape({
dropbox: PropTypes.boolean,
github: PropTypes.boolean,
mendeley: PropTypes.boolean,
zotero: PropTypes.boolean,
references: PropTypes.boolean,
compileTimeout: PropTypes.number,
gitBridge: PropTypes.boolean,
}),
refProviders: PropTypes.shape({
mendeley: PropTypes.boolean,
zotero: PropTypes.boolean,
}),
writefull: PropTypes.shape({
enabled: PropTypes.boolean,
}),
}),
}),
}
export function UserProvider({ children }) {
const user = getMeta('ol-user')
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}
UserProvider.propTypes = {
children: PropTypes.any,
}
export function useUserContext(propTypes) {
const data = useContext(UserContext)
if (!data) {
throw new Error(
'useUserContext is only available inside UserContext, or `ol-user` meta is not defined'
)
}
PropTypes.checkPropTypes(propTypes, data, 'data', 'UserContext.Provider')
return data
}

View file

@ -0,0 +1,23 @@
import { createContext, FC, useContext } from 'react'
import getMeta from '../../utils/meta'
import { User } from '../../../../types/user'
export const UserContext = createContext<User | undefined>(undefined)
export const UserProvider: FC = ({ children }) => {
const user = getMeta('ol-user')
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}
export function useUserContext() {
const context = useContext(UserContext)
if (!context) {
throw new Error(
'useUserContext is only available inside UserContext, or `ol-user` meta is not defined'
)
}
return context
}

View file

@ -1,17 +1,17 @@
import { useCallback, useRef } from 'react'
export default function useCallbackHandlers() {
const handlersRef = useRef(new Set<(...arg: unknown[]) => void>())
const handlersRef = useRef(new Set<(...arg: any[]) => void>())
const addHandler = useCallback((handler: (...args: unknown[]) => void) => {
const addHandler = useCallback((handler: (...args: any[]) => void) => {
handlersRef.current.add(handler)
}, [])
const deleteHandler = useCallback((handler: (...args: unknown[]) => void) => {
const deleteHandler = useCallback((handler: (...args: any[]) => void) => {
handlersRef.current.delete(handler)
}, [])
const callHandlers = useCallback((...args: unknown[]) => {
const callHandlers = useCallback((...args: any[]) => {
for (const handler of handlersRef.current) {
handler(...args)
}

View file

@ -6,6 +6,7 @@ import { useEffect } from 'react'
import { EditorProviders } from '../../helpers/editor-providers'
import { mockScope } from './scope'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
import { FindResult } from '@/features/file-tree/util/path'
const mockHighlights = [
{
@ -36,11 +37,7 @@ const mockPosition: Position = {
pageSize: { height: 500, width: 500 },
}
type Entity = {
type: string
}
const mockSelectedEntities: Entity[] = [{ type: 'doc' }]
const mockSelectedEntities = [{ type: 'doc' }] as FindResult[]
const WithPosition = ({ mockPosition }: { mockPosition: Position }) => {
const { setPosition } = useCompileContext()
@ -65,7 +62,7 @@ const setDetachedPosition = (mockPosition: Position) => {
const WithSelectedEntities = ({
mockSelectedEntities = [],
}: {
mockSelectedEntities: Entity[]
mockSelectedEntities: FindResult[]
}) => {
const { setSelectedEntities } = useFileTreeData()
@ -153,7 +150,9 @@ describe('<PdfSynctexControls/>', function () {
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities
mockSelectedEntities={[{ type: 'doc' }, { type: 'doc' }]}
mockSelectedEntities={
[{ type: 'doc' }, { type: 'doc' }] as FindResult[]
}
/>
<PdfSynctexControls />
</EditorProviders>
@ -174,7 +173,9 @@ describe('<PdfSynctexControls/>', function () {
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={[{ type: 'file' }]} />
<WithSelectedEntities
mockSelectedEntities={[{ type: 'fileRef' }] as FindResult[]}
/>
<PdfSynctexControls />
</EditorProviders>
)

View file

@ -6,7 +6,7 @@ import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { activeEditorLine } from '../helpers/active-editor-line'
import { UserId } from '../../../../../types/user'
import { User, UserId } from '../../../../../types/user'
const Container: FC = ({ children }) => (
<div style={{ width: 785, height: 785 }}>{children}</div>
@ -863,7 +863,7 @@ describe('autocomplete', { scrollBehavior: false }, function () {
const user = {
id: '123abd' as UserId,
email: 'testuser@example.com',
}
} as User
cy.mount(
<Container>
<EditorProviders user={user} scope={scope}>

View file

@ -38,7 +38,7 @@ const memberGroupSubscriptions: MemberGroupSubscription[] = [
email: 'someone@example.com',
},
},
]
] as MemberGroupSubscription[]
describe('<GroupSubscriptionMemberships />', function () {
beforeEach(function () {

View file

@ -1,7 +0,0 @@
// todo: maybe change this to just tutorials, and move it from editor context to user context?
export type EditorTutorials = {
inactiveTutorials: [string]
deactivateTutorial: (key: string) => void
currentPopup: string
setCurrentPopup: (id: string) => void
}

View file

@ -9,8 +9,14 @@ export type UserId = Brand<string, 'UserId'>
export type User = {
id: UserId
isAdmin?: boolean
email: string
allowedFreeTrial?: boolean
first_name?: string
last_name?: string
alphaProgram?: boolean
betaProgram?: boolean
labsProgram?: boolean
signUpDate?: string // date string
features?: {
collaborators?: number
@ -29,6 +35,9 @@ export type User = {
zotero?: boolean
}
refProviders?: RefProviders
writefull?: {
enabled: boolean
}
}
export type MongoUser = Pick<User, Exclude<keyof User, 'id'>> & { _id: string }

View file

@ -28,6 +28,10 @@ declare global {
logEntryAnnotations: Record<string, unknown>
}
}
socket: {
on: (event: string, listener: any) => void
removeListener: (event: string, listener: any) => void
}
}
isRestrictedTokenMember: boolean
_reportCM6Perf: () => void
@ -42,6 +46,8 @@ declare global {
enterprise?: boolean
useRecaptchaNet?: boolean
}
brandVariation?: Record<string, any>
data?: Record<string, any>
expectingLinkedFileRefreshedSocketFor?: string | null
writefull?: any
io?: any