diff --git a/packages/code-studio/src/dashboard/panels/NotebookPanel.jsx b/packages/code-studio/src/dashboard/panels/NotebookPanel.jsx index 6c68531fc2..a25214a71b 100644 --- a/packages/code-studio/src/dashboard/panels/NotebookPanel.jsx +++ b/packages/code-studio/src/dashboard/panels/NotebookPanel.jsx @@ -580,6 +580,7 @@ class NotebookPanel extends Component { const { fileMetadata } = this.state; if (fileMetadata.id === oldName) { this.setState({ fileMetadata: { id: newName, itemName: newName } }); + this.debouncedSavePanelState(); } } diff --git a/packages/code-studio/src/styleguide/Grids.jsx b/packages/code-studio/src/styleguide/Grids.jsx index 2a4e93148b..237549cafe 100644 --- a/packages/code-studio/src/styleguide/Grids.jsx +++ b/packages/code-studio/src/styleguide/Grids.jsx @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import { Grid, MockGridModel, MockTreeGridModel } from '@deephaven/grid'; -import { IrisGrid } from '@deephaven/iris-grid/dist/IrisGrid'; +import IrisGrid from '@deephaven/iris-grid/dist/IrisGrid'; import MockIrisGridTreeModel from './MockIrisGridTreeModel'; class Grids extends PureComponent { diff --git a/packages/code-studio/src/styleguide/MockIrisGridTreeModel.js b/packages/code-studio/src/styleguide/MockIrisGridTreeModel.js index ff73b9c4ef..413069e9f8 100644 --- a/packages/code-studio/src/styleguide/MockIrisGridTreeModel.js +++ b/packages/code-studio/src/styleguide/MockIrisGridTreeModel.js @@ -41,6 +41,18 @@ class MockIrisGridTreeModel extends IrisGridModel { return true; } + get pendingRowCount() { + return 0; + } + + set pendingRowCount(count) {} + + get pendingDataMap() { + return new Map(); + } + + set pendingDataMap(value) {} + textForCell(column, row) { return ( this.editedData[column]?.[row] ?? this.model.textForCell(column, row) diff --git a/packages/code-studio/src/styleguide/StyleGuideInit.jsx b/packages/code-studio/src/styleguide/StyleGuideInit.jsx index c2ec5ae49d..70ac2a84a6 100644 --- a/packages/code-studio/src/styleguide/StyleGuideInit.jsx +++ b/packages/code-studio/src/styleguide/StyleGuideInit.jsx @@ -6,7 +6,7 @@ import { setWorkspace as setWorkspaceAction, } from '@deephaven/redux'; import StyleGuide from './StyleGuide'; -import WorkspaceStorage from '../dashboard/WorkspaceStorage'; +import LocalWorkspaceStorage from '../dashboard/LocalWorkspaceStorage'; /** * Initialize data needed for the styleguide @@ -15,7 +15,7 @@ const StyleGuideInit = props => { const { workspace, setWorkspace } = props; useEffect(() => { - setWorkspace(WorkspaceStorage.makeDefaultWorkspace()); + setWorkspace(LocalWorkspaceStorage.makeDefaultWorkspace()); }, [setWorkspace]); return <>{workspace && }; diff --git a/packages/components/src/SingleClickItemList.test.tsx b/packages/components/src/SingleClickItemList.test.tsx new file mode 100644 index 0000000000..2a1e5dad85 --- /dev/null +++ b/packages/components/src/SingleClickItemList.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import SingleClickItemList from './SingleClickItemList'; + +function makeItems(count = 20) { + const items = []; + + for (let i = 0; i < count; i += 1) { + items.push({ itemName: `${i}` }); + } + + return items; +} + +function makeItemList({ + itemCount = 100, + rowHeight = 20, + offset = 0, + items = makeItems(), + onSelect = jest.fn(), + onSelectionChange = jest.fn(), + onViewportChange = jest.fn(), + isMultiSelect = true, +} = {}) { + return mount( + + ); +} + +function clickItem(itemList: ReactWrapper, index: number, options = {}) { + itemList.find('.item-list-item').at(index).simulate('click', options); +} + +it('mounts and unmounts properly', () => { + const itemList = makeItemList(); + itemList.unmount(); +}); + +describe('mouse', () => { + it('Sends the proper signal when an item is clicked', () => { + const onSelect = jest.fn(); + const itemList = makeItemList({ onSelect }); + + clickItem(itemList, 3); + + expect(onSelect).toHaveBeenCalledWith(3); + + itemList.unmount(); + }); + + it('handles shift+click properly', () => { + const onSelectionChange = jest.fn(); + const itemList = makeItemList({ onSelectionChange }); + + clickItem(itemList, 3); + + expect(onSelectionChange).toHaveBeenCalledWith([[3, 3]], 3); + onSelectionChange.mockClear(); + + clickItem(itemList, 6, { shiftKey: true }); + + expect(onSelectionChange).toHaveBeenCalledWith([[3, 6]], 3); + }); +}); + +it('handles keyboard up and down properly', () => { + const itemList = makeItemList(); + + expect(itemList.state('keyboardIndex')).toBe(null); + + itemList.simulate('keydown', { key: 'ArrowDown' }); + + expect(itemList.state('keyboardIndex')).toBe(0); + + itemList.simulate('keydown', { key: 'ArrowDown' }); + itemList.simulate('keydown', { key: 'ArrowDown' }); + itemList.simulate('keydown', { key: 'ArrowDown' }); + + expect(itemList.state('keyboardIndex')).toBe(3); + + itemList.simulate('keydown', { key: 'ArrowUp' }); + itemList.simulate('keydown', { key: 'ArrowUp' }); + + expect(itemList.state('keyboardIndex')).toBe(1); + + itemList.unmount(); +}); diff --git a/packages/components/src/SingleClickItemList.tsx b/packages/components/src/SingleClickItemList.tsx index 4b1819d5e7..a83511c847 100644 --- a/packages/components/src/SingleClickItemList.tsx +++ b/packages/components/src/SingleClickItemList.tsx @@ -122,14 +122,11 @@ export class SingleClickItemList< constructor(props: SingleClickItemListProps) { super(props); - this.handleItemBlur = this.handleItemBlur.bind(this); - this.handleItemFocus = this.handleItemFocus.bind(this); this.handleItemClick = this.handleItemClick.bind(this); this.handleItemDragStart = this.handleItemDragStart.bind(this); this.handleItemDragOver = this.handleItemDragOver.bind(this); this.handleItemDragEnd = this.handleItemDragEnd.bind(this); this.handleItemDrop = this.handleItemDrop.bind(this); - this.handleItemMouseDown = this.handleItemMouseDown.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleScroll = this.handleScroll.bind(this); @@ -247,9 +244,6 @@ export class SingleClickItemList< onDragOver={this.handleItemDragOver} onDragEnd={this.handleItemDragEnd} onDrop={this.handleItemDrop} - onMouseDown={this.handleItemMouseDown} - onFocus={this.handleItemFocus} - onBlur={this.handleItemBlur} onKeyboardSelect={onKeyboardSelect} disableSelect={disableSelect} isDraggable={isDraggable} @@ -348,7 +342,7 @@ export class SingleClickItemList< ContextActionUtils.isModifierKeyDown(e) ); - if (isMultiSelect && e.shiftKey) { + if (isMultiSelect && e.shiftKey && oldFocus != null) { const range: Range = [ Math.min(oldFocus ?? index, index), Math.max(oldFocus ?? index, index), @@ -410,7 +404,7 @@ export class SingleClickItemList< draggedRanges, index ); - log.debug('handleItemDragOver', index); + log.debug('handleItemDragOver', index, isDropTargetValid); return { dragOverIndex: index, isDropTargetValid, @@ -438,52 +432,6 @@ export class SingleClickItemList< onDrop(draggedRanges, index); } - handleItemMouseDown( - index: number, - e: React.MouseEvent - ): void { - const { selectedRanges } = this.state; - log.debug('handleItemMouseDown', index, e.button); - if (e.button === 2) { - this.setKeyboardIndex(index); - this.setShiftRange(null); - if (!this.getItemSelected(index, selectedRanges)) { - this.deselectAll(); - this.selectItem(index); - } - } - } - - handleItemBlur( - itemIndex: number, - { currentTarget, relatedTarget }: React.FocusEvent - ): void { - log.debug2('item blur', itemIndex, currentTarget, relatedTarget); - if ( - !relatedTarget || - (relatedTarget instanceof Element && - !this.list.current?.contains(relatedTarget) && - !relatedTarget.classList?.contains('context-menu-container')) - ) { - // Next focused element is outside of the SingleClickItemList - this.setKeyboardIndex(null); - } - } - - handleItemFocus( - itemIndex: number, - e: React.FocusEvent - ): void { - log.debug2('item focus', itemIndex, e.target); - this.setState(state => { - const { keyboardIndex } = state; - if (keyboardIndex !== itemIndex) { - return { keyboardIndex: itemIndex }; - } - return null; - }); - } - handleKeyDown(e: React.KeyboardEvent): void { const { itemCount, isMultiSelect, onSelect } = this.props; const { keyboardIndex: oldFocus } = this.state; @@ -679,6 +627,7 @@ export class SingleClickItemList< draggedRanges, dragOverIndex, } = this.state; + const itemElements = this.getCachedItems( items, rowHeight, diff --git a/packages/file-explorer/package-lock.json b/packages/file-explorer/package-lock.json index 50274def10..73989cf634 100644 --- a/packages/file-explorer/package-lock.json +++ b/packages/file-explorer/package-lock.json @@ -1515,6 +1515,40 @@ "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==", "dev": true }, + "@fortawesome/fontawesome-common-types": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.35.tgz", + "integrity": "sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw==", + "dev": true + }, + "@fortawesome/fontawesome-svg-core": { + "version": "1.2.35", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.35.tgz", + "integrity": "sha512-uLEXifXIL7hnh2sNZQrIJWNol7cTVIzwI+4qcBIq9QWaZqUblm0IDrtSqbNg+3SQf8SMGHkiSigD++rHmCHjBg==", + "dev": true, + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.35" + } + }, + "@fortawesome/react-fontawesome": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.14.tgz", + "integrity": "sha512-4wqNb0gRLVaBm/h+lGe8UfPPivcbuJ6ecI4hIgW0LjI7kzpYB9FkN0L9apbVzg+lsBdcTf0AlBtODjcSX5mmKA==", + "dev": true, + "requires": { + "prop-types": "^15.7.2" + } + }, + "@hypnosphi/create-react-context": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz", + "integrity": "sha512-V1klUed202XahrWJLLOT3EXNeCpFHCcJntdFGI15ntCwau+jfT386w7OFTMaCqOgXUH1fa0w/I1oZs+i/Rfr0A==", + "dev": true, + "requires": { + "gud": "^1.0.0", + "warning": "^4.0.3" + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -5861,6 +5895,15 @@ "yallist": "^3.0.2" } }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6485,6 +6528,17 @@ "mkdirp": "^0.5.1", "rimraf": "^2.5.4", "run-queue": "^1.0.0" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } } }, "copy-descriptor": { @@ -7004,6 +7058,20 @@ "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", "dev": true }, + "deep-equal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", + "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "dev": true, + "requires": { + "is-arguments": "^1.0.4", + "is-date-object": "^1.0.1", + "is-regex": "^1.0.4", + "object-is": "^1.0.1", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.2.0" + } + }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -7196,6 +7264,15 @@ "utila": "~0.4" } }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.1.2" + } + }, "dom-serializer": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", @@ -8117,6 +8194,17 @@ "klaw": "^1.0.0", "path-is-absolute": "^1.0.0", "rimraf": "^2.2.8" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } } } } @@ -11622,7 +11710,8 @@ "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "js-yaml": { "version": "3.14.1", @@ -11867,6 +11956,12 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=", + "dev": true + }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -11877,6 +11972,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -12325,6 +12421,17 @@ "mkdirp": "^0.5.1", "rimraf": "^2.5.4", "run-queue": "^1.0.3" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } } }, "ms": { @@ -12665,7 +12772,8 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true }, "object-copy": { "version": "0.1.0", @@ -12704,6 +12812,16 @@ "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==", "dev": true }, + "object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -13285,6 +13403,12 @@ "@babel/runtime": "^7.14.0" } }, + "popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", + "dev": true + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -13583,6 +13707,7 @@ "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -14108,7 +14233,8 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true }, "react-lifecycles-compat": { "version": "3.0.4", @@ -14179,6 +14305,48 @@ "use-latest": "^1.0.0" } }, + "react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "dev": true, + "requires": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + } + }, + "reactstrap": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-8.9.0.tgz", + "integrity": "sha512-pmf33YjpNZk1IfrjqpWCUMq9hk6GzSnMWBAofTBNIRJQB1zQ0Au2kzv3lPUAFsBYgWEuI9iYa/xKXHaboSiMkQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "classnames": "^2.2.3", + "prop-types": "^15.5.8", + "react-popper": "^1.3.6", + "react-transition-group": "^2.3.1" + }, + "dependencies": { + "react-popper": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.11.tgz", + "integrity": "sha512-VSA/bS+pSndSF2fiasHK/PTEEAyOpX60+H5EPAjoArr8JGm+oihu4UbrqcEBpQibJxBVCpYyjAX7abJ+7DoYVg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.1.2", + "@hypnosphi/create-react-context": "^0.3.1", + "deep-equal": "^1.1.1", + "popper.js": "^1.14.4", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.7", + "warning": "^4.0.2" + } + } + } + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -14589,9 +14757,9 @@ "dev": true }, "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "dev": true, "requires": { "glob": "^7.1.3" @@ -16115,6 +16283,12 @@ "mime-types": "~2.1.24" } }, + "typed-styles": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz", + "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==", + "dev": true + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/packages/file-explorer/package.json b/packages/file-explorer/package.json index df4a5f2b9e..03ff3569e9 100644 --- a/packages/file-explorer/package.json +++ b/packages/file-explorer/package.json @@ -47,6 +47,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.32", "@fortawesome/react-fontawesome": "^0.1.12", "classnames": "^2.3.1", + "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", "react": "^16.0.0", "react-dom": "^16.0.0", @@ -75,6 +76,7 @@ "gh-pages": "^2.2.0", "identity-obj-proxy": "^3.0.0", "jest": "26.6.0", + "lodash.throttle": "^4.1.1", "npm-run-all": "^4.1.5", "prop-types": "^15.7.2", "react": "^16.14.0", diff --git a/packages/file-explorer/src/FileExplorer.scss b/packages/file-explorer/src/FileExplorer.scss index 059ab655bb..5df0916338 100644 --- a/packages/file-explorer/src/FileExplorer.scss +++ b/packages/file-explorer/src/FileExplorer.scss @@ -1,4 +1,9 @@ +@import '../../components/scss/custom.scss'; + +$file-explorer-bg: $gray-900; + .file-explorer { + background: $file-explorer-bg; position: relative; flex-grow: 1; diff --git a/packages/file-explorer/src/FileExplorer.tsx b/packages/file-explorer/src/FileExplorer.tsx index 08b5e209cb..349b74490b 100644 --- a/packages/file-explorer/src/FileExplorer.tsx +++ b/packages/file-explorer/src/FileExplorer.tsx @@ -100,6 +100,27 @@ export const FileExplorer = React.forwardRef( setItemsToDelete([]); }, []); + const handleMove = useCallback( + (files: FileListItem[], path: string) => { + const filesToMove = FileUtils.reducePaths( + files.map(file => file.filename) + ); + + filesToMove.forEach(file => { + const newFile = `${path}${FileUtils.getBaseName(file)}`; + storage + .moveFile(file, newFile) + .then(() => { + // Each moved file triggers a rename so parent knows something has happened + // We signal each individually if for some reason there's an error moving one of the files + onRename(file, newFile); + }) + .catch(handleError); + }); + }, + [handleError, onRename, storage] + ); + const handleRename = useCallback( (item: FileListItem, newName: string) => { let name = item.filename; @@ -161,6 +182,7 @@ export const FileExplorer = React.forwardRef( ref={fileListContainer} isMultiSelect={isMultiSelect} showContextMenu + onMove={handleMove} onDelete={handleDelete} onRename={handleRename} onSelect={onSelect} diff --git a/packages/file-explorer/src/FileList.scss b/packages/file-explorer/src/FileList.scss index ce52f87e86..6823653624 100644 --- a/packages/file-explorer/src/FileList.scss +++ b/packages/file-explorer/src/FileList.scss @@ -3,6 +3,7 @@ $depth-line-color: $gray-600; $depth-margin: 5px; $depth-indentation: 9px; +$item-list-color: $text-muted; .file-list { position: absolute; @@ -10,6 +11,7 @@ $depth-indentation: 9px; bottom: 0; left: 0; right: 0; + color: $item-list-color; .file-list-depth-line { margin-left: $depth-margin; @@ -17,4 +19,12 @@ $depth-indentation: 9px; height: 100%; border-left: 1px solid $depth-line-color; } + + .item-list-item { + padding: 0 $input-btn-padding-x; + } + + .item-icon { + margin-right: $spacer-1; + } } diff --git a/packages/file-explorer/src/FileList.tsx b/packages/file-explorer/src/FileList.tsx index 8740af0da9..6259bef6a9 100644 --- a/packages/file-explorer/src/FileList.tsx +++ b/packages/file-explorer/src/FileList.tsx @@ -61,10 +61,38 @@ export interface FileListProps { rowHeight?: number; } -export const DEFAULT_RENDER_ITEM = ( +export const getPathFromItem = (file: FileListItem): string => + isDirectory(file) + ? FileUtils.makePath(file.filename) + : FileUtils.getPath(file.filename); + +export const DEFAULT_ROW_HEIGHT = 26; + +export const renderFileListItem = ( props: SingleClickRenderItemProps & { children?: JSX.Element } ): JSX.Element => { - const { children, isDragged, isSelected, item } = props; + const { + children, + isDragged, + isDropTargetValid, + isSelected, + item, + dragOverItem, + } = props; + const { isDragInProgress } = props; + const itemPath = getPathFromItem(item); + const dropTargetPath = + isDragInProgress && dragOverItem ? getPathFromItem(dragOverItem) : null; + + const isExactDropTarget = + isDragInProgress && + isDropTargetValid && + isDirectory(item) && + dropTargetPath === FileUtils.makePath(item.filename); + const isInDropTarget = + isDragInProgress && isDropTargetValid && dropTargetPath === itemPath; + const isInvalidDropTarget = + isDragInProgress && !isDropTargetValid && dropTargetPath === itemPath; const icon = getItemIcon(item); const depth = FileUtils.getDepth(item.filename); @@ -79,6 +107,9 @@ export const DEFAULT_RENDER_ITEM = (
@@ -120,8 +151,8 @@ export const FileList = React.forwardRef( onMove, onSelect, onSelectionChange = () => undefined, - renderItem = DEFAULT_RENDER_ITEM, - rowHeight = SingleClickItemList.DEFAULT_ROW_HEIGHT, + renderItem = renderFileListItem, + rowHeight = DEFAULT_ROW_HEIGHT, } = props; const [loadedViewport, setLoadedViewport] = useState( () => ({ @@ -159,27 +190,50 @@ export const FileList = React.forwardRef( [loadedViewport] ); - const handleItemDrop = useCallback( - (ranges: Range[], targetIndex: number) => { - log.debug2('handleItemDrop', ranges, targetIndex); - + const getMoveOperation = useCallback( + ( + ranges: Range[], + targetIndex: number + ): { files: FileListItem[]; targetPath: string } => { const draggedItems = getSelectedItems(ranges); const [targetItem] = getSelectedItems([[targetIndex, targetIndex]]); if (draggedItems.length === 0 || !targetItem) { - return; + throw new Error('No items to move'); } - const targetPath = FileUtils.getPath(targetItem.filename); + const targetPath = isDirectory(targetItem) + ? FileUtils.makePath(targetItem.filename) + : FileUtils.getPath(targetItem.filename); if ( - draggedItems.some(({ filename }) => filename.startsWith(targetPath)) + draggedItems.some( + ({ filename }) => FileUtils.getPath(filename) === targetPath + ) ) { - // Cannot drop if target is one of the dragged items - // or at least one of the dragged items is already in the target folder - return; + // Cannot drop if target is one of the dragged items is already in the target folder + throw new Error('File already in the destination folder'); } - onMove(draggedItems, targetPath); + return { files: draggedItems, targetPath }; }, - [getSelectedItems, onMove] + [getSelectedItems] + ); + + const handleItemDrop = useCallback( + (ranges: Range[], targetIndex: number) => { + log.debug2('handleItemDrop', ranges, targetIndex); + + try { + const { files, targetPath } = getMoveOperation(ranges, targetIndex); + onMove(files, targetPath); + itemList.current?.deselectAll(); + itemList.current?.selectItem(targetIndex); + itemList.current?.setKeyboardIndex(null); + itemList.current?.setShiftRange(null); + itemList.current?.focus(); + } catch (e) { + log.error('Unable to complete move', e); + } + }, + [getMoveOperation, onMove] ); const handleItemSelect = useCallback( @@ -214,10 +268,19 @@ export const FileList = React.forwardRef( setViewport({ top, bottom }); }, []); - const handleValidateDropTarget = useCallback(() => { - log.debug('handleValidateDropTarget'); - return false; - }, []); + const handleValidateDropTarget = useCallback( + (draggedRanges: Range[], targetIndex: number) => { + try { + getMoveOperation(draggedRanges, targetIndex); + log.debug('handleValidateDropTarget true'); + return true; + } catch (e) { + log.debug('handleValidateDropTarget false'); + return false; + } + }, + [getMoveOperation] + ); useEffect(() => { log.debug('updating table viewport', viewport); @@ -261,8 +324,7 @@ export const FileList = React.forwardRef( onViewportChange={handleViewportChange} renderItem={renderItem} rowHeight={rowHeight} - // TODO: web-client-ui#86, re-enable drag and drop to move - // isDraggable + isDraggable isMultiSelect={isMultiSelect} validateDropTarget={handleValidateDropTarget} /> diff --git a/packages/file-explorer/src/FileListContainer.tsx b/packages/file-explorer/src/FileListContainer.tsx index 3651b6955e..f7a5ac5fd6 100644 --- a/packages/file-explorer/src/FileListContainer.tsx +++ b/packages/file-explorer/src/FileListContainer.tsx @@ -1,7 +1,6 @@ import { ContextAction, ContextActions, - SingleClickItemList, SingleClickRenderItemProps, } from '@deephaven/components'; import React, { @@ -13,9 +12,10 @@ import React, { useState, } from 'react'; import FileList, { - DEFAULT_RENDER_ITEM, + renderFileListItem, FileListItem, UpdateableComponent, + DEFAULT_ROW_HEIGHT, } from './FileList'; import { FileStorageTable, isDirectory } from './FileStorage'; import SHORTCUTS from './FileExplorerShortcuts'; @@ -58,7 +58,7 @@ export const FileListContainer = React.forwardRef( onRename, onSelect, table, - rowHeight = SingleClickItemList.DEFAULT_ROW_HEIGHT, + rowHeight = DEFAULT_ROW_HEIGHT, validateRename = () => Promise.resolve(), } = props; const [renameItem, setRenameItem] = useState(); @@ -202,7 +202,7 @@ export const FileListContainer = React.forwardRef( (itemProps: SingleClickRenderItemProps): JSX.Element => { const { item } = itemProps; if (renameItem && renameItem.filename === item.filename) { - return DEFAULT_RENDER_ITEM({ + return renderFileListItem({ ...itemProps, children: ( { testValidFileName('.extension-no-name'); }); }); + +it('reduces a selection', () => { + function testReduction(paths: string[], expectedPaths: string[]) { + expect(FileUtils.reducePaths(paths)).toEqual(expectedPaths); + } + + testReduction([], []); + testReduction(['/'], ['/']); + testReduction(['/', '/foo/', '/foo/bar.txt'], ['/']); + testReduction(['/foo/', '/foo/bar.txt'], ['/foo/']); + testReduction( + ['/foo/baz.txt', '/foo/bar.txt'], + ['/foo/baz.txt', '/foo/bar.txt'] + ); +}); diff --git a/packages/file-explorer/src/FileUtils.ts b/packages/file-explorer/src/FileUtils.ts index 6e6c6bb517..34b788e97c 100644 --- a/packages/file-explorer/src/FileUtils.ts +++ b/packages/file-explorer/src/FileUtils.ts @@ -204,6 +204,19 @@ export class FileUtils { ); } } + + /** + * Reduce the provided paths to the minimum selection. + * Removes any nested files or directories if a parent is already selected. + * @param paths The paths to reduce + */ + static reducePaths(paths: string[]): string[] { + const folders = paths.filter(path => FileUtils.isPath(path)); + return paths.filter( + path => + !folders.some(folder => path !== folder && path.startsWith(folder)) + ); + } } export default FileUtils; diff --git a/packages/file-explorer/src/WebdavFileStorage.ts b/packages/file-explorer/src/WebdavFileStorage.ts index 18211d0790..7ff0cf7794 100644 --- a/packages/file-explorer/src/WebdavFileStorage.ts +++ b/packages/file-explorer/src/WebdavFileStorage.ts @@ -1,5 +1,7 @@ /* eslint-disable class-methods-use-this */ + import { FileStat, WebDAVClient } from 'webdav/web'; +import throttle from 'lodash.throttle'; import FileNotFoundError from './FileNotFoundError'; import FileStorage, { File, @@ -10,6 +12,8 @@ import FileUtils from './FileUtils'; import WebdavFileStorageTable from './WebdavFileStorageTable'; export class WebdavFileStorage implements FileStorage { + private static readonly REFRESH_THROTTLE = 150; + readonly client; private tables = [] as WebdavFileStorageTable[]; @@ -83,9 +87,9 @@ export class WebdavFileStorage implements FileStorage { } } - private refreshTables(): void { + private refreshTables = throttle(() => { this.tables.every(table => table.refresh().catch(() => undefined)); - } + }, WebdavFileStorage.REFRESH_THROTTLE); } export default WebdavFileStorage;