Merge branch '15067-tag-editing-by-ids'
authorLucas Di Pentima <lucas@di-pentima.com.ar>
Fri, 15 Nov 2019 17:12:58 +0000 (14:12 -0300)
committerLucas Di Pentima <lucas@di-pentima.com.ar>
Fri, 15 Nov 2019 17:12:58 +0000 (14:12 -0300)
Refs #15067

Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <ldipentima@veritasgenetics.com>

17 files changed:
public/vocabulary-example.json
src/components/autocomplete/autocomplete.tsx
src/models/tag.ts
src/models/vocabulary.test.ts [new file with mode: 0644]
src/models/vocabulary.ts
src/store/collection-panel/collection-panel-action.ts
src/store/details-panel/details-panel-action.ts
src/store/vocabulary/vocabulary-actions.ts
src/store/vocabulary/vocabulary-selectors.ts [moved from src/store/vocabulary/vocabulary-selctors.ts with 95% similarity]
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 [new file with mode: 0644]
src/views-components/resource-properties-form/property-field-common.tsx
src/views-components/resource-properties-form/property-key-field.tsx
src/views-components/resource-properties-form/property-value-field.tsx
src/views-components/resource-properties-form/resource-properties-form.tsx
src/views/collection-panel/collection-panel.tsx

index b227dc23b26c7fdf2dccfa4f25d7f05c557d2541..59d4de7aab3f9a69b1bac6af67225b01c107083e 100644 (file)
 {
-    "strict": false,
+    "strict_tags": false,
     "tags": {
-        "fruit": {
-            "values": ["pineapple", "tomato", "orange", "banana", "advocado", "lemon", "apple", "peach", "strawberry"],
-            "strict": true
+        "IDTAGFRUITS": {
+            "strict": false,
+            "labels": [
+                {"label": "Fruit"}
+            ],
+            "values": {
+                "IDVALFRUITS1": {
+                    "labels": [
+                        {"label": "Pineapple"}
+                    ]
+                },
+                "IDVALFRUITS2": {
+                    "labels": [
+                        {"label": "Tomato"}
+                    ]
+                },
+                "IDVALFRUITS3": {
+                    "labels": [
+                        {"label": "Orange"}
+                    ]
+                },
+                "IDVALFRUITS4": {
+                    "labels": [
+                        {"label": "Banana"}
+                    ]
+                },
+                "IDVALFRUITS5": {
+                    "labels": [
+                        {"label": "Advocado"}
+                    ]
+                },
+                "IDVALFRUITS6": {
+                    "labels": [
+                        {"label": "Lemon"}
+                    ]
+                },
+                "IDVALFRUITS7": {
+                    "labels": [
+                        {"label": "Apple"}
+                    ]
+                },
+                "IDVALFRUITS8": {
+                    "labels": [
+                        {"label": "Peach"}
+                    ]
+                },
+                "IDVALFRUITS9": {
+                    "labels": [
+                        {"label": "Strawberry"}
+                    ]
+                }
+            }
         },
-        "animal": {
-            "values": ["human", "dog", "elephant", "eagle"],
-            "strict": false
+        "IDTAGANIMALS": {
+            "strict": false,
+            "labels": [
+                {"label": "Animal" },
+                {"label": "Creature"}
+            ],
+            "values": {
+                "IDVALANIMALS1": {
+                    "labels": [
+                        {"label": "Human"},
+                        {"label": "Homo sapiens"}
+                    ]
+                },
+                "IDVALANIMALS2": {
+                    "labels": [
+                        {"label": "Dog"},
+                        {"label": "Canis lupus familiaris"}
+                    ]
+                },
+                "IDVALANIMALS3": {
+                    "labels": [
+                        {"label": "Elephant"},
+                        {"label": "Loxodonta"}
+                    ]
+                },
+                "IDVALANIMALS4": {
+                    "labels": [
+                        {"label": "Eagle"},
+                        {"label": "Haliaeetus leucocephalus"}
+                    ]
+                }
+            }
         },
-        "color": {
-            "values": ["yellow", "red", "magenta", "green"],
-            "strict": false
+        "IDTAGCOLORS": {
+            "strict": false,
+            "labels": [
+                {"label": "Color"}
+            ],
+            "values": {
+                "IDVALCOLORS1": {
+                    "labels": [
+                        {"label": "Yellow"}
+                    ]
+                },
+                "IDVALCOLORS2": {
+                    "labels": [
+                        {"label": "Red"}
+                    ]
+                },
+                "IDVALCOLORS3": {
+                    "labels": [
+                        {"label": "Magenta"}
+                    ]
+                },
+                "IDVALCOLORS4": {
+                    "labels": [
+                        {"label": "Green"}
+                    ]
+                }
+            }
         },
-        "text": {},
-        "category": {
-            "values": ["experimental", "development", "production"]
+        "IDTAGCOMMENT": {
+            "labels": [
+                {"label": "Comment"},
+                {"label": "Text"}
+            ]
         },
-        "comments": {},
-        "importance": {
-            "values": ["critical", "important", "low priority"]
+        "IDTAGCATEGORIES": {
+            "strict": true,
+            "labels": [
+                {"label": "Category"}
+            ],
+            "values": {
+                "IDTAGCAT1": {
+                    "labels": [
+                        {"label": "Experimental"}
+                    ]
+                },
+                "IDTAGCAT2": {
+                    "labels": [
+                        {"label": "Development"}
+                    ]
+                },
+                "IDTAGCAT3": {
+                    "labels": [
+                        {"label": "Production"}
+                    ]
+                }
+            }
         },
-        "size": {
-            "values": ["x-small", "small", "medium", "large", "x-large"]
+        "IDTAGIMPORTANCES": {
+            "strict": true,
+            "labels": [
+                {"label": "Importance"},
+                {"label": "Priority"}
+            ],
+            "values": {
+                "IDVALIMPORTANCES1": {
+                    "labels": [
+                        {"label": "Critical"},
+                        {"label": "Urgent"},
+                        {"label": "High"}
+                    ]
+                },
+                "IDVALIMPORTANCES2": {
+                    "labels": [
+                        {"label": "Normal"},
+                        {"label": "Moderate"}
+                    ]
+                },
+                "IDVALIMPORTANCES3": {
+                    "labels": [
+                        {"label": "Low"}
+                    ]
+                }
+            }
         },
-        "country": {
-            "values": ["Afghanistan","Ă…land Islands","Albania","Algeria","American Samoa","AndorrA","Angola","Anguilla","Antarctica","Antigua and Barbuda","Argentina","Armenia","Aruba","Australia","Austria","Azerbaijan","Bahamas","Bahrain","Bangladesh","Barbados","Belarus","Belgium","Belize","Benin","Bermuda","Bhutan","Bolivia","Bosnia and Herzegovina","Botswana","Bouvet Island","Brazil","British Indian Ocean Territory","Brunei Darussalam","Bulgaria","Burkina Faso","Burundi","Cambodia","Cameroon","Canada","Cape Verde","Cayman Islands","Central African Republic","Chad","Chile","China","Christmas Island","Cocos (Keeling) Islands","Colombia","Comoros","Congo","Congo, The Democratic Republic of the","Cook Islands","Costa Rica","Cote D'Ivoire","Croatia","Cuba","Cyprus","Czech Republic","Denmark","Djibouti","Dominica","Dominican Republic","Ecuador","Egypt","El Salvador","Equatorial Guinea","Eritrea","Estonia","Ethiopia","Falkland Islands (Malvinas)","Faroe Islands","Fiji","Finland","France","French Guiana","French Polynesia","French Southern Territories","Gabon","Gambia","Georgia","Germany","Ghana","Gibraltar","Greece","Greenland","Grenada","Guadeloupe","Guam","Guatemala","Guernsey","Guinea","Guinea-Bissau","Guyana","Haiti","Heard Island and Mcdonald Islands","Holy See (Vatican City State)","Honduras","Hong Kong","Hungary","Iceland","India","Indonesia","Iran, Islamic Republic Of","Iraq","Ireland","Isle of Man","Israel","Italy","Jamaica","Japan","Jersey","Jordan","Kazakhstan","Kenya","Kiribati","Korea, Democratic People'S Republic of","Korea, Republic of","Kuwait","Kyrgyzstan","Lao People'S Democratic Republic","Latvia","Lebanon","Lesotho","Liberia","Libyan Arab Jamahiriya","Liechtenstein","Lithuania","Luxembourg","Macao","Macedonia, The Former Yugoslav Republic of","Madagascar","Malawi","Malaysia","Maldives","Mali","Malta","Marshall Islands","Martinique","Mauritania","Mauritius","Mayotte","Mexico","Micronesia, Federated States of","Moldova, Republic of","Monaco","Mongolia","Montserrat","Morocco","Mozambique","Myanmar","Namibia","Nauru","Nepal","Netherlands","Netherlands Antilles","New Caledonia","New Zealand","Nicaragua","Niger","Nigeria","Niue","Norfolk Island","Northern Mariana Islands","Norway","Oman","Pakistan","Palau","Palestinian Territory, Occupied","Panama","Papua New Guinea","Paraguay","Peru","Philippines","Pitcairn","Poland","Portugal","Puerto Rico","Qatar","Reunion","Romania","Russian Federation","RWANDA","Saint Helena","Saint Kitts and Nevis","Saint Lucia","Saint Pierre and Miquelon","Saint Vincent and the Grenadines","Samoa","San Marino","Sao Tome and Principe","Saudi Arabia","Senegal","Serbia and Montenegro","Seychelles","Sierra Leone","Singapore","Slovakia","Slovenia","Solomon Islands","Somalia","South Africa","South Georgia and the South Sandwich Islands","Spain","Sri Lanka","Sudan","Suriname","Svalbard and Jan Mayen","Swaziland","Sweden","Switzerland","Syrian Arab Republic","Taiwan, Province of China","Tajikistan","Tanzania, United Republic of","Thailand","Timor-Leste","Togo","Tokelau","Tonga","Trinidad and Tobago","Tunisia","Turkey","Turkmenistan","Turks and Caicos Islands","Tuvalu","Uganda","Ukraine","United Arab Emirates","United Kingdom","United States","United States Minor Outlying Islands","Uruguay","Uzbekistan","Vanuatu","Venezuela","Viet Nam","Virgin Islands, British","Virgin Islands, U.S.","Wallis and Futuna","Western Sahara","Yemen","Zambia","Zimbabwe"],
-            "strict": true
+        "IDTAGSIZES": {
+            "strict": true,
+            "labels": [
+                {"label": "Size"}
+            ],
+            "values": {
+                "IDVALSIZES1": {
+                    "labels": [
+                        {"label": "XS"},
+                        {"label": "x-small"}
+                    ]
+                },
+                "IDVALSIZES2": {
+                    "labels": [
+                        {"label": "S"},
+                        {"label": "small"}
+                    ]
+                },
+                "IDVALSIZES3": {
+                    "labels": [
+                        {"label": "M"},
+                        {"label": "medium"}
+                    ]
+                },
+                "IDVALSIZES4": {
+                    "labels": [
+                        {"label": "L"},
+                        {"label": "large"}
+                    ]
+                },
+                "IDVALSIZES5": {
+                    "labels": [
+                        {"label": "XL"},
+                        {"label": "x-large"}
+                    ]
+                }
+            }
         }
     }
 }
\ No newline at end of file
index 4b19b77115b388e3613c6004013ef6501aded2b6..e01673b70e6d992bb608fbf958072510dfd4c654 100644 (file)
@@ -3,7 +3,13 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper as MuiPaper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText } from '@material-ui/core';
+import {
+    Input as MuiInput,
+    Chip as MuiChip,
+    Popper as MuiPopper,
+    Paper as MuiPaper,
+    FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText
+} from '@material-ui/core';
 import { PopperProps } from '@material-ui/core/Popper';
 import { WithStyles } from '@material-ui/core/styles';
 import { noop } from 'lodash';
index 9c229affe85cf720e19513d3c8b121527b4b1e58..f4e5854ad109a26fd2b46908484e2b0ad6c6ba0c 100644 (file)
@@ -11,7 +11,9 @@ export interface TagResource extends LinkResource {
 
 export interface TagProperty {
     key: string;
+    keyID?: string;
     value: string;
+    valueID?: string;
 }
 
 export enum TagTailType {
diff --git a/src/models/vocabulary.test.ts b/src/models/vocabulary.test.ts
new file mode 100644 (file)
index 0000000..87a8dfb
--- /dev/null
@@ -0,0 +1,148 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as Vocabulary from './vocabulary';
+import { pipe } from 'lodash/fp';
+
+describe('Vocabulary', () => {
+    let vocabulary: Vocabulary.Vocabulary;
+
+    beforeEach(() => {
+        vocabulary = {
+            strict_tags: false,
+            tags: {
+                IDKEYCOMMENT: {
+                    labels: []
+                },
+                IDKEYANIMALS: {
+                    strict: false,
+                    labels: [
+                        {label: "Animal" },
+                        {label: "Creature"}
+                    ],
+                    values: {
+                        IDVALANIMALS1: {
+                            labels: [
+                                {label: "Human"},
+                                {label: "Homo sapiens"}
+                            ]
+                        },
+                        IDVALANIMALS2: {
+                            labels: [
+                                {label: "Dog"},
+                                {label: "Canis lupus familiaris"}
+                            ]
+                        },
+                    }
+                },
+                IDKEYSIZES: {
+                    labels: [{label: "Sizes"}],
+                    values: {
+                        IDVALSIZES1: {
+                            labels: [{label: "Small"}]
+                        },
+                        IDVALSIZES2: {
+                            labels: [{label: "Medium"}]
+                        },
+                        IDVALSIZES3: {
+                            labels: [{label: "Large"}]
+                        },
+                        IDVALSIZES4: {
+                            labels: []
+                        }
+                    }
+                }
+            }
+        }
+    });
+
+    it('returns the list of tag keys', () => {
+        const tagKeys = Vocabulary.getTags(vocabulary);
+        // Alphabetically ordered by label
+        expect(tagKeys).toEqual([
+            {id: "IDKEYANIMALS", label: "Animal"},
+            {id: "IDKEYANIMALS", label: "Creature"},
+            {id: "IDKEYCOMMENT", label: "IDKEYCOMMENT"},
+            {id: "IDKEYSIZES", label: "Sizes"},
+        ]);
+    });
+
+    it('returns the tag values for a given key', () => {
+        const tagValues = Vocabulary.getTagValues('IDKEYSIZES', vocabulary);
+        // Alphabetically ordered by label
+        expect(tagValues).toEqual([
+            {id: "IDVALSIZES4", label: "IDVALSIZES4"},
+            {id: "IDVALSIZES3", label: "Large"},
+            {id: "IDVALSIZES2", label: "Medium"},
+            {id: "IDVALSIZES1", label: "Small"},
+        ])
+    });
+
+    it('returns an empty list of values for an non-existent key', () => {
+        const tagValues = Vocabulary.getTagValues('IDNONSENSE', vocabulary);
+        expect(tagValues).toEqual([]);
+    });
+
+    it('returns a key id for a given key label', () => {
+        const testCases = [
+            // Two labels belonging to the same ID
+            {keyLabel: 'Animal', expected: 'IDKEYANIMALS'},
+            {keyLabel: 'Creature', expected: 'IDKEYANIMALS'},
+            // Non-existent label returns empty string
+            {keyLabel: 'ThisKeyLabelDoesntExist', expected: ''},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagKeyID(tc.keyLabel, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns an key label for a given key id', () => {
+        const testCases = [
+            // ID with many labels return the first one
+            {keyID: 'IDKEYANIMALS', expected: 'Animal'},
+            // Key IDs without any labels or unknown keys should return the literal
+            // key from the API's response (that is, the key 'id')
+            {keyID: 'IDKEYCOMMENT', expected: 'IDKEYCOMMENT'},
+            {keyID: 'FOO', expected: 'FOO'},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagKeyLabel(tc.keyID, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns a value id for a given key id and value label', () => {
+        const testCases = [
+            // Key ID and value label known
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Human', expected: 'IDVALANIMALS1'},
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Homo sapiens', expected: 'IDVALANIMALS1'},
+            // Key ID known, value label unknown
+            {keyID: 'IDKEYANIMALS', valueLabel: 'Dinosaur', expected: ''},
+            // Key ID unknown
+            {keyID: 'IDNONSENSE', valueLabel: 'Does not matter', expected: ''},
+        ]
+        testCases.forEach(tc => {
+            const tagValueID = Vocabulary.getTagValueID(tc.keyID, tc.valueLabel, vocabulary);
+            expect(tagValueID).toEqual(tc.expected);
+        });
+    });
+
+    it('returns a value label for a given key & value id pair', () => {
+        const testCases = [
+            // Known key & value ids with multiple value labels: returns the first label
+            {keyId: 'IDKEYANIMALS', valueId: 'IDVALANIMALS1', expected: 'Human'},
+            // Values without label or unknown values should return the literal value from
+            // the API's response (that is, the value 'id')
+            {keyId: 'IDKEYSIZES', valueId: 'IDVALSIZES4', expected: 'IDVALSIZES4'},
+            {keyId: 'IDKEYCOMMENT', valueId: 'FOO', expected: 'FOO'},
+            {keyId: 'IDKEYANIMALS', valueId: 'BAR', expected: 'BAR'},
+            {keyId: 'IDKEYNONSENSE', valueId: 'FOOBAR', expected: 'FOOBAR'},
+        ]
+        testCases.forEach(tc => {
+            const tagValueLabel = Vocabulary.getTagValueLabel(tc.keyId, tc.valueId, vocabulary);
+            expect(tagValueLabel).toEqual(tc.expected);
+        });
+    });
+});
index ea23ad2c79ffc05d56f3e0fa42572a37bc6a38ad..03f28c07bf9c5edc21de1e1996c52f28156c5f89 100644 (file)
@@ -5,20 +5,99 @@
 import { isObject, has, every } from 'lodash/fp';
 
 export interface Vocabulary {
-    strict: boolean;
+    strict_tags: boolean;
     tags: Record<string, Tag>;
 }
 
+export interface Label {
+    lang?: string;
+    label: string;
+}
+
+export interface TagValue {
+    labels: Label[];
+}
+
 export interface Tag {
     strict?: boolean;
-    values?: string[];
+    labels: Label[];
+    values?: Record<string, TagValue>;
+}
+
+export interface PropFieldSuggestion {
+    id: string;
+    label: string;
 }
 
 const VOCABULARY_VALIDATORS = [
     isObject,
-    has('strict'),
+    has('strict_tags'),
     has('tags'),
 ];
 
 export const isVocabulary = (value: any) =>
-    every(validator => validator(value), VOCABULARY_VALIDATORS);
\ No newline at end of file
+    every(validator => validator(value), VOCABULARY_VALIDATORS);
+
+export const isStrictTag = (tagKeyID: string, vocabulary: Vocabulary) => {
+    const tag = vocabulary.tags[tagKeyID];
+    return tag ? tag.strict : false;
+};
+
+export const getTagValueID = (tagKeyID:string, tagValueLabel:string, vocabulary: Vocabulary) =>
+    (tagKeyID && vocabulary.tags[tagKeyID] && vocabulary.tags[tagKeyID].values)
+    ? Object.keys(vocabulary.tags[tagKeyID].values!).find(
+        k => vocabulary.tags[tagKeyID].values![k].labels.find(
+            l => l.label === tagValueLabel) !== undefined) || ''
+    : '';
+
+export const getTagValueLabel = (tagKeyID:string, tagValueID:string, vocabulary: Vocabulary) =>
+    vocabulary.tags[tagKeyID] &&
+    vocabulary.tags[tagKeyID].values &&
+    vocabulary.tags[tagKeyID].values![tagValueID] &&
+    vocabulary.tags[tagKeyID].values![tagValueID].labels.length > 0
+        ? vocabulary.tags[tagKeyID].values![tagValueID].labels[0].label
+        : tagValueID;
+
+const compare = (a: PropFieldSuggestion, b: PropFieldSuggestion) => {
+    if (a.label < b.label) {return -1;}
+    if (a.label > b.label) {return 1;}
+    return 0;
+};
+
+export const getTagValues = (tagKeyID: string, vocabulary: Vocabulary) => {
+    const tag = vocabulary.tags[tagKeyID];
+    const ret = tag && tag.values
+        ? Object.keys(tag.values).map(
+            tagValueID => tag.values![tagValueID].labels && tag.values![tagValueID].labels.length > 0
+                ? tag.values![tagValueID].labels.map(
+                    lbl => Object.assign({}, {"id": tagValueID, "label": lbl.label}))
+                : [{"id": tagValueID, "label": tagValueID}])
+            .reduce((prev, curr) => [...prev, ...curr], [])
+            .sort(compare)
+        : [];
+    return ret;
+};
+
+export const getTags = ({ tags }: Vocabulary) => {
+    const ret = tags && Object.keys(tags)
+        ? Object.keys(tags).map(
+            tagID => tags[tagID].labels && tags[tagID].labels.length > 0
+                ? tags[tagID].labels.map(
+                    lbl => Object.assign({}, {"id": tagID, "label": lbl.label}))
+                : [{"id": tagID, "label": tagID}])
+            .reduce((prev, curr) => [...prev, ...curr], [])
+            .sort(compare)
+        : [];
+    return ret;
+};
+
+export const getTagKeyID = (tagKeyLabel:string, vocabulary: Vocabulary) =>
+    Object.keys(vocabulary.tags).find(
+        k => vocabulary.tags[k].labels.find(
+            l => l.label === tagKeyLabel) !== undefined
+        ) || '';
+
+export const getTagKeyLabel = (tagKeyID:string, vocabulary: Vocabulary) =>
+    vocabulary.tags[tagKeyID] && vocabulary.tags[tagKeyID].labels.length > 0
+    ? vocabulary.tags[tagKeyID].labels[0].label
+    : tagKeyID;
index 159fb27de8cd57ae25efb9f9caad1327b265949c..540b8c6a011b6ab80b2163b52000db20a050b6af 100644 (file)
@@ -49,11 +49,11 @@ export const createCollectionTag = (data: TagProperty) =>
                     uuid, {
                         properties: {
                             ...JSON.parse(JSON.stringify(item.properties)),
-                            [data.key]: data.value
+                            [data.keyID || data.key]: data.valueID || data.value
                         }
                     }
                 );
-                item.properties[data.key] = 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;
index 8083716e7adfc9f7138907fb40888b4b2fc46781..6874671432ff8b31bd8bdf77092e753262b000b3 100644 (file)
@@ -60,8 +60,14 @@ export const createProjectProperty = (data: TagProperty) =>
         dispatch(startSubmit(PROJECT_PROPERTIES_FORM_NAME));
         try {
             if (project) {
-                project.properties[data.key] = data.value;
-                const updatedProject = await services.projectService.update(project.uuid, { properties: project.properties });
+                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));
index 799cffa034f01f026aedfc5c6ef52e71ff6d2d59..ceef5e6c79665f9bdfde98faeadbff126a9aecae 100644 (file)
@@ -5,7 +5,7 @@
 import { Dispatch } from 'redux';
 import { ServiceRepository } from '~/services/services';
 import { propertiesActions } from '~/store/properties/properties-actions';
-import { VOCABULARY_PROPERTY_NAME, DEFAULT_VOCABULARY } from './vocabulary-selctors';
+import { VOCABULARY_PROPERTY_NAME, DEFAULT_VOCABULARY } from './vocabulary-selectors';
 import { isVocabulary } from '~/models/vocabulary';
 
 export const loadVocabulary = async (dispatch: Dispatch, _: {}, { vocabularyService }: ServiceRepository) => {
similarity index 95%
rename from src/store/vocabulary/vocabulary-selctors.ts
rename to src/store/vocabulary/vocabulary-selectors.ts
index d317cb4786e4babb6db6242916197a27aaabae73..39f5a01ec86e78786345e0411209df3e97b6833f 100644 (file)
@@ -8,7 +8,7 @@ import { Vocabulary } from '~/models/vocabulary';
 export const VOCABULARY_PROPERTY_NAME = 'vocabulary';
 
 export const DEFAULT_VOCABULARY: Vocabulary = {
-    strict: false,
+    strict_tags: false,
     tags: {},
 };
 
index 8f03cc5d66c1355d3c84a0891893d555f18a143f..59035da115574b075a4d58eb0ab5d610302046be 100644 (file)
@@ -13,8 +13,10 @@ import { resourceLabel } from '~/common/labels';
 import { DetailsData } from "./details-data";
 import { DetailsAttribute } from "~/components/details-attribute/details-attribute";
 import { RichTextEditorLink } from '~/components/rich-text-editor-link/rich-text-editor-link';
-import { withStyles, StyleRulesCallback, Chip, WithStyles } from '@material-ui/core';
+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';
 
 export class ProjectDetails extends DetailsData<ProjectResource> {
     getIcon(className?: string) {
@@ -39,7 +41,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     }
 });
 
-
 interface ProjectDetailsComponentDataProps {
     project: ProjectResource;
 }
@@ -48,7 +49,9 @@ interface ProjectDetailsComponentActionProps {
     onClick: () => void;
 }
 
-const mapDispatchToProps = ({ onClick: openProjectPropertiesDialog });
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onClick: () => dispatch<any>(openProjectPropertiesDialog()),
+});
 
 type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDetailsComponentActionProps & WithStyles<CssRules>;
 
@@ -79,9 +82,11 @@ const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
                 </div>
             </DetailsAttribute>
             {
-                Object.keys(project.properties).map(k => {
-                    return <Chip key={k} className={classes.tag} label={`${k}: ${project.properties[k]}`} />;
-                })
+                Object.keys(project.properties).map(k =>
+                    <PropertyChipComponent key={k}
+                        propKey={k} propValue={project.properties[k]}
+                        className={classes.tag} />
+                )
             }
         </div>
     ));
index caedd4e6593703970ed47ee047635d924130e496..7a4cfba6c56e5d133c2a61a94101f33c2d01cd3f 100644 (file)
@@ -9,10 +9,11 @@ import { RootState } from '~/store/store';
 import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
 import { ProjectResource } from '~/models/project';
 import { PROJECT_PROPERTIES_DIALOG_NAME, deleteProjectProperty } from '~/store/details-panel/details-panel-action';
-import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Chip, withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
 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";
 
 type CssRules = 'tag';
 
@@ -31,13 +32,12 @@ interface ProjectPropertiesDialogActionProps {
     handleDelete: (key: string) => void;
 }
 
-const mapStateToProps = ({ detailsPanel, resources }: RootState): ProjectPropertiesDialogDataProps => {
-    const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
-    return { project };
-};
+const mapStateToProps = ({ detailsPanel, resources, properties }: RootState): ProjectPropertiesDialogDataProps => ({
+    project: getResource(detailsPanel.resourceUuid)(resources) as ProjectResource,
+});
 
 const mapDispatchToProps = (dispatch: Dispatch): ProjectPropertiesDialogActionProps => ({
-    handleDelete: (key: string) => dispatch<any>(deleteProjectProperty(key))
+    handleDelete: (key: string) => dispatch<any>(deleteProjectProperty(key)),
 });
 
 type ProjectPropertiesDialogProps =  ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
@@ -53,12 +53,12 @@ export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToPro
                 <DialogTitle>Properties</DialogTitle>
                 <DialogContent>
                     <ProjectPropertiesForm />
-                    {project && project.properties && 
-                        Object.keys(project.properties).map(k => {
-                            return <Chip key={k} className={classes.tag}
+                    {project && project.properties &&
+                        Object.keys(project.properties).map(k =>
+                            <PropertyChipComponent
                                 onDelete={() => handleDelete(k)}
-                                label={`${k}: ${project.properties[k]}`} />;
-                        })
+                                key={k} className={classes.tag}
+                                propKey={k} propValue={project.properties[k]} />)
                     }
                 </DialogContent>
                 <DialogActions>
@@ -70,4 +70,5 @@ export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToPro
                     </Button>
                 </DialogActions>
             </Dialog>
-)));
\ No newline at end of file
+    )
+));
\ No newline at end of file
diff --git a/src/views-components/resource-properties-form/property-chip.tsx b/src/views-components/resource-properties-form/property-chip.tsx
new file mode 100644 (file)
index 0000000..c51a8d8
--- /dev/null
@@ -0,0 +1,52 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Chip } from '@material-ui/core';
+import { connect } from 'react-redux';
+import { RootState } from '~/store/store';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { getVocabulary } from '~/store/vocabulary/vocabulary-selectors';
+import { Dispatch } from 'redux';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getTagValueLabel, getTagKeyLabel, Vocabulary } from '~/models/vocabulary';
+
+interface PropertyChipComponentDataProps {
+    propKey: string;
+    propValue: string;
+    className: string;
+    vocabulary: Vocabulary;
+}
+
+interface PropertyChipComponentActionProps {
+    onDelete?: () => void;
+    onCopy: (message: string) => void;
+}
+
+type PropertyChipComponentProps = PropertyChipComponentActionProps & PropertyChipComponentDataProps;
+
+const mapStateToProps = ({ properties }: RootState) => ({
+    vocabulary: getVocabulary(properties),
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onCopy: (message: string) => dispatch(snackbarActions.OPEN_SNACKBAR({
+        message,
+        hideDuration: 2000,
+        kind: SnackbarKind.SUCCESS
+    }))
+});
+
+// Renders a Chip with copyable-on-click tag:value data based on the vocabulary
+export const PropertyChipComponent = connect(mapStateToProps, mapDispatchToProps)(
+    ({ propKey, propValue, vocabulary, className, onCopy, onDelete }: PropertyChipComponentProps) => {
+        const label = `${getTagKeyLabel(propKey, vocabulary)}: ${getTagValueLabel(propKey, propValue, vocabulary)}`;
+        return (
+            <CopyToClipboard key={propKey} text={label} onCopy={() => onCopy("Copied to clipboard")}>
+                <Chip onDelete={onDelete} key={propKey}
+                    className={className} label={label} />
+            </CopyToClipboard>
+        );
+    }
+);
index 028c46b9d34ab42d8517a4d9270f988823718b8b..65d0c7c87f18752df97ba6862dc7cef215b90a82 100644 (file)
@@ -3,11 +3,10 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { connect } from 'react-redux';
-import { WrappedFieldMetaProps, WrappedFieldInputProps, WrappedFieldProps } from 'redux-form';
-import { identity } from 'lodash';
-import { Vocabulary } from '~/models/vocabulary';
+import { change, WrappedFieldMetaProps, WrappedFieldInputProps, WrappedFieldProps } from 'redux-form';
+import { Vocabulary, PropFieldSuggestion } from '~/models/vocabulary';
 import { RootState } from '~/store/store';
