Просмотр исходного кода

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

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

GitOrigin-RevId: d92a91798286978410956ab791d73c17c5086d86
Alf Eaton 2 лет назад
Родитель
Сommit
0cde5be165
60 измененных файлов с 1154 добавлено и 1088 удалено
  1. 1 4
      services/web/frontend/js/features/chat/components/chat-pane.tsx
  2. 81 38
      services/web/frontend/js/features/chat/context/chat-context.tsx
  3. 1 2
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx
  4. 10 37
      services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx
  5. 30 20
      services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx
  6. 8 12
      services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx
  7. 9 16
      services/web/frontend/js/features/file-tree/components/file-tree-context.tsx
  8. 51 40
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx
  9. 30 24
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx
  10. 1 6
      services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx
  11. 2 6
      services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx
  12. 15 17
      services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx
  13. 13 27
      services/web/frontend/js/features/file-tree/components/file-tree-root.tsx
  14. 1 6
      services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx
  15. 180 85
      services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx
  16. 6 10
      services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx
  17. 19 13
      services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx
  18. 23 28
      services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx
  19. 0 52
      services/web/frontend/js/features/file-tree/contexts/file-tree-main.jsx
  20. 58 0
      services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx
  21. 74 47
      services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx
  22. 10 4
      services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js
  23. 16 15
      services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts
  24. 2 8
      services/web/frontend/js/features/file-view/components/file-view-header.tsx
  25. 0 24
      services/web/frontend/js/features/file-view/components/file-view-image.jsx
  26. 24 0
      services/web/frontend/js/features/file-view/components/file-view-image.tsx
  27. 16 14
      services/web/frontend/js/features/file-view/components/file-view-text.tsx
  28. 1 7
      services/web/frontend/js/features/history/components/change-list/all-history-list.tsx
  29. 0 1
      services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx
  30. 7 2
      services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx
  31. 7 6
      services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
  32. 13 14
      services/web/frontend/js/features/outline/components/outline-pane.tsx
  33. 1 1
      services/web/frontend/js/features/pdf-preview/components/compile-timeout-warning.tsx
  34. 3 3
      services/web/frontend/js/features/pdf-preview/components/faster-compiles-feedback.tsx
  35. 1 1
      services/web/frontend/js/features/settings/components/account-info-section.tsx
  36. 3 7
      services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx
  37. 2 5
      services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx
  38. 5 10
      services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
  39. 1 2
      services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx
  40. 2 5
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx
  41. 3 15
      services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx
  42. 16 23
      services/web/frontend/js/shared/context/detach-compile-context.tsx
  43. 24 25
      services/web/frontend/js/shared/context/detach-context.tsx
  44. 61 70
      services/web/frontend/js/shared/context/editor-context.tsx
  45. 95 63
      services/web/frontend/js/shared/context/file-tree-data-context.tsx
  46. 6 5
      services/web/frontend/js/shared/context/layout-context.tsx
  47. 83 73
      services/web/frontend/js/shared/context/local-compile-context.tsx
  48. 41 60
      services/web/frontend/js/shared/context/project-context.tsx
  49. 3 8
      services/web/frontend/js/shared/context/root-context.tsx
  50. 0 45
      services/web/frontend/js/shared/context/split-test-context.jsx
  51. 41 0
      services/web/frontend/js/shared/context/split-test-context.tsx
  52. 0 60
      services/web/frontend/js/shared/context/user-context.jsx
  53. 23 0
      services/web/frontend/js/shared/context/user-context.tsx
  54. 4 4
      services/web/frontend/js/shared/hooks/use-callback-handlers.ts
  55. 9 8
      services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx
  56. 2 2
      services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx
  57. 1 1
      services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
  58. 0 7
      services/web/types/tutorial.ts
  59. 9 0
      services/web/types/user.ts
  60. 6 0
      services/web/types/window.ts

+ 1 - 4
services/web/frontend/js/features/chat/components/chat-pane.jsx → services/web/frontend/js/features/chat/components/chat-pane.tsx

@@ -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,

+ 81 - 38
services/web/frontend/js/features/chat/context/chat-context.jsx → services/web/frontend/js/features/chat/context/chat-context.tsx

@@ -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()
-
-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 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)
 
-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
 }

+ 1 - 2
services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx

@@ -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(() => {

+ 10 - 37
services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.jsx → services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx

@@ -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

+ 30 - 20
services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.jsx → services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx

@@ -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,
-}

+ 8 - 12
services/web/frontend/js/features/file-tree/components/file-tree-context-menu.jsx → services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx

@@ -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'
 

+ 9 - 16
services/web/frontend/js/features/file-tree/components/file-tree-context.jsx → services/web/frontend/js/features/file-tree/components/file-tree-context.tsx

@@ -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

+ 51 - 40
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.jsx → services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx

@@ -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,
-}

+ 30 - 24
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.jsx → services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx

@@ -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,
-}

+ 1 - 6
services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.jsx → services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx

@@ -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,
-}

+ 2 - 6
services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx

@@ -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()
 

+ 15 - 17
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.jsx → services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx

@@ -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

+ 13 - 27
services/web/frontend/js/features/file-tree/components/file-tree-root.jsx → services/web/frontend/js/features/file-tree/components/file-tree-root.tsx

@@ -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)

+ 1 - 6
services/web/frontend/js/features/file-tree/components/file-tree-toolbar.jsx → services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx

