Add populating search bar project tree and auto selecting project, fix clearing selec...
[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 { getResourceKind, 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 { getClusterObjectType, PropertyValues, 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) => { hasKeywords: boolean; values: string[]; properties: any } = (searchValue: string): ParseSearchQuery => {
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.indexOf(keyword) >= 0 ? 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 export const getAdvancedDataFromQuery = (query: string): SearchBarAdvanceFormData => {
355     const r = parseSearchQuery(query);
356
357     const getFirstProp = (name: string) => r.properties[name] && r.properties[name][0];
358     const getPropValue = (name: string, value: string) => r.properties[name] && r.properties[name].find((v: string) => v === value);
359     const getProperties = () => {
360         if (r.properties.has) {
361             return r.properties.has.map((value: string) => {
362                 const v = value.split(':');
363                 return {
364                     key: v[0],
365                     value: v[1]
366                 };
367             });
368         }
369         return [];
370     };
371
372     return {
373         searchValue: r.values.join(' '),
374         type: getResourceKind(getFirstProp('type')),
375         cluster: getClusterObjectType(getFirstProp('cluster')),
376         projectUuid: getFirstProp('project'),
377         inTrash: getPropValue('is', 'trashed') !== undefined,
378         dateFrom: getFirstProp('from'),
379         dateTo: getFirstProp('to'),
380         properties: getProperties(),
381         saveQuery: false,
382         queryName: ''
383     };
384 };
385
386 export const getFilters = (filterName: string, searchValue: string): string => {
387     const filter = new FilterBuilder();
388
389     const pq = parseSearchQuery(searchValue);
390
391     if (!pq.hasKeywords) {
392         filter
393             .addILike(filterName, searchValue, GroupContentsResourcePrefix.COLLECTION)
394             .addILike(filterName, searchValue, GroupContentsResourcePrefix.PROCESS)
395             .addILike(filterName, searchValue, GroupContentsResourcePrefix.PROJECT);
396     } else {
397
398         if (pq.properties.type) {
399             pq.values.forEach(v => {
400                 let prefix = '';
401                 switch (ResourceKind[pq.properties.type]) {
402                     case ResourceKind.PROJECT:
403                         prefix = GroupContentsResourcePrefix.PROJECT;
404                         break;
405                     case ResourceKind.COLLECTION:
406                         prefix = GroupContentsResourcePrefix.COLLECTION;
407                         break;
408                     case ResourceKind.PROCESS:
409                         prefix = GroupContentsResourcePrefix.PROCESS;
410                         break;
411                 }
412                 if (prefix !== '') {
413                     filter.addILike(filterName, v, prefix);
414                 }
415             });
416         } else {
417             pq.values.forEach(v => {
418                 filter
419                     .addILike(filterName, v, GroupContentsResourcePrefix.COLLECTION)
420                     .addILike(filterName, v, GroupContentsResourcePrefix.PROCESS)
421                     .addILike(filterName, v, GroupContentsResourcePrefix.PROJECT);
422             });
423         }
424
425         if (pq.properties.is && pq.properties.is === 'trashed') {
426         }
427
428         if (pq.properties.project) {
429             filter.addEqual('owner_uuid', pq.properties.project, GroupContentsResourcePrefix.PROJECT);
430         }
431
432         if (pq.properties.from) {
433             filter.addGte('modified_at', buildDateFilter(pq.properties.from));
434         }
435
436         if (pq.properties.to) {
437             filter.addLte('modified_at', buildDateFilter(pq.properties.to));
438         }
439         // filter
440         //     .addIsA("uuid", buildUuidFilter(resourceKind))
441         //     .addILike(filterName, searchValue, GroupContentsResourcePrefix.COLLECTION)
442         //     .addILike(filterName, searchValue, GroupContentsResourcePrefix.PROCESS)
443         //     .addILike(filterName, searchValue, GroupContentsResourcePrefix.PROJECT)
444         //     .addLte('modified_at', buildDateFilter(dateTo))
445         //     .addGte('modified_at', buildDateFilter(dateFrom))
446         //     .addEqual('groupClass', GroupClass.PROJECT, GroupContentsResourcePrefix.PROJECT)
447     }
448
449     return filter
450         .addEqual('groupClass', GroupClass.PROJECT, GroupContentsResourcePrefix.PROJECT)
451         .getFilters();
452 };
453
454 const buildUuidFilter = (type?: ResourceKind): ResourceKind[] => {
455     return type ? [type] : [ResourceKind.PROJECT, ResourceKind.COLLECTION, ResourceKind.PROCESS];
456 };
457
458 const buildDateFilter = (date?: string): string => {
459     return date ? date : '';
460 };
461
462 export const initAdvanceFormProjectsTree = () =>
463     (dispatch: Dispatch) => {
464         dispatch<any>(initUserProject(SEARCH_BAR_ADVANCE_FORM_PICKER_ID));
465     };
466
467 export const changeAdvanceFormProperty = (property: string, value: PropertyValues[] | string = '') =>
468     (dispatch: Dispatch) => {
469         dispatch(change(SEARCH_BAR_ADVANCE_FORM_NAME, property, value));
470     };
471
472 export const updateAdvanceFormProperties = (propertyValues: PropertyValues) =>
473     (dispatch: Dispatch) => {
474         dispatch(arrayPush(SEARCH_BAR_ADVANCE_FORM_NAME, 'properties', propertyValues));
475     };
476
477 export const moveUp = () =>
478     (dispatch: Dispatch) => {
479         dispatch(searchBarActions.MOVE_UP());
480     };
481
482 export const moveDown = () =>
483     (dispatch: Dispatch) => {
484         dispatch(searchBarActions.MOVE_DOWN());
485     };