Merge branch 'master' into 15803-unsetup
authorPeter Amstutz <pamstutz@veritasgenetics.com>
Tue, 26 Nov 2019 21:18:48 +0000 (16:18 -0500)
committerPeter Amstutz <pamstutz@veritasgenetics.com>
Tue, 26 Nov 2019 21:18:48 +0000 (16:18 -0500)
31 files changed:
public/vocabulary-example.json
src/common/formatters.ts
src/components/autocomplete/autocomplete.tsx
src/index.tsx
src/models/search-bar.ts
src/models/tag.ts
src/models/vocabulary.test.ts [new file with mode: 0644]
src/models/vocabulary.ts
src/services/search-service/search-service.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/store/search-bar/search-bar-reducer.ts
src/store/search-bar/search-bar-tree-actions.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/form-fields/search-bar-form-fields.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-components/search-bar/search-bar-advanced-properties-view.tsx
src/views-components/search-bar/search-bar-advanced-view.tsx
src/views-components/search-bar/search-bar-save-queries.tsx
src/views-components/search-bar/search-bar-view.tsx
src/views-components/search-bar/search-bar.tsx
src/views/collection-panel/collection-panel.tsx
src/views/search-results-panel/search-results-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 377e78e42a8678dae93980549bda9c6a10fc8020..819875bec14c6527ce177ee6478ac435a698844d 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { PropertyValue } from "~/models/search-bar";
+import { Vocabulary, getTagKeyLabel, getTagValueLabel } from "~/models/vocabulary";
 
 export const formatDate = (isoDate?: string | null, utc: boolean = false) => {
     if (isoDate) {
@@ -76,7 +77,10 @@ const FILE_SIZES = [
     }
 ];
 
-export const formatPropertyValue = (pv: PropertyValue) => {
+export const formatPropertyValue = (pv: PropertyValue, vocabulary?: Vocabulary) => {
+    if (vocabulary && pv.keyID && pv.valueID) {
+        return `${getTagKeyLabel(pv.keyID, vocabulary)}: ${getTagValueLabel(pv.keyID, pv.valueID!, vocabulary)}`;
+    }
     if (pv.key) {
         return pv.value
             ? `${pv.key}: ${pv.value}`
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 fcd8b91b3d1c889fbe644369105a8943c9072da1..aa372fa5081c0eb2c4d05ea1b22babed765ece8a 100644 (file)
@@ -45,7 +45,7 @@ import { setBuildInfo } from '~/store/app-info/app-info-actions';
 import { getBuildInfo } from '~/common/app-info';
 import { DragDropContextProvider } from 'react-dnd';
 import HTML5Backend from 'react-dnd-html5-backend';
-import { initAdvanceFormProjectsTree } from '~/store/search-bar/search-bar-actions';
+import { initAdvancedFormProjectsTree } from '~/store/search-bar/search-bar-actions';
 import { repositoryActionSet } from '~/views-components/context-menu/action-sets/repository-action-set';
 import { sshKeyActionSet } from '~/views-components/context-menu/action-sets/ssh-key-action-set';
 import { keepServiceActionSet } from '~/views-components/context-menu/action-sets/keep-service-action-set';
@@ -162,7 +162,7 @@ const initListener = (history: History, store: RootStore, services: ServiceRepos
             await store.dispatch(loadWorkbench());
             addRouteChangeHandlers(history, store);
             // ToDo: move to searchBar component
-            store.dispatch(initAdvanceFormProjectsTree());
+            store.dispatch(initAdvancedFormProjectsTree());
         }
     };
 };
index effaeed4c0e676882e6bccc9f445e9eb732d9483..c71faf2ff47c132453e923a8d021816865ef67ee 100644 (file)
@@ -4,7 +4,7 @@
 
 import { ResourceKind } from '~/models/resource';
 
-export type SearchBarAdvanceFormData = {
+export type SearchBarAdvancedFormData = {
     type?: ResourceKind;
     cluster?: string;
     projectUuid?: string;
@@ -19,5 +19,7 @@ export type SearchBarAdvanceFormData = {
 
 export interface PropertyValue {
     key: string;
+    keyID?: string;
     value: string;
+    valueID?: string;
 }
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 84d120a89c52dacc835256b8f39e6f56ac0f8212..c6cfe786882ea7665eb4d83e49987eabb74bf37e 100644 (file)
@@ -2,11 +2,11 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 
 export class SearchService {
     private recentQueries = this.getRecentQueries();
-    private savedQueries: SearchBarAdvanceFormData[] = this.getSavedQueries();
+    private savedQueries: SearchBarAdvancedFormData[] = this.getSavedQueries();
 
     saveRecentQuery(query: string) {
         if (this.recentQueries.length >= MAX_NUMBER_OF_RECENT_QUERIES) {
@@ -20,19 +20,19 @@ export class SearchService {
         return JSON.parse(localStorage.getItem('recentQueries') || '[]');
     }
 
-    saveQuery(data: SearchBarAdvanceFormData) {
+    saveQuery(data: SearchBarAdvancedFormData) {
         this.savedQueries.push({...data});
         localStorage.setItem('savedQueries', JSON.stringify(this.savedQueries));
     }
 
-    editSavedQueries(data: SearchBarAdvanceFormData) {
+    editSavedQueries(data: SearchBarAdvancedFormData) {
         const itemIndex = this.savedQueries.findIndex(item => item.queryName === data.queryName);
         this.savedQueries[itemIndex] = {...data};
         localStorage.setItem('savedQueries', JSON.stringify(this.savedQueries));
     }
 
     getSavedQueries() {
-        return JSON.parse(localStorage.getItem('savedQueries') || '[]') as SearchBarAdvanceFormData[];
+        return JSON.parse(localStorage.getItem('savedQueries') || '[]') as SearchBarAdvancedFormData[];
     }
 
     deleteSavedQuery(id: number) {
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 d9238ef209677788121e50c3862fe7b1f4dce185..ddaf8f317ddae98aa86660a443ad59f3235f16f4 100644 (file)
@@ -5,7 +5,7 @@
 import { ofType, unionize, UnionOf } from "~/common/unionize";
 import { GroupContentsResource, GroupContentsResourcePrefix } from '~/services/groups-service/groups-service';
 import { Dispatch } from 'redux';
-import { arrayPush, change, initialize } from 'redux-form';
+import { change, initialize, untouch } from 'redux-form';
 import { RootState } from '~/store/store';
 import { initUserProject, treePickerActions } from '~/store/tree-picker/tree-picker-actions';
 import { ServiceRepository } from '~/services/services';
@@ -14,7 +14,7 @@ import { ResourceKind, RESOURCE_UUID_REGEX, COLLECTION_PDH_REGEX } from '~/model
 import { SearchView } from '~/store/search-bar/search-bar-reducer';
 import { navigateTo, navigateToSearchResults } from '~/store/navigation/navigation-action';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-import { PropertyValue, SearchBarAdvanceFormData } from '~/models/search-bar';
+import { PropertyValue, SearchBarAdvancedFormData } from '~/models/search-bar';
 import * as _ from "lodash";
 import { getModifiedKeysValues } from "~/common/objects";
 import { activateSearchBarProject } from "~/store/search-bar/search-bar-tree-actions";
@@ -23,6 +23,7 @@ import { searchResultsPanelActions } from "~/store/search-results-panel/search-r
 import { ListResults } from "~/services/common-service/common-service";
 import * as parser from './search-query/arv-parser';
 import { Keywords } from './search-query/arv-parser';
+import { Vocabulary, getTagKeyLabel, getTagValueLabel } from "~/models/vocabulary";
 
 export const searchBarActions = unionize({
     SET_CURRENT_VIEW: ofType<string>(),
@@ -30,9 +31,9 @@ export const searchBarActions = unionize({
     CLOSE_SEARCH_VIEW: ofType<{}>(),
     SET_SEARCH_RESULTS: ofType<GroupContentsResource[]>(),
     SET_SEARCH_VALUE: ofType<string>(),
-    SET_SAVED_QUERIES: ofType<SearchBarAdvanceFormData[]>(),
+    SET_SAVED_QUERIES: ofType<SearchBarAdvancedFormData[]>(),
     SET_RECENT_QUERIES: ofType<string[]>(),
-    UPDATE_SAVED_QUERY: ofType<SearchBarAdvanceFormData[]>(),
+    UPDATE_SAVED_QUERY: ofType<SearchBarAdvancedFormData[]>(),
     SET_SELECTED_ITEM: ofType<string>(),
     MOVE_UP: ofType<{}>(),
     MOVE_DOWN: ofType<{}>(),
@@ -41,9 +42,9 @@ export const searchBarActions = unionize({
 
 export type SearchBarActions = UnionOf<typeof searchBarActions>;
 
-export const SEARCH_BAR_ADVANCE_FORM_NAME = 'searchBarAdvanceFormName';
+export const SEARCH_BAR_ADVANCED_FORM_NAME = 'searchBarAdvancedFormName';
 
-export const SEARCH_BAR_ADVANCE_FORM_PICKER_ID = 'searchBarAdvanceFormPickerId';
+export const SEARCH_BAR_ADVANCED_FORM_PICKER_ID = 'searchBarAdvancedFormPickerId';
 
 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
 
@@ -75,7 +76,7 @@ export const searchData = (searchValue: string) =>
         }
     };
 
-export const searchAdvanceData = (data: SearchBarAdvanceFormData) =>
+export const searchAdvancedData = (data: SearchBarAdvancedFormData) =>
     async (dispatch: Dispatch, getState: () => RootState) => {
         dispatch<any>(saveQuery(data));
         const searchValue = getState().searchBar.searchValue;
@@ -85,7 +86,7 @@ export const searchAdvanceData = (data: SearchBarAdvanceFormData) =>
         dispatch(navigateToSearchResults(searchValue));
     };
 
-export const setSearchValueFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) =>
+export const setSearchValueFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const searchValue = getState().searchBar.searchValue;
         const value = getQueryFromAdvancedData({
@@ -95,17 +96,17 @@ export const setSearchValueFromAdvancedData = (data: SearchBarAdvanceFormData, p
         dispatch(searchBarActions.SET_SEARCH_VALUE(value));
     };
 
-export const setAdvancedDataFromSearchValue = (search: string) =>
+export const setAdvancedDataFromSearchValue = (search: string, vocabulary: Vocabulary) =>
     async (dispatch: Dispatch) => {
-        const data = getAdvancedDataFromQuery(search);
-        dispatch<any>(initialize(SEARCH_BAR_ADVANCE_FORM_NAME, data));
+        const data = getAdvancedDataFromQuery(search, vocabulary);
+        dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
         if (data.projectUuid) {
             await dispatch<any>(activateSearchBarProject(data.projectUuid));
-            dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID, id: data.projectUuid }));
+            dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID, id: data.projectUuid }));
         }
     };
 
-const saveQuery = (data: SearchBarAdvanceFormData) =>
+const saveQuery = (data: SearchBarAdvancedFormData) =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const savedQueries = services.searchService.getSavedQueries();
         if (data.saveQuery && data.queryName) {
@@ -131,11 +132,11 @@ export const deleteSavedQuery = (id: number) =>
         return savedSearchQueries || [];
     };
 
-export const editSavedQuery = (data: SearchBarAdvanceFormData) =>
+export const editSavedQuery = (data: SearchBarAdvancedFormData) =>
     (dispatch: Dispatch<any>) => {
         dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.ADVANCED));
         dispatch(searchBarActions.SET_SEARCH_VALUE(getQueryFromAdvancedData(data)));
-        dispatch<any>(initialize(SEARCH_BAR_ADVANCE_FORM_NAME, data));
+        dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
     };
 
 export const openSearchView = () =>
@@ -156,7 +157,7 @@ export const closeSearchView = () =>
 export const closeAdvanceView = () =>
     (dispatch: Dispatch<any>) => {
         dispatch(searchBarActions.SET_SEARCH_VALUE(''));
-        dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+        dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
         dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
     };
 
@@ -255,10 +256,10 @@ const buildQueryFromKeyMap = (data: any, keyMap: string[][], mode: 'rebuild' | '
     return value;
 };
 
-export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevData?: SearchBarAdvanceFormData) => {
+export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) => {
     let value = '';
 
-    const flatData = (data: SearchBarAdvanceFormData) => {
+    const flatData = (data: SearchBarAdvancedFormData) => {
         const fo = {
             searchValue: data.searchValue,
             type: data.type,
@@ -268,7 +269,7 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevDat
             dateFrom: data.dateFrom,
             dateTo: data.dateTo,
         };
-        (data.properties || []).forEach(p => fo[`prop-"${p.key}"`] = `"${p.value}"`);
+        (data.properties || []).forEach(p => fo[`prop-"${p.keyID || p.key}"`] = `"${p.valueID || p.value}"`);
         return fo;
     };
 
@@ -281,14 +282,14 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevDat
         ['to', 'dateTo']
     ];
     _.union(data.properties, prevData ? prevData.properties : [])
-        .forEach(p => keyMap.push([`has:"${p.key}"`, `prop-"${p.key}"`]));
+        .forEach(p => keyMap.push([`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}"`]));
 
     if (prevData) {
         const obj = getModifiedKeysValues(flatData(data), flatData(prevData));
         value = buildQueryFromKeyMap({
             searchValue: data.searchValue,
             ...obj
-        } as SearchBarAdvanceFormData, keyMap, "reuse");
+        } as SearchBarAdvancedFormData, keyMap, "reuse");
     } else {
         value = buildQueryFromKeyMap(flatData(data), keyMap, "rebuild");
     }
@@ -297,7 +298,7 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvanceFormData, prevDat
     return value;
 };
 
-export const getAdvancedDataFromQuery = (query: string): SearchBarAdvanceFormData => {
+export const getAdvancedDataFromQuery = (query: string, vocabulary?: Vocabulary): SearchBarAdvancedFormData => {
     const { tokens, searchString } = parser.parseSearchQuery(query);
     const getValue = parser.getValue(tokens);
     return {
@@ -308,7 +309,17 @@ export const getAdvancedDataFromQuery = (query: string): SearchBarAdvanceFormDat
         inTrash: parser.isTrashed(tokens),
         dateFrom: getValue(Keywords.FROM) || '',
         dateTo: getValue(Keywords.TO) || '',
-        properties: parser.getProperties(tokens),
+        properties: vocabulary
+            ? parser.getProperties(tokens).map(
+                p => {
+                    return {
+                        keyID: p.key,
+                        key: getTagKeyLabel(p.key, vocabulary),
+                        valueID: p.value,
+                        value: getTagValueLabel(p.key, p.value, vocabulary),
+                    };
+                })
+            : parser.getProperties(tokens),
         saveQuery: false,
         queryName: ''
     };
@@ -361,19 +372,20 @@ const buildDateFilter = (date?: string): string => {
     return date ? date : '';
 };
 
-export const initAdvanceFormProjectsTree = () =>
+export const initAdvancedFormProjectsTree = () =>
     (dispatch: Dispatch) => {
-        dispatch<any>(initUserProject(SEARCH_BAR_ADVANCE_FORM_PICKER_ID));
+        dispatch<any>(initUserProject(SEARCH_BAR_ADVANCED_FORM_PICKER_ID));
     };
 
-export const changeAdvanceFormProperty = (property: string, value: PropertyValue[] | string = '') =>
+export const changeAdvancedFormProperty = (propertyField: string, value: PropertyValue[] | string = '') =>
     (dispatch: Dispatch) => {
-        dispatch(change(SEARCH_BAR_ADVANCE_FORM_NAME, property, value));
+        dispatch(change(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField, value));
     };
 
-export const updateAdvanceFormProperties = (propertyValues: PropertyValue) =>
+export const resetAdvancedFormProperty = (propertyField: string) =>
     (dispatch: Dispatch) => {
-        dispatch(arrayPush(SEARCH_BAR_ADVANCE_FORM_NAME, 'properties', propertyValues));
+        dispatch(change(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField, null));
+        dispatch(untouch(SEARCH_BAR_ADVANCED_FORM_NAME, propertyField));
     };
 
 export const moveUp = () =>
index 4f663eeb393f6f1ea115ca43d42152bcce5ca0cc..32d9305f956df81c69a356096f2a4438bbf0a9bf 100644 (file)
@@ -8,7 +8,7 @@ import {
     SearchBarActions
 } from '~/store/search-bar/search-bar-actions';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 
 type SearchResult = GroupContentsResource;
 export type SearchBarSelectedItem = {
@@ -21,7 +21,7 @@ interface SearchBar {
     open: boolean;
     searchResults: SearchResult[];
     searchValue: string;
-    savedQueries: SearchBarAdvanceFormData[];
+    savedQueries: SearchBarAdvancedFormData[];
     recentQueries: string[];
     selectedItem: SearchBarSelectedItem;
 }
@@ -47,7 +47,7 @@ const initialState: SearchBar = {
 
 const makeSelectedItem = (id: string, query?: string): SearchBarSelectedItem => ({ id, query: query ? query : id });
 
-const makeQueryList = (recentQueries: string[], savedQueries: SearchBarAdvanceFormData[]) => {
+const makeQueryList = (recentQueries: string[], savedQueries: SearchBarAdvancedFormData[]) => {
     const recentIds = recentQueries.map((q, idx) => makeSelectedItem(`RQ-${idx}-${q}`, q));
     const savedIds = savedQueries.map((q, idx) => makeSelectedItem(`SQ-${idx}-${q.queryName}`, getQueryFromAdvancedData(q)));
     return recentIds.concat(savedIds);
index dadd8771bdade934f0936c5cd3da6678d1774e2d..0d193fa21e2fd9ac6ee2c02d60ee3a997c3f1e29 100644 (file)
@@ -13,10 +13,10 @@ import { FilterBuilder } from "~/services/api/filter-builder";
 import { OrderBuilder } from "~/services/api/order-builder";
 import { ProjectResource } from "~/models/project";
 import { resourcesActions } from "~/store/resources/resources-actions";
-import { SEARCH_BAR_ADVANCE_FORM_PICKER_ID } from "~/store/search-bar/search-bar-actions";
+import { SEARCH_BAR_ADVANCED_FORM_PICKER_ID } from "~/store/search-bar/search-bar-actions";
 
 const getSearchBarTreeNode = (id: string) => (treePicker: TreePicker) => {
-    const searchTree = getTreePicker(SEARCH_BAR_ADVANCE_FORM_PICKER_ID)(treePicker);
+    const searchTree = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(treePicker);
     return searchTree
         ? getNode(id)(searchTree)
         : undefined;
@@ -24,7 +24,7 @@ const getSearchBarTreeNode = (id: string) => (treePicker: TreePicker) => {
 
 export const loadSearchBarTreeProjects = (projectUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState) => {
-        const treePicker = getTreePicker(SEARCH_BAR_ADVANCE_FORM_PICKER_ID)(getState().treePicker);
+        const treePicker = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(getState().treePicker);
         const node = treePicker ? getNode(projectUuid)(treePicker) : undefined;
         if (node || projectUuid === '') {
             await dispatch<any>(loadSearchBarProject(projectUuid));
@@ -32,7 +32,7 @@ export const loadSearchBarTreeProjects = (projectUuid: string) =>
     };
 
 export const getSearchBarTreeNodeAncestorsIds = (id: string) => (treePicker: TreePicker) => {
-    const searchTree = getTreePicker(SEARCH_BAR_ADVANCE_FORM_PICKER_ID)(treePicker);
+    const searchTree = getTreePicker(SEARCH_BAR_ADVANCED_FORM_PICKER_ID)(treePicker);
     return searchTree
         ? getNodeAncestorsIds(id)(searchTree)
         : [];
@@ -54,16 +54,16 @@ export const activateSearchBarTreeBranch = (id: string) =>
                 ...[],
                 ...ancestors.map(ancestor => ancestor.uuid)
             ],
-            pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID
         }));
-        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
     };
 
 export const expandSearchBarTreeItem = (id: string) =>
     async (dispatch: Dispatch, getState: () => RootState) => {
         const node = getSearchBarTreeNode(id)(getState().treePicker);
         if (node && !node.expanded) {
-            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
         }
     };
 
@@ -78,7 +78,7 @@ export const activateSearchBarProject = (id: string) =>
         }
         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
             ids: getSearchBarTreeNodeAncestorsIds(id)(treePicker),
-            pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID
         }));
         dispatch<any>(expandSearchBarTreeItem(id));
     };
@@ -86,7 +86,7 @@ export const activateSearchBarProject = (id: string) =>
 
 const loadSearchBarProject = (projectUuid: string) =>
     async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
-        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: projectUuid, pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: projectUuid, pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
         const params = {
             filters: new FilterBuilder()
                 .addEqual('ownerUuid', projectUuid)
@@ -98,7 +98,7 @@ const loadSearchBarProject = (projectUuid: string) =>
         const { items } = await services.projectService.list(params);
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
             id: projectUuid,
-            pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID,
+            pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID,
             nodes: items.map(item => initTreeNode({ id: item.uuid, value: item })),
         }));
         dispatch(resourcesActions.SET_RESOURCES(items));
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 8de48ea744a8494ae2575a5ee51358a0c591902f..837f13cb548b3144e8677f086030665cd02a366f 100644 (file)
@@ -3,20 +3,18 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
-import { Field, WrappedFieldProps, FieldArray, formValues } from 'redux-form';
+import { Field, WrappedFieldProps, FieldArray } from 'redux-form';
 import { TextField, DateTextField } from "~/components/text-field/text-field";
 import { CheckboxField } from '~/components/checkbox-field/checkbox-field';
 import { NativeSelectField } from '~/components/select-field/select-field';
 import { ResourceKind } from '~/models/resource';
 import { HomeTreePicker } from '~/views-components/projects-tree-picker/home-tree-picker';
-import { SEARCH_BAR_ADVANCE_FORM_PICKER_ID } from '~/store/search-bar/search-bar-actions';
+import { SEARCH_BAR_ADVANCED_FORM_PICKER_ID } from '~/store/search-bar/search-bar-actions';
 import { SearchBarAdvancedPropertiesView } from '~/views-components/search-bar/search-bar-advanced-properties-view';
 import { TreeItem } from "~/components/tree/tree";
 import { ProjectsTreePickerItem } from "~/views-components/projects-tree-picker/generic-projects-tree-picker";
-import { PropertyKeyInput } from '~/views-components/resource-properties-form/property-key-field';
-import { PropertyValueInput, PropertyValueFieldProps } from '~/views-components/resource-properties-form/property-value-field';
-import { VocabularyProp, connectVocabulary } from '~/views-components/resource-properties-form/property-field-common';
-import { compose } from 'redux';
+import { PropertyKeyField, } from '~/views-components/resource-properties-form/property-key-field';
+import { PropertyValueField } from '~/views-components/resource-properties-form/property-value-field';
 import { connect } from "react-redux";
 import { RootState } from "~/store/store";
 
@@ -59,7 +57,7 @@ export const SearchBarProjectField = () =>
 const ProjectsPicker = (props: WrappedFieldProps) =>
     <div style={{ height: '100px', display: 'flex', flexDirection: 'column', overflow: 'overlay' }}>
         <HomeTreePicker
-            pickerId={SEARCH_BAR_ADVANCE_FORM_PICKER_ID}
+            pickerId={SEARCH_BAR_ADVANCED_FORM_PICKER_ID}
             toggleItemActive={
                 (_: any, { id }: TreeItem<ProjectsTreePickerItem>) => {
                     props.input.onChange(id);
@@ -88,22 +86,11 @@ export const SearchBarPropertiesField = () =>
         name="properties"
         component={SearchBarAdvancedPropertiesView} />;
 
-export const SearchBarKeyField = connectVocabulary(
-    ({ vocabulary }: VocabularyProp) =>
-        <Field
-            name='key'
-            component={PropertyKeyInput}
-            vocabulary={vocabulary} />);
+export const SearchBarKeyField = () =>
+    <PropertyKeyField skipValidation={true} />;
 
-export const SearchBarValueField = compose(
-    connectVocabulary,
-    formValues({ propertyKey: 'key' })
-)(
-    (props: PropertyValueFieldProps) =>
-        <Field
-            name='value'
-            component={PropertyValueInput}
-            {...props} />);
+export const SearchBarValueField = () =>
+    <PropertyValueField skipValidation={true} />;
 
 export const SearchBarSaveSearchField = () =>
     <Field
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..e802ad5ccbd8b355b4d362eab0b3065714cfaa7f 100644 (file)
@@ -3,17 +3,21 @@
 // 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;
 }
 
-export const mapStateToProps = (state: RootState): VocabularyProp => ({
+export interface ValidationProp {
+    skipValidation?: boolean;
+}
+
+export const mapStateToProps = (state: RootState, ownProps: ValidationProp): VocabularyProp & ValidationProp => ({
+    skipValidation: ownProps.skipValidation,
     vocabulary: getVocabulary(state.properties),
 });
 
@@ -29,17 +33,39 @@ export const getErrorMsg = (meta: WrappedFieldMetaProps) =>
         ? meta.error
         : '';
 
-export const handleBlur = ({ onBlur, value }: WrappedFieldInputProps) =>
-    () =>
+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);
+    };
 
-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),
-});
+// 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..1f92118885690992b19cb6c81e5a88eaea47c959 100644 (file)
@@ -3,47 +3,50 @@
 // 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, ValidationProp, 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) =>
+    ({ vocabulary, skipValidation }: VocabularyProp & ValidationProp) =>
         <Field
             name={PROPERTY_KEY_FIELD_NAME}
             component={PropertyKeyInput}
             vocabulary={vocabulary}
-            validate={getValidation(vocabulary)} />);
-
-export const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) =>
-    <Autocomplete
-        label='Key'
-        suggestions={getSuggestions(props.input.value, vocabulary)}
-        {...buildProps(props)}
-    />;
+            validate={skipValidation ? undefined : getValidation(vocabulary)} />
+);
+
+const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) =>
+    <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..99745199feebe96b7dad0ca80cb140da4c6853e2 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, ValidationProp, connectVocabulary, buildProps } from '~/views-components/resource-properties-form/property-field-common';
 import { TAG_VALUE_VALIDATION } from '~/validators/validators';
 import { escapeRegExp } from '~/common/regexp.ts';
 
