// 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;
};
}
-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: {}
};
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} />
}
-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);
--- /dev/null
+// 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
--- /dev/null
+// 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
interface ProjectExplorerState {
columns: Array<DataColumn<ProjectExplorerItem>>;
+ searchValue: string;
}
class ProjectExplorer extends React.Component<ProjectExplorerProps, ProjectExplorerState> {
state: ProjectExplorerState = {
+ searchValue: "",
columns: [{
name: "Name",
selected: true,
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} />;
}
executeAction = (action: ContextMenuAction, item: ProjectExplorerItem) => {
alert(`Executing ${action.name} on ${item.name}`);
}
+
+ search = (searchValue: string) => {
+ this.setState({ searchValue });
+ }
}
const renderName = (item: ProjectExplorerItem) =>