Add multi cluster search
[arvados-workbench2.git] / src / store / search-bar / search-bar-actions.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import { ofType, unionize, UnionOf } from "~/common/unionize";
6 import { GroupContentsResource, GroupContentsResourcePrefix } from '~/services/groups-service/groups-service';
7 import { Dispatch } from 'redux';
8 import { arrayPush, change, initialize } from 'redux-form';
9 import { RootState } from '~/store/store';
10 import { initUserProject, treePickerActions } from '~/store/tree-picker/tree-picker-actions';
11 import { ServiceRepository } from '~/services/services';
12 import { FilterBuilder } from "~/services/api/filter-builder";
13 import { ResourceKind } from '~/models/resource';
14 import { GroupClass } from '~/models/group';
15 import { SearchView } from '~/store/search-bar/search-bar-reducer';
16 import { navigateTo, navigateToSearchResults } from '~/store/navigation/navigation-action';
17 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
18 import { PropertyValue, SearchBarAdvanceFormData } from '~/models/search-bar';
19 import { debounce } from 'debounce';
20 import * as _ from "lodash";
21 import { getModifiedKeysValues } from "~/common/objects";
22 import { activateSearchBarProject } from "~/store/search-bar/search-bar-tree-actions";
23 import { Session } from "~/models/session";
24
25 export const searchBarActions = unionize({
26     SET_CURRENT_VIEW: ofType<string>(),
27     OPEN_SEARCH_VIEW: ofType<{}>(),
28     CLOSE_SEARCH_VIEW: ofType<{}>(),
29     SET_SEARCH_RESULTS: ofType<GroupContentsResource[]>(),
30     SET_SEARCH_VALUE: ofType<string>(),
31     SET_SAVED_QUERIES: ofType<SearchBarAdvanceFormData[]>(),
32     SET_RECENT_QUERIES: ofType<string[]>(),
33     UPDATE_SAVED_QUERY: ofType<SearchBarAdvanceFormData[]>(),
34     SET_SELECTED_ITEM: ofType<string>(),
35     MOVE_UP: ofType<{}>(),
36     MOVE_DOWN: ofType<{}>(),
37     SELECT_FIRST_ITEM: ofType<{}>()
38 });
39
40 export type SearchBarActions = UnionOf<typeof searchBarActions>;
41
42 export const SEARCH_BAR_ADVANCE_FORM_NAME = 'searchBarAdvanceFormName';
43
44 export const SEARCH_BAR_ADVANCE_FORM_PICKER_ID = 'searchBarAdvanceFormPickerId';
45
46 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
47
48 export const goToView = (currentView: string) => searchBarActions.SET_CURRENT_VIEW(currentView);
49
50 export const saveRecentQuery = (query: string) =>
51     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) =>
52         services.searchService.saveRecentQuery(query);
53
54
55 export const loadRecentQueries = () =>
56     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
57         const recentQueries = services.searchService.getRecentQueries();
58         dispatch(searchBarActions.SET_RECENT_QUERIES(recentQueries));
59         return recentQueries;
60     };
61
62 export const searchData = (searchValue: string) =>
63     async (dispatch: Dispatch, getState: () => RootState) => {
64         const currentView = getState().searchBar.currentView;
65         dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
66         if (searchValue.length > 0) {
67             dispatch<any>(searchGroups(searchValue, 5));
68             if (currentView === SearchView.BASIC) {
69                 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
70                 dispatch(navigateToSearchResults);
71             }
72         }
73     };
74
75 export const searchAdvanceData = (data: SearchBarAdvanceFormData) =>
76     async (dispatch: Dispatch) => {
77         dispatch<any>(saveQuery(data));
78         dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
79         dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
80         dispatch(navigateToSearchResults);
81     };
82
83 export const setSearchValueFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) =>
84     (dispatch: Dispatch, getState: () => RootState) => {
85         const searchValue = getState().searchBar.searchValue;
86         const value = getQueryFromAdvancedData({
87             ...data,
88             searchValue
89         }, prevData);
90         dispatch(searchBarActions.SET_SEARCH_VALUE(value));
91     };
92
93 export const setAdvancedDataFromSearchValue = (search: string) =>
94     async (dispatch: Dispatch) => {
95         const data = getAdvancedDataFromQuery(search);
96         dispatch<any>(initialize(SEARCH_BAR_ADVANCE_FORM_NAME, data));
97         if (data.projectUuid) {
98             await dispatch<any>(activateSearchBarProject(data.projectUuid));
99             dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID, id: data.projectUuid }));
100         }
101     };
102
103 const saveQuery = (data: SearchBarAdvanceFormData) =>
104     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
105         const savedQueries = services.searchService.getSavedQueries();
106         if (data.saveQuery && data.queryName) {
107             const filteredQuery = savedQueries.find(query => query.queryName === data.queryName);
108             data.searchValue = getState().searchBar.searchValue;
109             if (filteredQuery) {
110                 services.searchService.editSavedQueries(data);
111                 dispatch(searchBarActions.UPDATE_SAVED_QUERY(savedQueries));
112                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Query has been successfully updated', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
113             } else {
114                 services.searchService.saveQuery(data);
115                 dispatch(searchBarActions.SET_SAVED_QUERIES(savedQueries));
116                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Query has been successfully saved', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
117             }
118         }
119     };
120
121 export const deleteSavedQuery = (id: number) =>
122     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
123         services.searchService.deleteSavedQuery(id);
124         const savedSearchQueries = services.searchService.getSavedQueries();
125         dispatch(searchBarActions.SET_SAVED_QUERIES(savedSearchQueries));
126         return savedSearchQueries || [];
127     };
128
129 export const editSavedQuery = (data: SearchBarAdvanceFormData) =>
130     (dispatch: Dispatch<any>) => {
131         dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.ADVANCED));
132         dispatch(searchBarActions.SET_SEARCH_VALUE(getQueryFromAdvancedData(data)));
133         dispatch<any>(initialize(SEARCH_BAR_ADVANCE_FORM_NAME, data));
134     };
135
136 export const openSearchView = () =>
137     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
138         const savedSearchQueries = services.searchService.getSavedQueries();
139         dispatch(searchBarActions.SET_SAVED_QUERIES(savedSearchQueries));
140         dispatch(loadRecentQueries());
141         dispatch(searchBarActions.OPEN_SEARCH_VIEW());
142         dispatch(searchBarActions.SELECT_FIRST_ITEM());
143     };
144
145 export const closeSearchView = () =>
146     (dispatch: Dispatch<any>) => {
147         dispatch(searchBarActions.SET_SELECTED_ITEM(''));
148         dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
149     };
150
151 export const closeAdvanceView = () =>
152     (dispatch: Dispatch<any>) => {
153         dispatch(searchBarActions.SET_SEARCH_VALUE(''));
154         dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
155         dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
156     };
157
158 export const navigateToItem = (uuid: string) =>
159     (dispatch: Dispatch<any>) => {
160         dispatch(searchBarActions.SET_SELECTED_ITEM(''));
161         dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
162         dispatch(navigateTo(uuid));
163     };
164
165 export const changeData = (searchValue: string) =>
166     (dispatch: Dispatch, getState: () => RootState) => {
167         dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
168         const currentView = getState().searchBar.currentView;
169         const searchValuePresent = searchValue.length > 0;
170
171         if (currentView === SearchView.ADVANCED) {
172             dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.AUTOCOMPLETE));
173         } else if (searchValuePresent) {
174             dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.AUTOCOMPLETE));
175             dispatch(searchBarActions.SET_SELECTED_ITEM(searchValue));
176             debounceStartSearch(dispatch);
177         } else {
178             dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
179             dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
180             dispatch(searchBarActions.SELECT_FIRST_ITEM());
181         }
182     };
183
184 export const submitData = (event: React.FormEvent<HTMLFormElement>) =>
185     (dispatch: Dispatch, getState: () => RootState) => {
186         event.preventDefault();
187         const searchValue = getState().searchBar.searchValue;
188         dispatch<any>(saveRecentQuery(searchValue));
189         dispatch<any>(loadRecentQueries());
190         dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
191         dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
192         dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
193         dispatch(navigateToSearchResults);
194     };
195
196 const debounceStartSearch = debounce((dispatch: Dispatch) => dispatch<any>(startSearch()), DEFAULT_SEARCH_DEBOUNCE);
197
198 const startSearch = () =>
199     (dispatch: Dispatch, getState: () => RootState) => {
200         const searchValue = getState().searchBar.searchValue;
201         dispatch<any>(searchData(searchValue));
202     };
203
204 const searchGroups = (searchValue: string, limit: number) =>
205     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
206         const currentView = getState().searchBar.currentView;
207
208         if (searchValue || currentView === SearchView.ADVANCED) {
209             const sq = parseSearchQuery(searchValue);
210             const clusterId = getSearchQueryFirstProp(sq, 'cluster');
211             const sessions = getSearchSessions(clusterId, getState().auth.sessions);
212             sessions.forEach(async session => {
213                 const filters = getFilters('name', searchValue, sq);
214                 const { items } = await services.groupsService.contents('', {
215                     filters,
216                     limit,
217                     recursive: true
218                 }, session);
219                 dispatch(searchBarActions.SET_SEARCH_RESULTS(items));
220             });
221         }
222     };
223
224 const buildQueryFromKeyMap = (data: any, keyMap: string[][], mode: 'rebuild' | 'reuse') => {
225     let value = data.searchValue;
226
227     const addRem = (field: string, key: string) => {
228         const v = data[key];
229
230         if (data.hasOwnProperty(key)) {
231             const pattern = v === false
232                 ? `${field.replace(':', '\\:\\s*')}\\s*`
233                 : `${field.replace(':', '\\:\\s*')}\\:\\s*[\\w|\\#|\\-|\\/]*\\s*`;
234             value = value.replace(new RegExp(pattern), '');
235         }
236
237         if (v) {
238             const nv = v === true
239                 ? `${field}`
240                 : `${field}:${v}`;
241
242             if (mode === 'rebuild') {
243                 value = value + ' ' + nv;
244             } else {
245                 value = nv + ' ' + value;
246             }
247         }
248     };
249
250     keyMap.forEach(km => addRem(km[0], km[1]));
251
252     return value;
253 };
254
255 export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) => {
256     let value = '';
257
258     const flatData = (data: SearchBarAdvanceFormData) => {
259         const fo = {
260             searchValue: data.searchValue,
261             type: data.type,
262             cluster: data.cluster,
263             projectUuid: data.projectUuid,
264             inTrash: data.inTrash,
265             dateFrom: data.dateFrom,
266             dateTo: data.dateTo,
267         };
268         (data.properties || []).forEach(p => fo[`prop-${p.key}`] = p.value);
269         return fo;
270     };
271
272     const keyMap = [
273         ['type', 'type'],
274         ['cluster', 'cluster'],
275         ['project', 'projectUuid'],
276         ['is:trashed', 'inTrash'],
277         ['from', 'dateFrom'],
278         ['to', 'dateTo']
279     ];
280     _.union(data.properties, prevData ? prevData.properties : [])
281         .forEach(p => keyMap.push([`has:${p.key}`, `prop-${p.key}`]));
282
283     if (prevData) {
284         const obj = getModifiedKeysValues(flatData(data), flatData(prevData));
285         value = buildQueryFromKeyMap({
286             searchValue: data.searchValue,
287             ...obj
288         } as SearchBarAdvanceFormData, keyMap, "reuse");
289     } else {
290         value = buildQueryFromKeyMap(flatData(data), keyMap, "rebuild");
291     }
292
293     value = value.trim();
294     return value;
295 };
296
297 export class ParseSearchQuery {
298     hasKeywords: boolean;
299     values: string[];
300     properties: {
301         [key: string]: string[]
302     };
303 }
304
305 export const parseSearchQuery: (query: string) => ParseSearchQuery = (searchValue: string) => {
306     const keywords = [
307         'type:',
308         'cluster:',
309         'project:',
310         'is:',
311         'from:',
312         'to:',
313         'has:'
314     ];
315
316     const hasKeywords = (search: string) => keywords.reduce((acc, keyword) => acc + (search.includes(keyword) ? 1 : 0), 0);
317     let keywordsCnt = 0;
318
319     const properties = {};
320
321     keywords.forEach(k => {
322         let p = searchValue.indexOf(k);
323         const key = k.substr(0, k.length - 1);
324
325         while (p >= 0) {
326             const l = searchValue.length;
327             keywordsCnt += 1;
328
329             let v = '';
330             let i = p + k.length;
331             while (i < l && searchValue[i] === ' ') {
332                 ++i;
333             }
334             const vp = i;
335             while (i < l && searchValue[i] !== ' ') {
336                 v += searchValue[i];
337                 ++i;
338             }
339
340             if (hasKeywords(v)) {
341                 searchValue = searchValue.substr(0, p) + searchValue.substr(vp);
342             } else {
343                 if (v !== '') {
344                     if (!properties[key]) {
345                         properties[key] = [];
346                     }
347                     properties[key].push(v);
348                 }
349                 searchValue = searchValue.substr(0, p) + searchValue.substr(i);
350             }
351             p = searchValue.indexOf(k);
352         }
353     });
354
355     const values = _.uniq(searchValue.split(' ').filter(v => v.length > 0));
356
357     return { hasKeywords: keywordsCnt > 0, values, properties };
358 };
359
360 export const getSearchQueryFirstProp = (sq: ParseSearchQuery, name: string) => sq.properties[name] && sq.properties[name][0];
361 export const getSearchQueryPropValue = (sq: ParseSearchQuery, name: string, value: string) => sq.properties[name] && sq.properties[name].find((v: string) => v === value);
362 export const getSearchQueryProperties = (sq: ParseSearchQuery): PropertyValue[] => {
363     if (sq.properties.has) {
364         return sq.properties.has.map((value: string) => {
365             const v = value.split(':');
366             return {
367                 key: v[0],
368                 value: v[1]
369             };
370         });
371     }
372     return [];
373 };
374
375 export const getAdvancedDataFromQuery = (query: string): SearchBarAdvanceFormData => {
376     const sq = parseSearchQuery(query);
377
378     return {
379         searchValue: sq.values.join(' '),
380         type: getSearchQueryFirstProp(sq, 'type') as ResourceKind,
381         cluster: getSearchQueryFirstProp(sq, 'cluster'),
382         projectUuid: getSearchQueryFirstProp(sq, 'project'),
383         inTrash: getSearchQueryPropValue(sq, 'is', 'trashed') !== undefined,
384         dateFrom: getSearchQueryFirstProp(sq, 'from'),
385         dateTo: getSearchQueryFirstProp(sq, 'to'),
386         properties: getSearchQueryProperties(sq),
387         saveQuery: false,
388         queryName: ''
389     };
390 };
391
392 export const getSearchSessions = (clusterId: string | undefined, sessions: Session[]): Session[] => {
393     return sessions.filter(s => s.loggedIn && (!clusterId || s.clusterId === clusterId));
394 };
395
396 export const getFilters = (filterName: string, searchValue: string, sq: ParseSearchQuery): string => {
397     const filter = new FilterBuilder();
398
399     const resourceKind = getSearchQueryFirstProp(sq, 'type') as ResourceKind;
400
401     let prefix = '';
402     switch (resourceKind) {
403         case ResourceKind.COLLECTION:
404             prefix = GroupContentsResourcePrefix.COLLECTION;
405             break;
406         case ResourceKind.PROCESS:
407             prefix = GroupContentsResourcePrefix.PROCESS;
408             break;
409         default:
410             prefix = GroupContentsResourcePrefix.PROJECT;
411             break;
412     }
413
414     const isTrashed = getSearchQueryPropValue(sq, 'is', 'trashed');
415
416     if (!sq.hasKeywords) {
417         filter
418             .addILike(filterName, searchValue, GroupContentsResourcePrefix.COLLECTION)
419             .addILike(filterName, searchValue, GroupContentsResourcePrefix.PROJECT);
420
421         if (isTrashed) {
422             filter.addILike(filterName, searchValue, GroupContentsResourcePrefix.PROCESS);
423         }
424     } else {
425         if (prefix) {
426             sq.values.forEach(v =>
427                 filter.addILike(filterName, v, prefix)
428             );
429         } else {
430             sq.values.forEach(v => {
431                 filter
432                     .addILike(filterName, v, GroupContentsResourcePrefix.COLLECTION)
433                     .addILike(filterName, v, GroupContentsResourcePrefix.PROJECT);
434
435                 if (isTrashed) {
436                     filter.addILike(filterName, v, GroupContentsResourcePrefix.PROCESS);
437                 }
438             });
439         }
440
441         if (isTrashed) {
442             filter.addEqual("is_trashed", true);
443         }
444
445         const projectUuid = getSearchQueryFirstProp(sq, 'project');
446         if (projectUuid) {
447             filter.addEqual('uuid', projectUuid, prefix);
448         }
449
450         const dateFrom = getSearchQueryFirstProp(sq, 'from');
451         if (dateFrom) {
452             filter.addGte('modified_at', buildDateFilter(dateFrom));
453         }
454
455         const dateTo = getSearchQueryFirstProp(sq, 'to');
456         if (dateTo) {
457             filter.addLte('modified_at', buildDateFilter(dateTo));
458         }
459
460         const props = getSearchQueryProperties(sq);
461         props.forEach(p => {
462             if (p.value) {
463                 filter.addILike(`properties.${p.key}`, p.value);
464             }
465             filter.addExists(p.key);
466         });
467     }
468
469     return filter
470         .addEqual('groupClass', GroupClass.PROJECT, GroupContentsResourcePrefix.PROJECT)
471         .addIsA("uuid", buildUuidFilter(resourceKind))
472         .getFilters();
473 };
474
475 const buildUuidFilter = (type?: ResourceKind): ResourceKind[] => {
476     return type ? [type] : [ResourceKind.PROJECT, ResourceKind.COLLECTION, ResourceKind.PROCESS];
477 };
478
479 const buildDateFilter = (date?: string): string => {
480     return date ? date : '';
481 };
482
483 export const initAdvanceFormProjectsTree = () =>
484     (dispatch: Dispatch) => {
485         dispatch<any>(initUserProject(SEARCH_BAR_ADVANCE_FORM_PICKER_ID));
486     };
487
488 export const changeAdvanceFormProperty = (property: string, value: PropertyValue[] | string = '') =>
489     (dispatch: Dispatch) => {
490         dispatch(change(SEARCH_BAR_ADVANCE_FORM_NAME, property, value));
491     };
492
493 export const updateAdvanceFormProperties = (propertyValues: PropertyValue) =>
494     (dispatch: Dispatch) => {
495         dispatch(arrayPush(SEARCH_BAR_ADVANCE_FORM_NAME, 'properties', propertyValues));
496     };
497
498 export const moveUp = () =>
499     (dispatch: Dispatch) => {
500         dispatch(searchBarActions.MOVE_UP());
501     };
502
503 export const moveDown = () =>
504     (dispatch: Dispatch) => {
505         dispatch(searchBarActions.MOVE_DOWN());
506     };