@@ -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 {

+ 180 - 85
services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.jsx → services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx

@@ -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'
-
-const FileTreeActionableContext = createContext()
-
-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',
+import { Folder } from '../../../../../types/folder'
+
+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)
+
+/* 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
   }
 

+ 6 - 10
services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.jsx → services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx

@@ -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

+ 19 - 13
services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.jsx → services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx

@@ -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

+ 23 - 28
services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.jsx → services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx

@@ -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'

+ 0 - 52
services/web/frontend/js/features/file-tree/contexts/file-tree-main.jsx

@@ -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,
-}

+ 58 - 0
services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx

@@ -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>
+  )
+}

+ 74 - 47
services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.jsx → services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx

@@ -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'
-
-const FileTreeSelectableContext = createContext()
-
-const ACTION_TYPES = {
-  SELECT: 'SELECT',
-  MULTI_SELECT: 'MULTI_SELECT',
-  UNSELECT: 'UNSELECT',
+import { Folder } from '../../../../../types/folder'
+import { FileTreeEntity } from '../../../../../types/file-tree-entity'
+
+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)
+
+/* eslint-disable no-unused-vars */
+enum ACTION_TYPES {
+  SELECT = 'SELECT',
+  MULTI_SELECT = 'MULTI_SELECT',
+  UNSELECT = 'UNSELECT',
 }
+/* eslint-enable no-unused-vars */
+
+type Action =
+  | {
+      type: ACTION_TYPES.SELECT
+      id: string
+    }
+  | {
+      type: ACTION_TYPES.MULTI_SELECT
+      id: string
+    }
+  | {
+      type: ACTION_TYPES.UNSELECT
+      id: string
+    }
 
-function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
+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)

+ 10 - 4
services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js

@@ -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',
+  ])
 )

+ 16 - 15
services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js → services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts

@@ -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) {

+ 2 - 8
services/web/frontend/js/features/file-view/components/file-view-header.tsx

@@ -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={

+ 0 - 24
services/web/frontend/js/features/file-view/components/file-view-image.jsx

@@ -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,
-}

+ 24 - 0
services/web/frontend/js/features/file-view/components/file-view-image.tsx

@@ -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}
+    />
+  )
+}

+ 16 - 14
services/web/frontend/js/features/file-view/components/file-view-text.jsx → services/web/frontend/js/features/file-view/components/file-view-text.tsx

@@ -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,
-}

+ 1 - 7
services/web/frontend/js/features/history/components/change-list/all-history-list.tsx

@@ -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

+ 0 - 1
services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx

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

+ 7 - 2
services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx

@@ -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)
     }

+ 7 - 6
services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts

@@ -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

+ 13 - 14
services/web/frontend/js/features/outline/components/outline-pane.jsx → services/web/frontend/js/features/outline/components/outline-pane.tsx

@@ -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)

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/compile-timeout-warning.tsx

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

+ 3 - 3
services/web/frontend/js/features/pdf-preview/components/faster-compiles-feedback.tsx

@@ -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)

+ 1 - 1
services/web/frontend/js/features/settings/components/account-info-section.tsx

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

+ 3 - 7
services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.jsx → services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx

@@ -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 (

+ 2 - 5
services/web/frontend/js/features/share-project-modal/components/share-modal-body.jsx → services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx

@@ -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']) {

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

@@ -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(() => {

+ 1 - 2
services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx

@@ -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>

+ 2 - 5
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx

@@ -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()
 

+ 3 - 15
services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx

@@ -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(() => {

+ 16 - 23
services/web/frontend/js/shared/context/detach-compile-context.jsx → services/web/frontend/js/shared/context/detach-compile-context.tsx

@@ -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
 }

+ 24 - 25
services/web/frontend/js/shared/context/detach-context.jsx → services/web/frontend/js/shared/context/detach-context.tsx

@@ -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
 }

+ 61 - 70
services/web/frontend/js/shared/context/editor-context.jsx → services/web/frontend/js/shared/context/editor-context.tsx

@@ -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<
+  | {
+      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)
 
-export const EditorContext = createContext()
-
-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
 }

+ 95 - 63
services/web/frontend/js/shared/context/file-tree-data-context.jsx → services/web/frontend/js/shared/context/file-tree-data-context.tsx

@@ -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'
-
-const FileTreeDataContext = createContext()
-
-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,
-  }),
+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<
+  | {
+      // 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)
+
+/* 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,
-}

+ 6 - 5
services/web/frontend/js/shared/context/layout-context.tsx

@@ -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') {

+ 83 - 73
services/web/frontend/js/shared/context/local-compile-context.jsx → services/web/frontend/js/shared/context/local-compile-context.tsx

@@ -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()
-
-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,
-  }),
+type PdfFile = Record<string, any>
+
+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
 }

+ 41 - 60
services/web/frontend/js/shared/context/project-context.jsx → services/web/frontend/js/shared/context/project-context.tsx

@@ -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()
-
-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),
-}
+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 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,
-}

+ 3 - 8
services/web/frontend/js/shared/context/root-context.jsx → services/web/frontend/js/shared/context/root-context.tsx

@@ -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)

+ 0 - 45
services/web/frontend/js/shared/context/split-test-context.jsx

@@ -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
-}

+ 41 - 0
services/web/frontend/js/shared/context/split-test-context.tsx

@@ -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
+}

+ 0 - 60
services/web/frontend/js/shared/context/user-context.jsx

@@ -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
-}

+ 23 - 0
services/web/frontend/js/shared/context/user-context.tsx

@@ -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
+}

+ 4 - 4
services/web/frontend/js/shared/hooks/use-callback-handlers.ts

@@ -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)
     }

+ 9 - 8
services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx

@@ -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>
     )

+ 2 - 2
services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx

@@ -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}>

+ 1 - 1
services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx

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

+ 0 - 7
services/web/types/tutorial.ts

@@ -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
-}

+ 9 - 0
services/web/types/user.ts

@@ -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 }

+ 6 - 0
services/web/types/window.ts

@@ -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