@@ -16,27 +16,35 @@ interface PropertyKeyProp {
     propertyKey: string;
 }
 
-export type PropertyValueFieldProps = VocabularyProp & PropertyKeyProp;
+type PropertyValueFieldProps = VocabularyProp & PropertyKeyProp & ValidationProp;
 
 export const PROPERTY_VALUE_FIELD_NAME = 'value';
+export const PROPERTY_VALUE_FIELD_ID = 'valueID';
 
-export const PropertyValueField = compose(
+const connectVocabularyAndPropertyKey = compose(
     connectVocabulary,
-    formValues({ propertyKey: PROPERTY_KEY_FIELD_NAME })
-)(
-    (props: PropertyValueFieldProps) =>
+    formValues({ propertyKey: PROPERTY_KEY_FIELD_ID }),
+);
+
+export const PropertyValueField = connectVocabularyAndPropertyKey(
+    ({ skipValidation, ...props }: PropertyValueFieldProps) =>
         <Field
             name={PROPERTY_VALUE_FIELD_NAME}
             component={PropertyValueInput}
-            validate={getValidation(props)}
-            {...props} />);
-
-export const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) =>
-    <Autocomplete
-        label='Value'
-        suggestions={getSuggestions(props.input.value, propertyKey, vocabulary)}
-        {...buildProps(props)}
-    />;
+            validate={skipValidation ? undefined : getValidation(props)}
+            {...props} />
+);
+
+const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) =>
+    <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 +53,11 @@ 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);
-};
-
-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 : [];
+    return getTagValues(tagName, vocabulary).filter(v => re.test(v.label) && v.label !== value);
 };
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 d4044f958d55b0d1c270232b32469c0c74fe3559..eb049b7625262dfe5caee013d21681f8df3bc0ae 100644 (file)
@@ -3,21 +3,23 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Dispatch } from 'redux';
+import { Dispatch, compose } from 'redux';
 import { connect } from 'react-redux';
 import { InjectedFormProps, formValueSelector } from 'redux-form';
 import { Grid, withStyles, StyleRulesCallback, WithStyles, Button } from '@material-ui/core';
 import { RootState } from '~/store/store';
 import {
-    SEARCH_BAR_ADVANCE_FORM_NAME,
-    changeAdvanceFormProperty,
-    updateAdvanceFormProperties
+    SEARCH_BAR_ADVANCED_FORM_NAME,
+    changeAdvancedFormProperty,
+    resetAdvancedFormProperty
 } from '~/store/search-bar/search-bar-actions';
 import { PropertyValue } from '~/models/search-bar';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { SearchBarKeyField, SearchBarValueField } from '~/views-components/form-fields/search-bar-form-fields';
 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';
 
 type CssRules = 'label' | 'button';
 
