From 5a0e3b1cc6d021a25005bc2dd840df6cda505eea Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Wed, 6 Jul 2022 11:06:54 +0800 Subject: [PATCH 01/15] Add 'tags' property to dataset model --- backend/database/datasets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/database/datasets.py b/backend/database/datasets.py index d761b7dc..9afbf99d 100644 --- a/backend/database/datasets.py +++ b/backend/database/datasets.py @@ -14,6 +14,7 @@ class DatasetModel(DynamicDocument): name = StringField(required=True, unique=True) directory = StringField() thumbnails = StringField() + tags = DictField(default={}) categories = ListField(default=[]) owner = StringField(required=True) From cd8d279dc2053cfc6ccbe4e3bff35ab182aba357 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Wed, 6 Jul 2022 11:09:21 +0800 Subject: [PATCH 02/15] Add ability to parse 'project' tag when updating a dataset --- backend/webserver/api/datasets.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/webserver/api/datasets.py b/backend/webserver/api/datasets.py index a601adb0..09e048aa 100644 --- a/backend/webserver/api/datasets.py +++ b/backend/webserver/api/datasets.py @@ -48,6 +48,7 @@ export.add_argument('categories', type=str, default=None, required=False, help='Ids of categories to export') update_dataset = reqparse.RequestParser() +update_dataset.add_argument('project', location='json', type=str, help="New project") update_dataset.add_argument('categories', location='json', type=list, help="New list of categories") update_dataset.add_argument('default_annotation_metadata', location='json', type=dict, help="Default annotation metadata") @@ -234,16 +235,20 @@ def delete(self, dataset_id): def post(self, dataset_id): """ Updates dataset by ID """ - + dataset = current_user.datasets.filter(id=dataset_id, deleted=False).first() if dataset is None: return {"message": "Invalid dataset id"}, 400 args = update_dataset.parse_args() + project = args.get('project') categories = args.get('categories') default_annotation_metadata = args.get('default_annotation_metadata') set_default_annotation_metadata = args.get('set_default_annotation_metadata') + if project is not None: + dataset.tags['project'] = project + if categories is not None: dataset.categories = CategoryModel.bulk_create(categories) @@ -261,6 +266,7 @@ def post(self, dataset_id): .update(**update) dataset.update( + tags = dataset.tags, categories=dataset.categories, default_annotation_metadata=dataset.default_annotation_metadata ) From d02bcf15df29ce9d7f7cab0a8bc4339feb258175 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Thu, 7 Jul 2022 18:52:39 +0800 Subject: [PATCH 03/15] Add ability to parse tags and filter datasets by tags --- backend/webserver/api/datasets.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/backend/webserver/api/datasets.py b/backend/webserver/api/datasets.py index 09e048aa..620c54ec 100644 --- a/backend/webserver/api/datasets.py +++ b/backend/webserver/api/datasets.py @@ -1,3 +1,4 @@ +from typing import Any from flask import request from flask_restplus import Namespace, Resource, reqparse from flask_login import login_required, current_user @@ -36,6 +37,7 @@ page_data.add_argument('limit', default=20, type=int) page_data.add_argument('folder', default='', help='Folder for data') page_data.add_argument('order', default='file_name', help='Order to display images') +page_data.add_argument('tags', default='[]', type=str, required=False) delete_data = reqparse.RequestParser() delete_data.add_argument('fully', default=False, type=bool, @@ -48,7 +50,7 @@ export.add_argument('categories', type=str, default=None, required=False, help='Ids of categories to export') update_dataset = reqparse.RequestParser() -update_dataset.add_argument('project', location='json', type=str, help="New project") +update_dataset.add_argument('tags', location='json', type=dict, help="New tags") update_dataset.add_argument('categories', location='json', type=list, help="New list of categories") update_dataset.add_argument('default_annotation_metadata', location='json', type=dict, help="Default annotation metadata") @@ -87,7 +89,6 @@ def post(self): return query_util.fix_ids(dataset) - def download_images(output_dir, args): for keyword in args['keywords']: response = gid.googleimagesdownload() @@ -241,13 +242,13 @@ def post(self, dataset_id): return {"message": "Invalid dataset id"}, 400 args = update_dataset.parse_args() - project = args.get('project') + tags = args.get('tags') categories = args.get('categories') default_annotation_metadata = args.get('default_annotation_metadata') set_default_annotation_metadata = args.get('set_default_annotation_metadata') - if project is not None: - dataset.tags['project'] = project + if tags is not None: + dataset.tags = tags if categories is not None: dataset.categories = CategoryModel.bulk_create(categories) @@ -299,14 +300,27 @@ class DatasetData(Resource): @login_required def get(self): """ Endpoint called by dataset viewer client """ - args = page_data.parse_args() limit = args['limit'] page = args['page'] folder = args['folder'] + tags = args['tags'] + + # Convert tags string to dict mapping unique keys to lists of values + tags_dict = {} + for tag in json.loads(tags): + key, value = tag.split(":") + if key not in tags_dict: + tags_dict[key] = [] + tags_dict[key].append(value) + + # Get filtered list of datasets whose tags include all keys in tags_dict and any of each key's possible values + datasets = [] + for dataset in current_user.datasets.filter(deleted=False): + if not tags_dict or all(key in dataset.tags and dataset.tags[key] in values for key, values in tags_dict.items()): + datasets.append(dataset) - datasets = current_user.datasets.filter(deleted=False) - pagination = Pagination(datasets.count(), limit, page) + pagination = Pagination(len(datasets), limit, page) datasets = datasets[pagination.start:pagination.end] datasets_json = [] From 2ef718db3ae9fb60803d41d4ba6b237a6772e2ad Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Thu, 7 Jul 2022 18:53:43 +0800 Subject: [PATCH 04/15] Add endpoint to fetch all unique tags per user --- backend/webserver/api/datasets.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/webserver/api/datasets.py b/backend/webserver/api/datasets.py index 620c54ec..bf067d63 100644 --- a/backend/webserver/api/datasets.py +++ b/backend/webserver/api/datasets.py @@ -344,6 +344,27 @@ def get(self): "categories": query_util.fix_ids(current_user.categories.filter(deleted=False).all()) } + +@api.route('/tags') +class AllUniqueTags(Resource): + + @login_required + def get(self): + """ Endpoint called by dataset viewer client """ + datasets = current_user.datasets.filter(deleted=False) + tags = [] + + for dataset in datasets: + for key, value in dataset.tags.items(): + if len(value) > 0: + tag_string = key + ":" + value + tags.append(tag_string) + + return { + "tags": list(set(tags)) + } + + @api.route('//data') class DatasetDataId(Resource): From 8811c292f9cc23ab3ada507f731702fb67c025e5 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Fri, 8 Jul 2022 11:09:23 +0800 Subject: [PATCH 05/15] Remove 'tag' property from datasets and route tagging and filtering functionality to 'default_annotation_metadata' instead --- backend/database/datasets.py | 1 - backend/webserver/api/datasets.py | 54 +++++++++++++++---------------- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/backend/database/datasets.py b/backend/database/datasets.py index 9afbf99d..d761b7dc 100644 --- a/backend/database/datasets.py +++ b/backend/database/datasets.py @@ -14,7 +14,6 @@ class DatasetModel(DynamicDocument): name = StringField(required=True, unique=True) directory = StringField() thumbnails = StringField() - tags = DictField(default={}) categories = ListField(default=[]) owner = StringField(required=True) diff --git a/backend/webserver/api/datasets.py b/backend/webserver/api/datasets.py index bf067d63..625774ea 100644 --- a/backend/webserver/api/datasets.py +++ b/backend/webserver/api/datasets.py @@ -1,4 +1,3 @@ -from typing import Any from flask import request from flask_restplus import Namespace, Resource, reqparse from flask_login import login_required, current_user @@ -37,7 +36,7 @@ page_data.add_argument('limit', default=20, type=int) page_data.add_argument('folder', default='', help='Folder for data') page_data.add_argument('order', default='file_name', help='Order to display images') -page_data.add_argument('tags', default='[]', type=str, required=False) +page_data.add_argument('filters', default='[]', type=str, required=False) delete_data = reqparse.RequestParser() delete_data.add_argument('fully', default=False, type=bool, @@ -50,7 +49,6 @@ export.add_argument('categories', type=str, default=None, required=False, help='Ids of categories to export') update_dataset = reqparse.RequestParser() -update_dataset.add_argument('tags', location='json', type=dict, help="New tags") update_dataset.add_argument('categories', location='json', type=list, help="New list of categories") update_dataset.add_argument('default_annotation_metadata', location='json', type=dict, help="Default annotation metadata") @@ -242,14 +240,10 @@ def post(self, dataset_id): return {"message": "Invalid dataset id"}, 400 args = update_dataset.parse_args() - tags = args.get('tags') categories = args.get('categories') default_annotation_metadata = args.get('default_annotation_metadata') set_default_annotation_metadata = args.get('set_default_annotation_metadata') - if tags is not None: - dataset.tags = tags - if categories is not None: dataset.categories = CategoryModel.bulk_create(categories) @@ -267,7 +261,6 @@ def post(self, dataset_id): .update(**update) dataset.update( - tags = dataset.tags, categories=dataset.categories, default_annotation_metadata=dataset.default_annotation_metadata ) @@ -304,20 +297,22 @@ def get(self): limit = args['limit'] page = args['page'] folder = args['folder'] - tags = args['tags'] - - # Convert tags string to dict mapping unique keys to lists of values - tags_dict = {} - for tag in json.loads(tags): - key, value = tag.split(":") - if key not in tags_dict: - tags_dict[key] = [] - tags_dict[key].append(value) + filters = args['filters'] + + # Convert filters string to dict mapping unique keys to lists of values + filters_dict = {} + for filter in json.loads(filters): + key, value = filter.split(":") + if key not in filters_dict: + filters_dict[key] = [] + filters_dict[key].append(value) - # Get filtered list of datasets whose tags include all keys in tags_dict and any of each key's possible values + # Get filtered list of datasets whose default_annotation_metadata + # includes all keys in filters_dict and any of each key's possible values datasets = [] for dataset in current_user.datasets.filter(deleted=False): - if not tags_dict or all(key in dataset.tags and dataset.tags[key] in values for key, values in tags_dict.items()): + metadata = dataset.default_annotation_metadata + if not filters_dict or all(key in metadata and metadata[key] in values for key, values in filters_dict.items()): datasets.append(dataset) pagination = Pagination(len(datasets), limit, page) @@ -345,24 +340,27 @@ def get(self): } -@api.route('/tags') -class AllUniqueTags(Resource): +@api.route('/filters') +class DatasetFilters(Resource): @login_required def get(self): """ Endpoint called by dataset viewer client """ + + # Get all unique default_annotation_metadata items across all datasets per user + # Each item becomes a string "key:value" that can be used to filter datasets in the client datasets = current_user.datasets.filter(deleted=False) - tags = [] - + filters = [] + for dataset in datasets: - for key, value in dataset.tags.items(): + for key, value in dataset.default_annotation_metadata.items(): if len(value) > 0: - tag_string = key + ":" + value - tags.append(tag_string) + filter_string = key + ":" + value + filters.append(filter_string) return { - "tags": list(set(tags)) - } + "filters": list(set(filters)) + } @api.route('//data') From d3ca7c8d7efa99445fad384704a910e5b9df880c Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Fri, 8 Jul 2022 12:34:08 +0800 Subject: [PATCH 06/15] Add validation to annotation metadata to ensure keys are unique --- client/src/components/Metadata.vue | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/client/src/components/Metadata.vue b/client/src/components/Metadata.vue index eab9ec1c..96c7844f 100755 --- a/client/src/components/Metadata.vue +++ b/client/src/components/Metadata.vue @@ -33,6 +33,7 @@ type="text" class="meta-input" :placeholder="keyTitle" + @input="validateKeys()" /> @@ -46,6 +47,10 @@ + +
+ {{ errorMessage }} +
@@ -77,7 +82,8 @@ export default { }, data() { return { - metadataList: [] + metadataList: [], + errorMessage: null, }; }, methods: { @@ -116,7 +122,17 @@ export default { this.metadataList.push({ key: key, value: value }); } } - } + }, + validateKeys() { + const keys = this.metadataList.map(metadata => metadata.key).filter(key => key.length); + const uniqueKeys = [...new Set(keys)]; + + this.errorMessage = keys.length !== uniqueKeys.length + ? "Keys must be unique" + : null; + + this.$emit("error", !!this.errorMessage); + }, }, watch: { metadata() { From a7b0a87cbc73728d58d1a30c3276769a7904e5e4 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Fri, 8 Jul 2022 12:50:25 +0800 Subject: [PATCH 07/15] Add ability to delete metadata items --- client/src/components/Metadata.vue | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/client/src/components/Metadata.vue b/client/src/components/Metadata.vue index 96c7844f..fe6b49e7 100755 --- a/client/src/components/Metadata.vue +++ b/client/src/components/Metadata.vue @@ -2,7 +2,7 @@
@@ -45,6 +45,14 @@ :placeholder="valueTitle" />
+ +
+ +
@@ -108,6 +116,16 @@ export default { createMetadata() { this.metadataList.push({ key: "", value: "" }); }, + deleteMetadata(index) { + delete this.metadataList[index]; + this.metadataList = this.metadataList.filter(metadata => metadata); + this.validateKeys(); + }, + clearEmptyItems() { + this.metadataList = this.metadataList.filter((metadata) => { + return metadata.key || metadata.value; + }) + }, loadMetadata() { if (this.metadata != null) { for (var key in this.metadata) { From 810924727e1cf62c2043c55c7eeb85fd5c57a2e8 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Fri, 8 Jul 2022 12:59:32 +0800 Subject: [PATCH 08/15] Disable saving dataset edits when metadata keys are not unique --- client/src/components/cards/DatasetCard.vue | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/client/src/components/cards/DatasetCard.vue b/client/src/components/cards/DatasetCard.vue index 2bf569e3..b4acc687 100755 --- a/client/src/components/cards/DatasetCard.vue +++ b/client/src/components/cards/DatasetCard.vue @@ -130,10 +130,11 @@ @@ -143,6 +144,7 @@ class="btn btn-success" @click="onSave" data-dismiss="modal" + :disabled="!allowSave" > Save @@ -239,7 +241,8 @@ export default { defaultMetadata: this.dataset.default_annotation_metadata, noImageUrl: require("@/assets/no-image.png"), notFoundImageUrl: require("@/assets/404-image.png"), - sharedUsers: [] + sharedUsers: [], + allowSave: true, }; }, methods: { @@ -278,8 +281,12 @@ export default { this.$parent.updatePage(); }); }, + onValidationError(error) { + this.allowSave = !error; + }, onSave() { this.dataset.categories = this.selectedCategories; + this.$refs.defaultAnnotation.clearEmptyItems(); axios .post("/api/dataset/" + this.dataset.id, { @@ -389,6 +396,7 @@ p { padding: 2px; background-color: #4b5162; } + .icon-more { width: 10%; margin: 3px 0; @@ -401,6 +409,7 @@ p { margin: 0 5px 7px 5px; height: 5px; } + .card-footer { padding: 2px; font-size: 11px; From 9f28a6e66f772dc736228172875b8846ded5eb97 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Fri, 8 Jul 2022 13:00:17 +0800 Subject: [PATCH 09/15] Emit tags list to parent component when tags change --- client/src/components/TagsInput.vue | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client/src/components/TagsInput.vue b/client/src/components/TagsInput.vue index cc220843..9d31ef17 100644 --- a/client/src/components/TagsInput.vue +++ b/client/src/components/TagsInput.vue @@ -143,18 +143,16 @@ export default { type: Function, default: () => true }, - addTagsOnComma: { type: Boolean, default: false }, - wrapperClass: { type: String, default: "tags-input-wrapper-default" } }, - + data() { return { badgeId: 0, @@ -268,7 +266,7 @@ export default { // Emit events this.$emit("tag-added", slug); - this.$emit("tags-updated"); + this.$emit("tags-updated", this.tags); }, removeLastTag() { @@ -285,7 +283,7 @@ export default { // Emit events this.$emit("tag-removed", slug); - this.$emit("tags-updated"); + this.$emit("tags-updated", this.tags); }, searchTag() { @@ -448,5 +446,6 @@ export default { From de640690c69871eeca0e7c28f663eae13c493be7 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Fri, 8 Jul 2022 13:01:27 +0800 Subject: [PATCH 10/15] Add ability to filter datasets by metadata tags --- client/src/models/datasets.js | 9 ++++++++- client/src/views/Datasets.vue | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/client/src/models/datasets.js b/client/src/models/datasets.js index 4cc06a7f..77df753b 100644 --- a/client/src/models/datasets.js +++ b/client/src/models/datasets.js @@ -6,10 +6,17 @@ export default { allData(params) { return axios.get(`${baseURL}/data`, { params: { - ...params + ...params, } }); }, + getFilterOptions(params) { + return axios.get(`${baseURL}/filters`, { + params: { + ...params + } + }) + }, getData(id, params) { return axios.get(`${baseURL}/${id}/data`, { params: { diff --git a/client/src/views/Datasets.vue b/client/src/views/Datasets.vue index e9ea5820..b94859b8 100755 --- a/client/src/views/Datasets.vue +++ b/client/src/views/Datasets.vue @@ -46,6 +46,20 @@ +
+
+ +
+
+

You need to create a dataset! @@ -207,6 +221,8 @@ export default { datasets: [], subdirectories: [], categories: [], + selectedFilters: [], + filterOptions: [], users: [] }; }, @@ -221,13 +237,19 @@ export default { Datasets.allData({ limit: this.limit, - page: page + page: page, + filters: JSON.stringify(this.selectedFilters), }).then(response => { this.datasets = response.data.datasets; this.categories = response.data.categories; this.subdirectories = response.data.subdirectories; this.pages = response.data.pagination.pages; this.page = response.data.pagination.page; + + Datasets.getFilterOptions().then(response => { + this.filterOptions = response.data.filters; + }); + AdminPanel.getUsers(this.limit) .then(response => { this.users = response.data.users; @@ -254,6 +276,10 @@ export default { error.response.data.message ); }); + }, + setFilters(filters) { + this.selectedFilters = filters; + this.updatePage(); } }, watch: { @@ -273,6 +299,13 @@ export default { }); return tags; }, + filterTags() { + let tags = {} + this.filterOptions.forEach(filter => { + tags[filter] = filter + }) + return tags; + }, validDatasetName() { if (this.create.name.length === 0) return "Dataset name is required"; return ""; From e38707e7d6d7c51e35267d15e62e8924e2489da5 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Fri, 8 Jul 2022 16:21:48 +0800 Subject: [PATCH 11/15] Restore dedicated 'tags' property in datasets --- backend/database/datasets.py | 1 + backend/webserver/api/datasets.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/database/datasets.py b/backend/database/datasets.py index d761b7dc..bc6b95d3 100644 --- a/backend/database/datasets.py +++ b/backend/database/datasets.py @@ -22,6 +22,7 @@ class DatasetModel(DynamicDocument): annotate_url = StringField(default="") default_annotation_metadata = DictField(default={}) + tags = DictField(default={}) deleted = BooleanField(default=False) deleted_date = DateTimeField() diff --git a/backend/webserver/api/datasets.py b/backend/webserver/api/datasets.py index 625774ea..dcc5a4a9 100644 --- a/backend/webserver/api/datasets.py +++ b/backend/webserver/api/datasets.py @@ -52,6 +52,7 @@ update_dataset.add_argument('categories', location='json', type=list, help="New list of categories") update_dataset.add_argument('default_annotation_metadata', location='json', type=dict, help="Default annotation metadata") +update_dataset.add_argument('tags', location='json', type=dict, help="Dataset tags") dataset_generate = reqparse.RequestParser() dataset_generate.add_argument('keywords', location='json', type=list, default=[], @@ -243,6 +244,7 @@ def post(self, dataset_id): categories = args.get('categories') default_annotation_metadata = args.get('default_annotation_metadata') set_default_annotation_metadata = args.get('set_default_annotation_metadata') + tags = args.get('tags') if categories is not None: dataset.categories = CategoryModel.bulk_create(categories) @@ -260,7 +262,11 @@ def post(self, dataset_id): AnnotationModel.objects(dataset_id=dataset.id, deleted=False)\ .update(**update) + if tags is not None: + dataset.tags = tags + dataset.update( + tags = dataset.tags, categories=dataset.categories, default_annotation_metadata=dataset.default_annotation_metadata ) @@ -311,8 +317,8 @@ def get(self): # includes all keys in filters_dict and any of each key's possible values datasets = [] for dataset in current_user.datasets.filter(deleted=False): - metadata = dataset.default_annotation_metadata - if not filters_dict or all(key in metadata and metadata[key] in values for key, values in filters_dict.items()): + tags = dataset.tags + if not filters_dict or all(key in tags and tags[key] in values for key, values in filters_dict.items()): datasets.append(dataset) pagination = Pagination(len(datasets), limit, page) @@ -347,13 +353,13 @@ class DatasetFilters(Resource): def get(self): """ Endpoint called by dataset viewer client """ - # Get all unique default_annotation_metadata items across all datasets per user - # Each item becomes a string "key:value" that can be used to filter datasets in the client + # Get all unique tag items across all datasets per user + # Each tag becomes a string "key:value" that can be used to filter datasets in the client datasets = current_user.datasets.filter(deleted=False) filters = [] for dataset in datasets: - for key, value in dataset.default_annotation_metadata.items(): + for key, value in dataset.tags.items(): if len(value) > 0: filter_string = key + ":" + value filters.append(filter_string) From 661c9764133635751fa7de2c51b61fa59f9dff44 Mon Sep 17 00:00:00 2001 From: Joshua Cerdenia Date: Fri, 8 Jul 2022 16:24:18 +0800 Subject: [PATCH 12/15] Add UI for adding and removing tags per dataset --- client/src/components/Metadata.vue | 33 +++++++++++++-------- client/src/components/cards/DatasetCard.vue | 19 +++++++++--- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/client/src/components/Metadata.vue b/client/src/components/Metadata.vue index fe6b49e7..6f5f8df1 100755 --- a/client/src/components/Metadata.vue +++ b/client/src/components/Metadata.vue @@ -2,12 +2,13 @@

-

{{ title }}

+

{{ title }}

+
  • - No items in metadata. + {{ emptyMessage }}
  • -
    -
    +
    +
    -
    +
    -
    +
    + +
  • -
    +
    {{ errorMessage }}
@@ -77,15 +81,19 @@ export default { }, keyTitle: { type: String, - default: "Keys" + default: "Key" }, valueTitle: { type: String, - default: "Values" + default: "Value" }, exclude: { type: String, default: "" + }, + emptyMessage: { + type: String, + default: "No items in metadata" } }, data() { @@ -174,7 +182,8 @@ export default { .meta-item { background-color: inherit; - height: 30px; + padding-top: 2px !important; + padding-bottom: 2px !important; border: none; } diff --git a/client/src/components/cards/DatasetCard.vue b/client/src/components/cards/DatasetCard.vue index b4acc687..b08158f3 100755 --- a/client/src/components/cards/DatasetCard.vue +++ b/client/src/components/cards/DatasetCard.vue @@ -127,15 +127,23 @@ :typeahead-activation-threshold="0" />
+
+
+ +