diff --git a/services/web/frontend/js/features/chat/components/chat-pane.jsx b/services/web/frontend/js/features/chat/components/chat-pane.tsx similarity index 96% rename from services/web/frontend/js/features/chat/components/chat-pane.jsx rename to services/web/frontend/js/features/chat/components/chat-pane.tsx index 6cb55a1356..ff62f7e5ed 100644 --- a/services/web/frontend/js/features/chat/components/chat-pane.jsx +++ b/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, diff --git a/services/web/frontend/js/features/chat/context/chat-context.jsx b/services/web/frontend/js/features/chat/context/chat-context.tsx similarity index 77% rename from services/web/frontend/js/features/chat/context/chat-context.jsx rename to services/web/frontend/js/features/chat/context/chat-context.tsx index a3ea07dd22..e6d93f1d64 100644 --- a/services/web/frontend/js/features/chat/context/chat-context.jsx +++ b/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() +export const ChatContext = createContext< + | { + status: 'idle' | 'pending' | 'error' + messages: Message[] + initialMessagesLoaded: boolean + atEnd: boolean + unreadMessageCount: number + loadInitialMessages: () => void + loadMoreMessages: () => void + sendMessage: (message: any) => void + markMessagesAsRead: () => void + reset: () => void + error?: Error | null + } + | undefined +>(undefined) -ChatContext.Provider.propTypes = { - value: PropTypes.shape({ - status: PropTypes.string.isRequired, - messages: PropTypes.array.isRequired, - initialMessagesLoaded: PropTypes.bool.isRequired, - atEnd: PropTypes.bool.isRequired, - unreadMessageCount: PropTypes.number.isRequired, - loadInitialMessages: PropTypes.func.isRequired, - loadMoreMessages: PropTypes.func.isRequired, - sendMessage: PropTypes.func.isRequired, - markMessagesAsRead: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired, - error: PropTypes.object, - }).isRequired, -} - -export function ChatProvider({ children }) { +export const ChatProvider: FC = ({ children }) => { const clientId = useRef(uuid()) - const user = useUserContext({ - id: PropTypes.string.isRequired, - }) - const { _id: projectId } = useProjectContext({ - _id: PropTypes.string.isRequired, - }) + const user = useUserContext() + const { _id: projectId } = useProjectContext() const { chatIsOpen } = useLayoutContext() @@ -151,10 +194,12 @@ export function ChatProvider({ children }) { function fetchMessages() { if (state.atEnd) return - const query = { limit: PAGE_SIZE } + const query: Record = { + 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 {children} } -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 } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx index 9c9356b7ff..ee2ed6f692 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx +++ b/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(() => { diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx similarity index 73% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx index ddd1d8a16e..d4b062202c 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.jsx +++ b/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 : ` causes UI glitches return ( @@ -38,7 +42,17 @@ function IconPdfOnly() { return } -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 } @@ -57,7 +71,16 @@ function IconCheckmark({ iconFor, pdfLayout, view, detachRole }) { return } -function LayoutMenuItem({ checkmark, icon, text, ...props }) { +function LayoutMenuItem({ + checkmark, + icon, + text, + ...props +}: { + checkmark: ReactNode + icon: ReactNode + text: string | ReactNode +} & MenuItemProps) { return (
@@ -70,12 +93,6 @@ function LayoutMenuItem({ checkmark, icon, text, ...props }) { ) } -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, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx similarity index 77% rename from services/web/frontend/js/features/file-tree/components/file-tree-context-menu.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx index f246fb1d97..effb7a0f8d 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.jsx +++ b/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() { , - 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( + ({ bsRole }, ref) => { + return null + } +) FakeDropDownToggle.displayName = 'FakeDropDownToggle' diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx similarity index 73% rename from services/web/frontend/js/features/file-tree/components/file-tree-context.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-context.tsx index 0d2d69f8ac..0832bce71f 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context.jsx +++ b/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 + setRefProviderEnabled: (provider: string, value: boolean) => void + setStartedFreeTrial: (value: boolean) => void + onSelect: () => void +}> = ({ refProviders, reindexReferences, setRefProviderEnabled, setStartedFreeTrial, onSelect, children, -}) { +}) => { return ( () + const [selectedProjectEntity, setSelectedProjectEntity] = useState() + 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 }) { ) } -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({ ) } -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({ ) } -SelectProjectEntity.propTypes = { - selectedProjectId: PropTypes.string, - selectedProjectEntity: PropTypes.object, - setSelectedProjectEntity: PropTypes.func.isRequired, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx similarity index 89% rename from services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx index 902f28cfa5..5fe41f8877 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.jsx +++ b/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() - const [conflicts, setConflicts] = useState([]) + const [conflicts, setConflicts] = useState([]) const [overwrite, setOverwrite] = useState(false) const maxNumberOfFiles = 40 const maxFileSize = window.ExposedSettings.maxUploadSize // calculate conflicts - const buildConflicts = files => + const buildConflicts = (files: Record) => 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 } } -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 }) {
) } -UploadConflicts.propTypes = { - cancel: PropTypes.func.isRequired, - conflicts: PropTypes.array.isRequired, - handleOverwrite: PropTypes.func.isRequired, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx similarity index 84% rename from services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx index fb91a864db..ab32fcecdb 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.jsx +++ b/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, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx index 26eb464069..de211c6c07 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx +++ b/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() diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx similarity index 81% rename from services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx index ce497963ab..ebee24ef92 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.jsx +++ b/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(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) { 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 diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-root.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx similarity index 79% rename from services/web/frontend/js/features/file-tree/components/file-tree-root.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-root.tsx index 5131f23912..a39e8b353d 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-root.jsx +++ b/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 +}>(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) diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx similarity index 93% rename from services/web/frontend/js/features/file-tree/components/file-tree-toolbar.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx index 70a873816f..0b15ee72f8 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.jsx +++ b/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 { diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.jsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx similarity index 71% rename from services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.jsx rename to services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx index fff9b09d71..fbd5739eae 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.jsx +++ b/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' +import { Folder } from '../../../../../types/folder' -const FileTreeActionableContext = createContext() +const FileTreeActionableContext = createContext< + | { + isDeleting: boolean + isRenaming: boolean + isCreatingFile: boolean + isCreatingFolder: boolean + isMoving: boolean + inFlight: boolean + actionedEntities: any | null + newFileCreateMode: any | null + error: any | null + canDelete: boolean + canRename: boolean + canCreate: boolean + parentFolderId: string + isDuplicate: (parentFolderId: string, name: string) => boolean + startRenaming: any + finishRenaming: any + startDeleting: any + finishDeleting: any + finishMoving: any + startCreatingFile: any + startCreatingFolder: any + finishCreatingFolder: any + startCreatingDocOrFile: any + startUploadingDocOrFile: any + finishCreatingDoc: any + finishCreatingLinkedFile: any + cancel: () => void + droppedFiles: { files: any; targetFolderId: string } | null + setDroppedFiles: (files: any) => void + downloadPath?: string + } + | undefined +>(undefined) -const ACTION_TYPES = { - START_RENAME: 'START_RENAME', - START_DELETE: 'START_DELETE', - DELETING: 'DELETING', - START_CREATE_FILE: 'START_CREATE_FILE', - START_CREATE_FOLDER: 'START_CREATE_FOLDER', - CREATING_FILE: 'CREATING_FILE', - CREATING_FOLDER: 'CREATING_FOLDER', - MOVING: 'MOVING', - CANCEL: 'CANCEL', - CLEAR: 'CLEAR', - ERROR: 'ERROR', +/* eslint-disable no-unused-vars */ +enum ACTION_TYPES { + START_RENAME = 'START_RENAME', + START_DELETE = 'START_DELETE', + DELETING = 'DELETING', + START_CREATE_FILE = 'START_CREATE_FILE', + START_CREATE_FOLDER = 'START_CREATE_FOLDER', + CREATING_FILE = 'CREATING_FILE', + CREATING_FOLDER = 'CREATING_FOLDER', + MOVING = 'MOVING', + CANCEL = 'CANCEL', + CLEAR = 'CLEAR', + ERROR = 'ERROR', +} +/* eslint-enable no-unused-vars */ + +type State = { + isDeleting: boolean + isRenaming: boolean + isCreatingFile: boolean + isCreatingFolder: boolean + isMoving: boolean + inFlight: boolean + actionedEntities: any | null + newFileCreateMode: any | null + error: unknown | null } -const defaultState = { +const defaultState: State = { isDeleting: false, isRenaming: false, isCreatingFile: false, @@ -61,11 +110,49 @@ const defaultState = { error: null, } -function fileTreeActionableReadOnlyReducer(state) { +function fileTreeActionableReadOnlyReducer(state: State) { return state } -function fileTreeActionableReducer(state, action) { +type Action = + | { + type: ACTION_TYPES.START_RENAME + } + | { + type: ACTION_TYPES.START_DELETE + actionedEntities: any | null + } + | { + type: ACTION_TYPES.START_CREATE_FILE + newFileCreateMode: any | null + } + | { + type: ACTION_TYPES.START_CREATE_FOLDER + } + | { + type: ACTION_TYPES.CREATING_FILE + } + | { + type: ACTION_TYPES.CREATING_FOLDER + } + | { + type: ACTION_TYPES.DELETING + } + | { + type: ACTION_TYPES.MOVING + } + | { + type: ACTION_TYPES.CLEAR + } + | { + type: ACTION_TYPES.CANCEL + } + | { + type: ACTION_TYPES.ERROR + error: unknown + } + +function fileTreeActionableReducer(state: State, action: Action) { switch (action.type) { case ACTION_TYPES.START_RENAME: return { ...defaultState, isRenaming: true } @@ -115,13 +202,15 @@ function fileTreeActionableReducer(state, action) { case ACTION_TYPES.ERROR: return { ...state, inFlight: false, error: action.error } default: - throw new Error(`Unknown user action type: ${action.type}`) + throw new Error(`Unknown user action type: ${(action as Action).type}`) } } -export function FileTreeActionableProvider({ reindexReferences, children }) { - const { _id: projectId } = useProjectContext(projectContextPropTypes) - const { permissionsLevel } = useEditorContext(editorContextPropTypes) +export const FileTreeActionableProvider: FC<{ + reindexReferences: () => void +}> = ({ reindexReferences, children }) => { + const { _id: projectId } = useProjectContext() + const { permissionsLevel } = useEditorContext() const [state, dispatch] = useReducer( permissionsLevel === 'readOnly' @@ -142,7 +231,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) { // update the entity with the new name immediately in the tree, but revert to // the old name if the sync fails const finishRenaming = useCallback( - newName => { + (newName: string) => { const selectedEntityId = Array.from(selectedEntityIds)[0] const found = findInTreeOrThrow(fileTreeData, selectedEntityId) const oldName = found.entity.name @@ -168,7 +257,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) { ) const isDuplicate = useCallback( - (parentFolderId, name) => { + (parentFolderId: string, name: string) => { return !isNameUniqueInFolder(fileTreeData, parentFolderId, name) }, [fileTreeData] @@ -189,29 +278,32 @@ export function FileTreeActionableProvider({ reindexReferences, children }) { dispatch({ type: ACTION_TYPES.DELETING }) let shouldReindexReferences = false - return mapSeries(Array.from(selectedEntityIds), id => { - const found = findInTreeOrThrow(fileTreeData, id) - shouldReindexReferences = - shouldReindexReferences || /\.bib$/.test(found.entity.name) - return syncDelete(projectId, found.type, found.entity._id).catch( - error => { - // throw unless 404 - if (error.info.statusCode !== 404) { - throw error + return ( + mapSeries(Array.from(selectedEntityIds), id => { + const found = findInTreeOrThrow(fileTreeData, id) + shouldReindexReferences = + shouldReindexReferences || /\.bib$/.test(found.entity.name) + return syncDelete(projectId, found.type, found.entity._id).catch( + error => { + // throw unless 404 + if (error.info.statusCode !== 404) { + throw error + } } - } - ) - }) - .then(() => { - if (shouldReindexReferences) { - reindexReferences() - } - dispatch({ type: ACTION_TYPES.CLEAR }) - }) - .catch(error => { - // set an error and allow user to retry - dispatch({ type: ACTION_TYPES.ERROR, error }) + ) }) + // @ts-ignore (TODO: improve mapSeries types) + .then(() => { + if (shouldReindexReferences) { + reindexReferences() + } + dispatch({ type: ACTION_TYPES.CLEAR }) + }) + .catch((error: Error) => { + // set an error and allow user to retry + dispatch({ type: ACTION_TYPES.ERROR, error }) + }) + ) }, [fileTreeData, projectId, selectedEntityIds, reindexReferences]) // moves entities. Tree is updated immediately and data are sync'd after. @@ -242,7 +334,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) { } // keep track of old parent folder ids so we can revert entities if sync fails - const oldParentFolderIds = {} + const oldParentFolderIds: Record = {} 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, + 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 } diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.jsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx similarity index 56% rename from services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.jsx rename to services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx index ee6c49c95f..93315ec836 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.jsx +++ b/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 diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.jsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx similarity index 63% rename from services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.jsx rename to services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx index 4bf7336bc1..ed9a8f4a38 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.jsx +++ b/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 diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.jsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx similarity index 82% rename from services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.jsx rename to services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx index f025a69c10..32fd0974ab 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.jsx +++ b/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 ( - + {children} ) } -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({ 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, + 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, + t: (key: string, options: Record) => 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) { const draggedFoldersArray = Array.from(draggedItems) .filter(draggedItem => { return draggedItem.type === 'folder' diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.jsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.jsx deleted file mode 100644 index 9e8126c91f..0000000000 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.jsx +++ /dev/null @@ -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 ( - - {children} - - ) -} - -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, -} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx new file mode 100644 index 0000000000..33a051a633 --- /dev/null +++ b/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(null) + + return ( + + {children} + + ) +} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.jsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx similarity index 80% rename from services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.jsx rename to services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx index 58f979dd97..7c3d532717 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.jsx +++ b/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' +import { Folder } from '../../../../../types/folder' +import { FileTreeEntity } from '../../../../../types/file-tree-entity' -const FileTreeSelectableContext = createContext() +const FileTreeSelectableContext = createContext< + | { + selectedEntityIds: Set + isRootFolderSelected: boolean + selectOrMultiSelectEntity: ( + id: string | string[], + multiple?: boolean + ) => void + setIsRootFolderSelected: (value: boolean) => void + selectedEntityParentIds: Set + select: (id: string | string[]) => void + unselect: (id: string) => void + } + | undefined +>(undefined) -const ACTION_TYPES = { - SELECT: 'SELECT', - MULTI_SELECT: 'MULTI_SELECT', - UNSELECT: 'UNSELECT', +/* eslint-disable no-unused-vars */ +enum ACTION_TYPES { + SELECT = 'SELECT', + MULTI_SELECT = 'MULTI_SELECT', + UNSELECT = 'UNSELECT', } +/* eslint-enable no-unused-vars */ -function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) { +type Action = + | { + type: ACTION_TYPES.SELECT + id: string + } + | { + type: ACTION_TYPES.MULTI_SELECT + id: string + } + | { + type: ACTION_TYPES.UNSELECT + id: string + } + +function fileTreeSelectableReadWriteReducer( + selectedEntityIds: Set, + 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, + 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() // 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() } ) - const [selectedEntityParentIds, setSelectedEntityParentIds] = useState( - new Set() - ) + const [selectedEntityParentIds, setSelectedEntityParentIds] = useState< + Set + >(new Set()) // fills `selectedEntityParentIds` set useEffect(() => { - const ids = new Set() + const ids = new Set() 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 { yield folder._id const folders = [...folder.folders].sort(alphabetical) diff --git a/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js b/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js index 61a9666664..31757d09b1 100644 --- a/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js +++ b/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', + ]) ) diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts similarity index 86% rename from services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js rename to services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts index 239b81bd8c..54adda0b76 100644 --- a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js +++ b/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) { diff --git a/services/web/frontend/js/features/file-view/components/file-view-header.tsx b/services/web/frontend/js/features/file-view/components/file-view-header.tsx index aa2e0b2030..3a33715b76 100644 --- a/services/web/frontend/js/features/file-view/components/file-view-header.tsx +++ b/services/web/frontend/js/features/file-view/components/file-view-header.tsx @@ -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>(null) @@ -150,7 +145,6 @@ function ProjectFilePathProvider({ file }: ProjectFilePathProviderProps) { return (

-   - ) -} - -FileViewImage.propTypes = { - fileName: PropTypes.string.isRequired, - fileId: PropTypes.string.isRequired, - onLoad: PropTypes.func.isRequired, - onError: PropTypes.func.isRequired, -} diff --git a/services/web/frontend/js/features/file-view/components/file-view-image.tsx b/services/web/frontend/js/features/file-view/components/file-view-image.tsx new file mode 100644 index 0000000000..163ce5cc50 --- /dev/null +++ b/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 ( + {fileName} + ) +} diff --git a/services/web/frontend/js/features/file-view/components/file-view-text.jsx b/services/web/frontend/js/features/file-view/components/file-view-text.tsx similarity index 82% rename from services/web/frontend/js/features/file-view/components/file-view-text.jsx rename to services/web/frontend/js/features/file-view/components/file-view-text.tsx index 284cacbd35..f066c6028d 100644 --- a/services/web/frontend/js/features/file-view/components/file-view-text.jsx +++ b/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 }) { ) } - -FileViewText.propTypes = { - file: PropTypes.shape({ id: PropTypes.string }).isRequired, - onLoad: PropTypes.func.isRequired, - onError: PropTypes.func.isRequired, -} diff --git a/services/web/frontend/js/features/history/components/change-list/all-history-list.tsx b/services/web/frontend/js/features/history/components/change-list/all-history-list.tsx index 6be472a30e..a98c63a258 100644 --- a/services/web/frontend/js/features/history/components/change-list/all-history-list.tsx +++ b/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 diff --git a/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx b/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx index 684a804b10..461ee26d41 100644 --- a/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx +++ b/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx @@ -22,7 +22,6 @@ function EditorNavigationToolbar() { return ( <> { 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) } diff --git a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts index c3681b701d..f0eabb2c20 100644 --- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts +++ b/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) => { + (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 diff --git a/services/web/frontend/js/features/outline/components/outline-pane.jsx b/services/web/frontend/js/features/outline/components/outline-pane.tsx similarity index 81% rename from services/web/frontend/js/features/outline/components/outline-pane.jsx rename to services/web/frontend/js/features/outline/components/outline-pane.tsx index c25ef8d4ba..d65c6a20f3 100644 --- a/services/web/frontend/js/features/outline/components/outline-pane.jsx +++ b/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) diff --git a/services/web/frontend/js/features/pdf-preview/components/compile-timeout-warning.tsx b/services/web/frontend/js/features/pdf-preview/components/compile-timeout-warning.tsx index 485890381e..f593e06ea9 100644 --- a/services/web/frontend/js/features/pdf-preview/components/compile-timeout-warning.tsx +++ b/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() diff --git a/services/web/frontend/js/features/pdf-preview/components/faster-compiles-feedback.tsx b/services/web/frontend/js/features/pdf-preview/components/faster-compiles-feedback.tsx index e3fabd657e..48b05337b4 100644 --- a/services/web/frontend/js/features/pdf-preview/components/faster-compiles-feedback.tsx +++ b/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(undefined) + const lastPdfUrl = useRef(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) diff --git a/services/web/frontend/js/features/settings/components/account-info-section.tsx b/services/web/frontend/js/features/settings/components/account-info-section.tsx index c947f14e04..bdcd2d122a 100644 --- a/services/web/frontend/js/features/settings/components/account-info-section.tsx +++ b/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 diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.jsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx similarity index 89% rename from services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.jsx rename to services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx index 1f391d780a..89258e7732 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.jsx +++ b/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 ( diff --git a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.jsx b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx similarity index 95% rename from services/web/frontend/js/features/share-project-modal/components/share-modal-body.jsx rename to services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx index 02938c37f4..cd564c3e83 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.jsx +++ b/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']) { diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx index 96e4d0887a..1dd29a843f 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx +++ b/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: >(request: () => T) => T inFlight: boolean setInFlight: React.Dispatch< @@ -58,11 +55,9 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ useState(false) const [error, setError] = useState() - 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(() => { diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx index 63bdbda29f..d45d2eb8e8 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx +++ b/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 ( diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx index 896e0a5a64..6e03282cf8 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx +++ b/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() diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx index a445e407db..ff46ff5b9e 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx +++ b/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(null) - const { inactiveTutorials, currentPopup, setCurrentPopup }: EditorTutorials = - useEditorContext(editorContextPropTypes) - + const { inactiveTutorials, currentPopup, setCurrentPopup } = + useEditorContext() const { splitTestVariants, }: { splitTestVariants: Record } = @@ -88,9 +78,7 @@ const PromotionOverlayContent = memo( _props, ref: Ref ) { - const { deactivateTutorial }: EditorTutorials = useEditorContext( - editorContextPropTypes - ) + const { deactivateTutorial } = useEditorContext() const [timeoutExpired, setTimeoutExpired] = useState(false) const onClose = useCallback(() => { diff --git a/services/web/frontend/js/shared/context/detach-compile-context.jsx b/services/web/frontend/js/shared/context/detach-compile-context.tsx similarity index 94% rename from services/web/frontend/js/shared/context/detach-compile-context.jsx rename to services/web/frontend/js/shared/context/detach-compile-context.tsx index c280a7fc3d..eaa73754ba 100644 --- a/services/web/frontend/js/shared/context/detach-compile-context.jsx +++ b/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( + 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 } diff --git a/services/web/frontend/js/shared/context/detach-context.jsx b/services/web/frontend/js/shared/context/detach-context.tsx similarity index 79% rename from services/web/frontend/js/shared/context/detach-context.jsx rename to services/web/frontend/js/shared/context/detach-context.tsx index 9263dafc07..dd0f6acc91 100644 --- a/services/web/frontend/js/shared/context/detach-context.jsx +++ b/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() 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 } diff --git a/services/web/frontend/js/shared/context/editor-context.jsx b/services/web/frontend/js/shared/context/editor-context.tsx similarity index 64% rename from services/web/frontend/js/shared/context/editor-context.jsx rename to services/web/frontend/js/shared/context/editor-context.tsx index 66ea152897..bfbfe5fcfa 100644 --- a/services/web/frontend/js/shared/context/editor-context.jsx +++ b/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() +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> + } + | undefined +>(undefined) -EditorContext.Provider.propTypes = { - value: PropTypes.shape({ - cobranding: PropTypes.shape({ - logoImgUrl: PropTypes.string.isRequired, - brandVariationName: PropTypes.string.isRequired, - brandVariationId: PropTypes.number.isRequired, - brandId: PropTypes.number.isRequired, - brandVariationHomeUrl: PropTypes.string.isRequired, - publishGuideHtml: PropTypes.string, - partner: PropTypes.string, - brandedMenu: PropTypes.bool, - submitBtnHtml: PropTypes.string, - }), - hasPremiumCompile: PropTypes.bool, - loading: PropTypes.bool, - renameProject: PropTypes.func.isRequired, - showSymbolPalette: PropTypes.bool, - toggleSymbolPalette: PropTypes.func, - insertSymbol: PropTypes.func, - isProjectOwner: PropTypes.bool, - isRestrictedTokenMember: PropTypes.bool, - permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']), - }), -} - -export function EditorProvider({ children }) { +export const EditorProvider: FC = ({ children }) => { const ide = useIdeContext() const { id: userId } = useUserContext() const { role } = useDetachContext() - const { - owner, - features, - _id: projectId, - } = useProjectContext({ - owner: PropTypes.shape({ - _id: PropTypes.string.isRequired, - }), - features: PropTypes.shape({ - compileGroup: PropTypes.string, - }), - _id: PropTypes.string.isRequired, - }) + const { owner, features, _id: projectId } = useProjectContext() const cobranding = useMemo(() => { - if (window.brandVariation) { - return { + return ( + window.brandVariation && { logoImgUrl: window.brandVariation.logo_url, brandVariationName: window.brandVariation.name, brandVariationId: window.brandVariation.id, @@ -75,9 +70,7 @@ export function EditorProvider({ children }) { brandedMenu: window.brandVariation.branded_menu, submitBtnHtml: window.brandVariation.submit_button_html, } - } else { - return undefined - } + ) }, []) const [loading] = useScopeValue('state.loading') @@ -91,7 +84,7 @@ export function EditorProvider({ children }) { getMeta('ol-inactiveTutorials', []) ) - const [currentPopup, setCurrentPopup] = useState(null) + const [currentPopup, setCurrentPopup] = useState(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 }) { {children} ) } - -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 } diff --git a/services/web/frontend/js/shared/context/file-tree-data-context.jsx b/services/web/frontend/js/shared/context/file-tree-data-context.tsx similarity index 59% rename from services/web/frontend/js/shared/context/file-tree-data-context.jsx rename to services/web/frontend/js/shared/context/file-tree-data-context.tsx index 758ade8898..22cc1a044d 100644 --- a/services/web/frontend/js/shared/context/file-tree-data-context.jsx +++ b/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' +import { Folder } from '../../../../types/folder' +import { Project } from '../../../../types/project' +import { MainDocument } from '../../../../types/project-settings' +import { FindResult } from '@/features/file-tree/util/path' -const FileTreeDataContext = createContext() +const FileTreeDataContext = createContext< + | { + // fileTreeData is the up-to-date representation of the files list, updated + // by the file tree + fileTreeData: Folder + fileCount: { value: number; status: string; limit: number } | number + hasFolders: boolean + selectedEntities: FindResult[] + setSelectedEntities: (selectedEntities: FindResult[]) => void + dispatchRename: (id: string, name: string) => void + dispatchMove: (id: string, target: string) => void + dispatchDelete: (id: string) => void + dispatchCreateFolder: (name: string, folder: any) => void + dispatchCreateDoc: (name: string, doc: any) => void + dispatchCreateFile: (name: string, file: any) => void + docs?: MainDocument[] + } + | undefined +>(undefined) -const fileTreeDataPropType = PropTypes.shape({ - _id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - docs: PropTypes.array.isRequired, - fileRefs: PropTypes.array.isRequired, - folders: PropTypes.array.isRequired, -}) - -FileTreeDataContext.Provider.propTypes = { - value: PropTypes.shape({ - // fileTreeData is the up-to-date representation of the files list, updated - // by the file tree - fileTreeData: fileTreeDataPropType, - hasFolders: PropTypes.bool, - }), +/* eslint-disable no-unused-vars */ +enum ACTION_TYPES { + RENAME = 'RENAME', + RESET = 'RESET', + DELETE = 'DELETE', + MOVE = 'MOVE', + CREATE = 'CREATE', } +/* eslint-enable no-unused-vars */ -const ACTION_TYPES = { - RENAME: 'RENAME', - RESET: 'RESET', - DELETE: 'DELETE', - MOVE: 'MOVE', - CREATE: 'CREATE', -} +type Action = + | { + type: ACTION_TYPES.RESET + fileTreeData?: Folder + } + | { + type: ACTION_TYPES.RENAME + id: string + newName: string + } + | { + type: ACTION_TYPES.DELETE + id: string + } + | { + type: ACTION_TYPES.MOVE + entityId: string + toFolderId: string + } + | { + type: typeof ACTION_TYPES.CREATE + parentFolderId: string + entity: any // TODO + } -function fileTreeMutableReducer({ fileTreeData }, action) { +function fileTreeMutableReducer( + { fileTreeData }: { fileTreeData: Folder }, + action: Action +) { switch (action.type) { case ACTION_TYPES.RESET: { const newFileTreeData = action.fileTreeData @@ -104,12 +139,14 @@ function fileTreeMutableReducer({ fileTreeData }, action) { } default: { - throw new Error(`Unknown mutable file tree action type: ${action.type}`) + throw new Error( + `Unknown mutable file tree action type: ${(action as Action).type}` + ) } } } -const initialState = rootFolder => { +const initialState = (rootFolder?: Folder[]) => { const fileTreeData = rootFolder?.[0] return { fileTreeData, @@ -117,7 +154,7 @@ const initialState = rootFolder => { } } -export function useFileTreeData(propTypes) { +export function useFileTreeData() { const context = useContext(FileTreeDataContext) if (!context) { @@ -126,18 +163,11 @@ export function useFileTreeData(propTypes) { ) } - PropTypes.checkPropTypes( - propTypes, - context, - 'data', - 'FileTreeDataContext.Provider' - ) - return context } -export function FileTreeDataProvider({ children }) { - const [project] = useScopeValue('project') +export const FileTreeDataProvider: FC = ({ children }) => { + const [project] = useScopeValue('project') 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([]) 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 }) { ) } - -FileTreeDataProvider.propTypes = { - children: PropTypes.any, -} diff --git a/services/web/frontend/js/shared/context/layout-context.tsx b/services/web/frontend/js/shared/context/layout-context.tsx index 6f3e6b0485..3736419a56 100644 --- a/services/web/frontend/js/shared/context/layout-context.tsx +++ b/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> 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('ui.view') + const [view, _setView] = useScopeValue('ui.view') const [openFile] = useScopeValue('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') { diff --git a/services/web/frontend/js/shared/context/local-compile-context.jsx b/services/web/frontend/js/shared/context/local-compile-context.tsx similarity index 86% rename from services/web/frontend/js/shared/context/local-compile-context.jsx rename to services/web/frontend/js/shared/context/local-compile-context.tsx index c3568fe464..ce86812ffa 100644 --- a/services/web/frontend/js/shared/context/local-compile-context.jsx +++ b/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() +type PdfFile = Record -export const CompileContextPropTypes = { - value: PropTypes.shape({ - autoCompile: PropTypes.bool.isRequired, - clearingCache: PropTypes.bool.isRequired, - clsiServerId: PropTypes.string, - codeCheckFailed: PropTypes.bool.isRequired, - compiling: PropTypes.bool.isRequired, - deliveryLatencies: PropTypes.object.isRequired, - draft: PropTypes.bool.isRequired, - error: PropTypes.string, - fileList: PropTypes.object, - hasChanges: PropTypes.bool.isRequired, - highlights: PropTypes.arrayOf(PropTypes.object), - logEntries: PropTypes.object, - logEntryAnnotations: PropTypes.object, - pdfDownloadUrl: PropTypes.string, - pdfFile: PropTypes.object, - pdfUrl: PropTypes.string, - pdfViewer: PropTypes.string, - position: PropTypes.object, - rawLog: PropTypes.string, - setAutoCompile: PropTypes.func.isRequired, - setDraft: PropTypes.func.isRequired, - setError: PropTypes.func.isRequired, - setHasLintingError: PropTypes.func.isRequired, // only for storybook - setHighlights: PropTypes.func.isRequired, - setPosition: PropTypes.func.isRequired, - setShowCompileTimeWarning: PropTypes.func.isRequired, - setShowLogs: PropTypes.func.isRequired, - toggleLogs: PropTypes.func.isRequired, - setStopOnFirstError: PropTypes.func.isRequired, - setStopOnValidationError: PropTypes.func.isRequired, - showCompileTimeWarning: PropTypes.bool.isRequired, - showLogs: PropTypes.bool.isRequired, - showNewCompileTimeoutUI: PropTypes.string, - showFasterCompilesFeedbackUI: PropTypes.bool.isRequired, - stopOnFirstError: PropTypes.bool.isRequired, - stopOnValidationError: PropTypes.bool.isRequired, - stoppedOnFirstError: PropTypes.bool.isRequired, - uncompiled: PropTypes.bool, - validationIssues: PropTypes.object, - firstRenderDone: PropTypes.func.isRequired, - cleanupCompileResult: PropTypes.func, - }), +export type CompileContext = { + autoCompile: boolean + clearingCache: boolean + clsiServerId?: string + codeCheckFailed: boolean + compiling: boolean + deliveryLatencies: Record + draft: boolean + error?: string + fileList?: Record + hasChanges: boolean + highlights?: Record[] + isProjectOwner: boolean + logEntries?: Record + logEntryAnnotations?: Record + pdfDownloadUrl?: string + pdfFile?: PdfFile + pdfUrl?: string + pdfViewer?: string + position?: Record + 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 + 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( + 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('pdf.downloadUrl') // the URL for loading the PDF in the preview pane - const [, setPdfUrl] = useScopeValueSetterOnly('pdf.url') + const [, setPdfUrl] = useScopeValueSetterOnly('pdf.url') // low level details for metrics - const [pdfFile, setPdfFile] = useState() + const [pdfFile, setPdfFile] = useState() 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>() // 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() // the list of files that can be downloaded - const [fileList, setFileList] = useState() + const [fileList, setFileList] = useState>() // the raw contents of the log file - const [rawLog, setRawLog] = useState() + const [rawLog, setRawLog] = useState() // 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) => { 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 } diff --git a/services/web/frontend/js/shared/context/project-context.jsx b/services/web/frontend/js/shared/context/project-context.tsx similarity index 54% rename from services/web/frontend/js/shared/context/project-context.jsx rename to services/web/frontend/js/shared/context/project-context.tsx index 654086bf01..7bbca6e177 100644 --- a/services/web/frontend/js/shared/context/project-context.jsx +++ b/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() +const ProjectContext = createContext< + | { + _id: string + name: string + rootDocId?: string + members: { _id: UserId; email: string; privileges: string }[] + invites: { _id: UserId }[] + features: { + collaborators?: number + compileGroup?: 'alpha' | 'standard' | 'priority' + trackChanges?: boolean + trackChangesVisible?: boolean + references?: boolean + mendeley?: boolean + zotero?: boolean + versioning?: boolean + gitBridge?: boolean + referencesSearch?: boolean + } + publicAccessLevel?: PublicAccessLevel + owner: { + _id: UserId + email: string + } + showNewCompileTimeoutUI?: string + tags: { + _id: string + name: string + color?: string + }[] + trackChangesState: ReviewPanel.Value<'trackChangesState'> + } + | undefined +>(undefined) -export const projectShape = { - _id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - rootDocId: PropTypes.string, - members: PropTypes.arrayOf( - PropTypes.shape({ - _id: PropTypes.string.isRequired, - }) - ), - invites: PropTypes.arrayOf( - PropTypes.shape({ - _id: PropTypes.string.isRequired, - }) - ), - features: PropTypes.shape({ - collaborators: PropTypes.number, - compileGroup: PropTypes.oneOf(['alpha', 'standard', 'priority']), - trackChangesVisible: PropTypes.bool, - references: PropTypes.bool, - mendeley: PropTypes.bool, - zotero: PropTypes.bool, - versioning: PropTypes.bool, - gitBridge: PropTypes.bool, - }), - publicAccessLevel: PropTypes.string, - owner: PropTypes.shape({ - _id: PropTypes.string.isRequired, - email: PropTypes.string.isRequired, - }), - useNewCompileTimeoutUI: PropTypes.string, - tags: PropTypes.arrayOf( - PropTypes.shape({ - _id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - }) - ).isRequired, -} - -ProjectContext.Provider.propTypes = { - value: PropTypes.shape(projectShape), -} - -export function useProjectContext(propTypes) { +export function useProjectContext() { const context = useContext(ProjectContext) if (!context) { @@ -57,13 +49,6 @@ export function useProjectContext(propTypes) { ) } - PropTypes.checkPropTypes( - propTypes, - context, - 'data', - 'ProjectContext.Provider' - ) - return context } @@ -76,7 +61,7 @@ const projectFallback = { features: {}, } -export function ProjectProvider({ children }) { +export const ProjectProvider: FC = ({ children }) => { const [project] = useScopeValue('project', true) const { @@ -96,7 +81,7 @@ export function ProjectProvider({ children }) { () => getMeta('ol-projectTags', []) // `tag.name` data may be null for some old users - .map(tag => ({ ...tag, name: tag.name ?? '' })), + .map((tag: any) => ({ ...tag, name: tag.name ?? '' })), [] ) @@ -145,7 +130,3 @@ export function ProjectProvider({ children }) { {children} ) } - -ProjectProvider.propTypes = { - children: PropTypes.any, -} diff --git a/services/web/frontend/js/shared/context/root-context.jsx b/services/web/frontend/js/shared/context/root-context.tsx similarity index 93% rename from services/web/frontend/js/shared/context/root-context.jsx rename to services/web/frontend/js/shared/context/root-context.tsx index 893f74ce80..52b8bf7904 100644 --- a/services/web/frontend/js/shared/context/root-context.jsx +++ b/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 ( @@ -51,9 +51,4 @@ export function ContextRoot({ children, ide }) { ) } -ContextRoot.propTypes = { - children: PropTypes.any, - ide: PropTypes.object, -} - export const rootContext = createSharedContext(ContextRoot) diff --git a/services/web/frontend/js/shared/context/split-test-context.jsx b/services/web/frontend/js/shared/context/split-test-context.jsx deleted file mode 100644 index a85848a953..0000000000 --- a/services/web/frontend/js/shared/context/split-test-context.jsx +++ /dev/null @@ -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 ( - - {children} - - ) -} - -SplitTestProvider.propTypes = { - children: PropTypes.any, -} - -export function useSplitTestContext(propTypes) { - const context = useContext(SplitTestContext) - - PropTypes.checkPropTypes( - propTypes, - context, - 'data', - 'SplitTestContext.Provider' - ) - - return context -} diff --git a/services/web/frontend/js/shared/context/split-test-context.tsx b/services/web/frontend/js/shared/context/split-test-context.tsx new file mode 100644 index 0000000000..3cd5d5a98b --- /dev/null +++ b/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 +type SplitTestInfo = Record + +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 ( + + {children} + + ) +} + +export function useSplitTestContext() { + const context = useContext(SplitTestContext) + + if (!context) { + throw new Error( + 'useSplitTestContext is only available within SplitTestProvider' + ) + } + + return context +} diff --git a/services/web/frontend/js/shared/context/user-context.jsx b/services/web/frontend/js/shared/context/user-context.jsx deleted file mode 100644 index 186bccdbc8..0000000000 --- a/services/web/frontend/js/shared/context/user-context.jsx +++ /dev/null @@ -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 {children} -} - -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 -} diff --git a/services/web/frontend/js/shared/context/user-context.tsx b/services/web/frontend/js/shared/context/user-context.tsx new file mode 100644 index 0000000000..df6888e206 --- /dev/null +++ b/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(undefined) + +export const UserProvider: FC = ({ children }) => { + const user = getMeta('ol-user') + + return {children} +} + +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 +} diff --git a/services/web/frontend/js/shared/hooks/use-callback-handlers.ts b/services/web/frontend/js/shared/hooks/use-callback-handlers.ts index 38b3bfcc0b..897765d144 100644 --- a/services/web/frontend/js/shared/hooks/use-callback-handlers.ts +++ b/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) } diff --git a/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx index f8bc2263fa..210814af1a 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx +++ b/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('', function () { @@ -174,7 +173,9 @@ describe('', function () { cy.mount( - + ) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx index 654ecb7ba1..c1f43f4182 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx @@ -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 }) => (

{children}
@@ -863,7 +863,7 @@ describe('autocomplete', { scrollBehavior: false }, function () { const user = { id: '123abd' as UserId, email: 'testuser@example.com', - } + } as User cy.mount( diff --git a/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx index de0a52b8f2..e13045bd3a 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx +++ b/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('', function () { beforeEach(function () { diff --git a/services/web/types/tutorial.ts b/services/web/types/tutorial.ts deleted file mode 100644 index 1da5d837c2..0000000000 --- a/services/web/types/tutorial.ts +++ /dev/null @@ -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 -} diff --git a/services/web/types/user.ts b/services/web/types/user.ts index e7d4718e83..5372677c7a 100644 --- a/services/web/types/user.ts +++ b/services/web/types/user.ts @@ -9,8 +9,14 @@ export type UserId = Brand 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> & { _id: string } diff --git a/services/web/types/window.ts b/services/web/types/window.ts index adfc0a476f..eef48c61b3 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -28,6 +28,10 @@ declare global { logEntryAnnotations: Record } } + 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 + data?: Record expectingLinkedFileRefreshedSocketFor?: string | null writefull?: any io?: any