Add search input component
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Thu, 21 Jun 2018 14:06:20 +0000 (16:06 +0200)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Thu, 21 Jun 2018 14:06:20 +0000 (16:06 +0200)
Feature #13633

Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski@contractors.roche.com>

src/components/data-explorer/data-explorer.tsx
src/components/search-input/search-input.test.tsx [new file with mode: 0644]
src/components/search-input/search-input.tsx [new file with mode: 0644]
src/views-components/project-explorer/project-explorer.tsx

index 44868800ff017d6053c11a66d065aa3dc9461b30..cf9886c3bcedd1d8a9aa39ea9b434d444d264702 100644 (file)
@@ -3,18 +3,21 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Grid, Paper, Toolbar } from '@material-ui/core';
+import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, Theme, WithStyles } from '@material-ui/core';
 import ContextMenu, { ContextMenuActionGroup, ContextMenuAction } from "../../components/context-menu/context-menu";
 import ColumnSelector from "../../components/column-selector/column-selector";
 import DataTable from "../../components/data-table/data-table";
 import { mockAnchorFromMouseEvent } from "../../components/popover/helpers";
 import { DataColumn, toggleSortDirection } from "../../components/data-table/data-column";
 import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters';
+import SearchInput from '../search-input/search-input';
 
 interface DataExplorerProps<T> {
     items: T[];
     columns: Array<DataColumn<T>>;
     contextActions: ContextMenuActionGroup[];
+    searchValue: string;
+    onSearch: (value: string) => void;
     onRowClick: (item: T) => void;
     onColumnToggle: (column: DataColumn<T>) => void;
     onContextAction: (action: ContextMenuAction, item: T) => void;
@@ -29,7 +32,7 @@ interface DataExplorerState<T> {
     };
 }
 
