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