--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { configure, shallow, mount } from "enzyme";
+import { WithStyles } from "@material-ui/core";
+import * as Adapter from "enzyme-adapter-react-16";
+import { TreeItem, TreeItemStatus } from '../tree/tree';
+import { FileTreeData } from '../file-tree/file-tree-data';
+import { CollectionFileType } from "../../models/collection-file";
+import { CollectionPanelFilesComponent, CollectionPanelFilesProps, CssRules } from './collection-panel-files';
+import { SearchInput } from '../search-input/search-input';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('~/components/file-tree/file-tree', () => ({
+ FileTree: () => 'FileTree',
+}));
+
+describe('<CollectionPanelFiles />', () => {
+ let props: CollectionPanelFilesProps & WithStyles<CssRules>;
+
+ beforeEach(() => {
+ props = {
+ classes: {} as Record<CssRules, string>,
+ items: [],
+ isWritable: true,
+ isLoading: false,
+ tooManyFiles: false,
+ onUploadDataClick: jest.fn(),
+ onSearchChange: jest.fn(),
+ onItemMenuOpen: jest.fn(),
+ onOptionsMenuOpen: jest.fn(),
+ onSelectionToggle: jest.fn(),
+ onCollapseToggle: jest.fn(),
+ onFileClick: jest.fn(),
+ loadFilesFunc: jest.fn(),
+ currentItemUuid: '',
+ };
+ });
+
+ it('renders properly', () => {
+ // when
+ const wrapper = shallow(<CollectionPanelFilesComponent {...props} />);
+
+ // then
+ expect(wrapper).not.toBeUndefined();
+ });
+
+ it('filters out files', () => {
+ // given
+ const searchPhrase = 'test';
+ const items: Array<TreeItem<FileTreeData>> = [
+ {
+ data: {
+ url: '',
+ type: CollectionFileType.DIRECTORY,
+ name: 'test',
+ },
+ id: '1',
+ open: true,
+ active: true,
+ status: TreeItemStatus.LOADED,
+ },
+ {
+ data: {
+ url: '',
+ type: CollectionFileType.FILE,
+ name: 'test123',
+ },
+ id: '2',
+ open: true,
+ active: true,
+ status: TreeItemStatus.LOADED,
+ },
+ {
+ data: {
+ url: '',
+ type: CollectionFileType.FILE,
+ name: 'another-file',
+ },
+ id: '3',
+ open: true,
+ active: true,
+ status: TreeItemStatus.LOADED,
+ }
+ ];
+
+ // setup
+ props.items = items;
+ const wrapper = mount(<CollectionPanelFilesComponent {...props} />);
+ wrapper.find(SearchInput).simulate('change', { target: { value: searchPhrase } });
+
+ // when
+ setTimeout(() => { // we have to use set timeout because of the debounce
+ expect(wrapper.find('FileTree').prop('items'))
+ .toEqual([
+ {
+ data: { url: '', type: 'directory', name: 'test' },
+ id: '1',
+ open: true,
+ active: true,
+ status: 'loaded'
+ },
+ {
+ data: { url: '', type: 'file', name: 'test123' },
+ id: '2',
+ open: true,
+ active: true,
+ status: 'loaded'
+ }
+ ]);
+ }, 0);
+ });
+});
\ No newline at end of file
import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, Card, Button, Tooltip, CircularProgress } from '@material-ui/core';
import { CustomizeTableIcon } from '~/components/icon/icon';
import { DownloadIcon } from '~/components/icon/icon';
+import { SearchInput } from '../search-input/search-input';
export interface CollectionPanelFilesProps {
items: Array<TreeItem<FileTreeData>>;
isLoading: boolean;
tooManyFiles: boolean;
onUploadDataClick: () => void;
+ onSearchChange: (searchValue: string) => void;
onItemMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>, isWritable: boolean) => void;
onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => void;
onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
currentItemUuid?: string;
}
-type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader' | 'uploadIcon' | 'button' | 'centeredLabel';
+export type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader' | 'uploadIcon' | 'button' | 'centeredLabel' | 'cardHeaderContent' | 'cardHeaderContentTitle';
const styles: StyleRulesCallback<CssRules> = theme => ({
root: {
},
cardSubheader: {
paddingTop: 0,
- paddingBottom: 0
+ paddingBottom: 0,
+ minHeight: 8 * theme.spacing.unit,
+ },
+ cardHeaderContent: {
+ display: 'flex',
+ paddingRight: 2 * theme.spacing.unit,
+ justifyContent: 'space-between',
+ },
+ cardHeaderContentTitle: {
+ paddingLeft: theme.spacing.unit,
+ paddingTop: 2 * theme.spacing.unit,
+ paddingRight: 2 * theme.spacing.unit,
},
nameHeader: {
marginLeft: '75px'
},
button: {
marginRight: -theme.spacing.unit,
- marginTop: '0px'
+ marginTop: '8px'
},
centeredLabel: {
fontSize: '0.875rem',
},
});
-export const CollectionPanelFiles =
- withStyles(styles)(
- ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes,
- isWritable, isLoading, tooManyFiles, loadFilesFunc, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
- <Card data-cy='collection-files-panel' className={classes.root}>
- <CardHeader
- title="Files"
- className={classes.cardSubheader}
- classes={{ action: classes.button }}
- action={<>
- {isWritable &&
- <Button
- data-cy='upload-button'
- onClick={onUploadDataClick}
- variant='contained'
- color='primary'
- size='small'>
- <DownloadIcon className={classes.uploadIcon} />
- Upload data
- </Button>}
- {!tooManyFiles &&
- <Tooltip title="More options" disableFocusListener>
- <IconButton
- data-cy='collection-files-panel-options-btn'
- onClick={(ev) => onOptionsMenuOpen(ev, isWritable)}>
- <CustomizeTableIcon />
- </IconButton>
- </Tooltip>}
- </>
- } />
- { tooManyFiles
- ? <div className={classes.centeredLabel}>
- File listing may take some time, please click to browse: <Button onClick={loadFilesFunc}><DownloadIcon/>Show files</Button>
+export const CollectionPanelFilesComponent = ({ onItemMenuOpen, onSearchChange, onOptionsMenuOpen, onUploadDataClick, classes,
+ isWritable, isLoading, tooManyFiles, loadFilesFunc, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) => {
+ const { useState, useEffect } = React;
+ const [searchValue, setSearchValue] = useState('');
+
+ useEffect(() => {
+ onSearchChange(searchValue);
+ }, [searchValue]);
+
+ return (<Card data-cy='collection-files-panel' className={classes.root}>
+ <CardHeader
+ title={
+ <div className={classes.cardHeaderContent}>
+ <span className={classes.cardHeaderContentTitle}>Files</span>
+ <SearchInput
+ value={searchValue}
+ onSearch={setSearchValue} />
</div>
- : <>
- <Grid container justify="space-between">
- <Typography variant="caption" className={classes.nameHeader}>
- Name
- </Typography>
- <Typography variant="caption" className={classes.fileSizeHeader}>
- File size
- </Typography>
- </Grid>
- { isLoading
+ }
+ className={classes.cardSubheader}
+ classes={{ action: classes.button }}
+ action={<>
+ {isWritable &&
+ <Button
+ data-cy='upload-button'
+ onClick={onUploadDataClick}
+ variant='contained'
+ color='primary'
+ size='small'>
+ <DownloadIcon className={classes.uploadIcon} />
+ Upload data
+ </Button>}
+ {!tooManyFiles &&
+ <Tooltip title="More options" disableFocusListener>
+ <IconButton
+ data-cy='collection-files-panel-options-btn'
+ onClick={(ev) => onOptionsMenuOpen(ev, isWritable)}>
+ <CustomizeTableIcon />
+ </IconButton>
+ </Tooltip>}
+ </>
+ } />
+ {tooManyFiles
+ ? <div className={classes.centeredLabel}>
+ File listing may take some time, please click to browse: <Button onClick={loadFilesFunc}><DownloadIcon />Show files</Button>
+ </div>
+ : <>
+ <Grid container justify="space-between">
+ <Typography variant="caption" className={classes.nameHeader}>
+ Name
+ </Typography>
+ <Typography variant="caption" className={classes.fileSizeHeader}>
+ File size
+ </Typography>
+ </Grid>
+ {isLoading
? <div className={classes.centeredLabel}><CircularProgress /></div>
- : <div style={{height: 'calc(100% - 60px)'}}><FileTree onMenuOpen={(ev, item) => onItemMenuOpen(ev, item, isWritable)} {...treeProps} /></div> }
- </>
- }
- </Card>);
+ : <div style={{ height: 'calc(100% - 60px)' }}>
+ <FileTree
+ onMenuOpen={(ev, item) => onItemMenuOpen(ev, item, isWritable)}
+ {...treeProps}
+ items={treeProps.items} /></div>}
+ </>
+ }
+ </Card>);
+};
+
+export const CollectionPanelFiles = withStyles(styles)(CollectionPanelFilesComponent);
render() {
return <form onSubmit={this.handleSubmit}>
<FormControl>
- <InputLabel>Search</InputLabel>
+ <InputLabel>Search files</InputLabel>
<Input
type="text"
value={this.state.value}
onChange={this.handleChange}
endAdornment={
<InputAdornment position="end">
- <Tooltip title='Search'>
+ <Tooltip title='Search files'>
<IconButton
onClick={this.handleSubmit}>
<SearchIcon />
export const mapTreeValues = <T, R>(mapFn: (value: T) => R) => (tree: Tree<T>): Tree<R> =>
getNodeDescendantsIds('')(tree)
.map(id => getNode(id)(tree))
+ .filter(node => !!node)
.map(mapNodeValue(mapFn))
.reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
TOGGLE_COLLECTION_FILE_SELECTION: ofType<{ id: string }>(),
SELECT_ALL_COLLECTION_FILES: ofType<{}>(),
UNSELECT_ALL_COLLECTION_FILES: ofType<{}>(),
+ ON_SEARCH_CHANGE: ofType<string>(),
});
export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesAction>;
import { createTree, mapTreeValues, getNode, setNode, getNodeAncestorsIds, getNodeDescendantsIds, setNodeValueWith, mapTree } from "~/models/tree";
import { CollectionFileType } from "~/models/collection-file";
+let fetchedFiles: any = {};
+
export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => {
// Low-level tree handling setNode() func does in-place data modifications
// for performance reasons, so we pass a copy of 'state' to avoid side effects.
return collectionPanelFilesAction.match(action, {
- SET_COLLECTION_FILES: files =>
- mergeCollectionPanelFilesStates({...state}, mapTree(mapCollectionFileToCollectionPanelFile)(files)),
+ SET_COLLECTION_FILES: files => {
+ fetchedFiles = files;
+ return mergeCollectionPanelFilesStates({ ...state }, mapTree(mapCollectionFileToCollectionPanelFile)(files));
+ },
TOGGLE_COLLECTION_FILE_COLLAPSE: data =>
- toggleCollapse(data.id)({...state}),
+ toggleCollapse(data.id)({ ...state }),
- TOGGLE_COLLECTION_FILE_SELECTION: data => [{...state}]
+ TOGGLE_COLLECTION_FILE_SELECTION: data => [{ ...state }]
.map(toggleSelected(data.id))
.map(toggleAncestors(data.id))
.map(toggleDescendants(data.id))[0],
+ ON_SEARCH_CHANGE: (searchValue) => {
+ const fileIds: string[] = [];
+ const directoryIds: string[] = [];
+ const filteredFiles = Object.keys(fetchedFiles)
+ .filter((key: string) => {
+ const node = fetchedFiles[key];
+
+ if (node.value === undefined) {
+ return false;
+ }
+
+ const { id, value: { type, name } } = node;
+
+ if (type === CollectionFileType.DIRECTORY) {
+ directoryIds.push(id);
+ return true;
+ }
+
+ const includeFile = name.toLowerCase().indexOf(searchValue.toLowerCase()) > -1;
+
+ if (includeFile) {
+ fileIds.push(id);
+ }
+
+ return includeFile;
+ })
+ .reduce((prev, next) => {
+ const node = JSON.parse(JSON.stringify(fetchedFiles[next]));
+ const { value: { type }, children } = node;
+
+ node.children = node.children.filter((key: string) => {
+ const isFile = directoryIds.indexOf(key) === -1;
+ return isFile ?
+ fileIds.indexOf(key) > -1 :
+ !!fileIds.find(id => id.indexOf(key) > -1);
+ });
+
+ if (type === CollectionFileType.FILE || children.length > 0) {
+ prev[next] = node;
+ }
+
+ return prev;
+ }, {});
+
+ return mapTreeValues((v: CollectionPanelDirectory | CollectionPanelFile) => {
+ if (v.type === CollectionFileType.DIRECTORY) {
+ return ({
+ ...v,
+ collapsed: searchValue.length === 0,
+ });
+ }
+
+ return ({ ...v });
+ })({ ...filteredFiles });
+ },
+
SELECT_ALL_COLLECTION_FILES: () =>
- mapTreeValues(v => ({ ...v, selected: true }))({...state}),
+ mapTreeValues(v => ({ ...v, selected: true }))({ ...state }),
UNSELECT_ALL_COLLECTION_FILES: () =>
- mapTreeValues(v => ({ ...v, selected: false }))({...state}),
+ mapTreeValues(v => ({ ...v, selected: false }))({ ...state }),
default: () => state
}) as CollectionPanelFilesState;
export const filterCollectionFilesBySelection = (tree: CollectionPanelFilesState, selected: boolean) => {
const allFiles = getNodeDescendants('')(tree).map(node => node.value);
-
const selectedDirectories = allFiles.filter(file => file.selected === selected && file.type === CollectionFileType.DIRECTORY);
const selectedFiles = allFiles.filter(file => file.selected === selected && !selectedDirectories.some(dir => dir.id === file.path));
return [...selectedDirectories, ...selectedFiles];
manifestText: collection.manifestText,
};
const newCollection = await services.collectionService.create(collectionCopy);
- const paths = filterCollectionFilesBySelection(state.collectionPanelFiles, false).map(file => file.id);
- await services.collectionService.deleteFiles(newCollection.uuid, paths);
+ const copiedFiles = await services.collectionService.files(newCollection.uuid);
+ const paths = filterCollectionFilesBySelection(state.collectionPanelFiles, true).map(file => file.id);
+ const filesToDelete = copiedFiles.map(({ id }) => id).filter(file => {
+ return !paths.find(path => path.indexOf(file.replace(newCollection.uuid, '')) > -1);
+ });
+ await services.collectionService.deleteFiles(
+ '',
+ filesToDelete
+ );
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
dispatch(snackbarActions.OPEN_SNACKBAR({
message: 'New collection created.',
};
};
-const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onFileClick' | 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
+const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onSearchChange' | 'onFileClick' | 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
onUploadDataClick: () => {
dispatch<any>(openUploadCollectionFilesDialog());
},
}
));
},
+ onSearchChange: (searchValue: string) => {
+ dispatch(collectionPanelFilesAction.ON_SEARCH_CHANGE(searchValue));
+ },
onOptionsMenuOpen: (event, isWritable) => {
dispatch<any>(openCollectionFilesContextMenu(event, isWritable));
},