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