-import { getVocabulary } from '~/store/vocabulary/vocabulary-selctors';
+import { getVocabulary } from '~/store/vocabulary/vocabulary-selectors';
 
 export interface VocabularyProp {
     vocabulary: Vocabulary;
@@ -29,17 +28,39 @@ export const getErrorMsg = (meta: WrappedFieldMetaProps) =>
         ? meta.error
         : '';
 
-export const handleBlur = ({ onBlur, value }: WrappedFieldInputProps) =>
-    () =>
-        onBlur(value);
-
-export const buildProps = ({ input, meta }: WrappedFieldProps) => ({
-    value: input.value,
-    onChange: input.onChange,
-    onBlur: handleBlur(input),
-    items: ITEMS_PLACEHOLDER,
-    onSelect: input.onChange,
-    renderSuggestion: identity,
-    error: hasError(meta),
-    helperText: getErrorMsg(meta),
-});
+export const buildProps = ({ input, meta }: WrappedFieldProps) => {
+    return {
+        value: input.value,
+        onChange: input.onChange,
+        items: ITEMS_PLACEHOLDER,
+        renderSuggestion: (item:PropFieldSuggestion) => item.label,
+        error: hasError(meta),
+        helperText: getErrorMsg(meta),
+    };
+};
+
+// Attempts to match a manually typed value label with a value ID, when the user
+// doesn't select the value from the suggestions list.
+export const handleBlur = (
+    fieldName: string,
+    formName: string,
+    { dispatch }: WrappedFieldMetaProps,
+    { onBlur, value }: WrappedFieldInputProps,
+    fieldValue: string) =>
+        () => {
+            dispatch(change(formName, fieldName, fieldValue));
+            onBlur(value);
+        };
+
+// When selecting a property value, save its ID for later usage.
+export const handleSelect = (
+    fieldName: string,
+    formName: string,
+    { onChange }: WrappedFieldInputProps,
+    { dispatch }: WrappedFieldMetaProps ) =>
+        (item:PropFieldSuggestion) => {
+            if (item) {
+                onChange(item.label);
+                dispatch(change(formName, fieldName, item.id));
+            }
+        };
index 686c858ec75ea8ba5c2104ac9f62383c3c37eb50..89a03946d594d49ced6a567b1af0edbe9b50ae55 100644 (file)
@@ -3,15 +3,16 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { WrappedFieldProps, Field } from 'redux-form';
+import { WrappedFieldProps, Field, FormName } from 'redux-form';
 import { memoize } from 'lodash';
 import { Autocomplete } from '~/components/autocomplete/autocomplete';
-import { Vocabulary } from '~/models/vocabulary';
-import { connectVocabulary, VocabularyProp, buildProps } from '~/views-components/resource-properties-form/property-field-common';
+import { Vocabulary, getTags, getTagKeyID } from '~/models/vocabulary';
+import { handleSelect, handleBlur, connectVocabulary, VocabularyProp, buildProps } from '~/views-components/resource-properties-form/property-field-common';
 import { TAG_KEY_VALIDATION } from '~/validators/validators';
 import { escapeRegExp } from '~/common/regexp.ts';
 
 export const PROPERTY_KEY_FIELD_NAME = 'key';
+export const PROPERTY_KEY_FIELD_ID = 'keyID';
 
 export const PropertyKeyField = connectVocabulary(
     ({ vocabulary }: VocabularyProp) =>
@@ -19,31 +20,33 @@ export const PropertyKeyField = connectVocabulary(
             name={PROPERTY_KEY_FIELD_NAME}
             component={PropertyKeyInput}
             vocabulary={vocabulary}
-            validate={getValidation(vocabulary)} />);
+            validate={getValidation(vocabulary)} />
+);
 
 export const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) =>
