diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context.js b/services/web/frontend/js/features/file-tree/components/file-tree-context.js index db1a748dc7..7d45259dd1 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-context.js @@ -15,6 +15,7 @@ function FileTreeContext({ reindexReferences, setRefProviderEnabled, setStartedFreeTrial, + setShouldShowVisualSelection, onSelect, children, }) { @@ -23,9 +24,13 @@ function FileTreeContext({ refProviders={refProviders} setRefProviderEnabled={setRefProviderEnabled} setStartedFreeTrial={setStartedFreeTrial} + setShouldShowVisualSelection={setShouldShowVisualSelection} reindexReferences={reindexReferences} > - + {children} @@ -39,6 +44,7 @@ FileTreeContext.propTypes = { refProviders: PropTypes.object.isRequired, setRefProviderEnabled: PropTypes.func.isRequired, setStartedFreeTrial: PropTypes.func.isRequired, + setShouldShowVisualSelection: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-doc.js b/services/web/frontend/js/features/file-tree/components/file-tree-doc.js index 1c7bd54d53..252c5d1e21 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-doc.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-doc.js @@ -8,9 +8,16 @@ import Icon from '../../../shared/components/icon' import iconTypeFromName from '../util/icon-type-from-name' import classnames from 'classnames' -function FileTreeDoc({ name, id, isFile, isLinkedFile }) { +function FileTreeDoc({ + name, + id, + isFile, + isLinkedFile, + shouldShowVisualSelection, +}) { const { isSelected, props: selectableEntityProps } = useSelectableEntity( id, + shouldShowVisualSelection, isFile ) @@ -38,6 +45,7 @@ FileTreeDoc.propTypes = { id: PropTypes.string.isRequired, isFile: PropTypes.bool, isLinkedFile: PropTypes.bool, + shouldShowVisualSelection: PropTypes.bool, } export const FileTreeIcon = ({ isLinkedFile, name }) => { diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js b/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js index 0795fa3650..7f8c81ec07 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js @@ -12,6 +12,7 @@ function FileTreeFolderList({ classes = {}, dropRef = null, children, + shouldShowVisualSelection, }) { files = files.map(file => ({ ...file, isFile: true })) const docsAndFiles = [...docs, ...files] @@ -32,6 +33,7 @@ function FileTreeFolderList({ folders={folder.folders} docs={folder.docs} files={folder.fileRefs} + shouldShowVisualSelection={shouldShowVisualSelection} /> ) })} @@ -43,6 +45,7 @@ function FileTreeFolderList({ id={doc._id} isFile={doc.isFile} isLinkedFile={doc.linkedFileData && !!doc.linkedFileData.provider} + shouldShowVisualSelection={shouldShowVisualSelection} /> ) })} @@ -60,6 +63,7 @@ FileTreeFolderList.propTypes = { }), dropRef: PropTypes.func, children: PropTypes.node, + shouldShowVisualSelection: PropTypes.bool, } function compareFunction(one, two) { diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-folder.js b/services/web/frontend/js/features/file-tree/components/file-tree-folder.js index 0f319db6f8..3514b4bc96 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-folder.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-folder.js @@ -14,10 +14,20 @@ import FileTreeItemInner from './file-tree-item/file-tree-item-inner' import FileTreeFolderList from './file-tree-folder-list' import usePersistedState from '../../../shared/hooks/use-persisted-state' -function FileTreeFolder({ name, id, folders, docs, files }) { +function FileTreeFolder({ + name, + id, + folders, + docs, + files, + shouldShowVisualSelection, +}) { const { t } = useTranslation() - const { isSelected, props: selectableEntityProps } = useSelectableEntity(id) + const { isSelected, props: selectableEntityProps } = useSelectableEntity( + id, + shouldShowVisualSelection + ) const { selectedEntityParentIds } = useFileTreeSelectable(id) @@ -87,6 +97,7 @@ function FileTreeFolder({ name, id, folders, docs, files }) { docs={docs} files={files} dropRef={dropRefList} + shouldShowVisualSelection={shouldShowVisualSelection} /> ) : null} @@ -99,6 +110,7 @@ FileTreeFolder.propTypes = { folders: PropTypes.array.isRequired, docs: PropTypes.array.isRequired, files: PropTypes.array.isRequired, + shouldShowVisualSelection: PropTypes.bool, } export default FileTreeFolder diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-root.js b/services/web/frontend/js/features/file-tree/components/file-tree-root.js index 2bf901d6cb..51097a2170 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-root.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-root.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import PropTypes from 'prop-types' import withErrorBoundary from '../../../infrastructure/error-boundary' @@ -31,6 +31,12 @@ const FileTreeRoot = React.memo(function FileTreeRoot({ const { _id: projectId } = useProjectContext(projectContextPropTypes) const { fileTreeData } = useFileTreeData() const isReady = projectId && fileTreeData + const [shouldShowVisualSelection, setShouldShowVisualSelection] = + useState(true) + + const handleFileTreeClick = () => { + setShouldShowVisualSelection(false) + } useEffect(() => { if (isReady) onInit() @@ -42,14 +48,22 @@ const FileTreeRoot = React.memo(function FileTreeRoot({ refProviders={refProviders} setRefProviderEnabled={setRefProviderEnabled} setStartedFreeTrial={setStartedFreeTrial} + setShouldShowVisualSelection={setShouldShowVisualSelection} reindexReferences={reindexReferences} onSelect={onSelect} > {isConnected ? null :
} -
- + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+
@@ -59,7 +73,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({ ) }) -function FileTreeRootFolder() { +function FileTreeRootFolder({ shouldShowVisualSelection }) { useFileTreeSocketListener() const { fileTreeData } = useFileTreeData() @@ -75,6 +89,7 @@ function FileTreeRootFolder() { classes={{ root: 'file-tree-list' }} dropRef={dropRef} isOver={isOver} + shouldShowVisualSelection={shouldShowVisualSelection} >
  • @@ -82,6 +97,10 @@ function FileTreeRootFolder() { ) } +FileTreeRootFolder.propTypes = { + shouldShowVisualSelection: PropTypes.bool, +} + FileTreeRoot.propTypes = { onSelect: PropTypes.func.isRequired, onInit: PropTypes.func.isRequired, diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js index f15832b4c3..b37b7f579c 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js @@ -75,7 +75,11 @@ function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) { } } -export function FileTreeSelectableProvider({ onSelect, children }) { +export function FileTreeSelectableProvider({ + onSelect, + setShouldShowVisualSelection, + children, +}) { const { _id: projectId, rootDocId } = useProjectContext( projectContextPropTypes ) @@ -150,6 +154,7 @@ export function FileTreeSelectableProvider({ onSelect, children }) { dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id }) } + window.addEventListener('editor.openDoc', handleOpenDoc) return () => window.removeEventListener('editor.openDoc', handleOpenDoc) }, [fileTreeData]) @@ -176,6 +181,7 @@ export function FileTreeSelectableProvider({ onSelect, children }) { select, unselect, selectOrMultiSelectEntity, + setShouldShowVisualSelection, } return ( @@ -187,6 +193,7 @@ export function FileTreeSelectableProvider({ onSelect, children }) { FileTreeSelectableProvider.propTypes = { onSelect: PropTypes.func.isRequired, + setShouldShowVisualSelection: PropTypes.func.isRequired, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node, @@ -202,20 +209,34 @@ const editorContextPropTypes = { permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']), } -export function useSelectableEntity(id, isFile) { +export function useSelectableEntity( + id, + shouldShowVisualSelection = true, + isFile +) { const { view, setView } = useLayoutContext(layoutContextPropTypes) - const { selectedEntityIds, selectOrMultiSelectEntity } = useContext( - FileTreeSelectableContext - ) + const { + selectedEntityIds, + selectOrMultiSelectEntity, + setShouldShowVisualSelection, + } = useContext(FileTreeSelectableContext) const isSelected = selectedEntityIds.has(id) const handleEvent = useCallback( ev => { + ev.stopPropagation() + setShouldShowVisualSelection(true) selectOrMultiSelectEntity(id, ev.ctrlKey || ev.metaKey) setView(isFile ? 'file' : 'editor') }, - [id, selectOrMultiSelectEntity, setView, isFile] + [ + id, + setShouldShowVisualSelection, + selectOrMultiSelectEntity, + setView, + isFile, + ] ) const handleClick = useCallback( @@ -244,7 +265,8 @@ export function useSelectableEntity(id, isFile) { [id, handleEvent, selectedEntityIds] ) - const isVisuallySelected = isSelected && view !== 'pdf' + const isVisuallySelected = + shouldShowVisualSelection && isSelected && view !== 'pdf' const props = useMemo( () => ({ className: classNames({ selected: isVisuallySelected }), diff --git a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx index 90309c67e5..d783a44a3d 100644 --- a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx +++ b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx @@ -9,7 +9,7 @@ type HistoryFileTreeDocProps = { file: FileDiff name: string selected: boolean - onClick: (file: FileDiff) => void + onClick: (file: FileDiff, event: React.MouseEvent) => void onKeyDown: (file: FileDiff, event: React.KeyboardEvent) => void } @@ -24,7 +24,7 @@ function HistoryFileTreeDoc({
  • onClick(file)} + onClick={e => onClick(file, e)} onKeyDown={e => onKeyDown(file, e)} aria-selected={selected} aria-label={name} diff --git a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder-list.tsx b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder-list.tsx index fb66c42445..0a5630bdf9 100644 --- a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder-list.tsx +++ b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder-list.tsx @@ -21,10 +21,21 @@ function HistoryFileTreeFolderList({ rootClassName, children, }: HistoryFileTreeFolderListProps) { - const { selection, setSelection } = useHistoryContext() + const { + selection, + setSelection, + shouldShowVisualSelection, + setShouldShowVisualSelection, + } = useHistoryContext() + + const handleTopLevelClick = () => { + setShouldShowVisualSelection(false) + } const handleEvent = useCallback( - (file: FileDiff) => { + (file: FileDiff, event: React.UIEvent) => { + event.stopPropagation() + setShouldShowVisualSelection(true) setSelection(prevSelection => { if (file.pathname !== prevSelection.selectedFile?.pathname) { return { @@ -37,12 +48,12 @@ function HistoryFileTreeFolderList({ return prevSelection }) }, - [setSelection] + [setSelection, setShouldShowVisualSelection] ) const handleClick = useCallback( - (file: FileDiff) => { - handleEvent(file) + (file: FileDiff, event: React.MouseEvent) => { + handleEvent(file, event) }, [handleEvent] ) @@ -50,14 +61,19 @@ function HistoryFileTreeFolderList({ const handleKeyDown = useCallback( (file: FileDiff, event: React.KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { - handleEvent(file) + handleEvent(file, event) } }, [handleEvent] ) return ( -
      + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
        {folders.map(folder => ( (selectionInitialState) + const [shouldShowVisualSelection, setShouldShowVisualSelection] = + useState(true) const [updatesInfo, setUpdatesInfo] = useState< HistoryContextValue['updatesInfo'] @@ -330,6 +332,8 @@ function useHistory() { setSelection, fetchNextBatchOfUpdates, resetSelection, + shouldShowVisualSelection, + setShouldShowVisualSelection, }), [ loadingFileDiffs, @@ -346,6 +350,8 @@ function useHistory() { setSelection, fetchNextBatchOfUpdates, resetSelection, + shouldShowVisualSelection, + setShouldShowVisualSelection, ] ) diff --git a/services/web/frontend/js/features/history/context/types/history-context-value.ts b/services/web/frontend/js/features/history/context/types/history-context-value.ts index b68b22357b..d37966f241 100644 --- a/services/web/frontend/js/features/history/context/types/history-context-value.ts +++ b/services/web/frontend/js/features/history/context/types/history-context-value.ts @@ -31,4 +31,8 @@ export type HistoryContextValue = { > fetchNextBatchOfUpdates: () => (() => void) | void resetSelection: () => void + shouldShowVisualSelection: boolean + setShouldShowVisualSelection: React.Dispatch< + React.SetStateAction + > } diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js index e9e94f01b0..4ad0613a36 100644 --- a/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js +++ b/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js @@ -305,4 +305,38 @@ describe('', function () { // multiple items selected: no menu button is visible expect(screen.queryAllByRole('button', { name: 'Menu' })).to.have.length(0) }) + + it('deselects files when clicked outside the list but inside wrapping container', function () { + const rootFolder = [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [{ _id: '456def', name: 'main.tex' }], + folders: [], + fileRefs: [], + }, + ] + renderWithEditorContext( + null} + setRefProviderEnabled={() => null} + setStartedFreeTrial={() => null} + onSelect={onSelect} + onInit={onInit} + isConnected + />, + { + rootFolder, + projectId: '123abc', + rootDocId: '456def', + features: {}, + permissionsLevel: 'owner', + } + ) + + screen.getByRole('treeitem', { selected: true }) + fireEvent.click(screen.getByTestId('file-tree-inner')) + expect(screen.queryByRole('treeitem', { selected: true })).to.be.null + }) }) diff --git a/services/web/test/frontend/features/file-tree/helpers/render-with-context.js b/services/web/test/frontend/features/file-tree/helpers/render-with-context.js index 4f2a6049c6..93ab1665a3 100644 --- a/services/web/test/frontend/features/file-tree/helpers/render-with-context.js +++ b/services/web/test/frontend/features/file-tree/helpers/render-with-context.js @@ -24,6 +24,7 @@ export default (children, options = {}) => { setStartedFreeTrial: () => { console.log('started free trial') }, + setShouldShowVisualSelection: () => {}, onSelect: () => {}, ...contextProps, } @@ -32,6 +33,7 @@ export default (children, options = {}) => { reindexReferences, setRefProviderEnabled, setStartedFreeTrial, + setShouldShowVisualSelection, onSelect, ...editorContextProps } = contextProps @@ -41,6 +43,7 @@ export default (children, options = {}) => { reindexReferences={reindexReferences} setRefProviderEnabled={setRefProviderEnabled} setStartedFreeTrial={setStartedFreeTrial} + setShouldShowVisualSelection={setShouldShowVisualSelection} onSelect={onSelect} > {children}