Merge branch '15781-multi-value-property-edit'
authorLucas Di Pentima <lucas@di-pentima.com.ar>
Wed, 19 Feb 2020 21:43:32 +0000 (18:43 -0300)
committerLucas Di Pentima <lucas@di-pentima.com.ar>
Wed, 19 Feb 2020 21:43:32 +0000 (18:43 -0300)
Refs #15781

19 files changed:
src/common/config.ts
src/lib/resource-properties.test.ts [new file with mode: 0644]
src/lib/resource-properties.ts [new file with mode: 0644]
src/models/session.ts
src/services/api/filter-builder.test.ts
src/services/api/filter-builder.ts
src/services/auth-service/auth-service.ts
src/store/auth/auth-action-session.ts
src/store/auth/auth-action.test.ts
src/store/collection-panel/collection-panel-action.ts
src/store/details-panel/details-panel-action.ts
src/store/search-bar/search-bar-actions.test.ts
src/store/search-bar/search-bar-actions.ts
src/store/search-results-panel/search-results-middleware-service.ts
src/views-components/details-panel/project-details.tsx
src/views-components/project-properties-dialog/project-properties-dialog.tsx
src/views-components/resource-properties-form/property-chip.tsx
src/views-components/search-bar/search-bar-advanced-properties-view.tsx
src/views/collection-panel/collection-panel.tsx

