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