Merge branch 'jszlenk/create_new_subproject' refs #21937
[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 import { Session } from "models/session";
28
29 type CssRules = "container" | "containerSearchViewOpened" | "input" | "view";
30
31 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => {
32     return {
33         container: {
34             position: "relative",
35             width: "100%",
36             borderRadius: theme.spacing.unit / 2,
37             zIndex: theme.zIndex.modal,
38         },
39         containerSearchViewOpened: {
40             position: "relative",
41             width: "100%",
42             borderRadius: `${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px 0 0`,
43             zIndex: theme.zIndex.modal,
44         },
45         input: {
46             border: "none",
47             padding: `0`,
48         },
49         view: {
50             position: "absolute",
51             width: "100%",
52             zIndex: 1,
53         },
54     };
55 };
56
57 export type SearchBarDataProps = SearchBarViewDataProps &
58     SearchBarAutocompleteViewDataProps &
59     SearchBarAdvancedViewDataProps &
60     SearchBarBasicViewDataProps;
61
62 interface SearchBarViewDataProps {
63     searchValue: string;
64     currentView: string;
65     isPopoverOpen: boolean;
66     debounce?: number;
67     vocabulary?: Vocabulary;
68     sessions: Session[];
69 }
70
71 export type SearchBarActionProps = SearchBarViewActionProps &
72     SearchBarAutocompleteViewActionProps &
73     SearchBarAdvancedViewActionProps &
74     SearchBarBasicViewActionProps;
75
76 interface SearchBarViewActionProps {
77     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
78     onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
79     onSetView: (currentView: string) => void;
80     closeView: () => void;
81     openSearchView: () => void;
82     loadRecentQueries: () => string[];
83     moveUp: () => void;
84     moveDown: () => void;
85     setAdvancedDataFromSearchValue: (search: string, vocabulary?: Vocabulary) => void;
86     searchSingleCluster: (session: Session, searchValue: string) => any;
87 }
88
89 type SearchBarViewProps = SearchBarDataProps & SearchBarActionProps & WithStyles<CssRules>;
90
91 const handleKeyDown = (e: React.KeyboardEvent, props: SearchBarViewProps) => {
92     if (e.keyCode === KEY_CODE_DOWN) {
93         e.preventDefault();
94         if (!props.isPopoverOpen) {
95             props.onSetView(SearchView.AUTOCOMPLETE);
96             props.openSearchView();
97         } else {
98             props.moveDown();
99         }
100     } else if (e.keyCode === KEY_CODE_UP) {
101         e.preventDefault();
102         props.moveUp();
103     } else if (e.keyCode === KEY_CODE_ESC) {
104         e.preventDefault();
105         props.closeView();
106     } else if (e.keyCode === KEY_ENTER) {
107         if (props.currentView === SearchView.BASIC) {
108             e.preventDefault();
109             props.onSearch(props.selectedItem.query);
110         } else if (props.currentView === SearchView.AUTOCOMPLETE) {
111             if (props.selectedItem.id !== props.searchValue) {
112                 e.preventDefault();
113                 props.navigateTo(props.selectedItem.id);
114             }
115         }
116     }
117 };
118
119 const handleInputClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
120     if (props.searchValue) {
121         props.onSetView(SearchView.AUTOCOMPLETE);
122     } else {
123         props.onSetView(SearchView.BASIC);
124     }
125     props.openSearchView();
126 };
127
128 const handleDropdownClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
129     e.stopPropagation();
130     if (props.isPopoverOpen && props.currentView === SearchView.ADVANCED) {
131         props.closeView();
132     } else {
133         props.setAdvancedDataFromSearchValue(props.searchValue, props.vocabulary);
134         props.onSetView(SearchView.ADVANCED);
135     }
136 };
137
138 export const SearchBarView = compose(
139     connectVocabulary,
140     withStyles(styles)
141 )(
142     class extends React.Component<SearchBarViewProps> {
143         state={
144             loggedInSessions: [],
145         }
146
147         debouncedSearch = debounce(() => {
148             this.props.onSearch(this.props.searchValue);
149         }, 1000);
150
151         handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
152             this.debouncedSearch();
153             this.props.onChange(event);
154         };
155
156         handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
157             this.debouncedSearch.clear();
158             this.props.onSubmit(event);
159         };
160
161         componentDidMount(): void {
162             this.setState({ loggedInSessions: this.props.sessions.filter((ss) => ss.loggedIn && ss.userIsActive)});
163         }
164
165         componentDidUpdate( prevProps: Readonly<SearchBarViewProps>, prevState: Readonly<{loggedInSessions: Session[]}>, snapshot?: any ): void {
166             if (prevProps.sessions !== this.props.sessions) {
167                 this.setState({ loggedInSessions: this.props.sessions.filter((ss) => ss.loggedIn)});
168             }
169             //if a new session is logged in after a search is started, search the new cluster and append those to the results
170             if(prevState.loggedInSessions.length !== this.state.loggedInSessions.length){
171                 const newLogin = this.state.loggedInSessions.filter((ss) => !prevState.loggedInSessions.includes(ss));
172                 this.props.searchSingleCluster(newLogin[0], this.props.searchValue);
173             }
174         }
175
176         componentWillUnmount() {
177             this.debouncedSearch.clear();
178         }
179
180         render() {
181             const { children, ...props } = this.props;
182             const { classes, isPopoverOpen } = this.props;
183             return (
184                 <>
185                     {isPopoverOpen && <Backdrop onClick={props.closeView} />}
186
187                     <Paper className={isPopoverOpen ? classes.containerSearchViewOpened : classes.container}>
188                         <form
189                             data-cy="searchbar-parent-form"
190                             onSubmit={this.handleSubmit}>
191                             <Input
192                                 data-cy="searchbar-input-field"
193                                 className={classes.input}
194                                 onChange={this.handleChange}
195                                 placeholder="Search"
196                                 value={props.searchValue}
197                                 fullWidth={true}
198                                 disableUnderline={true}
199                                 onClick={e => handleInputClick(e, props)}
200                                 onKeyDown={e => handleKeyDown(e, props)}
201                                 startAdornment={
202                                     <InputAdornment position="start">
203                                         <Tooltip title="Search">
204                                             <IconButton type="submit">
205                                                 <SearchIcon />
206                                             </IconButton>
207                                         </Tooltip>
208                                     </InputAdornment>
209                                 }
210                                 endAdornment={
211                                     <InputAdornment position="end">
212                                         <Tooltip title="Advanced search">
213                                             <IconButton onClick={e => handleDropdownClick(e, props)}>
214                                                 <ArrowDropDownIcon />
215                                             </IconButton>
216                                         </Tooltip>
217                                     </InputAdornment>
218                                 }
219                             />
220                         </form>
221                         <div className={classes.view}>{isPopoverOpen && getView({ ...props })}</div>
222                     </Paper>
223                 </>
224             );
225         }
226     }
227 );
228
229 const getView = (props: SearchBarViewProps) => {
230     switch (props.currentView) {
231         case SearchView.AUTOCOMPLETE:
232             return (
233                 <SearchBarAutocompleteView
234                     navigateTo={props.navigateTo}
235                     searchResults={props.searchResults}
236                     searchValue={props.searchValue}
237                     selectedItem={props.selectedItem}
238                 />
239             );
240         case SearchView.ADVANCED:
241             return (
242                 <SearchBarAdvancedView
243                     closeAdvanceView={props.closeAdvanceView}
244                     tags={props.tags}
245                     saveQuery={props.saveQuery}
246                 />
247             );
248         default:
249             return (
250                 <SearchBarBasicView
251                     onSetView={props.onSetView}
252                     onSearch={props.onSearch}
253                     loadRecentQueries={props.loadRecentQueries}
254                     savedQueries={props.savedQueries}
255                     deleteSavedQuery={props.deleteSavedQuery}
256                     editSavedQuery={props.editSavedQuery}
257                     selectedItem={props.selectedItem}
258                 />
259             );
260     }
261 };
262
263 const Backdrop = withStyles<"backdrop">(theme => ({
264     backdrop: {
265         position: "fixed",
266         top: 0,
267         right: 0,
268         bottom: 0,
269         left: 0,
270         zIndex: theme.zIndex.modal,
271     },
272 }))(({ classes, ...props }: WithStyles<"backdrop"> & React.HTMLProps<HTMLDivElement>) => (
273     <div
274         className={classes.backdrop}
275         {...props}
276     />
277 ));