From: Daniel Kos Date: Tue, 13 Nov 2018 08:06:22 +0000 (+0100) Subject: Add query <-> advanced data synchronization X-Git-Tag: 1.4.0~95^2~13 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/72b6c853b95b1ef3da2a0beca3a31e4838a17896?ds=sidebyside Add query <-> advanced data synchronization Arvados-DCO-1.1-Signed-off-by: Daniel Kos --- diff --git a/src/common/objects.ts b/src/common/objects.ts new file mode 100644 index 00000000..0a01ed62 --- /dev/null +++ b/src/common/objects.ts @@ -0,0 +1,18 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 +import * as _ from "lodash"; + +export function getModifiedKeys(a: any, b: any) { + const keys = _.union(_.keys(a), _.keys(b)); + return _.filter(keys, key => a[key] !== b[key]); +} + +export function getModifiedKeysValues(a: any, b: any) { + const keys = getModifiedKeys(a, b); + const obj = {}; + keys.forEach(k => { + obj[k] = a[k]; + }); + return obj; +} diff --git a/src/models/resource.ts b/src/models/resource.ts index b8156cf2..f8028c76 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -77,3 +77,22 @@ export const extractUuidKind = (uuid: string = '') => { return undefined; } }; + +export const getResourceKind = (kind?: string) => { + switch (kind) { + case "arvados#collection": + return ResourceKind.COLLECTION; + case "arvados#container": + return ResourceKind.CONTAINER; + case "arvados#containerRequest": + return ResourceKind.CONTAINER_REQUEST; + case "arvados#group": + return ResourceKind.GROUP; + case "arvados#log": + return ResourceKind.LOG; + case "arvados#workflow": + return ResourceKind.WORKFLOW; + default: + return undefined; + } +} diff --git a/src/models/search-bar.ts b/src/models/search-bar.ts index 4df5c38f..efbbb157 100644 --- a/src/models/search-bar.ts +++ b/src/models/search-bar.ts @@ -12,9 +12,10 @@ export type SearchBarAdvanceFormData = { dateFrom: string; dateTo: string; saveQuery: boolean; - searchQuery: string; + queryName: string; + searchValue: string; properties: PropertyValues[]; -} & PropertyValues; +}; export interface PropertyValues { key: string; @@ -25,4 +26,17 @@ export enum ClusterObjectType { INDIANAPOLIS = "indianapolis", KAISERAUGST = "kaiseraugst", PENZBERG = "penzberg" -} \ No newline at end of file +} + +export const getClusterObjectType = (cluster?: string) => { + switch (cluster) { + case "indianapolis": + return ClusterObjectType.INDIANAPOLIS; + case "kaiseraugst": + return ClusterObjectType.KAISERAUGST; + case "penzberg": + return ClusterObjectType.PENZBERG; + default: + return undefined; + } +}; diff --git a/src/services/search-service/search-service.ts b/src/services/search-service/search-service.ts index a8e91c39..84d120a8 100644 --- a/src/services/search-service/search-service.ts +++ b/src/services/search-service/search-service.ts @@ -26,7 +26,7 @@ export class SearchService { } editSavedQueries(data: SearchBarAdvanceFormData) { - const itemIndex = this.savedQueries.findIndex(item => item.searchQuery === data.searchQuery); + const itemIndex = this.savedQueries.findIndex(item => item.queryName === data.queryName); this.savedQueries[itemIndex] = {...data}; localStorage.setItem('savedQueries', JSON.stringify(this.savedQueries)); } diff --git a/src/store/search-bar/search-bar-actions.test.ts b/src/store/search-bar/search-bar-actions.test.ts index 30b05157..48dfeecf 100644 --- a/src/store/search-bar/search-bar-actions.test.ts +++ b/src/store/search-bar/search-bar-actions.test.ts @@ -2,47 +2,136 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { parseQuery } from "~/store/search-bar/search-bar-actions"; +import { getAdvancedDataFromQuery, getQueryFromAdvancedData, parseQuery } from "~/store/search-bar/search-bar-actions"; +import { ResourceKind } from "~/models/resource"; +import { ClusterObjectType } from "~/models/search-bar"; describe('search-bar-actions', () => { - it('should correctly parse query #1', () => { - const q = 'val0 is:trashed val1'; - const r = parseQuery(q); - expect(r.hasKeywords).toBeTruthy(); - expect(r.values).toEqual(['val0', 'val1']); - expect(r.properties).toEqual({ - is: 'trashed' + describe('parseQuery', () => { + it('should correctly parse query #1', () => { + const q = 'val0 is:trashed val1'; + const r = parseQuery(q); + expect(r.hasKeywords).toBeTruthy(); + expect(r.values).toEqual(['val0', 'val1']); + expect(r.properties).toEqual({ + is: ['trashed'] + }); }); - }); - it('should correctly parse query #2 (value with keyword should be ignored)', () => { - const q = 'val0 is:from:trashed val1'; - const r = parseQuery(q); - expect(r.hasKeywords).toBeTruthy(); - expect(r.values).toEqual(['val0', 'val1']); - expect(r.properties).toEqual({ - from: 'trashed' + it('should correctly parse query #2 (value with keyword should be ignored)', () => { + const q = 'val0 is:from:trashed val1'; + const r = parseQuery(q); + expect(r.hasKeywords).toBeTruthy(); + expect(r.values).toEqual(['val0', 'val1']); + expect(r.properties).toEqual({ + from: ['trashed'] + }); + }); + + it('should correctly parse query #3 (many keywords)', () => { + const q = 'val0 is:trashed val2 from:2017-04-01 val1'; + const r = parseQuery(q); + expect(r.hasKeywords).toBeTruthy(); + expect(r.values).toEqual(['val0', 'val2', 'val1']); + expect(r.properties).toEqual({ + is: ['trashed'], + from: ['2017-04-01'] + }); + }); + + it('should correctly parse query #4 (no duplicated values)', () => { + const q = 'val0 is:trashed val2 val2 val0'; + const r = parseQuery(q); + expect(r.hasKeywords).toBeTruthy(); + expect(r.values).toEqual(['val0', 'val2']); + expect(r.properties).toEqual({ + is: ['trashed'] + }); }); - }); - it('should correctly parse query #3 (many keywords)', () => { - const q = 'val0 is:trashed val2 from:2017-04-01 val1'; - const r = parseQuery(q); - expect(r.hasKeywords).toBeTruthy(); - expect(r.values).toEqual(['val0', 'val2', 'val1']); - expect(r.properties).toEqual({ - is: 'trashed', - from: '2017-04-01' + it('should correctly parse query #5 (properties)', () => { + const q = 'val0 has:filesize:100mb val2 val2 val0'; + const r = parseQuery(q); + expect(r.hasKeywords).toBeTruthy(); + expect(r.values).toEqual(['val0', 'val2']); + expect(r.properties).toEqual({ + 'has': ['filesize:100mb'] + }); + }); + + it('should correctly parse query #6 (multiple properties, multiple is)', () => { + const q = 'val0 has:filesize:100mb val2 has:user:daniel is:starred val2 val0 is:trashed'; + const r = parseQuery(q); + expect(r.hasKeywords).toBeTruthy(); + expect(r.values).toEqual(['val0', 'val2']); + expect(r.properties).toEqual({ + 'has': ['filesize:100mb', 'user:daniel'], + 'is': ['starred', 'trashed'] + }); }); }); - it('should correctly parse query #4 (no duplicated values)', () => { - const q = 'val0 is:trashed val2 val2 val0'; - const r = parseQuery(q); - expect(r.hasKeywords).toBeTruthy(); - expect(r.values).toEqual(['val0', 'val2']); - expect(r.properties).toEqual({ - is: 'trashed' + describe('getAdvancedDataFromQuery', () => { + it('should correctly build advanced data record from query #1', () => { + const r = getAdvancedDataFromQuery('val0 has:filesize:100mb val2 has:user:daniel is:starred val2 val0 is:trashed'); + expect(r).toEqual({ + searchValue: 'val0 val2', + type: undefined, + cluster: undefined, + projectUuid: undefined, + inTrash: true, + dateFrom: undefined, + dateTo: undefined, + properties: [{ + key: 'filesize', + value: '100mb' + }, { + key: 'user', + value: 'daniel' + }], + saveQuery: false, + queryName: '' + }) + }); + + it('should correctly build advanced data record from query #2', () => { + const r = getAdvancedDataFromQuery('document from:2017-08-01 pdf has:filesize:101mb is:trashed type:arvados#collection cluster:indianapolis'); + expect(r).toEqual({ + searchValue: 'document pdf', + type: ResourceKind.COLLECTION, + cluster: ClusterObjectType.INDIANAPOLIS, + projectUuid: undefined, + inTrash: true, + dateFrom: '2017-08-01', + dateTo: undefined, + properties: [{ + key: 'filesize', + value: '101mb' + }], + saveQuery: false, + queryName: '' + }) }); }); + + describe('getQueryFromAdvancedData', () => { + it('should build query from advanced data', () => { + const q = getQueryFromAdvancedData({ + searchValue: 'document pdf', + type: ResourceKind.COLLECTION, + cluster: ClusterObjectType.INDIANAPOLIS, + projectUuid: undefined, + inTrash: true, + dateFrom: '2017-08-01', + dateTo: '', + properties: [{ + key: 'filesize', + value: '101mb' + }], + saveQuery: false, + queryName: '' + }); + expect(q).toBe('document pdf type:arvados#collection cluster:indianapolis is:trashed from:2017-08-01 has:filesize:101mb'); + }) + }); }); diff --git a/src/store/search-bar/search-bar-actions.ts b/src/store/search-bar/search-bar-actions.ts index c4fb727d..1746c953 100644 --- a/src/store/search-bar/search-bar-actions.ts +++ b/src/store/search-bar/search-bar-actions.ts @@ -2,23 +2,23 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { unionize, ofType, UnionOf } from "~/common/unionize"; +import { ofType, unionize, UnionOf } from "~/common/unionize"; import { GroupContentsResource, GroupContentsResourcePrefix } from '~/services/groups-service/groups-service'; import { Dispatch } from 'redux'; -import { change, arrayPush } from 'redux-form'; +import { arrayPush, change, initialize } from 'redux-form'; import { RootState } from '~/store/store'; import { initUserProject } from '~/store/tree-picker/tree-picker-actions'; import { ServiceRepository } from '~/services/services'; import { FilterBuilder } from "~/services/api/filter-builder"; -import { ResourceKind } from '~/models/resource'; +import { getResourceKind, ResourceKind } from '~/models/resource'; import { GroupClass } from '~/models/group'; import { SearchView } from '~/store/search-bar/search-bar-reducer'; -import { navigateToSearchResults, navigateTo } from '~/store/navigation/navigation-action'; +import { navigateTo, navigateToSearchResults } from '~/store/navigation/navigation-action'; import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions'; -import { initialize } from 'redux-form'; -import { SearchBarAdvanceFormData, PropertyValues, ClusterObjectType } from '~/models/search-bar'; +import { ClusterObjectType, getClusterObjectType, PropertyValues, SearchBarAdvanceFormData } from '~/models/search-bar'; import { debounce } from 'debounce'; import * as _ from "lodash"; +import { getModifiedKeysValues } from "~/common/objects"; export const searchBarActions = unionize({ SET_CURRENT_VIEW: ofType(), @@ -78,36 +78,21 @@ export const searchAdvanceData = (data: SearchBarAdvanceFormData) => dispatch(navigateToSearchResults); }; -export const setSearchValueFromAdvancedData = (data: SearchBarAdvanceFormData, prevData: SearchBarAdvanceFormData) => - (dispatch: Dispatch) => { - let value = ''; - if (data.type) { - value += ` type:${data.type}`; - } - if (data.cluster) { - value += ` cluster:${data.cluster}`; - } - if (data.projectUuid) { - value += ` project:${data.projectUuid}`; - } - if (data.inTrash) { - value += ` is:trashed`; - } - if (data.dateFrom) { - value += ` from:${data.dateFrom}`; - } - if (data.dateTo) { - value += ` from:${data.dateTo}`; - } - value = value.substr(1) + data.searchQuery; +export const setSearchValueFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) => + (dispatch: Dispatch, getState: () => RootState) => { + const value = getQueryFromAdvancedData({ + searchValue: getState().searchBar.searchValue, + ...data + }, prevData); dispatch(searchBarActions.SET_SEARCH_VALUE(value)); }; const saveQuery = (data: SearchBarAdvanceFormData) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const savedQueries = services.searchService.getSavedQueries(); - if (data.saveQuery && data.searchQuery) { - const filteredQuery = savedQueries.find(query => query.searchQuery === data.searchQuery); + if (data.saveQuery && data.queryName) { + const filteredQuery = savedQueries.find(query => query.queryName === data.queryName); + data.searchValue = getState().searchBar.searchValue; if (filteredQuery) { services.searchService.editSavedQueries(data); dispatch(searchBarActions.UPDATE_SAVED_QUERY(savedQueries)); @@ -131,7 +116,7 @@ export const deleteSavedQuery = (id: number) => export const editSavedQuery = (data: SearchBarAdvanceFormData) => (dispatch: Dispatch) => { dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.ADVANCED)); - dispatch(searchBarActions.SET_SEARCH_VALUE(data.searchQuery)); + dispatch(searchBarActions.SET_SEARCH_VALUE(getQueryFromAdvancedData(data))); dispatch(initialize(SEARCH_BAR_ADVANCE_FORM_NAME, data)); }; @@ -217,14 +202,86 @@ const searchGroups = (searchValue: string, limit: number) => } }; -export const parseQuery: (searchValue: string) => { hasKeywords: boolean; values: string[]; properties: any } = (searchValue: string) => { +const buildQueryFromKeyMap = (data: any, keyMap: string[][], mode: 'rebuild' | 'reuse') => { + let value = data.searchValue; + + const addRem = (field: string, key: string) => { + const v = data[key]; + if (v) { + const nv = v === true + ? `${field}` + : `${field}:${v}`; + + if (mode === 'rebuild') { + value = value + ' ' + nv; + } else { + value = nv + ' ' + value; + } + } else if (data.hasOwnProperty(key) && (v === undefined || v === false)) { + const pattern = v === false + ? `${field.replace(':', '\\:\\s*')}\\s*` + : `${field.replace(':', '\\:\\s*')}\\:\\s*[\\w|\\#|\\-|\\/]*`; + value = value.replace(new RegExp(pattern), ''); + } + }; + + keyMap.forEach(km => addRem(km[0], km[1])); + + return value; +}; + +export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) => { + let value = ''; + + const flatData = (data: SearchBarAdvanceFormData) => { + const fo = { + searchValue: data.searchValue, + type: data.type, + cluster: data.cluster, + projectUuid: data.projectUuid, + inTrash: data.inTrash, + dateFrom: data.dateFrom, + dateTo: data.dateTo, + }; + (data.properties || []).forEach(p => fo[`prop-${p.key}`] = p.value); + return fo; + }; + + const keyMap = [ + ['type', 'type'], + ['cluster', 'cluster'], + ['project', 'projectUuid'], + ['is:trashed', 'inTrash'], + ['from', 'dateFrom'], + ['to', 'dateTo'] + ]; + _.union(data.properties, prevData ? prevData.properties : []) + .forEach(p => keyMap.push([`has:${p.key}`, `prop-${p.key}`])); + + if (prevData) { + const obj = getModifiedKeysValues(flatData(data), flatData(prevData)); + console.log(obj); + value = buildQueryFromKeyMap({ + searchValue: data.searchValue, + ...obj + } as SearchBarAdvanceFormData, keyMap, "reuse"); + } else { + value = buildQueryFromKeyMap(flatData(data), keyMap, "rebuild"); + } + + value = value.trim(); + return value; +}; + +export const parseQuery: (query: string) => { hasKeywords: boolean; values: string[]; properties: any } = (searchValue: string) => { const keywords = [ 'type:', 'cluster:', 'project:', 'is:', 'from:', - 'to:' + 'to:', + 'has:' ]; const hasKeywords = (search: string) => keywords.reduce((acc, keyword) => acc + search.indexOf(keyword) >= 0 ? 1 : 0, 0); @@ -233,9 +290,11 @@ export const parseQuery: (searchValue: string) => { hasKeywords: boolean; values const properties = {}; keywords.forEach(k => { - const p = searchValue.indexOf(k); - const l = searchValue.length; - if (p >= 0) { + let p = searchValue.indexOf(k); + const key = k.substr(0, k.length - 1); + + while (p >= 0) { + const l = searchValue.length; keywordsCnt += 1; let v = ''; @@ -253,10 +312,14 @@ export const parseQuery: (searchValue: string) => { hasKeywords: boolean; values searchValue = searchValue.substr(0, p) + searchValue.substr(vp); } else { if (v !== '') { - properties[k.substr(0, k.length - 1)] = v; + if (!properties[key]) { + properties[key] = []; + } + properties[key].push(v); } searchValue = searchValue.substr(0, p) + searchValue.substr(i); } + p = searchValue.indexOf(k); } }); @@ -265,6 +328,38 @@ export const parseQuery: (searchValue: string) => { hasKeywords: boolean; values return { hasKeywords: keywordsCnt > 0, values, properties }; }; +export const getAdvancedDataFromQuery = (query: string): SearchBarAdvanceFormData => { + const r = parseQuery(query); + + const getFirstProp = (name: string) => r.properties[name] && r.properties[name][0]; + const getPropValue = (name: string, value: string) => r.properties[name] && r.properties[name].find((v: string) => v === value); + const getProperties = () => { + if (r.properties['has']) { + return r.properties['has'].map((value: string) => { + const v = value.split(':'); + return { + key: v[0], + value: v[1] + } + }) + } + return []; + }; + + return { + searchValue: r.values.join(' '), + type: getResourceKind(getFirstProp('type')), + cluster: getClusterObjectType(getFirstProp('cluster')), + projectUuid: getFirstProp('project'), + inTrash: getPropValue('is', 'trashed') !== undefined, + dateFrom: getFirstProp('from'), + dateTo: getFirstProp('to'), + properties: getProperties(), + saveQuery: false, + queryName: '', + } +}; + export const getFilters = (filterName: string, searchValue: string): string => { const filter = new FilterBuilder(); diff --git a/src/store/search-bar/search-bar-reducer.ts b/src/store/search-bar/search-bar-reducer.ts index 8508c05d..46477c1c 100644 --- a/src/store/search-bar/search-bar-reducer.ts +++ b/src/store/search-bar/search-bar-reducer.ts @@ -2,7 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { searchBarActions, SearchBarActions } from '~/store/search-bar/search-bar-actions'; +import { + getQueryFromAdvancedData, + searchBarActions, + SearchBarActions, + setSearchValueFromAdvancedData +} from '~/store/search-bar/search-bar-actions'; import { GroupContentsResource } from '~/services/groups-service/groups-service'; import { SearchBarAdvanceFormData } from '~/models/search-bar'; @@ -45,7 +50,7 @@ const makeSelectedItem = (id: string, query?: string): SearchBarSelectedItem => const makeQueryList = (recentQueries: string[], savedQueries: SearchBarAdvanceFormData[]) => { const recentIds = recentQueries.map((q, idx) => makeSelectedItem(`RQ-${idx}-${q}`, q)); - const savedIds = savedQueries.map((q, idx) => makeSelectedItem(`SQ-${idx}-${q.searchQuery}`, q.searchQuery)); + const savedIds = savedQueries.map((q, idx) => makeSelectedItem(`SQ-${idx}-${q.queryName}`, getQueryFromAdvancedData(q))); return recentIds.concat(savedIds); }; diff --git a/src/views-components/form-fields/search-bar-form-fields.tsx b/src/views-components/form-fields/search-bar-form-fields.tsx index 7a5703fe..e05b1d3b 100644 --- a/src/views-components/form-fields/search-bar-form-fields.tsx +++ b/src/views-components/form-fields/search-bar-form-fields.tsx @@ -82,10 +82,10 @@ export const SearchBarSaveSearchField = () => ; + label="Save query" />; export const SearchBarQuerySearchField = () => ; \ No newline at end of file + label="Query name" />; diff --git a/src/views-components/search-bar/search-bar-advanced-view.tsx b/src/views-components/search-bar/search-bar-advanced-view.tsx index 66ef6512..f620f45d 100644 --- a/src/views-components/search-bar/search-bar-advanced-view.tsx +++ b/src/views-components/search-bar/search-bar-advanced-view.tsx @@ -128,7 +128,7 @@ export const SearchBarAdvancedView = compose( Project - + diff --git a/src/views-components/search-bar/search-bar-basic-view.tsx b/src/views-components/search-bar/search-bar-basic-view.tsx index f62e146d..b23a96a0 100644 --- a/src/views-components/search-bar/search-bar-basic-view.tsx +++ b/src/views-components/search-bar/search-bar-basic-view.tsx @@ -66,6 +66,5 @@ export const SearchBarBasicView = withStyles(styles)( editSavedQuery={editSavedQuery} deleteSavedQuery={deleteSavedQuery} selectedItem={selectedItem} /> -
onSetView(SearchView.ADVANCED)}>Advanced search
); diff --git a/src/views-components/search-bar/search-bar-save-queries.tsx b/src/views-components/search-bar/search-bar-save-queries.tsx index aa62c58f..5234c214 100644 --- a/src/views-components/search-bar/search-bar-save-queries.tsx +++ b/src/views-components/search-bar/search-bar-save-queries.tsx @@ -8,6 +8,7 @@ import { ArvadosTheme } from '~/common/custom-theme'; import { RemoveIcon, EditSavedQueryIcon } from '~/components/icon/icon'; import { SearchBarAdvanceFormData } from '~/models/search-bar'; import { SearchBarSelectedItem } from "~/store/search-bar/search-bar-reducer"; +import { getQueryFromAdvancedData } from "~/store/search-bar/search-bar-actions"; type CssRules = 'root' | 'listItem' | 'listItemText' | 'button'; @@ -48,10 +49,10 @@ export const SearchBarSavedQueries = withStyles(styles)( ({ classes, savedQueries, onSearch, editSavedQuery, deleteSavedQuery, selectedItem }: SearchBarSavedQueriesProps) => {savedQueries.map((query, index) => - + onSearch(query.searchQuery)} + secondary={query.queryName} + onClick={() => onSearch(getQueryFromAdvancedData(query))} className={classes.listItemText} /> diff --git a/src/views-components/search-bar/search-bar-view.tsx b/src/views-components/search-bar/search-bar-view.tsx index 1a19b47d..ec1c8b83 100644 --- a/src/views-components/search-bar/search-bar-view.tsx +++ b/src/views-components/search-bar/search-bar-view.tsx @@ -14,6 +14,7 @@ import { ClickAwayListener } from '@material-ui/core'; import SearchIcon from '@material-ui/icons/Search'; +import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; import { ArvadosTheme } from '~/common/custom-theme'; import { SearchView } from '~/store/search-bar/search-bar-reducer'; import { @@ -49,7 +50,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => { }, input: { border: 'none', - padding: `0px ${theme.spacing.unit}px` + padding: `0` }, view: { position: 'absolute', @@ -132,14 +133,23 @@ export const SearchBarView = withStyles(styles)( disableUnderline={true} onClick={props.openSearchView} onKeyDown={e => handleKeyDown(e, props)} - endAdornment={ - + startAdornment={ + + } + endAdornment={ + + + props.onSetView(SearchView.ADVANCED)}> + + + + } />