-class DataExplorer<T> extends React.Component<DataExplorerProps<T>, DataExplorerState<T>> {
+class DataExplorer<T> extends React.Component<DataExplorerProps<T> & WithStyles<CssRules>, DataExplorerState<T>> {
     state: DataExplorerState<T> = {
         contextMenu: {}
     };
@@ -41,8 +44,13 @@ class DataExplorer<T> extends React.Component<DataExplorerProps<T>, DataExplorer
                 actions={this.props.contextActions}
                 onActionClick={this.callAction}
                 onClose={this.closeContextMenu} />
-            <Toolbar>
-                <Grid container justify="flex-end">
+            <Toolbar className={this.props.classes.toolbar}>
+                <Grid container justify="space-between" wrap="nowrap" alignItems="center">
+                    <div className={this.props.classes.searchBox}>
+                        <SearchInput
+                            value={this.props.searchValue}
+                            onSearch={this.props.onSearch} />
+                    </div>
                     <ColumnSelector
                         columns={this.props.columns}
                         onColumnToggle={this.props.onColumnToggle} />
@@ -83,4 +91,15 @@ class DataExplorer<T> extends React.Component<DataExplorerProps<T>, DataExplorer
 
 }
 
-export default DataExplorer;
+type CssRules = "searchBox" | "toolbar";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    searchBox: {
+        paddingBottom: theme.spacing.unit * 2
+    },
+    toolbar: {
+        paddingTop: theme.spacing.unit * 2
+    }
+});
+
+export default withStyles(styles)(DataExplorer);
diff --git a/src/components/search-input/search-input.test.tsx b/src/components/search-input/search-input.test.tsx
new file mode 100644 (file)
index 0000000..b07445a
--- /dev/null
@@ -0,0 +1,99 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { mount, configure } from "enzyme";
+import SearchInput, { DEFAULT_SEARCH_DEBOUNCE } from "./search-input";
+
+import * as Adapter from 'enzyme-adapter-react-16';
+
+configure({ adapter: new Adapter() });
+
+describe("<SearchInput />", () => {
+
+    jest.useFakeTimers();
+
+    let onSearch: () => void;
+
+    beforeEach(() => {
+        onSearch = jest.fn();
+    });
+
+    describe("on submit", () => {
+        it("calls onSearch with initial value passed via props", () => {
+            const searchInput = mount(<SearchInput value="initial value" onSearch={onSearch} />);
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("initial value");
+        });
+
+        it("calls onSearch with current value", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("current value");
+        });
+
+        it("calls onSearch with new value passed via props", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.setProps({value: "new value"});
+            searchInput.find("form").simulate("submit");
+            expect(onSearch).toBeCalledWith("new value");
+        });
+
+        it("cancels timeout set on input value change", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            searchInput.find("form").simulate("submit");
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+
+    });
+
+    describe("on input value change", () => {
+        it("calls onSearch after default timeout", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            expect(onSearch).not.toBeCalled();
+            jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+        
+        it("calls onSearch after the time specified in props has passed", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={2000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).not.toBeCalled();
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toBeCalledWith("current value");
+        });
+        
+        it("calls onSearch only once after no change happened during the specified time", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.advanceTimersByTime(500);
+            searchInput.find("input").simulate("change", { target: { value: "changed value" } });
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+        });
+        
+        it("calls onSearch again after the specified time has passed since previous call", () => {
+            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000}/>);
+            searchInput.find("input").simulate("change", { target: { value: "current value" } });
+            jest.advanceTimersByTime(500);
+            searchInput.find("input").simulate("change", { target: { value: "intermediate value" } });
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toBeCalledWith("intermediate value");
+            searchInput.find("input").simulate("change", { target: { value: "latest value" } });
+            jest.advanceTimersByTime(1000);
+            expect(onSearch).toBeCalledWith("latest value");
+            expect(onSearch).toHaveBeenCalledTimes(2);
+            
+        });
+
+    });
+
+});
\ No newline at end of file
diff --git a/src/components/search-input/search-input.tsx b/src/components/search-input/search-input.tsx
new file mode 100644 (file)
index 0000000..edc82d5
--- /dev/null
@@ -0,0 +1,113 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { IconButton, Paper, StyleRulesCallback, withStyles, WithStyles, FormControl, InputLabel, Input, InputAdornment, FormHelperText } from '@material-ui/core';
+import SearchIcon from '@material-ui/icons/Search';
+
+interface SearchInputDataProps {
+    value: string;
+}
+
+interface SearchInputActionProps {
+    onSearch: (value: string) => any;
+    debounce?: number;
+}
+
+type SearchInputProps = SearchInputDataProps & SearchInputActionProps & WithStyles<CssRules>;
+
+interface SearchInputState {
+    value: string;
+}
+
+export const DEFAULT_SEARCH_DEBOUNCE = 1000;
+
+class SearchInput extends React.Component<SearchInputProps> {
+
+    state: SearchInputState = {
+        value: ""
+    };
+
+    timeout: number;
+
+    render() {
+        const { classes } = this.props;
+        return <form onSubmit={this.handleSubmit}>
+            <FormControl>
+                <InputLabel>Search</InputLabel>
+                <Input
+                    type="text"
+                    value={this.state.value}
+                    onChange={this.handleChange}
+                    endAdornment={
+                        <InputAdornment position="end">
+                            <IconButton
+                                onClick={this.handleSubmit}>
+                                <SearchIcon />
+                            </IconButton>
+                        </InputAdornment>
+                    } />
+            </FormControl>
+        </form>;
+    }
+
+    componentDidMount() {
+        this.setState({ value: this.props.value });
+    }
+
+    componentWillReceiveProps(nextProps: SearchInputProps) {
+        if (nextProps.value !== this.props.value) {
+            this.setState({ value: nextProps.value });
+        }
+    }
+
+    componentWillUnmount() {
+        clearTimeout(this.timeout);
+    }
+
+    handleSubmit = (event: React.FormEvent<HTMLElement>) => {
+        event.preventDefault();
+        clearTimeout(this.timeout);
+        this.props.onSearch(this.state.value);
+    }
+
+    handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+        clearTimeout(this.timeout);
+        this.setState({ value: event.target.value });
+        this.timeout = window.setTimeout(
+            () => this.props.onSearch(this.state.value),
+            this.props.debounce || DEFAULT_SEARCH_DEBOUNCE
+        );
+
+    }
+
+}
+
+type CssRules = 'container' | 'input' | 'button';
+
+const styles: StyleRulesCallback<CssRules> = theme => {
+    return {
+        container: {
+            position: 'relative',
+            width: '100%'
+        },
+        input: {
+            border: 'none',
+            borderRadius: theme.spacing.unit / 4,
+            boxSizing: 'border-box',
+            padding: theme.spacing.unit,
+            paddingRight: theme.spacing.unit * 4,
+            width: '100%',
+        },
+        button: {
+            position: 'absolute',
+            top: theme.spacing.unit / 2,
+            right: theme.spacing.unit / 2,
+            width: theme.spacing.unit * 3,
+            height: theme.spacing.unit * 3
+        }
+    };
+};
+
+export default withStyles(styles)(SearchInput);
\ No newline at end of file
index 3fac6df6902d3e1393da506fcc0d0c8ce2dc7054..4757440cc0d9757f8c362bb84ffaf86f74701ad1 100644 (file)
@@ -27,10 +27,12 @@ interface ProjectExplorerProps {
 
 interface ProjectExplorerState {
     columns: Array<DataColumn<ProjectExplorerItem>>;
+    searchValue: string;
 }
 
 class ProjectExplorer extends React.Component<ProjectExplorerProps, ProjectExplorerState> {
     state: ProjectExplorerState = {
+        searchValue: "",
         columns: [{
             name: "Name",
             selected: true,
@@ -103,10 +105,12 @@ class ProjectExplorer extends React.Component<ProjectExplorerProps, ProjectExplo
             items={this.props.items}
             columns={this.state.columns}
             contextActions={this.contextMenuActions}
+            searchValue={this.state.searchValue}
             onColumnToggle={this.toggleColumn}
             onFiltersChange={this.changeFilters}
             onRowClick={console.log}
             onSortToggle={this.toggleSort}
+            onSearch={this.search}
             onContextAction={this.executeAction} />;
     }
 
@@ -143,6 +147,10 @@ class ProjectExplorer extends React.Component<ProjectExplorerProps, ProjectExplo
     executeAction = (action: ContextMenuAction, item: ProjectExplorerItem) => {
         alert(`Executing ${action.name} on ${item.name}`);
     }
+
+    search = (searchValue: string) => {
+        this.setState({ searchValue });
+    }
 }
 
 const renderName = (item: ProjectExplorerItem) =>