-    <Autocomplete
-        label='Key'
-        suggestions={getSuggestions(props.input.value, vocabulary)}
-        {...buildProps(props)}
-    />;
+    <FormName children={data => (
+        <Autocomplete
+            label='Key'
+            suggestions={getSuggestions(props.input.value, vocabulary)}
+            onSelect={handleSelect(PROPERTY_KEY_FIELD_ID, data.form, props.input, props.meta)}
+            onBlur={handleBlur(PROPERTY_KEY_FIELD_ID, data.form, props.meta, props.input, getTagKeyID(props.input.value, vocabulary))}
+            {...buildProps(props)}
+        />
+    )}/>;
 
 const getValidation = memoize(
     (vocabulary: Vocabulary) =>
-        vocabulary.strict
+        vocabulary.strict_tags
             ? [...TAG_KEY_VALIDATION, matchTags(vocabulary)]
             : TAG_KEY_VALIDATION);
 
 const matchTags = (vocabulary: Vocabulary) =>
     (value: string) =>
-        getTagsList(vocabulary).find(tag => tag.includes(value))
+        getTags(vocabulary).find(tag => tag.label === value)
             ? undefined
             : 'Incorrect key';
 
 const getSuggestions = (value: string, vocabulary: Vocabulary) => {
     const re = new RegExp(escapeRegExp(value), "i");
-    return getTagsList(vocabulary).filter(tag => re.test(tag) && tag !== value);
+    return getTags(vocabulary).filter(tag => re.test(tag.label) && tag.label !== value);
 };
