Merge branch '16243-filter-files-by-name-on-collections-file-listing'
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Tue, 22 Sep 2020 15:49:21 +0000 (17:49 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Tue, 22 Sep 2020 15:49:40 +0000 (17:49 +0200)
closes #16243

Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>

src/components/collection-panel-files/collection-panel-files.test.tsx [new file with mode: 0644]
src/components/collection-panel-files/collection-panel-files.tsx
src/components/search-input/search-input.tsx
src/models/tree.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts
src/store/collections/collection-partial-copy-actions.ts
src/views-components/collection-panel-files/collection-panel-files.ts

diff --git a/src/components/collection-panel-files/collection-panel-files.test.tsx b/src/components/collection-panel-files/collection-panel-files.test.tsx
new file mode 100644 (file)
index 0000000..86f823a
--- /dev/null
@@ -0,0 +1,116 @@
+// 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
index c7db48c4b8b5bc63952491a19076cb27bacece49..29f20be26fbc61a9dab267d5707f62774ee3fa57 100644 (file)
@@ -9,6 +9,7 @@ import { FileTree } from '~/components/file-tree/file-tree';
 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>>;
@@ -16,6 +17,7 @@ export interface CollectionPanelFilesProps {
     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;
@@ -25,7 +27,7 @@ export interface CollectionPanelFilesProps {
     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: {
@@ -34,7 +36,18 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     },
     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'
@@ -47,7 +60,7 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     },
     button: {
         marginRight: -theme.spacing.unit,
-        marginTop: '0px'
+        marginTop: '8px'
     },
     centeredLabel: {
         fontSize: '0.875rem',
@@ -55,52 +68,71 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     },
 });
 
-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);
index 64ffc396923ce6d097e05fb89317070ff6470488..3b4ab35a1f669e388740fc325bdfede2185a7d2d 100644 (file)
@@ -60,14 +60,14 @@ export const SearchInput = withStyles(styles)(
         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 />
index c7713cbcf08fc996429ff0905e601f14fd6a4ec8..e92913887a0dfc7b28e21ae20b047dc68d61f148 100644 (file)
@@ -74,6 +74,7 @@ export const setNodeValueWith = <T>(mapFn: (value: T) => T) => (id: string) => (
 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>());
 
index 204d4c0e1dbe8f4da27ba74313f560a9a33909fb..704e299990a055742c5a19fdc5cba2e112bc2de0 100644 (file)
@@ -22,6 +22,7 @@ export const collectionPanelFilesAction = unionize({
     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>;
index 08a717596f62df17779a590ebe4c7bb56d75327d..03de8e34f4c05c0b9bb849bce97ae68b02d8b6f0 100644 (file)
@@ -7,26 +7,86 @@ import { CollectionPanelFilesAction, collectionPanelFilesAction } from "./collec
 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;
index 9d5b06cea6b9c94f74e5fadbebd022b5b6366178..aa3bd3057d36601bc9119b4bb721348975e3422d 100644 (file)
@@ -38,7 +38,6 @@ export const mergeCollectionPanelFilesStates = (oldState: CollectionPanelFilesSt
 
 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];
index 72374e65970aae31c50a36387d2788107ec30222..74fa17b35cf47dc5d42d2e6dbd0871809ea15fbb 100644 (file)
@@ -61,8 +61,15 @@ export const copyCollectionPartial = ({ name, description, projectUuid }: Collec
                     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.',
index 7997000350c2639330c91eb82dcda78eda2831e3..9859f84b9de2d0af578d90526f936ad30951b7f4 100644 (file)
@@ -44,7 +44,7 @@ const memoizedMapStateToProps = () => {
     };
 };
 
-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());
     },
@@ -68,6 +68,9 @@ const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps,
             }
         ));
     },
+    onSearchChange: (searchValue: string) => {
+        dispatch(collectionPanelFilesAction.ON_SEARCH_CHANGE(searchValue));
+    },
     onOptionsMenuOpen: (event, isWritable) => {
         dispatch<any>(openCollectionFilesContextMenu(event, isWritable));
     },