@@ -38,11 +40,12 @@ interface SearchBarAdvancedPropertiesViewDataProps {
     pristine: boolean;
     propertyValues: PropertyValue;
     fields: PropertyValue[];
+    vocabulary: Vocabulary;
 }
 
 interface SearchBarAdvancedPropertiesViewActionProps {
     setProps: () => void;
-    addProp: (propertyValues: PropertyValue) => void;
+    setProp: (propertyValues: PropertyValue, properties: PropertyValue[]) => void;
     getAllFields: (propertyValues: PropertyValue[]) => PropertyValue[] | [];
 }
 
@@ -50,30 +53,37 @@ type SearchBarAdvancedPropertiesViewProps = SearchBarAdvancedPropertiesViewDataP
     & SearchBarAdvancedPropertiesViewActionProps
     & InjectedFormProps & WithStyles<CssRules>;
 
-const selector = formValueSelector(SEARCH_BAR_ADVANCE_FORM_NAME);
+const selector = formValueSelector(SEARCH_BAR_ADVANCED_FORM_NAME);
 const mapStateToProps = (state: RootState) => {
     return {
-        propertyValues: selector(state, 'key', 'value')
+        propertyValues: selector(state, 'key', 'value', 'keyID', 'valueID')
     };
 };
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     setProps: (propertyValues: PropertyValue[]) => {
-        dispatch<any>(changeAdvanceFormProperty('properties', propertyValues));
+        dispatch<any>(changeAdvancedFormProperty('properties', propertyValues));
     },