-
-const getTagsList = ({ tags }: Vocabulary) =>
-    Object.keys(tags);
index c8634acf4e386c071f684064197a53521e389c7f..4df44619a635ad4b56c24e11dca1f9d332f58b96 100644 (file)
@@ -3,12 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { WrappedFieldProps, Field, formValues } from 'redux-form';
+import { WrappedFieldProps, Field, formValues, FormName } from 'redux-form';
 import { compose } from 'redux';
 import { Autocomplete } from '~/components/autocomplete/autocomplete';
-import { Vocabulary } from '~/models/vocabulary';
-import { PROPERTY_KEY_FIELD_NAME } from '~/views-components/resource-properties-form/property-key-field';
-import { VocabularyProp, connectVocabulary, buildProps } from '~/views-components/resource-properties-form/property-field-common';
+import { Vocabulary, isStrictTag, getTagValues, getTagValueID } from '~/models/vocabulary';
+import { PROPERTY_KEY_FIELD_ID } from '~/views-components/resource-properties-form/property-key-field';
+import { handleSelect, handleBlur, VocabularyProp, connectVocabulary, buildProps } from '~/views-components/resource-properties-form/property-field-common';
 import { TAG_VALUE_VALIDATION } from '~/validators/validators';
 import { escapeRegExp } from '~/common/regexp.ts';
 
