15781: Merge branch 'master' into 15781-multi-value-property-edit
authorLucas Di Pentima <lucas@di-pentima.com.ar>
Tue, 11 Feb 2020 13:54:41 +0000 (10:54 -0300)
committerLucas Di Pentima <lucas@di-pentima.com.ar>
Tue, 11 Feb 2020 13:54:41 +0000 (10:54 -0300)
Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas@di-pentima.com.ar>

12 files changed:
src/lib/resource-properties.test.ts [new file with mode: 0644]
src/lib/resource-properties.ts [new file with mode: 0644]
src/services/api/filter-builder.test.ts
src/services/api/filter-builder.ts
src/store/collection-panel/collection-panel-action.ts
src/store/details-panel/details-panel-action.ts
src/store/search-bar/search-bar-actions.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

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 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 540b8c6a011b6ab80b2163b52000db20a050b6af..fee5bcd6b11c097f1c6e08220084db7aa4f7b5a9 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 { deleteProperty, addProperty } from "~/lib/resource-properties";
 
 export const collectionPanelActions = unionize({
     SET_COLLECTION: ofType<CollectionResource>(),
@@ -45,12 +46,12 @@ export const createCollectionTag = (data: TagProperty) =>
         const uuid = item ? item.uuid : '';
         try {
             if (item) {
+                const key = data.keyID || data.key;
+                const value = data.valueID || data.value;
+                item.properties = addProperty(item.properties, key, value);
                 const updatedCollection = await services.collectionService.update(
                     uuid, {
-                        properties: {
-                            ...JSON.parse(JSON.stringify(item.properties)),
-                            [data.keyID || data.key]: data.valueID || data.value
-                        }
+                        properties: {...item.properties}
                     }
                 );
                 item.properties = updatedCollection.properties;
@@ -75,13 +76,14 @@ 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 : '';
         try {
             if (item) {
-                delete item.properties[key];
+                item.properties = deleteProperty(item.properties, key, value);
+
                 const updatedCollection = await services.collectionService.update(
                     uuid, {
                         properties: {...item.properties}
index 6874671432ff8b31bd8bdf77092e753262b000b3..e0d720177d0aee2619f07a474dababbc374b488a 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,13 +37,13 @@ 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;
         try {
             if (project) {
-                delete project.properties[key];
+                project.properties = deleteProperty(project.properties, key, value);
                 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 }));
@@ -60,12 +61,12 @@ export const createProjectProperty = (data: TagProperty) =>
         dispatch(startSubmit(PROJECT_PROPERTIES_FORM_NAME));
         try {
             if (project) {
+                const key = data.keyID || data.key;
+                const value = data.valueID || data.value;
+                project.properties = addProperty(project.properties, key, value);
                 const updatedProject = await services.projectService.update(
                     project.uuid, {
-                        properties: {
-                            ...JSON.parse(JSON.stringify(project.properties)),
-                            [data.keyID || data.key]: data.valueID || data.value
-                        }
+                        properties: {...project.properties}
                     }
                 );
                 dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
index b91dc9d10fdbc1c8a6b0a063a30b9a20fbe96418..4af132ea9c6efa5d3c3a4594fbaeb0e08190af6f 100644 (file)
@@ -232,9 +232,16 @@ const buildQueryFromKeyMap = (data: any, keyMap: string[][], mode: 'rebuild' | '
         const v = data[key];
 
         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.
+                pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*${v}\\s*`;
+            } else {
+                pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*[\\w|\\#|\\-|\\/]*\\s*`;
+            }
             value = value.replace(new RegExp(pattern), '');
         }
 
@@ -353,8 +360,8 @@ 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);
+                .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
+                .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION);
         }
         filter.addExists(p.key);
     });
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..f25deb70ebd34222f49042adcab322433ffd7eb7 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} className={className}
+        onDelete={handleDelete}
+        propKey={k} propValue={v} />;
index eb049b7625262dfe5caee013d21681f8df3bc0ae..8add4b025b4dfb29e7f8a957287bf91a442360f1 100644 (file)
@@ -67,7 +67,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
     setProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => {
         dispatch<any>(changeAdvancedFormProperty(
             'properties',
-            [...properties.filter(e => e.keyID! !== propertyValue.keyID!), propertyValue]
+            [...properties, propertyValue]
         ));
         dispatch<any>(resetAdvancedFormProperty('key'));
         dispatch<any>(resetAdvancedFormProperty('value'));
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 = () => {