-    addProp: (propertyValues: PropertyValue) => {
-        dispatch<any>(updateAdvanceFormProperties(propertyValues));
-        dispatch<any>(changeAdvanceFormProperty('key'));
-        dispatch<any>(changeAdvanceFormProperty('value'));
+    setProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => {
+        dispatch<any>(changeAdvancedFormProperty(
+            'properties',
+            [...properties.filter(e => e.keyID! !== propertyValue.keyID!), propertyValue]
+        ));
+        dispatch<any>(resetAdvancedFormProperty('key'));
+        dispatch<any>(resetAdvancedFormProperty('value'));
+        dispatch<any>(resetAdvancedFormProperty('keyID'));
+        dispatch<any>(resetAdvancedFormProperty('valueID'));
     },
     getAllFields: (fields: any) => {
         return fields.getAll() || [];
     }
 });
 
-export const SearchBarAdvancedPropertiesView = connect(mapStateToProps, mapDispatchToProps)(
+export const SearchBarAdvancedPropertiesView = compose(
+    connectVocabulary,
+    connect(mapStateToProps, mapDispatchToProps))(
     withStyles(styles)(
-        ({ classes, fields, propertyValues, setProps, addProp, getAllFields }: SearchBarAdvancedPropertiesViewProps) =>
+        ({ classes, fields, propertyValues, setProps, setProp, getAllFields, vocabulary }: SearchBarAdvancedPropertiesViewProps) =>
             <Grid container item xs={12} spacing={16}>
                 <Grid item xs={2} className={classes.label}>Properties</Grid>
                 <Grid item xs={4}>
@@ -83,7 +93,7 @@ export const SearchBarAdvancedPropertiesView = connect(mapStateToProps, mapDispa
                     <SearchBarValueField />
                 </Grid>
                 <Grid container item xs={2} justify='flex-end' alignItems="center">
-                    <Button className={classes.button} onClick={() => addProp(propertyValues)}
+                    <Button className={classes.button} onClick={() => setProp(propertyValues, getAllFields(fields))}
                         color="primary"
                         size='small'
                         variant="contained"
@@ -96,7 +106,7 @@ export const SearchBarAdvancedPropertiesView = connect(mapStateToProps, mapDispa
                     <Chips values={getAllFields(fields)}
                         deletable
                         onChange={setProps}
-                        getLabel={(field: PropertyValue) => formatPropertyValue(field)} />
+                        getLabel={(field: PropertyValue) => formatPropertyValue(field, vocabulary)} />
                 </Grid>
             </Grid>
     )
index b001cb3eb1f1920e0b08ec1f50ff95e4e9aea1fb..71d32ad7e95dd51efa91ee4794b6e4172154cbc1 100644 (file)
@@ -7,13 +7,13 @@ import { reduxForm, InjectedFormProps, reset } from 'redux-form';
 import { compose, Dispatch } from 'redux';
 import { Paper, StyleRulesCallback, withStyles, WithStyles, Button, Grid, IconButton, CircularProgress } from '@material-ui/core';
 import {
-    SEARCH_BAR_ADVANCE_FORM_NAME, SEARCH_BAR_ADVANCE_FORM_PICKER_ID,
-    searchAdvanceData,
+    SEARCH_BAR_ADVANCED_FORM_NAME, SEARCH_BAR_ADVANCED_FORM_PICKER_ID,
+    searchAdvancedData,
     setSearchValueFromAdvancedData
 } from '~/store/search-bar/search-bar-actions';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { CloseIcon } from '~/components/icon/icon';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 import {
     SearchBarTypeField, SearchBarClusterField, SearchBarProjectField, SearchBarTrashField,
     SearchBarDateFromField, SearchBarDateToField, SearchBarPropertiesField,
@@ -100,15 +100,15 @@ const validate = (values: any) => {
 };
 
 export const SearchBarAdvancedView = compose(
-    reduxForm<SearchBarAdvanceFormData, SearchBarAdvancedViewProps>({
-        form: SEARCH_BAR_ADVANCE_FORM_NAME,
+    reduxForm<SearchBarAdvancedFormData, SearchBarAdvancedViewProps>({
+        form: SEARCH_BAR_ADVANCED_FORM_NAME,
         validate,
-        onSubmit: (data: SearchBarAdvanceFormData, dispatch: Dispatch) => {
-            dispatch<any>(searchAdvanceData(data));
-            dispatch(reset(SEARCH_BAR_ADVANCE_FORM_NAME));
-            dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCE_FORM_PICKER_ID }));
+        onSubmit: (data: SearchBarAdvancedFormData, dispatch: Dispatch) => {
+            dispatch<any>(searchAdvancedData(data));
+            dispatch(reset(SEARCH_BAR_ADVANCED_FORM_NAME));
+            dispatch(treePickerActions.DEACTIVATE_TREE_PICKER_NODE({ pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID }));
         },
-        onChange: (data: SearchBarAdvanceFormData, dispatch: Dispatch, props: any, prevData: SearchBarAdvanceFormData) => {
+        onChange: (data: SearchBarAdvancedFormData, dispatch: Dispatch, props: any, prevData: SearchBarAdvancedFormData) => {
             dispatch<any>(setSearchValueFromAdvancedData(data, prevData));
         },
     }),
index 5234c214cb9050d950781bf9224c36c030d9279d..af1c24b41dd5c4e176f7914159871e065f82cad6 100644 (file)
@@ -6,7 +6,7 @@ import * as React from 'react';
 import { withStyles, WithStyles, StyleRulesCallback, List, ListItem, ListItemText, ListItemSecondaryAction, Tooltip, IconButton } from '@material-ui/core';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { RemoveIcon, EditSavedQueryIcon } from '~/components/icon/icon';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 import { SearchBarSelectedItem } from "~/store/search-bar/search-bar-reducer";
 import { getQueryFromAdvancedData } from "~/store/search-bar/search-bar-actions";
 
@@ -31,14 +31,14 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 });
 
 export interface SearchBarSavedQueriesDataProps {
-    savedQueries: SearchBarAdvanceFormData[];
+    savedQueries: SearchBarAdvancedFormData[];
     selectedItem: SearchBarSelectedItem;
 }
 
 export interface SearchBarSavedQueriesActionProps {
     onSearch: (searchValue: string) => void;
     deleteSavedQuery: (id: number) => void;
-    editSavedQuery: (data: SearchBarAdvanceFormData, id: number) => void;
+    editSavedQuery: (data: SearchBarAdvancedFormData, id: number) => void;
 }
 
 type SearchBarSavedQueriesProps = SearchBarSavedQueriesDataProps
index 176ca018b420eff2a8ef6e3ae951d91cacb1e825..49a8ba6235e5183c2a528287572c2f7081d11dca 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { compose } from 'redux';
 import {
     IconButton,
     Paper,
@@ -33,6 +34,8 @@ import {
 } from '~/views-components/search-bar/search-bar-advanced-view';
 import { KEY_CODE_DOWN, KEY_CODE_ESC, KEY_CODE_UP, KEY_ENTER } from "~/common/codes";
 import { debounce } from 'debounce';
+import { Vocabulary } from '~/models/vocabulary';
+import { connectVocabulary } from '../resource-properties-form/property-field-common';
 
 type CssRules = 'container' | 'containerSearchViewOpened' | 'input' | 'view';
 
@@ -72,6 +75,7 @@ interface SearchBarViewDataProps {
     currentView: string;
     isPopoverOpen: boolean;
     debounce?: number;
+    vocabulary?: Vocabulary;
 }
 
 export type SearchBarActionProps = SearchBarViewActionProps
@@ -88,7 +92,7 @@ interface SearchBarViewActionProps {
     loadRecentQueries: () => string[];
     moveUp: () => void;
     moveDown: () => void;
-    setAdvancedDataFromSearchValue: (search: string) => void;
+    setAdvancedDataFromSearchValue: (search: string, vocabulary?: Vocabulary) => void;
 }
 
 type SearchBarViewProps = SearchBarDataProps & SearchBarActionProps & WithStyles<CssRules>;
@@ -132,20 +136,15 @@ const handleInputClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
 
 const handleDropdownClick = (e: React.MouseEvent, props: SearchBarViewProps) => {
     e.stopPropagation();
-    if (props.isPopoverOpen) {
-        if (props.currentView === SearchView.ADVANCED) {
-            props.closeView();
-        } else {
-            props.setAdvancedDataFromSearchValue(props.searchValue);
-            props.onSetView(SearchView.ADVANCED);
-        }
+    if (props.isPopoverOpen && props.currentView === SearchView.ADVANCED) {
+        props.closeView();
     } else {
-        props.setAdvancedDataFromSearchValue(props.searchValue);
+        props.setAdvancedDataFromSearchValue(props.searchValue, props.vocabulary);
         props.onSetView(SearchView.ADVANCED);
     }
 };
 
-export const SearchBarView = withStyles(styles)(
+export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
     class extends React.Component<SearchBarViewProps> {
 
         debouncedSearch = debounce(() => {
index 41cf291688dc2d53ef7534ee8048a2d9b2629f99..6e8ec0813f594d8de246eac446eef19d08f30975 100644 (file)
@@ -16,10 +16,11 @@ import {
     navigateToItem,
     editSavedQuery,
     changeData,
-    submitData, moveUp, moveDown, setAdvancedDataFromSearchValue
+    submitData, moveUp, moveDown, setAdvancedDataFromSearchValue, SEARCH_BAR_ADVANCED_FORM_NAME
 } from '~/store/search-bar/search-bar-actions';
 import { SearchBarView, SearchBarActionProps, SearchBarDataProps } from '~/views-components/search-bar/search-bar-view';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
+import { Vocabulary } from '~/models/vocabulary';
 
 const mapStateToProps = ({ searchBar, form }: RootState): SearchBarDataProps => {
     return {
@@ -29,10 +30,10 @@ const mapStateToProps = ({ searchBar, form }: RootState): SearchBarDataProps =>
         searchResults: searchBar.searchResults,
         selectedItem: searchBar.selectedItem,
         savedQueries: searchBar.savedQueries,
-        tags: form.searchBarAdvanceFormName,
-        saveQuery: form.searchBarAdvanceFormName &&
-            form.searchBarAdvanceFormName.values &&
-            form.searchBarAdvanceFormName.values.saveQuery
+        tags: form[SEARCH_BAR_ADVANCED_FORM_NAME],
+        saveQuery: form[SEARCH_BAR_ADVANCED_FORM_NAME] &&
+            form[SEARCH_BAR_ADVANCED_FORM_NAME].values &&
+            form[SEARCH_BAR_ADVANCED_FORM_NAME].values!.saveQuery
     };
 };
 
@@ -47,10 +48,10 @@ const mapDispatchToProps = (dispatch: Dispatch): SearchBarActionProps => ({
     deleteSavedQuery: (id: number) => dispatch<any>(deleteSavedQuery(id)),
     openSearchView: () => dispatch<any>(openSearchView()),
     navigateTo: (uuid: string) => dispatch<any>(navigateToItem(uuid)),
-    editSavedQuery: (data: SearchBarAdvanceFormData) => dispatch<any>(editSavedQuery(data)),
+    editSavedQuery: (data: SearchBarAdvancedFormData) => dispatch<any>(editSavedQuery(data)),
     moveUp: () => dispatch<any>(moveUp()),
     moveDown: () => dispatch<any>(moveDown()),
-    setAdvancedDataFromSearchValue: (search: string) => dispatch<any>(setAdvancedDataFromSearchValue(search))
+    setAdvancedDataFromSearchValue: (search: string, vocabulary: Vocabulary) => dispatch<any>(setAdvancedDataFromSearchValue(search, vocabulary))
 });
 
 export const SearchBar = connect(mapStateToProps, mapDispatchToProps)(SearchBarView);
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>
index 7de1abd3c501937ae9e7dcbf8aac97fb4b26d3ce..0cc52e5efc9a0d7186e8e97853efe5b3015cb620 100644 (file)
@@ -10,13 +10,13 @@ import { navigateTo } from '~/store/navigation/navigation-action';
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
 import { SearchResultsPanelView } from '~/views/search-results-panel/search-results-panel-view';
 import { RootState } from '~/store/store';
-import { SearchBarAdvanceFormData } from '~/models/search-bar';
+import { SearchBarAdvancedFormData } from '~/models/search-bar';
 import { User } from "~/models/user";
 import { Config } from '~/common/config';
 import { Session } from "~/models/session";
 
 export interface SearchResultsPanelDataProps {
-    data: SearchBarAdvanceFormData;
+    data: SearchBarAdvancedFormData;
     user: User;
     sessions: Session[];
     remoteHostsConfig: { [key: string]: Config };