@@ -19,24 +19,30 @@ interface PropertyKeyProp {
 export type PropertyValueFieldProps = VocabularyProp & PropertyKeyProp;
 
 export const PROPERTY_VALUE_FIELD_NAME = 'value';
+export const PROPERTY_VALUE_FIELD_ID = 'valueID';
 
 export const PropertyValueField = compose(
     connectVocabulary,
-    formValues({ propertyKey: PROPERTY_KEY_FIELD_NAME })
+    formValues({ propertyKey: PROPERTY_KEY_FIELD_ID })
 )(
     (props: PropertyValueFieldProps) =>
         <Field
             name={PROPERTY_VALUE_FIELD_NAME}
             component={PropertyValueInput}
             validate={getValidation(props)}
-            {...props} />);
+            {...props} />
+);
 
 export const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) =>
-    <Autocomplete
-        label='Value'
-        suggestions={getSuggestions(props.input.value, propertyKey, vocabulary)}
-        {...buildProps(props)}
-    />;
+    <FormName children={data => (
+        <Autocomplete
+            label='Value'
+            suggestions={getSuggestions(props.input.value, propertyKey, vocabulary)}
+            onSelect={handleSelect(PROPERTY_VALUE_FIELD_ID, data.form, props.input, props.meta)}
+            onBlur={handleBlur(PROPERTY_VALUE_FIELD_ID, data.form, props.meta, props.input, getTagValueID(propertyKey, props.input.value, vocabulary))}
+            {...buildProps(props)}
+        />
+    )}/>;
 
 const getValidation = (props: PropertyValueFieldProps) =>
     isStrictTag(props.propertyKey, props.vocabulary)
