1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import axios from "axios";
6 import { ofType, unionize, UnionOf } from "common/unionize";
7 import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
8 import { Dispatch } from 'redux';
9 import { change, initialize, untouch } from 'redux-form';
10 import { RootState } from 'store/store';
11 import { initUserProject, treePickerActions } from 'store/tree-picker/tree-picker-actions';
12 import { ServiceRepository } from 'services/services';
13 import { FilterBuilder } from "services/api/filter-builder";
14 import { ResourceKind, RESOURCE_UUID_REGEX, COLLECTION_PDH_REGEX } from 'models/resource';
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, SearchBarAdvancedFormData } from 'models/search-bar';
19 import { union } from "lodash";
20 import { getModifiedKeysValues } from "common/objects";
21 import { activateSearchBarProject } from "store/search-bar/search-bar-tree-actions";
22 import { Session } from "models/session";
23 import { searchResultsPanelActions } from "store/search-results-panel/search-results-panel-actions";
24 import { ListResults } from "services/common-service/common-service";
25 import * as parser from './search-query/arv-parser';
26 import { Keywords } from './search-query/arv-parser';
27 import { Vocabulary, getTagKeyLabel, getTagValueLabel } from "models/vocabulary";
29 export const searchBarActions = unionize({
30 SET_CURRENT_VIEW: ofType<string>(),
31 OPEN_SEARCH_VIEW: ofType<{}>(),
32 CLOSE_SEARCH_VIEW: ofType<{}>(),
33 SET_SEARCH_RESULTS: ofType<GroupContentsResource[]>(),
34 SET_SEARCH_VALUE: ofType<string>(),
35 SET_SAVED_QUERIES: ofType<SearchBarAdvancedFormData[]>(),
36 SET_RECENT_QUERIES: ofType<string[]>(),
37 UPDATE_SAVED_QUERY: ofType<SearchBarAdvancedFormData[]>(),
38 SET_SELECTED_ITEM: ofType<string>(),
39 MOVE_UP: ofType<{}>(),
40 MOVE_DOWN: ofType<{}>(),
41 SELECT_FIRST_ITEM: ofType<{}>()
44 export type SearchBarActions = UnionOf<typeof searchBarActions>;
46 export const SEARCH_BAR_ADVANCED_FORM_NAME = 'searchBarAdvancedFormName';
48 export const SEARCH_BAR_ADVANCED_FORM_PICKER_ID = 'searchBarAdvancedFormPickerId';
50 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
52 export const goToView = (currentView: string) => searchBarActions.SET_CURRENT_VIEW(currentView);
54 export const saveRecentQuery = (query: string) =>
55 (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) =>
56 services.searchService.saveRecentQuery(query);
59 export const loadRecentQueries = () =>
60 (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
61 const recentQueries = services.searchService.getRecentQueries();
62 dispatch(searchBarActions.SET_RECENT_QUERIES(recentQueries));
66 export const searchData = (searchValue: string, useCancel = false) =>
67 async (dispatch: Dispatch, getState: () => RootState) => {
68 const currentView = getState().searchBar.currentView;
69 dispatch(searchResultsPanelActions.CLEAR());
70 dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
71 if (searchValue.length > 0) {
72 dispatch<any>(searchGroups(searchValue, 5, useCancel));
73 if (currentView === SearchView.BASIC) {
74 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
75 dispatch(navigateToSearchResults(searchValue));
80 export const searchAdvancedData = (data: SearchBarAdvancedFormData) =>
81 async (dispatch: Dispatch, getState: () => RootState) => {
82 dispatch<any>(saveQuery(data));
83 const searchValue = getState().searchBar.searchValue;
84 dispatch(searchResultsPanelActions.CLEAR());
85 dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
86 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
87 dispatch(navigateToSearchResults(searchValue));
90 export const setSearchValueFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) =>
91 (dispatch: Dispatch, getState: () => RootState) => {
92 const searchValue = getState().searchBar.searchValue;
93 const value = getQueryFromAdvancedData({
97 dispatch(searchBarActions.SET_SEARCH_VALUE(value));
100 export const setAdvancedDataFromSearchValue = (search: string, vocabulary: Vocabulary) =>
101 async (dispatch: Dispatch) => {
102 const data = getAdvancedDataFromQuery(search, vocabulary);
103 dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
104 if (data.projectUuid) {
105 await dispatch<any>(activateSearchBarProject(data.projectUuid));
106 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID, id: data.projectUuid }));
110 const saveQuery = (data: SearchBarAdvancedFormData) =>
111 (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
112 const savedQueries = services.searchService.getSavedQueries();
113 if (data.saveQuery && data.queryName) {
114 const filteredQuery = savedQueries.find(query => query.queryName === data.queryName);
115 data.searchValue = getState().searchBar.searchValue;
117 services.searchService.editSavedQueries(data);
118 dispatch(searchBarActions.UPDATE_SAVED_QUERY(savedQueries));
119 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Query has been successfully updated', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
121 services.searchService.saveQuery(data);
122 dispatch(searchBarActions.SET_SAVED_QUERIES(savedQueries));
123 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Query has been successfully saved', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
128 export const deleteSavedQuery = (id: number) =>
129 (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
130 services.searchService.deleteSavedQuery(id);
131 const savedSearchQueries = services.searchService.getSavedQueries();
132 dispatch(searchBarActions.SET_SAVED_QUERIES(savedSearchQueries));
133 return savedSearchQueries || [];
136 export const editSavedQuery = (data: SearchBarAdvancedFormData) =>
137 (dispatch: Dispatch<any>) => {
138 dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.ADVANCED));
139 dispatch(searchBarActions.SET_SEARCH_VALUE(getQueryFromAdvancedData(data)));
140 dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
143 export const openSearchView = () =>
144 (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
145 const savedSearchQueries = services.searchService.getSavedQueries();
146 dispatch(searchBarActions.SET_SAVED_QUERIES(savedSearchQueries));
147 dispatch(loadRecentQueries());
148 dispatch(searchBarActions.OPEN_SEARCH_VIEW());
149 dispatch(searchBarActions.SELECT_FIRST_ITEM());
152 export const closeSearchView = () =>
153 (dispatch: Dispatch<any>) => {
154 dispatch(searchBarActions.SET_SELECTED_ITEM(''));
155 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
158 export const closeAdvanceView = () =>
159 (dispatch: Dispatch<any>) => {
160 dispatch(searchBarActions.SET_SEARCH_VALUE(''));
161 dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
162 dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
165 export const navigateToItem = (uuid: string) =>
166 (dispatch: Dispatch<any>) => {
167 dispatch(searchBarActions.SET_SELECTED_ITEM(''));
168 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
169 dispatch(navigateTo(uuid));
172 export const changeData = (searchValue: string) =>
173 (dispatch: Dispatch, getState: () => RootState) => {
174 dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
175 const currentView = getState().searchBar.currentView;
176 const searchValuePresent = searchValue.length > 0;
178 if (currentView === SearchView.ADVANCED) {
179 dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.AUTOCOMPLETE));
180 } else if (searchValuePresent) {
181 dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.AUTOCOMPLETE));
182 dispatch(searchBarActions.SET_SELECTED_ITEM(searchValue));
184 dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
185 dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
186 dispatch(searchBarActions.SELECT_FIRST_ITEM());
190 export const submitData = (event: React.FormEvent<HTMLFormElement>) =>
191 (dispatch: Dispatch, getState: () => RootState) => {
192 event.preventDefault();
193 const searchValue = getState().searchBar.searchValue;
194 dispatch<any>(saveRecentQuery(searchValue));
195 dispatch<any>(loadRecentQueries());
196 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
197 if (RESOURCE_UUID_REGEX.exec(searchValue) || COLLECTION_PDH_REGEX.exec(searchValue)) {
198 dispatch<any>(navigateTo(searchValue));
200 dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
201 dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
202 dispatch(searchResultsPanelActions.CLEAR());
203 dispatch(navigateToSearchResults(searchValue));
207 let cancelTokens: any[] = [];
208 const searchGroups = (searchValue: string, limit: number, useCancel = false) =>
209 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
210 const currentView = getState().searchBar.currentView;
212 if (cancelTokens.length > 0 && useCancel) {
213 cancelTokens.forEach(cancelToken => (cancelToken as any).cancel('New search request triggered.'));
217 setTimeout(async () => {
218 if (searchValue || currentView === SearchView.ADVANCED) {
219 const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue);
220 const sessions = getSearchSessions(clusterId, getState().auth.sessions);
221 const lists: ListResults<GroupContentsResource>[] = await Promise.all(sessions.map((session, index) => {
222 cancelTokens.push(axios.CancelToken.source());
223 const filters = queryToFilters(searchValue, session.apiRevision);
224 return services.groupsService.contents('', {
228 }, session, cancelTokens[index].token);
233 const items = lists.reduce((items, list) => items.concat(list.items), [] as GroupContentsResource[]);
235 if (lists.filter(list => !!(list as any).items).length !== lists.length) {
236 dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
238 dispatch(searchBarActions.SET_SEARCH_RESULTS(items));
244 const buildQueryFromKeyMap = (data: any, keyMap: string[][]) => {
245 let value = data.searchValue;
247 const addRem = (field: string, key: string) => {
249 // Remove previous search expression.
250 if (data.hasOwnProperty(key)) {
253 pattern = `${field.replace(':', '\\:\\s*')}\\s*`;
254 } else if (key.startsWith('prop-')) {
255 // On properties, only remove key:value duplicates, allowing
256 // multiple properties with the same key.
257 const oldValue = key.slice(5).split(':')[1];
258 pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*${oldValue}\\s*`;
260 pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*[\\w|\\#|\\-|\\/]*\\s*`;
262 value = value.replace(new RegExp(pattern), '');
264 // Re-add it with the current search value.
266 const nv = v === true
269 // Always append to the end to keep user-entered text at the start.
270 value = value + ' ' + nv;
273 keyMap.forEach(km => addRem(km[0], km[1]));
277 export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) => {
280 const flatData = (data: SearchBarAdvancedFormData) => {
282 searchValue: data.searchValue,
284 cluster: data.cluster,
285 projectUuid: data.projectUuid,
286 inTrash: data.inTrash,
287 pastVersions: data.pastVersions,
288 dateFrom: data.dateFrom,
291 (data.properties || []).forEach(p =>
292 fo[`prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`] = `"${p.valueID || p.value}"`
299 ['cluster', 'cluster'],
300 ['project', 'projectUuid'],
301 [`is:${parser.States.TRASHED}`, 'inTrash'],
302 [`is:${parser.States.PAST_VERSION}`, 'pastVersions'],
303 ['from', 'dateFrom'],
306 union(data.properties, prevData ? prevData.properties : [])
307 .forEach(p => keyMap.push(
308 [`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`]
311 const modified = getModifiedKeysValues(flatData(data), prevData ? flatData(prevData):{});
312 value = buildQueryFromKeyMap(
313 {searchValue: data.searchValue, ...modified} as SearchBarAdvancedFormData, keyMap);
315 value = value.trim();
319 export const getAdvancedDataFromQuery = (query: string, vocabulary?: Vocabulary): SearchBarAdvancedFormData => {
320 const { tokens, searchString } = parser.parseSearchQuery(query);
321 const getValue = parser.getValue(tokens);
323 searchValue: searchString,
324 type: getValue(Keywords.TYPE) as ResourceKind,
325 cluster: getValue(Keywords.CLUSTER),
326 projectUuid: getValue(Keywords.PROJECT),
327 inTrash: parser.isTrashed(tokens),
328 pastVersions: parser.isPastVersion(tokens),
329 dateFrom: getValue(Keywords.FROM) || '',
330 dateTo: getValue(Keywords.TO) || '',
331 properties: vocabulary
332 ? parser.getProperties(tokens).map(
336 key: getTagKeyLabel(p.key, vocabulary),
338 value: getTagValueLabel(p.key, p.value, vocabulary),
341 : parser.getProperties(tokens),
347 export const getSearchSessions = (clusterId: string | undefined, sessions: Session[]): Session[] => {
348 return sessions.filter(s => s.loggedIn && (!clusterId || s.clusterId === clusterId));
351 export const queryToFilters = (query: string, apiRevision: number) => {
352 const data = getAdvancedDataFromQuery(query);
353 const filter = new FilterBuilder();
354 const resourceKind = data.type;
356 if (data.searchValue) {
357 filter.addFullTextSearch(data.searchValue);
360 if (data.projectUuid) {
361 filter.addEqual('owner_uuid', data.projectUuid);
365 filter.addGte('modified_at', buildDateFilter(data.dateFrom));
369 filter.addLte('modified_at', buildDateFilter(data.dateTo));
372 data.properties.forEach(p => {
374 if (apiRevision < 20200212) {
376 .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
377 .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION)
378 .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROCESS);
381 .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
382 .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION)
383 .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROCESS);
386 filter.addExists(p.key);
390 .addIsA("uuid", buildUuidFilter(resourceKind))
394 const buildUuidFilter = (type?: ResourceKind): ResourceKind[] => {
395 return type ? [type] : [ResourceKind.PROJECT, ResourceKind.COLLECTION, ResourceKind.PROCESS];
398 const buildDateFilter = (date?: string): string => {
399 return date ? date : '';
402 export const initAdvancedFormProjectsTree = () =>
403 (dispatch: Dispatch) => {
404 dispatch<any>(initUserProject(SEARCH_BAR_ADVANCED_FORM_PICKER_ID));
407 export const changeAdvancedFormProperty = (propertyField: string, value: PropertyValue[] | string = '') =>
408 (dispatch: Dispatch) => {
409 dispatch(change(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField, value));
412 export const resetAdvancedFormProperty = (propertyField: string) =>
413 (dispatch: Dispatch) => {
414 dispatch(change(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField, null));
415 dispatch(untouch(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField));
418 export const moveUp = () =>
419 (dispatch: Dispatch) => {
420 dispatch(searchBarActions.MOVE_UP());
423 export const moveDown = () =>
424 (dispatch: Dispatch) => {
425 dispatch(searchBarActions.MOVE_DOWN());