Merge branch 'main' into 15397-remove-obsolete-apis
[arvados.git] / services / workbench2 / src / views-components / search-bar / search-bar-view.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from "react";
6 import { compose } from "redux";
7 import { IconButton, Paper, StyleRulesCallback, withStyles, WithStyles, Tooltip, InputAdornment, Input } from "@material-ui/core";
8 import SearchIcon from "@material-ui/icons/Search";
9 import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown";
10 import { ArvadosTheme } from "common/custom-theme";
11 import { SearchView } from "store/search-bar/search-bar-reducer";
12 import { SearchBarBasicView, SearchBarBasicViewDataProps, SearchBarBasicViewActionProps } from "views-components/search-bar/search-bar-basic-view";
13 import {
14     SearchBarAutocompleteView,
15     SearchBarAutocompleteViewDataProps,
16     SearchBarAutocompleteViewActionProps,
17 } from "views-components/search-bar/search-bar-autocomplete-view";
18 import {
19     SearchBarAdvancedView,
20     SearchBarAdvancedViewDataProps,
21     SearchBarAdvancedViewActionProps,
22 } from "views-components/search-bar/search-bar-advanced-view";
23 import { KEY_CODE_DOWN, KEY_CODE_ESC, KEY_CODE_UP, KEY_ENTER } from "common/codes";
24 import { debounce } from "debounce";
25 import { Vocabulary } from "models/vocabulary";
26 import { connectVocabulary } from "../resource-properties-form/property-field-common";
27
28 type CssRules = "container" | "containerSearchViewOpened" | "input" | "view";
29
30 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => {
31     return {
32         container: {
33             position: "relative",
34             width: "100%",
35             borderRadius: theme.spacing.unit / 2,
36             zIndex: theme.zIndex.modal,
37         },
38         containerSearchViewOpened: {
39             position: "relative",
40             width: "100%",
41             borderRadius: `${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px 0 0`,
42             zIndex: theme.zIndex.modal,
43         },
44         input: {
45             border: "none",
46             padding: `0`,
47         },
48         view: {
49             position: "absolute",
50             width: "100%",
51             zIndex: 1,
52         },
53     };
54 };
55
56 export type SearchBarDataProps = SearchBarViewDataProps &
57     SearchBarAutocompleteViewDataProps &
58     SearchBarAdvancedViewDataProps &
59     SearchBarBasicViewDataProps;
60
61 interface SearchBarViewDataProps {
62     searchValue: string;
63     currentView: string;
64     isPopoverOpen: boolean;
65     debounce?: number;
66     vocabulary?: Vocabulary;
67 }
68
69 export type SearchBarActionProps = SearchBarViewActionProps &
70     SearchBarAutocompleteViewActionProps &
71     SearchBarAdvancedViewActionProps &
72     SearchBarBasicViewActionProps;
73
74 interface SearchBarViewActionProps {
75     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
76     onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
77     onSetView: (currentView: string) => void;
78     closeView: () => void;
79     openSearchView: () => void;
80     loadRecentQueries: () => string[];
81     moveUp: () => void;
82     moveDown: () => void;
83     setAdvancedDataFromSearchValue: (search: string, vocabulary?: Vocabulary) => void;
84 }
85
86 type SearchBarViewProps = SearchBarDataProps & SearchBarActionProps & WithStyles<CssRules>;
87
88 const handleKeyDown = (e: React.KeyboardEvent, props: SearchBarViewProps) => {
89     if (e.keyCode === KEY_CODE_DOWN) {
90         e.preventDefault();
91         if (!props.isPopoverOpen) {
92             props.onSetView(SearchView.AUTOCOMPLETE);
93             props.openSearchView();
94         } else {
95             props.moveDown();
96         }
97     } else if (e.keyCode === KEY_CODE_UP) {
98         e.preventDefault();
99         props.moveUp();
100     } else if (e.keyCode === KEY_CODE_ESC) {
101         e.preventDefault();
102         props.closeView();
103     } else if (e.keyCode === KEY_ENTER) {
104         if (props.currentView === SearchView.BASIC) {
105             e.preventDefault();
106             props.onSearch(props.selectedItem.query);
107         } else if (props.currentView === SearchView.AUTOCOMPLETE) {
108             if (props.selectedItem.id !== props.searchValue) {
109                 e.preventDefault();
110                 props.navigateTo(props.selectedItem.id);
111             }
112         }
113     }
114 };
115
116 const handleInputClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
117     if (props.searchValue) {
118         props.onSetView(SearchView.AUTOCOMPLETE);
119     } else {
120         props.onSetView(SearchView.BASIC);
121     }
122     props.openSearchView();
123 };
124
125 const handleDropdownClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
126     e.stopPropagation();
127     if (props.isPopoverOpen && props.currentView === SearchView.ADVANCED) {
128         props.closeView();
129     } else {
130         props.setAdvancedDataFromSearchValue(props.searchValue, props.vocabulary);
131         props.onSetView(SearchView.ADVANCED);
132     }
133 };
134
135 export const SearchBarView = compose(
136     connectVocabulary,
137     withStyles(styles)
138 )(
139     class extends React.Component<SearchBarViewProps> {
140         debouncedSearch = debounce(() => {
141             this.props.onSearch(this.props.searchValue);
142         }, 1000);
143
144         handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
145             this.debouncedSearch();
146             this.props.onChange(event);
147         };
148
149         handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
150             this.debouncedSearch.clear();
151             this.props.onSubmit(event);
152         };
153
154         componentWillUnmount() {
155             this.debouncedSearch.clear();
156         }
157
158         render() {
159             const { children, ...props } = this.props;
160             const { classes, isPopoverOpen } = this.props;
161             return (
162                 <>
163                     {isPopoverOpen && <Backdrop onClick={props.closeView} />}
164
165                     <Paper className={isPopoverOpen ? classes.containerSearchViewOpened : classes.container}>
166                         <form
167                             data-cy="searchbar-parent-form"
168                             onSubmit={this.handleSubmit}>
169                             <Input
170                                 data-cy="searchbar-input-field"
171                                 className={classes.input}
172                                 onChange={this.handleChange}
173                                 placeholder="Search"
174                                 value={props.searchValue}
175                                 fullWidth={true}
176                                 disableUnderline={true}
177                                 onClick={e => handleInputClick(e, props)}
178                                 onKeyDown={e => handleKeyDown(e, props)}
179                                 startAdornment={
180                                     <InputAdornment position="start">
181                                         <Tooltip title="Search">
182                                             <IconButton type="submit">
183                                                 <SearchIcon />
184                                             </IconButton>
185                                         </Tooltip>
186                                     </InputAdornment>
187                                 }
188                                 endAdornment={
189                                     <InputAdornment position="end">
190                                         <Tooltip title="Advanced search">
191                                             <IconButton onClick={e => handleDropdownClick(e, props)}>
192                                                 <ArrowDropDownIcon />
193                                             </IconButton>
194                                         </Tooltip>
195                                     </InputAdornment>
196                                 }
197                             />
198                         </form>
199                         <div className={classes.view}>{isPopoverOpen && getView({ ...props })}</div>
200                     </Paper>
201                 </>
202             );
203         }
204     }
205 );
206
207 const getView = (props: SearchBarViewProps) => {
208     switch (props.currentView) {
209         case SearchView.AUTOCOMPLETE:
210             return (
211                 <SearchBarAutocompleteView
212                     navigateTo={props.navigateTo}
213                     searchResults={props.searchResults}
214                     searchValue={props.searchValue}
215                     selectedItem={props.selectedItem}
216                 />
217             );
218         case SearchView.ADVANCED:
219             return (
220                 <SearchBarAdvancedView
221                     closeAdvanceView={props.closeAdvanceView}
222                     tags={props.tags}
223                     saveQuery={props.saveQuery}
224                 />
225             );
226         default:
227             return (
228                 <SearchBarBasicView
229                     onSetView={props.onSetView}
230                     onSearch={props.onSearch}
231                     loadRecentQueries={props.loadRecentQueries}
232                     savedQueries={props.savedQueries}
233                     deleteSavedQuery={props.deleteSavedQuery}
234                     editSavedQuery={props.editSavedQuery}
235                     selectedItem={props.selectedItem}
236                 />
237             );
238     }
239 };
240
241 const Backdrop = withStyles<"backdrop">(theme => ({
242     backdrop: {
243         position: "fixed",
244         top: 0,
245         right: 0,
246         bottom: 0,
247         left: 0,
248         zIndex: theme.zIndex.modal,
249     },
250 }))(({ classes, ...props }: WithStyles<"backdrop"> & React.HTMLProps<HTMLDivElement>) => (
251     <div
252         className={classes.backdrop}
253         {...props}
254     />
255 ));