@@ -45,21 +51,12 @@ const getValidation = (props: PropertyValueFieldProps) =>
 
 const matchTagValues = ({ vocabulary, propertyKey }: PropertyValueFieldProps) =>
     (value: string) =>
-        getTagValues(propertyKey, vocabulary).find(v => v.includes(value))
+        getTagValues(propertyKey, vocabulary).find(v => v.label === value)
             ? undefined
             : 'Incorrect value';
 
 const getSuggestions = (value: string, tagName: string, vocabulary: Vocabulary) => {
     const re = new RegExp(escapeRegExp(value), "i");
-    return getTagValues(tagName, vocabulary).filter(v => re.test(v) && v !== value);
+    return getTagValues(tagName, vocabulary).filter(v => re.test(v.label) && v.label !== value);
 };
 
-const isStrictTag = (tagName: string, vocabulary: Vocabulary) => {
-    const tag = vocabulary.tags[tagName];
-    return tag ? tag.strict : false;
-};
-
-const getTagValues = (tagName: string, vocabulary: Vocabulary) => {
-    const tag = vocabulary.tags[tagName];
-    return tag && tag.values ? tag.values : [];
-};
index 6c2e025a0834f85d4c816f492ec717f6f0d164fb..db40e4a7e8718e609e7d13814bf9e6ac638a6946 100644 (file)
@@ -5,14 +5,16 @@
 import * as React from 'react';
 import { InjectedFormProps } from 'redux-form';
 import { Grid, withStyles, WithStyles } from '@material-ui/core';
