diff --git a/jest.config.js b/jest.config.js index 8285c0de..7a7a2f54 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,6 +14,7 @@ module.exports = { __dirname, ], moduleNameMapper: { - "\\$src(.*)": "/src/$1" + "\\.(s)?css(.*)$": "identity-obj-proxy", + "\\$src(.*)": "/src/$1" } }; diff --git a/package-lock.json b/package-lock.json index c0a694fe..99c3b21e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "hoist-non-react-statics": "^3.3.0", "html-webpack-plugin": "^5.5.0", "husky": "^3.0.9", + "identity-obj-proxy": "^3.0.0", "jest": "^29.3.1", "jest-cli": "^29.3.1", "jest-environment-jsdom": "^29.3.1", @@ -7434,6 +7435,12 @@ "node": ">=6" } }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -7966,6 +7973,18 @@ "postcss": "^8.1.0" } }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dev": true, + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", @@ -20952,6 +20971,12 @@ "har-schema": "^2.0.0" } }, + "harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -21342,6 +21367,15 @@ "dev": true, "requires": {} }, + "identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dev": true, + "requires": { + "harmony-reflect": "^1.4.6" + } + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", diff --git a/package.json b/package.json index d803a4c7..99d0ca31 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "hoist-non-react-statics": "^3.3.0", "html-webpack-plugin": "^5.5.0", "husky": "^3.0.9", + "identity-obj-proxy": "^3.0.0", "jest": "^29.3.1", "jest-cli": "^29.3.1", "jest-environment-jsdom": "^29.3.1", diff --git a/src/components/App.tsx b/src/components/App.tsx index a8d68c8d..90079761 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -24,12 +24,12 @@ import { KeyboardHotkeys } from '$src/components/shortcuts/KeyboardHotkeys' import { AppState } from '$src/state/appState' import Keys from '$src/constants/keys' -require('@blueprintjs/core/lib/css/blueprint.css') -require('@blueprintjs/icons/lib/css/blueprint-icons.css') -require('@blueprintjs/popover2/lib/css/blueprint-popover2.css') -require('$src/css/main.css') -require('$src/css/windows.css') -require('$src/css/scrollbars.css') +import '@blueprintjs/core/lib/css/blueprint.css' +import '@blueprintjs/icons/lib/css/blueprint-icons.css' +import '@blueprintjs/popover2/lib/css/blueprint-popover2.css' +import '$src/css/main.css' +import '$src/css/windows.css' +import '$src/css/scrollbars.css' interface InjectedProps extends WithTranslation { appState: AppState diff --git a/src/components/LeftPanel.tsx b/src/components/LeftPanel.tsx index 240c9c48..a3869b8a 100644 --- a/src/components/LeftPanel.tsx +++ b/src/components/LeftPanel.tsx @@ -14,7 +14,7 @@ import { AppState } from '$src/state/appState' import { AppAlert } from '$src/components/AppAlert' import CONFIG from '$src/config/appConfig' -require('$src/css/favoritesPanel.css') +import '$src/css/favoritesPanel.css' interface LeftPanelState { nodes: TreeNodeInfo[] diff --git a/src/components/Log.tsx b/src/components/Log.tsx index 39dc6641..e0ec5e2f 100644 --- a/src/components/Log.tsx +++ b/src/components/Log.tsx @@ -8,7 +8,7 @@ import { debounce } from '$src/utils/debounce' import { shouldCatchEvent } from '$src/utils/dom' import Keys from '$src/constants/keys' -require('$src/css/log.css') +import '$src/css/log.css' export interface JSObject extends Object { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/components/Overlay.tsx b/src/components/Overlay.tsx index 76931387..817a3587 100644 --- a/src/components/Overlay.tsx +++ b/src/components/Overlay.tsx @@ -4,10 +4,15 @@ import type { ReactElement } from 'react' interface Props { active: boolean children: ReactElement + id?: string } -export const Overlay = ({ active, children }: Props) => { +export const Overlay = ({ active, children, id = 'overlay' }: Props) => { const activeClass = (active && 'active') || '' - return
{children}
+ return ( +
+ {children} +
+ ) } diff --git a/src/components/SideView.tsx b/src/components/SideView.tsx index 6278c8e5..f06c744c 100644 --- a/src/components/SideView.tsx +++ b/src/components/SideView.tsx @@ -1,146 +1,85 @@ import * as React from 'react' import { Icon, Spinner } from '@blueprintjs/core' -import { inject, Provider, observer } from 'mobx-react' -import { withTranslation, WithTranslation } from 'react-i18next' -import { - DropTargetSpec, - DropTargetConnector, - DropTargetMonitor, - DropTargetCollector, - ConnectDropTarget, - DropTarget, -} from 'react-dnd' +import { Provider, observer } from 'mobx-react' +import { useDrop } from 'react-dnd' import classNames from 'classnames' import { Statusbar } from '$src/components/Statusbar' import { Toolbar } from '$src/components/Toolbar' -import { AppState } from '$src/state/appState' import { LoginDialog } from '$src/components/dialogs/LoginDialog' import { Overlay } from '$src/components/Overlay' import { FileTable } from '$src/components/filetable' import { TabList } from '$src/components/TabList' import { ViewState } from '$src/state/viewState' -import { CollectedProps, DraggedObject } from '$src/components/filetable/RowRenderer' +import { DraggedObject } from '$src/components/filetable/RowRenderer' +import { useStores } from '$src/hooks/useStores' -interface SideViewProps extends WithTranslation { +interface SideViewProps { hide: boolean viewState: ViewState onPaste: () => void - connectDropTarget?: ConnectDropTarget - isOver?: boolean - canDrop?: boolean } -interface InjectedProps extends SideViewProps { - appState?: AppState -} - -const fileTarget: DropTargetSpec = { - canDrop(props: InjectedProps, monitor): boolean { - // prevent drag and drop in same sideview for now - const sourceViewId = monitor.getItem().fileState.viewId - const viewState = props.viewState +export const SideView = observer(({ hide, viewState, onPaste }: SideViewProps) => { + const { appState } = useStores('appState') + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: 'file', + canDrop: ({ fileState }: DraggedObject) => { + const sourceViewId = fileState.viewId + const fileCache = viewState.getVisibleCache() + return viewState.viewId !== sourceViewId && fileCache.status !== 'busy' && !fileCache.error + }, + drop: (item) => appState.drop(item, viewState.getVisibleCache()), + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + canDrop: !!monitor.canDrop(), + }), + }), + [hide], + ) + + const onValidation = (/*dir: string*/): boolean => true + + const onClose = (): void => { const fileCache = viewState.getVisibleCache() - return props.viewState.viewId !== sourceViewId && fileCache.status !== 'busy' && !fileCache.error - }, - drop(props, monitor, component): void { - const item = monitor.getItem() - const sideView = component - sideView.onDrop(item) - }, -} - -const collect: DropTargetCollector = ( - connect: DropTargetConnector, - monitor: DropTargetMonitor, -) => { - return { - connectDropTarget: connect.dropTarget(), - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), + // doesn't work: it keeps the previous fs + fileCache.revertPath() } -} - -export const SideViewClass = inject('appState')( - observer( - class SideViewClass extends React.Component { - constructor(props: InjectedProps) { - super(props) - } - - get injected(): InjectedProps { - return this.props as InjectedProps - } - - onValidation = (/*dir: string*/): boolean => { - return true - } - - onClose = (): void => { - // login cancelled - const { viewState } = this.props - const fileCache = viewState.getVisibleCache() - // doesn't work: it keeps the previous fs - fileCache.revertPath() - } - - renderSideView(): React.ReactNode { - const { viewState, connectDropTarget, canDrop, isOver } = this.props - const fileCache = viewState.getVisibleCache() - const appState = this.injected.appState - const active = appState.getViewFromCache(fileCache).isActive - const dropAndOver = isOver && canDrop - const divId = 'view_' + viewState.viewId - - const activeClass = classNames('sideview', { - active: active, - inactive: !active, - hidden: this.props.hide, - dropTarget: dropAndOver, - notDropTarget: isOver && !canDrop, - }) - - const needLogin = fileCache.status === 'login' - const busy = fileCache.status === 'busy' - const dropOverlayActive = isOver - const dropOverlayIcon = isOver && !canDrop ? 'cross' : 'import' - - return connectDropTarget( -
- {needLogin && ( - - )} - - - - - - - - - - -
, - ) - } - - onDrop(item: DraggedObject /*| DataTransfer*/) { - const { appState, viewState } = this.injected - appState.drop(item, viewState.getVisibleCache()) - debugger - } - - render(): React.ReactNode { - const { viewState } = this.props - - return {this.renderSideView()} - } - }, - ), -) - -const SideViewDD = DropTarget('file', fileTarget, collect)(SideViewClass) - -const SideView = withTranslation()(SideViewDD) -export { SideView } + const fileCache = viewState.getVisibleCache() + const active = appState.getViewFromCache(fileCache).isActive + const dropAndOver = isOver && canDrop + const divId = 'view_' + viewState.viewId + + const activeClass = classNames('sideview', { + active, + inactive: !active, + hidden: hide, + dropTarget: dropAndOver, + notDropTarget: isOver && !canDrop, + }) + + const needLogin = fileCache.status === 'login' + const busy = fileCache.status === 'busy' + const dropOverlayIcon = isOver && !canDrop ? 'cross' : 'import' + const dropOverlayActive = isOver + + return ( + +
+ {needLogin && } + + + + + + + + + + +
+
+ ) +}) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 50d98ef2..cfb0f824 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -339,10 +339,8 @@ export const ToolbarClass = inject( > ) const intent = status === -1 ? 'danger' : 'none' - const count = selected.length - const isRoot = fileCache.isRoot() - console.log(fileCache) + return ( diff --git a/src/components/__tests__/SideView.test.tsx b/src/components/__tests__/SideView.test.tsx new file mode 100644 index 00000000..9a4e5161 --- /dev/null +++ b/src/components/__tests__/SideView.test.tsx @@ -0,0 +1,92 @@ +/** + * @jest-environment jsdom + */ +import React from 'react' + +import { render, screen, vol, t, waitFor } from 'rtl' +import { ViewState } from '$src/state/viewState' +import { AppState } from '$src/state/appState' +import { filterFiles, filterDirs } from '$src/utils/fileUtils' +import { SideView } from '../SideView' + +describe('SideView', () => { + const state = new AppState() + const options = { + providerProps: { + appState: state, + settingsState: state.settingsState, + }, + } + + const buildStatusBarText = () => { + const cache = PROPS.viewState.getVisibleCache() + const files = filterFiles(cache.files, cache.showHiddenFiles).length + const folders = filterDirs(cache.files, cache.showHiddenFiles).length + + return `${t('STATUS.FILES', { count: files })}, ${t('STATUS.FOLDERS', { + count: folders, + })}` + } + + const PROPS = { + hide: false, + onPaste: jest.fn(), + viewState: undefined as ViewState, + } + + beforeEach(async () => { + const appState = new AppState() + const { providerProps } = options + providerProps.appState = appState + providerProps.settingsState = appState.settingsState + await options.providerProps.appState.loadSettingsAndPrepareViews() + PROPS.viewState = appState.winStates[0].getActiveView() + + jest.clearAllMocks() + }) + + it('should render SideView', async () => { + render(, options) + + // // tab + expect(screen.getByText('virtual')).toBeInTheDocument() + + // // toolbar + expect(screen.getByDisplayValue('/virtual')).toBeInTheDocument() + + // // files loader + expect(screen.getByTestId('files-loader-0')).toBeInTheDocument() + + // // filetable + const files = Object.keys(vol.toJSON()) + .map((path) => path.replace('/virtual/', '')) + .filter((file) => !file.startsWith('.')) + for (const file of files) { + expect(await screen.findByText(file)).toBeInTheDocument() + } + + // status bar + expect(screen.getByDisplayValue(buildStatusBarText())).toBeInTheDocument() + + // overlay d&d + expect(screen.getByTestId('overlay')).toBeInTheDocument() + }) + + it('should show files loader when files are being loaded then hide it', async () => { + render(, options) + + const loader = screen.getByTestId('files-loader-0') + + // loader is active when loading + expect(loader.classList.contains('active')).toBe(true) + + // ... and is inactive when loading is over + await waitFor(() => expect(loader.classList.contains('active')).toBe(false)) + }) + + it('should hide sideview if hide prop is true', () => { + const { container } = render(, options) + + expect(container.firstElementChild.classList.contains('hidden')).toBe(true) + }) +}) diff --git a/src/components/__tests__/Statusbar.test.tsx b/src/components/__tests__/Statusbar.test.tsx index 515dcc30..0dc20f61 100644 --- a/src/components/__tests__/Statusbar.test.tsx +++ b/src/components/__tests__/Statusbar.test.tsx @@ -6,7 +6,6 @@ import { screen, setup, render, t } from 'rtl' import { Statusbar } from '../Statusbar' import { filterFiles, filterDirs } from '$src/utils/fileUtils' import { ViewState } from '$src/state/viewState' -import { vol } from 'memfs' describe('Statusbar', () => { const options = { diff --git a/src/components/__tests__/TabList.test.tsx b/src/components/__tests__/TabList.test.tsx index 0c7c9bb4..7ea12d7d 100644 --- a/src/components/__tests__/TabList.test.tsx +++ b/src/components/__tests__/TabList.test.tsx @@ -7,26 +7,15 @@ import { within } from '@testing-library/dom' import { screen, setup, render, t, isSelected } from 'rtl' import { ViewState } from '$src/state/viewState' import { ipcRenderer } from 'electron' -import { vol } from 'memfs' import { TabList } from '../TabList' import { SettingsState } from '$src/state/settingsState' -import { Classes } from '@blueprintjs/core' describe('TabList', () => { const settingsState = { defaultFolder: '/virtual', } - vol.fromJSON( - { - file1: '', - file2: '', - dir1: null, - }, - '/virtual', - ) - const options = { providerProps: { viewState: new ViewState(0), diff --git a/src/components/dialogs/MakedirDialog.tsx b/src/components/dialogs/MakedirDialog.tsx index 3f700306..b76f9c24 100644 --- a/src/components/dialogs/MakedirDialog.tsx +++ b/src/components/dialogs/MakedirDialog.tsx @@ -51,7 +51,7 @@ const MakedirDialog = ({ onValidation, onClose, isOpen, parentPath }: MakedirPro const onKeyDown: React.KeyboardEventHandler = (e): void => { if (e.key === optionKey) { setIsOptionKeyPressed(true) - } else if (e.key === Keys.ENTER && isOptionKeyPressed) { + } else if (e.key === Keys.ENTER) { isValid && path.length && onCreate() } } diff --git a/src/components/dialogs/__tests__/makedirDialog.test.tsx b/src/components/dialogs/__tests__/makedirDialog.test.tsx index a71affe0..6444a0b7 100644 --- a/src/components/dialogs/__tests__/makedirDialog.test.tsx +++ b/src/components/dialogs/__tests__/makedirDialog.test.tsx @@ -93,4 +93,15 @@ describe('MakedirDialog', () => { expect(PROPS.onClose).toHaveBeenCalledWith(folderName, true) }) + + it('should call onClose with path when pressing Enter', async () => { + const folderName = 'foo' + const { user } = setup() + + screen.getByPlaceholderText(LOCALE_EN.DIALOG.MAKEDIR.NAME).focus() + await user.paste(folderName) + await user.keyboard('{Enter>}') + + expect(PROPS.onClose).toHaveBeenCalledWith(folderName, false) + }) }) diff --git a/src/components/filetable/index.tsx b/src/components/filetable/index.tsx index ed9c642f..9e8a7b0a 100644 --- a/src/components/filetable/index.tsx +++ b/src/components/filetable/index.tsx @@ -39,8 +39,8 @@ import { FileContextMenu } from '$src/components/menus/FileContextMenu' import Keys from '$src/constants/keys' import { TypeIcons } from '$src/constants/icons' -require('react-virtualized/styles.css') -require('$src/css/filetable.css') +import 'react-virtualized/styles.css' +import '$src/css/filetable.css' const CLICK_DELAY = 400 const SCROLL_DEBOUNCE = 50 @@ -124,8 +124,6 @@ export class FileTableClass extends React.Component { // we re-render them after language has changed otherwise FileList // gets re-rendered with the wrong language after language has been changed this.bindLanguageChange() - - // this.cache.cd(this.cache.path); } get cache(): FileState { diff --git a/src/state/viewState.ts b/src/state/viewState.ts index 107d4123..c80ab6cd 100644 --- a/src/state/viewState.ts +++ b/src/state/viewState.ts @@ -52,6 +52,7 @@ export class ViewState { setVisibleCache(index: number): void { const previous = this.getVisibleCache() const next = this.caches[index] + // do nothing if previous === next if (next && previous !== next) { if (previous) { diff --git a/src/utils/test/rtl.tsx b/src/utils/test/rtl.tsx index 3667ac78..0544c76a 100644 --- a/src/utils/test/rtl.tsx +++ b/src/utils/test/rtl.tsx @@ -143,6 +143,19 @@ configure({ testIdAttribute: 'id', }) +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight') +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth') + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 }) + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 }) +}) + +afterAll(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight) + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth) +}) + // disable safeDescriptors so that we can spy on mobx actions configureMobx({ safeDescriptors: false }) @@ -169,4 +182,4 @@ vol.fromJSON( export * from '@testing-library/react' // override render method -export { customRender as render, setup, withMarkup, isSelected, LOCALE_EN, userEvent, t, i18next, wait } +export { customRender as render, setup, withMarkup, isSelected, LOCALE_EN, userEvent, t, i18next, wait, vol }