index 23faaf91adbe7c75a16b566edc3538add8d6d5f6..58fa13ae62e6ee946ada2ab931ee54064a8c6e8f 100644 (file)
@@ -78,6 +78,7 @@ export class Config {
     fileViewersConfigUrl: string;
     loginCluster: string;
     clusterConfig: ClusterConfigJSON;
+    apiRevision: number;
 }
 
 export const buildConfig = (clusterConfigJSON: ClusterConfigJSON): Config => {
@@ -91,10 +92,21 @@ export const buildConfig = (clusterConfigJSON: ClusterConfigJSON): Config => {
     config.keepWebServiceUrl = clusterConfigJSON.Services.WebDAVDownload.ExternalURL;
     config.loginCluster = clusterConfigJSON.Login.LoginCluster;
     config.clusterConfig = clusterConfigJSON;
+    config.apiRevision = 0;
     mapRemoteHosts(clusterConfigJSON, config);
     return config;
 };
 
+const getApiRevision = async (apiUrl: string) => {
+    try {
+        const dd = (await Axios.get<any>(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data;
+        return parseInt(dd.revision, 10) || 0;
+    } catch {
+        console.warn("Unable to get API Revision number, defaulting to zero. Some features may not work properly.");
+        return 0;
+    }
+};
+
 export const fetchConfig = () => {
     return Axios
         .get<WorkbenchConfig>(WORKBENCH_CONFIG_URL + "?nocache=" + (new Date()).getTime())
@@ -107,9 +119,10 @@ export const fetchConfig = () => {
             if (workbenchConfig.API_HOST === undefined) {
                 throw new Error(`Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.`);
             }
-            return Axios.get<ClusterConfigJSON>(getClusterConfigURL(workbenchConfig.API_HOST)).then(response => {
+            return Axios.get<ClusterConfigJSON>(getClusterConfigURL(workbenchConfig.API_HOST)).then(async response => {
                 const clusterConfigJSON = response.data;
-                const config = buildConfig(clusterConfigJSON);
+                const apiRevision = await getApiRevision(clusterConfigJSON.Services.Controller.ExternalURL);
+                const config = {...buildConfig(clusterConfigJSON), apiRevision};
                 const warnLocalConfig = (varName: string) => console.warn(
                     `A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \
 remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}`);
@@ -192,6 +205,7 @@ export const mockConfig = (config: Partial<Config>): Config => ({
     fileViewersConfigUrl: "",
     loginCluster: "",
     clusterConfig: mockClusterConfigJSON({}),
+    apiRevision: 0,
     ...config
 });
 
diff --git a/src/lib/resource-properties.test.ts b/src/lib/resource-properties.test.ts
new file mode 100644 (file)
index 0000000..c70b231
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as _ from "./resource-properties";
+import { omit } from "lodash";
+
+describe("Resource properties lib", () => {
+
+    let properties: any;
+
+    beforeEach(() => {
+        properties = {
+            animal: 'dog',
+            color: ['brown', 'black'],
+            name: ['Toby']
+        }
+    })
+
+    it("should convert a single string value into a list when adding values", () => {
+        expect(
+            _.addProperty(properties, 'animal', 'cat')
+        ).toEqual({
+            ...properties, animal: ['dog', 'cat']
+        });
+    });
+
+    it("should convert a 2 value list into a string when removing values", () => {
+        expect(
+            _.deleteProperty(properties, 'color', 'brown')
+        ).toEqual({
+            ...properties, color: 'black'
+        });
+    });
+
+    it("shouldn't add duplicated key:value items", () => {
+        expect(
+            _.addProperty(properties, 'animal', 'dog')
+        ).toEqual(properties);
+    });
+
+    it("should remove the key when deleting from a one value list", () => {
+        expect(
+            _.deleteProperty(properties, 'name', 'Toby')
+        ).toEqual(omit(properties, 'name'));
+    });
+
+    it("should return the same when deleting non-existant value", () => {
+        expect(
+            _.deleteProperty(properties, 'animal', 'dolphin')
+        ).toEqual(properties);
+    });
+
+    it("should return the same when deleting non-existant key", () => {
+        expect(
+            _.deleteProperty(properties, 'doesntexist', 'something')
+        ).toEqual(properties);
+    });
+});
\ No newline at end of file
diff --git a/src/lib/resource-properties.ts b/src/lib/resource-properties.ts
new file mode 100644 (file)
index 0000000..02f13b6
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const deleteProperty = (properties: any, key: string, value: string) => {
+    if (Array.isArray(properties[key])) {
+        properties[key] = properties[key].filter((v: string) => v !== value);
+        if (properties[key].length === 1) {
+            properties[key] = properties[key][0];
+        } else if (properties[key].length === 0) {
+            delete properties[key];
+        }
+    } else if (properties[key] === value) {
+        delete properties[key];
+    }
+    return properties;
+}
+
+export const addProperty = (properties: any, key: string, value: string) => {
+    if (properties[key]) {
+        if (Array.isArray(properties[key])) {
+            properties[key] = [...properties[key], value];
+        } else {
+            properties[key] = [properties[key], value];
+        }
+        // Remove potential duplicate and save as single value if needed
+        properties[key] = Array.from(new Set(properties[key]));
+        if (properties[key].length === 1) {
+            properties[key] = properties[key][0];
+        }
+    } else {
+        properties[key] = value;
+    }
+    return properties;
+}
\ No newline at end of file
index 91a0d997606257391e0958f595dc3696475b9402..d388f59926e0f1235f3c3aef0101252b24d03fe0 100644 (file)
@@ -19,4 +19,5 @@ export interface Session {
     loggedIn: boolean;
     status: SessionStatus;
     active: boolean;
+    apiRevision: number;
 }
index d9656934cb80ff8ad9cd73177f2abfec0bc67c3e..a4e2b2290cc368afa1209eeef2a2f5e65b3e15c3 100644 (file)
@@ -36,6 +36,12 @@ describe("FilterBuilder", () => {
         ).toEqual(`["etag","ilike","%etagValue%"]`);
     });
 
+    it("should add 'contains' rule", () => {
+        expect(
+            filters.addContains("properties.someProp", "someValue").getFilters()
+        ).toEqual(`["properties.someProp","contains","someValue"]`);
+    });
+
     it("should add 'is_a' rule", () => {
         expect(
             filters.addIsA("etag", "etagValue").getFilters()
index 102ff62c60e3eb1afc1076bf7ca2d85589e0fe63..489f7b8947a4f962ce1768b57e383269b28800db 100644 (file)
@@ -25,6 +25,10 @@ export class FilterBuilder {
         return this.addCondition(field, "ilike", value, "%", "%", resourcePrefix);
     }
 
+    public addContains(field: string, value?: string, resourcePrefix?: string) {
+        return this.addCondition(field, "contains", value, "", "", resourcePrefix);
+    }
+
     public addIsA(field: string, value?: string | string[], resourcePrefix?: string) {
         return this.addCondition(field, "is_a", value, "", "", resourcePrefix);
     }
index c6e93a8fe777210e45d8f8a4a1bc9f2abb7fec33..690420e78449ff35815cd63e0b1644a65012793a 100644 (file)
@@ -135,7 +135,8 @@ export class AuthService {
             loggedIn: true,
             active: true,
             uuid: user ? user.uuid : '',
-            status: SessionStatus.VALIDATED
+            status: SessionStatus.VALIDATED,
+            apiRevision: cfg.apiRevision,
         } as Session;
         const localSessions = this.getSessions().map(s => ({
             ...s,
@@ -155,7 +156,8 @@ export class AuthService {
                 loggedIn: false,
                 active: false,
                 uuid: '',
-                status: SessionStatus.INVALIDATED
+                status: SessionStatus.INVALIDATED,
+                apiRevision: 0,
             } as Session;
         });
         const sessions = [currentSession]
index c1b97adc3ea0faa1e9b685832150cfe59bf119b8..a63878286a94dc407d1060c98a8e8c7c5e5cceda 100644 (file)
@@ -21,31 +21,37 @@ import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions
 import * as jsSHA from "jssha";
 
 const getClusterConfig = async (origin: string): Promise<Config | null> => {
-    // Try the new public config endpoint
+    let configFromDD: Config | undefined;
     try {
-        const config = (await Axios.get<ClusterConfigJSON>(`${origin}/${CLUSTER_CONFIG_PATH}`)).data;
-        return buildConfig(config);
-    } catch { }
-
-    // Fall back to discovery document
-    try {
-        const config = (await Axios.get<any>(`${origin}/${DISCOVERY_DOC_PATH}`)).data;
-        return {
-            baseUrl: normalizeURLPath(config.baseUrl),
-            keepWebServiceUrl: config.keepWebServiceUrl,
-            remoteHosts: config.remoteHosts,
-            rootUrl: config.rootUrl,
-            uuidPrefix: config.uuidPrefix,
-            websocketUrl: config.websocketUrl,
-            workbenchUrl: config.workbenchUrl,
-            workbench2Url: config.workbench2Url,
+        const dd = (await Axios.get<any>(`${origin}/${DISCOVERY_DOC_PATH}`)).data;
+        configFromDD = {
+            baseUrl: normalizeURLPath(dd.baseUrl),
+            keepWebServiceUrl: dd.keepWebServiceUrl,
+            remoteHosts: dd.remoteHosts,
+            rootUrl: dd.rootUrl,
+            uuidPrefix: dd.uuidPrefix,
+            websocketUrl: dd.websocketUrl,
+            workbenchUrl: dd.workbenchUrl,
+            workbench2Url: dd.workbench2Url,
             loginCluster: "",
             vocabularyUrl: "",
             fileViewersConfigUrl: "",
-            clusterConfig: mockClusterConfigJSON({})
+            clusterConfig: mockClusterConfigJSON({}),
+            apiRevision: parseInt(dd.revision, 10),
         };
     } catch { }
 
+    // Try the new public config endpoint
+    try {
+        const config = (await Axios.get<ClusterConfigJSON>(`${origin}/${CLUSTER_CONFIG_PATH}`)).data;
+        return {...buildConfig(config), apiRevision: configFromDD ? configFromDD.apiRevision : 0};
+    } catch { }
+
+    // Fall back to discovery document
+    if (configFromDD !== undefined) {
+        return configFromDD;
+    }
+
     return null;
 };
 
@@ -120,13 +126,14 @@ export const validateSession = (session: Session, activeSession: Session) =>
         dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED }));
         session.loggedIn = false;
 
-        const setupSession = (baseUrl: string, user: User, token: string) => {
+        const setupSession = (baseUrl: string, user: User, token: string, apiRevision: number) => {
             session.baseUrl = baseUrl;
             session.token = token;
             session.email = user.email;
             session.uuid = user.uuid;
             session.name = getUserFullname(user);
             session.loggedIn = true;
+            session.apiRevision = apiRevision;
         };
 
         let fail: Error | null = null;
@@ -135,12 +142,12 @@ export const validateSession = (session: Session, activeSession: Session) =>
             dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
             try {
                 const { user, token } = await validateCluster(config, session.token);
-                setupSession(config.baseUrl, user, token);
+                setupSession(config.baseUrl, user, token, config.apiRevision);
             } catch (e) {
                 fail = new Error(`Getting current user for ${session.remoteHost}: ${e.message}`);
                 try {
                     const { user, token } = await validateCluster(config, activeSession.token);
-                    setupSession(config.baseUrl, user, token);
+                    setupSession(config.baseUrl, user, token, config.apiRevision);
                     fail = null;
                 } catch (e2) {
                     if (e.message === invalidV2Token) {
@@ -227,7 +234,8 @@ export const addSession = (remoteHost: string, token?: string, sendToLogin?: boo
                     baseUrl: config.baseUrl,
                     clusterId: config.uuidPrefix,
                     remoteHost,
-                    token
+                    token,
+                    apiRevision: config.apiRevision,
                 };
 
                 if (sessions.find(s => s.clusterId === config.uuidPrefix)) {
index d126d9caeb52f519e2b643be84384311765cfa5e..8a17fe9f42da87b0360845580d2ce4e8fcdabb6f 100644 (file)
@@ -67,6 +67,7 @@ describe('auth-actions', () => {
             rootUrl: "https://zzzzz.arvadosapi.com",
             uuidPrefix: "zzzzz",
             remoteHosts: { xc59z: "xc59z.arvadosapi.com" },
+            apiRevision: 12345678,
         };
 
         store.dispatch(initAuth(config));
@@ -82,6 +83,7 @@ describe('auth-actions', () => {
                     expect(auth).toEqual({
                         apiToken: "token",
                         config: {
+                            apiRevision: 12345678,
                             remoteHosts: {
                                 "xc59z": "xc59z.arvadosapi.com",
                             },
@@ -94,6 +96,7 @@ describe('auth-actions', () => {
                         loginCluster: undefined,
                         remoteHostsConfig: {
                             "zzzzz": {
+                                "apiRevision": 12345678,
                                 "remoteHosts": {
                                     "xc59z": "xc59z.arvadosapi.com",
                                 },
@@ -114,8 +117,9 @@ describe('auth-actions', () => {
                             "remoteHost": "https://zzzzz.arvadosapi.com",
                             "status": 2,
                             "token": "token",
-                            "name": "John Doe"
-                   "uuid": "zzzzz-tpzed-abcefg",
+                            "name": "John Doe",
+                            "apiRevision": 12345678,
+                            "uuid": "zzzzz-tpzed-abcefg",
                         }, {
                             "active": false,
                             "baseUrl": "",
@@ -127,6 +131,7 @@ describe('auth-actions', () => {
                             "token": "",
                             "name": "",
                             "uuid": "",
+                            "apiRevision": 0,
                         }],
                         user: {
                             email: "test@test.com",
index 540b8c6a011b6ab80b2163b52000db20a050b6af..9922d8b58ab9768b925aca4f6e17e19f7474244b 100644 (file)
@@ -16,6 +16,7 @@ import { unionize, ofType, UnionOf } from '~/common/unionize';
 import { SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { navigateTo } from '~/store/navigation/navigation-action';
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
+import { addProperty, deleteProperty } from "~/lib/resource-properties";
 
 export const collectionPanelActions = unionize({
     SET_COLLECTION: ofType<CollectionResource>(),
@@ -42,23 +43,21 @@ export const loadCollectionPanel = (uuid: string) =>
 export const createCollectionTag = (data: TagProperty) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const item = getState().collectionPanel.item;
-        const uuid = item ? item.uuid : '';
+        if (!item) { return; }
+
+        const properties = Object.assign({}, item.properties);
         try {
-            if (item) {
-                const updatedCollection = await services.collectionService.update(
-                    uuid, {
-                        properties: {
-                            ...JSON.parse(JSON.stringify(item.properties)),
-                            [data.keyID || data.key]: data.valueID || data.value
-                        }
-                    }
-                );
-                item.properties = updatedCollection.properties;
-                dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-                return updatedCollection;
-            }
-            return;
+            const key = data.keyID || data.key;
+            const value = data.valueID || data.value;
+            const updatedCollection = await services.collectionService.update(
+                item.uuid, {
+                    properties: addProperty(properties, key, value)
+                }
+            );
+            dispatch(collectionPanelActions.SET_COLLECTION(updatedCollection));
+            dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            return updatedCollection;
         } catch (e) {
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
             return;
@@ -75,23 +74,22 @@ export const navigateToProcess = (uuid: string) =>
         }
     };
 
-export const deleteCollectionTag = (key: string) =>
+export const deleteCollectionTag = (key: string, value: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const item = getState().collectionPanel.item;
-        const uuid = item ? item.uuid : '';
+        if (!item) { return; }
+
+        const properties = Object.assign({}, item.properties);
         try {
-            if (item) {
-                delete item.properties[key];
-                const updatedCollection = await services.collectionService.update(
-                    uuid, {
-                        properties: {...item.properties}
-                    }
-                );
-                dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-                return updatedCollection;
-            }
-            return;
+            const updatedCollection = await services.collectionService.update(
+                item.uuid, {
+                    properties: deleteProperty(properties, key, value)
+                }
+            );
+            dispatch(collectionPanelActions.SET_COLLECTION(updatedCollection));
+            dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            return updatedCollection;
         } catch (e) {
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
             return;
index 6874671432ff8b31bd8bdf77092e753262b000b3..c5d472ade5a268de6d53ccaa39747b036438488e 100644 (file)
@@ -13,6 +13,7 @@ import { TagProperty } from '~/models/tag';
 import { startSubmit, stopSubmit } from 'redux-form';
 import { resourcesActions } from '~/store/resources/resources-actions';
 import {snackbarActions, SnackbarKind} from '~/store/snackbar/snackbar-actions';
+import { addProperty, deleteProperty } from '~/lib/resource-properties';
 
 export const SLIDE_TIMEOUT = 500;
 
@@ -36,20 +37,23 @@ export const openProjectPropertiesDialog = () =>
         dispatch<any>(dialogActions.OPEN_DIALOG({ id: PROJECT_PROPERTIES_DIALOG_NAME, data: { } }));
     };
 
-export const deleteProjectProperty = (key: string) =>
+export const deleteProjectProperty = (key: string, value: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { detailsPanel, resources } = getState();
         const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
+        if (!project) { return; }
+
+        const properties = Object.assign({}, project.properties);
+
         try {
-            if (project) {
-                delete project.properties[key];
-                const updatedProject = await services.projectService.update(project.uuid, { properties: project.properties });
-                dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-            }
+            const updatedProject = await services.projectService.update(
+                project.uuid, {
+                    properties: deleteProperty(properties, key, value),
+                });
+            dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
         } catch (e) {
-            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_PROPERTIES_FORM_NAME }));
-            throw new Error('Could not remove property from the project.');
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
@@ -57,25 +61,23 @@ export const createProjectProperty = (data: TagProperty) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { detailsPanel, resources } = getState();
         const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
+        if (!project) { return; }
+
         dispatch(startSubmit(PROJECT_PROPERTIES_FORM_NAME));
         try {
-            if (project) {
-                const updatedProject = await services.projectService.update(
-                    project.uuid, {
-                        properties: {
-                            ...JSON.parse(JSON.stringify(project.properties)),
-                            [data.keyID || data.key]: data.valueID || data.value
-                        }
-                    }
-                );
-                dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-                dispatch(stopSubmit(PROJECT_PROPERTIES_FORM_NAME));
-            }
-            return;
+            const key = data.keyID || data.key;
+            const value = data.valueID || data.value;
+            const properties = Object.assign({}, project.properties);
+            const updatedProject = await services.projectService.update(
+                project.uuid, {
+                    properties: addProperty(properties, key, value),
+                }
+            );
+            dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            dispatch(stopSubmit(PROJECT_PROPERTIES_FORM_NAME));
         } catch (e) {
-            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_PROPERTIES_FORM_NAME }));
-            throw new Error('Could not add property to the project.');
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 export const toggleDetailsPanel = () => (dispatch: Dispatch) => {
index 51a73cc39b7a3e4e7680457fa522311c4697eefe..68804dfb6393e885de4bf91b0f3041a4ff46fac2 100644 (file)
@@ -59,14 +59,100 @@ describe('search-bar-actions', () => {
                 inTrash: true,
                 dateFrom: '2017-08-01',
                 dateTo: '',
-                properties: [{
-                    key: 'file size',
-                    value: '101mb'
-                }],
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Human' },
+                    { key: 'Species', value: 'Canine' },
+                ],
                 saveQuery: false,
                 queryName: ''
             });
-            expect(q).toBe('document pdf type:arvados#collection cluster:c97qx is:trashed from:2017-08-01 has:"file size":"101mb"');
+            expect(q).toBe('document pdf type:arvados#collection cluster:c97qx is:trashed from:2017-08-01 has:"file size":"101mb" has:"Species":"Human" has:"Species":"Canine"');
+        });
+
+        it('should add has:"key":"value" expression to query from same property key', () => {
+            const searchValue = 'document pdf has:"file size":"101mb" has:"Species":"Canine"';
+            const prevData = {
+                searchValue,
+                type: undefined,
+                cluster: undefined,
+                projectUuid: undefined,
+                inTrash: false,
+                dateFrom: '',
+                dateTo: '',
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Canine' },
+                ],
+                saveQuery: false,
+                queryName: ''
+            };
+            const currData = {
+                ...prevData,
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Canine' },
+                    { key: 'Species', value: 'Human' },
+                ],
+            };
+            const q = getQueryFromAdvancedData(currData, prevData);
+            expect(q).toBe('document pdf has:"file size":"101mb" has:"Species":"Canine" has:"Species":"Human"');
+        });
+
+        it('should add has:"keyID":"valueID" expression to query when necessary', () => {
+            const searchValue = 'document pdf has:"file size":"101mb"';
+            const prevData = {
+                searchValue,
+                type: undefined,
+                cluster: undefined,
+                projectUuid: undefined,
+                inTrash: false,
+                dateFrom: '',
+                dateTo: '',
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                ],
+                saveQuery: false,
+                queryName: ''
+            };
+            const currData = {
+                ...prevData,
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', keyID: 'IDTAGSPECIES', value: 'Human', valueID: 'IDVALHUMAN'},
+                ],
+            };
+            const q = getQueryFromAdvancedData(currData, prevData);
+            expect(q).toBe('document pdf has:"file size":"101mb" has:"IDTAGSPECIES":"IDVALHUMAN"');
+        });
+
+        it('should remove has:"key":"value" expression from query', () => {
+            const searchValue = 'document pdf has:"file size":"101mb" has:"Species":"Human" has:"Species":"Canine"';
+            const prevData = {
+                searchValue,
+                type: undefined,
+                cluster: undefined,
+                projectUuid: undefined,
+                inTrash: false,
+                dateFrom: '',
+                dateTo: '',
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Canine' },
+                    { key: 'Species', value: 'Human' },
+                ],
+                saveQuery: false,
+                queryName: ''
+            };
+            const currData = {
+                ...prevData,
+                properties: [
+                    { key: 'file size', value: '101mb' },
+                    { key: 'Species', value: 'Canine' },
+                ],
+            };
+            const q = getQueryFromAdvancedData(currData, prevData);
+            expect(q).toBe('document pdf has:"file size":"101mb" has:"Species":"Canine"');
         });
     });
 });
index b91dc9d10fdbc1c8a6b0a063a30b9a20fbe96418..d9dc0a64905aa5c3596e244f36b935e53bc7d4f4 100644 (file)
@@ -212,7 +212,7 @@ const searchGroups = (searchValue: string, limit: number) =>
             const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue);
             const sessions = getSearchSessions(clusterId, getState().auth.sessions);
             const lists: ListResults<GroupContentsResource>[] = await Promise.all(sessions.map(session => {
-                const filters = queryToFilters(searchValue);
+                const filters = queryToFilters(searchValue, session.apiRevision);
                 return services.groupsService.contents('', {
                     filters,
                     limit,
@@ -225,34 +225,36 @@ const searchGroups = (searchValue: string, limit: number) =>
         }
     };
 
-const buildQueryFromKeyMap = (data: any, keyMap: string[][], mode: 'rebuild' | 'reuse') => {
+const buildQueryFromKeyMap = (data: any, keyMap: string[][]) => {
     let value = data.searchValue;
 
     const addRem = (field: string, key: string) => {
         const v = data[key];
-
+        // Remove previous search expression.
         if (data.hasOwnProperty(key)) {
-            const pattern = v === false
-                ? `${field.replace(':', '\\:\\s*')}\\s*`
-                : `${field.replace(':', '\\:\\s*')}\\:\\s*"[\\w|\\#|\\-|\\/]*"\\s*`;
+            let pattern: string;
+            if (v === false) {
+                pattern = `${field.replace(':', '\\:\\s*')}\\s*`;
+            } else if (key.startsWith('prop-')) {
+                // On properties, only remove key:value duplicates, allowing
+                // multiple properties with the same key.
+                const oldValue = key.slice(5).split(':')[1];
+                pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*${oldValue}\\s*`;
+            } else {
+                pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*[\\w|\\#|\\-|\\/]*\\s*`;
+            }
             value = value.replace(new RegExp(pattern), '');
         }
-
+        // Re-add it with the current search value.
         if (v) {
             const nv = v === true
                 ? `${field}`
                 : `${field}:${v}`;
-
-            if (mode === 'rebuild') {
-                value = value + ' ' + nv;
-            } else {
-                value = nv + ' ' + value;
-            }
+            // Always append to the end to keep user-entered text at the start.
+            value = value + ' ' + nv;
         }
     };
-
     keyMap.forEach(km => addRem(km[0], km[1]));
-
     return value;
 };
 
@@ -269,7 +271,9 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevDa
             dateFrom: data.dateFrom,
             dateTo: data.dateTo,
         };
-        (data.properties || []).forEach(p => fo[`prop-"${p.keyID || p.key}"`] = `"${p.valueID || p.value}"`);
+        (data.properties || []).forEach(p =>
+            fo[`prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`] = `"${p.valueID || p.value}"`
+            );
         return fo;
     };
 
@@ -282,17 +286,13 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevDa
         ['to', 'dateTo']
     ];
     _.union(data.properties, prevData ? prevData.properties : [])
-        .forEach(p => keyMap.push([`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}"`]));
+        .forEach(p => keyMap.push(
+            [`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`]
+        ));
 
-    if (prevData) {
-        const obj = getModifiedKeysValues(flatData(data), flatData(prevData));
-        value = buildQueryFromKeyMap({
-            searchValue: data.searchValue,
-            ...obj
-        } as SearchBarAdvancedFormData, keyMap, "reuse");
-    } else {
-        value = buildQueryFromKeyMap(flatData(data), keyMap, "rebuild");
-    }
+    const modified = getModifiedKeysValues(flatData(data), prevData ? flatData(prevData):{});
+    value = buildQueryFromKeyMap(
+        {searchValue: data.searchValue, ...modified} as SearchBarAdvancedFormData, keyMap);
 
     value = value.trim();
     return value;
@@ -329,7 +329,7 @@ export const getSearchSessions = (clusterId: string | undefined, sessions: Sessi
     return sessions.filter(s => s.loggedIn && (!clusterId || s.clusterId === clusterId));
 };
 
-export const queryToFilters = (query: string) => {
+export const queryToFilters = (query: string, apiRevision: number) => {
     const data = getAdvancedDataFromQuery(query);
     const filter = new FilterBuilder();
     const resourceKind = data.type;
@@ -352,9 +352,17 @@ export const queryToFilters = (query: string) => {
 
     data.properties.forEach(p => {
         if (p.value) {
-            filter
-                .addILike(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
-                .addILike(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION);
+            if (apiRevision < 20200212) {
+                filter
+                    .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
+                    .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION)
+                    .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROCESS);
+            } else {
+                filter
+                    .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
+                    .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION)
+                    .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROCESS);
+            }
         }
         filter.addExists(p.key);
     });
index 84e68ab0d07d7a4ed2efacd39e287772f375a279..f054a4e4e83637949ad60665ca17647fbbc7f7c4 100644 (file)
@@ -42,8 +42,6 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic
             return;
         }
 
-        const params = getParams(dataExplorer, searchValue);
-
         const initial = {
             itemsAvailable: 0,
             items: [] as GroupContentsResource[],
@@ -56,24 +54,26 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic
             api.dispatch(setItems(initial));
         }
 
-        sessions.map(session =>
+        sessions.map(session => {
+            const params = getParams(dataExplorer, searchValue, session.apiRevision);
             this.services.groupsService.contents('', params, session)
                 .then((response) => {
                     api.dispatch(updateResources(response.items));
                     api.dispatch(appendItems(response));
                 }).catch(() => {
                     api.dispatch(couldNotFetchSearchResults(session.clusterId));
-                })
+                });
+            }
         );
     }
 }
 
 const typeFilters = (columns: DataColumns<string>) => serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
 
-export const getParams = (dataExplorer: DataExplorer, query: string) => ({
+const getParams = (dataExplorer: DataExplorer, query: string, apiRevision: number) => ({
     ...dataExplorerToListParams(dataExplorer),
     filters: joinFilters(
-        queryToFilters(query),
+        queryToFilters(query, apiRevision),
         typeFilters(dataExplorer.columns)
     ),
     order: getOrder(dataExplorer),
index 59035da115574b075a4d58eb0ab5d610302046be..1be04b00ee8d31e31a94530cb12754df2aee2084 100644 (file)
@@ -16,7 +16,7 @@ import { RichTextEditorLink } from '~/components/rich-text-editor-link/rich-text
 import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { Dispatch } from 'redux';
-import { PropertyChipComponent } from '../resource-properties-form/property-chip';
+import { getPropertyChip } from '../resource-properties-form/property-chip';
 
 export class ProjectDetails extends DetailsData<ProjectResource> {
     getIcon(className?: string) {
@@ -83,9 +83,10 @@ const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
             </DetailsAttribute>
             {
                 Object.keys(project.properties).map(k =>
-                    <PropertyChipComponent key={k}
-                        propKey={k} propValue={project.properties[k]}
-                        className={classes.tag} />
+                    Array.isArray(project.properties[k])
+                    ? project.properties[k].map((v: string) =>
+                        getPropertyChip(k, v, undefined, classes.tag))
+                    : getPropertyChip(k, project.properties[k], undefined, classes.tag)
                 )
             }
         </div>
index 7a4cfba6c56e5d133c2a61a94101f33c2d01cd3f..e1874d9548fe557f91bb7253a4efca839f6c064f 100644 (file)
@@ -13,7 +13,7 @@ import { Dialog, DialogTitle, DialogContent, DialogActions, Button, withStyles,
 import { ArvadosTheme } from '~/common/custom-theme';
 import { ProjectPropertiesForm } from '~/views-components/project-properties-dialog/project-properties-form';
 import { getResource } from '~/store/resources/resources';
-import { PropertyChipComponent } from "../resource-properties-form/property-chip";
+import { getPropertyChip } from "../resource-properties-form/property-chip";
 
 type CssRules = 'tag';
 
@@ -29,7 +29,7 @@ interface ProjectPropertiesDialogDataProps {
 }
 
 interface ProjectPropertiesDialogActionProps {
-    handleDelete: (key: string) => void;
+    handleDelete: (key: string, value: string) => void;
 }
 
 const mapStateToProps = ({ detailsPanel, resources, properties }: RootState): ProjectPropertiesDialogDataProps => ({
@@ -37,7 +37,7 @@ const mapStateToProps = ({ detailsPanel, resources, properties }: RootState): Pr
 });
 
 const mapDispatchToProps = (dispatch: Dispatch): ProjectPropertiesDialogActionProps => ({
-    handleDelete: (key: string) => dispatch<any>(deleteProjectProperty(key)),
+    handleDelete: (key: string, value: string) => () => dispatch<any>(deleteProjectProperty(key, value)),
 });
 
 type ProjectPropertiesDialogProps =  ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
@@ -55,10 +55,17 @@ export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToPro
                     <ProjectPropertiesForm />
                     {project && project.properties &&
                         Object.keys(project.properties).map(k =>
-                            <PropertyChipComponent
-                                onDelete={() => handleDelete(k)}
-                                key={k} className={classes.tag}
-                                propKey={k} propValue={project.properties[k]} />)
+                            Array.isArray(project.properties[k])
+                            ? project.properties[k].map((v: string) =>
+                                getPropertyChip(
+                                    k, v,
+                                    handleDelete(k, v),
+                                    classes.tag))
+                            : getPropertyChip(
+                                k, project.properties[k],
+                                handleDelete(k, project.properties[k]),
+                                classes.tag)
+                        )
                     }
                 </DialogContent>
                 <DialogActions>
index c51a8d8e0cfb5cfc3d736aa8435c21b9d75e96d4..1fba8a40a98b1dbdc1a22386a8f10ba41bdb8e58 100644 (file)
@@ -50,3 +50,9 @@ export const PropertyChipComponent = connect(mapStateToProps, mapDispatchToProps
         );
     }
 );
+
+export const getPropertyChip = (k:string, v:string, handleDelete:any, className:string) =>
+    <PropertyChipComponent
+        key={`${k}-${v}`} className={className}
+        onDelete={handleDelete}
+        propKey={k} propValue={v} />;
index eb049b7625262dfe5caee013d21681f8df3bc0ae..f3509a028988f7f829c018c8b176df55f86f3418 100644 (file)
@@ -20,6 +20,7 @@ import { Chips } from '~/components/chips/chips';
 import { formatPropertyValue } from "~/common/formatters";
 import { Vocabulary } from '~/models/vocabulary';
 import { connectVocabulary } from '../resource-properties-form/property-field-common';
+import * as _ from 'lodash';
 
 type CssRules = 'label' | 'button';
 
@@ -45,7 +46,7 @@ interface SearchBarAdvancedPropertiesViewDataProps {
 
 interface SearchBarAdvancedPropertiesViewActionProps {
     setProps: () => void;
-    setProp: (propertyValues: PropertyValue, properties: PropertyValue[]) => void;
+    addProp: (propertyValues: PropertyValue, properties: PropertyValue[]) => void;
     getAllFields: (propertyValues: PropertyValue[]) => PropertyValue[] | [];
 }
 
@@ -64,10 +65,19 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
     setProps: (propertyValues: PropertyValue[]) => {
         dispatch<any>(changeAdvancedFormProperty('properties', propertyValues));
     },
-    setProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => {
+    addProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => {
+        // Remove potential duplicates
+        properties = properties.filter(x => ! _.isEqual(
+            {
+                key: x.keyID || x.key,
+                value: x.valueID || x.value
+            }, {
+                key: propertyValue.keyID || propertyValue.key,
+                value: propertyValue.valueID || propertyValue.value
+            }));
         dispatch<any>(changeAdvancedFormProperty(
             'properties',
-            [...properties.filter(e => e.keyID! !== propertyValue.keyID!), propertyValue]
+            [...properties, propertyValue]
         ));
         dispatch<any>(resetAdvancedFormProperty('key'));
         dispatch<any>(resetAdvancedFormProperty('value'));
@@ -83,7 +93,7 @@ export const SearchBarAdvancedPropertiesView = compose(
     connectVocabulary,
     connect(mapStateToProps, mapDispatchToProps))(
     withStyles(styles)(
-        ({ classes, fields, propertyValues, setProps, setProp, getAllFields, vocabulary }: SearchBarAdvancedPropertiesViewProps) =>
+        ({ classes, fields, propertyValues, setProps, addProp, getAllFields, vocabulary }: SearchBarAdvancedPropertiesViewProps) =>
             <Grid container item xs={12} spacing={16}>
                 <Grid item xs={2} className={classes.label}>Properties</Grid>
                 <Grid item xs={4}>
@@ -93,7 +103,7 @@ export const SearchBarAdvancedPropertiesView = compose(
                     <SearchBarValueField />
                 </Grid>
                 <Grid container item xs={2} justify='flex-end' alignItems="center">
-                    <Button className={classes.button} onClick={() => setProp(propertyValues, getAllFields(fields))}
+                    <Button className={classes.button} onClick={() => addProp(propertyValues, getAllFields(fields))}
                         color="primary"
                         size='small'
                         variant="contained"
index b92557f9de35b59557318ea6a4ba8b69f5f3c588..c4221937e74bd4079fb5ad252a63d3b5e8d25641 100644 (file)
@@ -23,7 +23,7 @@ import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
 import { formatFileSize } from "~/common/formatters";
 import { openDetailsPanel } from '~/store/details-panel/details-panel-action';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-import { PropertyChipComponent } from '~/views-components/resource-properties-form/property-chip';
+import { getPropertyChip } from '~/views-components/resource-properties-form/property-chip';
 import { IllegalNamingWarning } from '~/components/warning/warning';
 
 type CssRules = 'card' | 'iconHeader' | 'tag' | 'label' | 'value' | 'link';
@@ -128,10 +128,16 @@ export const CollectionPanel = withStyles(styles)(
                                     </Grid>
                                     <Grid item xs={12}>
                                         {Object.keys(item.properties).map(k =>
-                                            <PropertyChipComponent
-                                                key={k} className={classes.tag}
-                                                onDelete={this.handleDelete(k)}
-                                                propKey={k} propValue={item.properties[k]} />
+                                            Array.isArray(item.properties[k])
+                                            ? item.properties[k].map((v: string) =>
+                                                getPropertyChip(
+                                                    k, v,
+                                                    this.handleDelete(k, v),
+                                                    classes.tag))
+                                            : getPropertyChip(
+                                                k, item.properties[k],
+                                                this.handleDelete(k, item.properties[k]),
+                                                classes.tag)
                                         )}
                                     </Grid>
                                 </Grid>
@@ -166,8 +172,8 @@ export const CollectionPanel = withStyles(styles)(
                     kind: SnackbarKind.SUCCESS
                 }))
 
-            handleDelete = (key: string) => () => {
-                this.props.dispatch<any>(deleteCollectionTag(key));
+            handleDelete = (key: string, value: string) => () => {
+                this.props.dispatch<any>(deleteCollectionTag(key, value));
             }
 
             openCollectionDetails = () => {