-import { PropertyKeyField, PROPERTY_KEY_FIELD_NAME } from './property-key-field';
-import { PropertyValueField, PROPERTY_VALUE_FIELD_NAME } from './property-value-field';
+import { PropertyKeyField, PROPERTY_KEY_FIELD_NAME, PROPERTY_KEY_FIELD_ID } from './property-key-field';
+import { PropertyValueField, PROPERTY_VALUE_FIELD_NAME, PROPERTY_VALUE_FIELD_ID } from './property-value-field';
 import { ProgressButton } from '~/components/progress-button/progress-button';
 import { GridClassKey } from '@material-ui/core/Grid';
 
 export interface ResourcePropertiesFormData {
     [PROPERTY_KEY_FIELD_NAME]: string;
+    [PROPERTY_KEY_FIELD_ID]: string;
     [PROPERTY_VALUE_FIELD_NAME]: string;
+    [PROPERTY_VALUE_FIELD_ID]: string;
 }
 
 export type ResourcePropertiesFormProps = InjectedFormProps<ResourcePropertiesFormData> & WithStyles<GridClassKey>;
index 77d91558e5660d5dbd23fd19eb95368c7f42e3c4..28ae2f05eddd1e70c83868ef03b341c16a204e87 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import {
     StyleRulesCallback, WithStyles, withStyles, Card,
-    CardHeader, IconButton, CardContent, Grid, Chip, Tooltip
+    CardHeader, IconButton, CardContent, Grid, Tooltip
 } from '@material-ui/core';
 import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
