1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
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";
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<{}>()
39 export type SearchBarActions = UnionOf<typeof searchBarActions>;
41 export const SEARCH_BAR_ADVANCE_FORM_NAME = 'searchBarAdvanceFormName';
43 export const SEARCH_BAR_ADVANCE_FORM_PICKER_ID = 'searchBarAdvanceFormPickerId';
45 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
47 export const goToView = (currentView: string) => searchBarActions.SET_CURRENT_VIEW(currentView);
49 export const saveRecentQuery = (query: string) =>
50 (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) =>
51 services.searchService.saveRecentQuery(query);
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));
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);
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);
82 export const setSearchValueFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) =>
83 (dispatch: Dispatch, getState: () => RootState) => {
84 const searchValue = getState().searchBar.searchValue;
85 const value = getQueryFromAdvancedData({
89 dispatch(searchBarActions.SET_SEARCH_VALUE(value));
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 }));
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;
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 }));
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 }));
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 || [];
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));
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());
144 export const closeSearchView = () =>
145 (dispatch: Dispatch<any>) => {
146 dispatch(searchBarActions.SET_SELECTED_ITEM(''));
147 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
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));
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));
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;
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);
177 dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
178 dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
179 dispatch(searchBarActions.SELECT_FIRST_ITEM());
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);
195 const debounceStartSearch = debounce((dispatch: Dispatch) => dispatch<any>(startSearch()), DEFAULT_SEARCH_DEBOUNCE);
197 const startSearch = () =>
198 (dispatch: Dispatch, getState: () => RootState) => {
199 const searchValue = getState().searchBar.searchValue;
200 dispatch<any>(searchData(searchValue));
203 const searchGroups = (searchValue: string, limit: number) =>
204 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
205 const currentView = getState().searchBar.currentView;
207 if (searchValue || currentView === SearchView.ADVANCED) {
208 const filters = getFilters('name', searchValue);
209 const { items } = await services.groupsService.contents('', {
214 dispatch(searchBarActions.SET_SEARCH_RESULTS(items));
218 const buildQueryFromKeyMap = (data: any, keyMap: string[][], mode: 'rebuild' | 'reuse') => {
219 let value = data.searchValue;
221 const addRem = (field: string, key: string) => {
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), '');
232 const nv = v === true
236 if (mode === 'rebuild') {
237 value = value + ' ' + nv;
239 value = nv + ' ' + value;
244 keyMap.forEach(km => addRem(km[0], km[1]));
249 export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) => {
252 const flatData = (data: SearchBarAdvanceFormData) => {
254 searchValue: data.searchValue,
256 cluster: data.cluster,
257 projectUuid: data.projectUuid,
258 inTrash: data.inTrash,
259 dateFrom: data.dateFrom,
262 (data.properties || []).forEach(p => fo[`prop-${p.key}`] = p.value);
268 ['cluster', 'cluster'],
269 ['project', 'projectUuid'],
270 ['is:trashed', 'inTrash'],
271 ['from', 'dateFrom'],
274 _.union(data.properties, prevData ? prevData.properties : [])
275 .forEach(p => keyMap.push([`has:${p.key}`, `prop-${p.key}`]));
278 const obj = getModifiedKeysValues(flatData(data), flatData(prevData));
279 value = buildQueryFromKeyMap({
280 searchValue: data.searchValue,
282 } as SearchBarAdvanceFormData, keyMap, "reuse");
284 value = buildQueryFromKeyMap(flatData(data), keyMap, "rebuild");
287 value = value.trim();
291 export interface ParseSearchQuery {
292 hasKeywords: boolean;
295 [key: string]: string[]
299 export const parseSearchQuery: (query: string) => ParseSearchQuery = (searchValue: string) => {
310 const hasKeywords = (search: string) => keywords.reduce((acc, keyword) => acc + (search.includes(keyword) ? 1 : 0), 0);
313 const properties = {};
315 keywords.forEach(k => {
316 let p = searchValue.indexOf(k);
317 const key = k.substr(0, k.length - 1);
320 const l = searchValue.length;
324 let i = p + k.length;
325 while (i < l && searchValue[i] === ' ') {
329 while (i < l && searchValue[i] !== ' ') {
334 if (hasKeywords(v)) {
335 searchValue = searchValue.substr(0, p) + searchValue.substr(vp);
338 if (!properties[key]) {
339 properties[key] = [];
341 properties[key].push(v);
343 searchValue = searchValue.substr(0, p) + searchValue.substr(i);
345 p = searchValue.indexOf(k);
349 const values = _.uniq(searchValue.split(' ').filter(v => v.length > 0));
351 return { hasKeywords: keywordsCnt > 0, values, properties };
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(':');
369 export const getAdvancedDataFromQuery = (query: string): SearchBarAdvanceFormData => {
370 const sq = parseSearchQuery(query);
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),
386 export const getFilters = (filterName: string, searchValue: string): string => {
387 const filter = new FilterBuilder();
388 const sq = parseSearchQuery(searchValue);
390 const resourceKind = getFirstProp(sq, 'type') as ResourceKind;
393 switch (resourceKind) {
394 case ResourceKind.COLLECTION:
395 prefix = GroupContentsResourcePrefix.COLLECTION;
397 case ResourceKind.PROCESS:
398 prefix = GroupContentsResourcePrefix.PROCESS;
401 prefix = GroupContentsResourcePrefix.PROJECT;
405 if (!sq.hasKeywords) {
407 .addILike(filterName, searchValue, GroupContentsResourcePrefix.COLLECTION)
408 .addILike(filterName, searchValue, GroupContentsResourcePrefix.PROCESS)
409 .addILike(filterName, searchValue, GroupContentsResourcePrefix.PROJECT);
412 sq.values.forEach(v =>
413 filter.addILike(filterName, v, prefix)
416 sq.values.forEach(v => {
418 .addILike(filterName, v, GroupContentsResourcePrefix.COLLECTION)
419 .addILike(filterName, v, GroupContentsResourcePrefix.PROCESS)
420 .addILike(filterName, v, GroupContentsResourcePrefix.PROJECT);
424 if (getPropValue(sq, 'is', 'trashed')) {
425 filter.addEqual("is_trashed", true);
428 const projectUuid = getFirstProp(sq, 'project');
430 filter.addEqual('uuid', projectUuid, prefix);
433 const dateFrom = getFirstProp(sq, 'from');
435 filter.addGte('modified_at', buildDateFilter(dateFrom));
438 const dateTo = getFirstProp(sq, 'to');
440 filter.addLte('modified_at', buildDateFilter(dateTo));
443 const props = getProperties(sq);
445 // filter.addILike(`properties.${p.key}`, p.value);
446 filter.addExists(p.key);
451 .addEqual('groupClass', GroupClass.PROJECT, GroupContentsResourcePrefix.PROJECT)
452 .addIsA("uuid", buildUuidFilter(resourceKind))
456 const buildUuidFilter = (type?: ResourceKind): ResourceKind[] => {
457 return type ? [type] : [ResourceKind.PROJECT, ResourceKind.COLLECTION, ResourceKind.PROCESS];
460 const buildDateFilter = (date?: string): string => {
461 return date ? date : '';
464 export const initAdvanceFormProjectsTree = () =>
465 (dispatch: Dispatch) => {
466 dispatch<any>(initUserProject(SEARCH_BAR_ADVANCE_FORM_PICKER_ID));
469 export const changeAdvanceFormProperty = (property: string, value: PropertyValue[] | string = '') =>
470 (dispatch: Dispatch) => {
471 dispatch(change(SEARCH_BAR_ADVANCE_FORM_NAME, property, value));
474 export const updateAdvanceFormProperties = (propertyValues: PropertyValue) =>
475 (dispatch: Dispatch) => {
476 dispatch(arrayPush(SEARCH_BAR_ADVANCE_FORM_NAME, 'properties', propertyValues));
479 export const moveUp = () =>
480 (dispatch: Dispatch) => {
481 dispatch(searchBarActions.MOVE_UP());
484 export const moveDown = () =>
485 (dispatch: Dispatch) => {
486 dispatch(searchBarActions.MOVE_DOWN());