@@ -24,8 +24,8 @@ import { formatFileSize } from "~/common/formatters";
 import { getResourceData } from "~/store/resources-data/resources-data";
 import { ResourceData } from "~/store/resources-data/resources-data-reducer";
 import { openDetailsPanel } from '~/store/details-panel/details-panel-action';
-import * as CopyToClipboard from 'react-copy-to-clipboard';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { PropertyChipComponent } from '~/views-components/resource-properties-form/property-chip';
 
 type CssRules = 'card' | 'iconHeader' | 'tag' | 'label' | 'value' | 'link';
 
@@ -66,11 +66,11 @@ type CollectionPanelProps = CollectionPanelDataProps & DispatchProp
     & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
 export const CollectionPanel = withStyles(styles)(
-    connect((state: RootState, props: RouteComponentProps<{ id: string }>) => {
-        const item = getResource(props.match.params.id)(state.resources);
-        const data = getResourceData(props.match.params.id)(state.resourcesData);
-        return { item, data };
-    })(
+        connect((state: RootState, props: RouteComponentProps<{ id: string }>) => {
+            const item = getResource(props.match.params.id)(state.resources);
+            const data = getResourceData(props.match.params.id)(state.resourcesData);
+            return { item, data };
+        })(
         class extends React.Component<CollectionPanelProps> {
 
             render() {
@@ -130,16 +130,12 @@ export const CollectionPanel = withStyles(styles)(
                                         <CollectionTagForm />
                                     </Grid>
                                     <Grid item xs={12}>
-                                        {
-                                            Object.keys(item.properties).map(k => {
-                                                const label = `${k}: ${item.properties[k]}`;
-                                                return <CopyToClipboard key={k} text={label} onCopy={() => this.onCopy("Copied")}>
-                                                    <Chip className={classes.tag}
-                                                        onDelete={this.handleDelete(k)}
-                                                        label={label} />
-                                                </CopyToClipboard>;
-                                            })
-                                        }
+                                        {Object.keys(item.properties).map(k =>
+                                            <PropertyChipComponent
+                                                key={k} className={classes.tag}
+                                                onDelete={this.handleDelete(k)}
+                                                propKey={k} propValue={item.properties[k]} />
+                                        )}
                                     </Grid>
                                 </Grid>
                             </CardContent>