From 60ec0a23688bbf159c68c9d7f2fea7deb4c1e1f5 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 4 Oct 2022 17:02:35 -0400 Subject: [PATCH] Add the ability to show annotation metadata in item annotation lists This has a little preliminary work to add checkboxes to annotation lists, since that would make bulk modifications for some but not all annotations easier. --- CHANGELOG.md | 1 + docs/girder_annotation_config_options.rst | 54 +++++++ .../rest/annotation.py | 2 + .../stylesheets/annotationListWidget.styl | 8 +- .../templates/annotationListWidget.pug | 74 +++++++--- .../web_client/views/annotationListWidget.js | 135 ++++++++++++++---- .../web_client_specs/annotationListSpec.js | 44 ++++-- 7 files changed, 260 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 617fb07cc..34ae69ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Show and edit yaml and json files using codemirror ([#969](../../pull/969), [#971](../../pull/971)) - Show configured item lists even if there are no large images ([#972](../../pull/972)) - Add metadata and annotation metadata search modes to Girder ([#974](../../pull/974)) +- Add the ability to show annotation metadata in item annotation lists ([#977](../../pull/977)) ## 1.17.0 diff --git a/docs/girder_annotation_config_options.rst b/docs/girder_annotation_config_options.rst index 1b872bcf7..ec4ad9b67 100644 --- a/docs/girder_annotation_config_options.rst +++ b/docs/girder_annotation_config_options.rst @@ -10,3 +10,57 @@ Store annotation history ~~~~~~~~~~~~~~~~~~~~~~~~ If ``Record annotation history`` is selected, whenever annotations are saved, previous versions are kept in the database. This can greatly increase the size of the database. The old versions of the annotations allow the API to be used to revent to previous versions or to audit changes over time. + +.large_image_config.yaml +~~~~~~~~~~~~~~~~~~~~~~~~ + +This can be used to specify how annotations are listed on the item page. + +:: + + --- + # If present, show a table with column headers in annotation lists + annotationList: + # show these columns in order from left to right. Each column has a + # "type" and "value". It optionally has a "title" used for the column + # header, and a "format" used for searching and filtering. There are + # always control columns at the left and right. + columns: + - + # The "record" type is from the default annotation record. The value + # is one of "name", "creator", "created", "updatedId", "updated", + type: record + value: name + - + type: record + value: creator + # A format of user will print the user name instead of the id + format: user + - + type: record + value: created + # A format of date will use the browser's default date format + format: date + - + # The "metadata" type is taken from the annotations's + # "annotation.attributes" contents. It can be a nested key by using + # dots in its name. + type: metadata + value: Stain + # "format" can be "text", "number", "category". Other values may be + # specified later. + format: text + defaultSort: + # The default lists a sort order for sortable columns. This must have + # type, value, and dir for each entry, where dir is either "up" or + # "down". + - + type: metadata + value: Stain + dir: up + - + type: record + value: name + dir: down + +These values can be combined with values from the base large_image plugin. diff --git a/girder_annotation/girder_large_image_annotation/rest/annotation.py b/girder_annotation/girder_large_image_annotation/rest/annotation.py index 132794a5c..6efab9071 100644 --- a/girder_annotation/girder_large_image_annotation/rest/annotation.py +++ b/girder_annotation/girder_large_image_annotation/rest/annotation.py @@ -90,6 +90,8 @@ def __init__(self): @filtermodel(model='annotation', plugin='large_image') def find(self, params): limit, offset, sort = self.getPagingParameters(params, 'lowerName') + if sort and sort[0][0][0] == '[': + sort = json.loads(sort[0][0]) query = {'_active': {'$ne': False}} if 'itemId' in params: item = Item().load(params.get('itemId'), force=True) diff --git a/girder_annotation/girder_large_image_annotation/web_client/stylesheets/annotationListWidget.styl b/girder_annotation/girder_large_image_annotation/web_client/stylesheets/annotationListWidget.styl index 82d08d3fc..aa7d57bef 100644 --- a/girder_annotation/girder_large_image_annotation/web_client/stylesheets/annotationListWidget.styl +++ b/girder_annotation/girder_large_image_annotation/web_client/stylesheets/annotationListWidget.styl @@ -7,14 +7,16 @@ font-weight bold .g-annotation-list + &.table + width initial + max-width initial + min-width 100% table-layout fixed td white-space nowrap - text-overflow ellipsis - overflow hidden - .g-annotation-toggle + .g-annotation-toggle, .g-annotation-select width 30px .g-annotation-actions diff --git a/girder_annotation/girder_large_image_annotation/web_client/templates/annotationListWidget.pug b/girder_annotation/girder_large_image_annotation/web_client/templates/annotationListWidget.pug index 9d23bef99..c1e79be94 100644 --- a/girder_annotation/girder_large_image_annotation/web_client/templates/annotationListWidget.pug +++ b/girder_annotation/girder_large_image_annotation/web_client/templates/annotationListWidget.pug @@ -2,12 +2,12 @@ i.icon-pencil | Annotations .btn-group.pull-right - if annotations.length - a.g-annotation-download(href=`${apiRoot}/annotation/item/${item.id}`, title='Download annotations', download=`${item.get('name')}_annotations.json`) - i.icon-download if creationAccess a.g-annotation-upload(title='Upload annotation') i.icon-upload + if annotations.length + a.g-annotation-download(href=`${apiRoot}/annotation/item/${item.id}`, title='Download annotations', download=`${item.get('name')}_annotations.json`) + i.icon-download if accessLevel >= AccessType.ADMIN && annotations.length a.g-annotation-permissions(title='Adjust permissions') i.icon-lock @@ -17,29 +17,65 @@ if annotations.length table.g-annotation-list.table.table-hover.table-condensed thead + // + th.g-annotation-select + input.g-select-all(type='checkbox') th.g-annotation-toggle - th.g-annotation-name Name - th.g-annotation-user Creator - th.g-annotation-date Date + a.g-annotation-toggle-all(class=canDraw ? 'disabled' : '', title='Hide or show all annotations') + - let anyDrawn = annotations.models.some((annotation) => drawn.has(annotation.id)) + if anyDrawn + i.icon-eye + else + i.icon-eye-off + for column in confList.columns || [] + if column.type !== 'record' || column.value !== 'controls' + th.g-annotation-column + if column.title !== undefined + = column.title + else + = `${column.value.substr(0, 1).toUpperCase()}${column.value.substr(1)}` th.g-annotation-actions tbody for annotation in annotations.models - - var name = annotation.get('annotation').name; - - var creatorModel = users.get(annotation.get('creatorId')); - - var creator = creatorModel ? creatorModel.get('login') : annotation.get('creatorId'); + - + var name = annotation.get('annotation').name; + var creatorModel = users.get(annotation.get('creatorId')); + var creator = creatorModel ? creatorModel.get('login') : annotation.get('creatorId'); + var updatedModel = users.get(annotation.get('updatedId')); + var updater = updatedModel ? updatedModel.get('login') : annotation.get('updatedId'); tr.g-annotation-row(data-annotation-id=annotation.id) + // + td.g-annotation-select + input(type='checkbox', title='Select annotation for bulk actions') td.g-annotation-toggle - input(type='checkbox', disabled=!canDraw, checked=drawn.has(annotation.id), title='Show annotation') - td.g-annotation-name(title=name) - = name - - td.g-annotation-user - a(href=`#user/${annotation.get('creatorId')}`) - = creator - - td.g-annotation-date - = (new Date(annotation.get('created'))).toLocaleString() + a.g-annotation-toggle-select(class=canDraw ? 'disabled' : '', title='Show annotation') + if drawn.has(annotation.id) + i.icon-eye + else + i.icon-eye-off + for column in confList.columns || [] + if column.type !== 'record' || column.value !== 'controls' + - + var value = (column.type === 'record' ? annotation.get(column.value) || annotation.get('annotation')[column.value] : (column.type === 'metadata' ? ((annotation.get('annotation').attributes || {})[column.value] || '') : '')) || ''; + if (column.type === 'record' && column.value === 'creator') { + value = creator; + } + if (column.type === 'record' && column.value === 'updatedId') { + value = updater; + } + td.g-annotation-entry(title=value) + if column.format === 'user' + a(href=`#user/${annotation.get(column.value) || annotation.get(column.value + 'Id')}`) + = value + else if column.format === 'date' + = (new Date(value)).toLocaleString() + else + = value td.g-annotation-actions + // + if annotation.get('_accessLevel') >= AccessType.WRITE + a.g-annotation-edit(title='Edit annotation') + i.icon-cog a.g-annotation-download(href=`${apiRoot}/annotation/${annotation.id}`, title='Download', download=`${name}.json`) i.icon-download if annotation.get('_accessLevel') >= AccessType.ADMIN diff --git a/girder_annotation/girder_large_image_annotation/web_client/views/annotationListWidget.js b/girder_annotation/girder_large_image_annotation/web_client/views/annotationListWidget.js index 6ba5317f7..8a22c0dca 100644 --- a/girder_annotation/girder_large_image_annotation/web_client/views/annotationListWidget.js +++ b/girder_annotation/girder_large_image_annotation/web_client/views/annotationListWidget.js @@ -20,15 +20,17 @@ import '../stylesheets/annotationListWidget.styl'; const AnnotationListWidget = View.extend({ events: { - 'change .g-annotation-toggle': '_displayAnnotation', + 'click .g-annotation-toggle-select': '_displayAnnotation', + 'click .g-annotation-toggle-all': '_displayAllAnnotations', 'click .g-annotation-delete': '_deleteAnnotation', 'click .g-annotation-upload': '_uploadAnnotation', 'click .g-annotation-permissions': '_changePermissions', + 'click .g-annotation-metadata': '_annotationMetadata', 'click .g-annotation-row'(evt) { var $el = $(evt.currentTarget); - $el.find('.g-annotation-toggle > input').click(); + $el.find('.g-annotation-toggle-select').click(); }, - 'click .g-annotation-row a,input'(evt) { + 'click .g-annotation-row a,.g-annotation-toggle-select'(evt) { evt.stopPropagation(); } }, @@ -49,32 +51,76 @@ const AnnotationListWidget = View.extend({ this.listenTo(eventStream, 'g:event.large_image_annotation.create', () => this.collection.fetch(null, true)); this.listenTo(eventStream, 'g:event.large_image_annotation.remove', () => this.collection.fetch(null, true)); - this.collection.fetch({ - itemId: this.model.id, - sort: 'created', - sortdir: -1 - }).done(() => { - this._fetchUsers(); - }); - }, - - render() { restRequest({ type: 'GET', url: 'annotation/folder/' + this.model.get('folderId') + '/create' }).done((createResp) => { - this.$el.html(annotationList({ - item: this.model, - accessLevel: this.model.getAccessLevel(), - creationAccess: createResp, - annotations: this.collection, - users: this.users, - canDraw: this._viewer && this._viewer.annotationAPI(), - drawn: this._drawn, - apiRoot: getApiRoot(), - AccessType - })); + this.createResp = createResp; + restRequest({ + url: `folder/${this.model.get('folderId')}/yaml_config/.large_image_config.yaml` + }).done((val) => { + this._liconfig = val || {}; + this._confList = this._liconfig.annotationList || { + columns: [{ + type: 'record', + value: 'name' + }, { + type: 'record', + value: 'creator', + format: 'user' + }, { + type: 'record', + value: 'created', + format: 'date' + }] + }; + this.collection.comparator = _.constant(0); + this._lastSort = this._confList.defaultSort || [{ + type: 'record', + value: 'updated', + dir: 'up' + }, { + type: 'record', + value: 'updated', + dir: 'down' + }]; + this.collection.sortField = JSON.stringify(this._lastSort.reduce((result, e) => { + result.push([ + (e.type === 'metadata' ? 'annotation.attributes.' : '') + e.value, + e.dir === 'down' ? 1 : -1 + ]); + if (e.type === 'record') { + result.push([ + `annotation.${e.value}`, + e.dir === 'down' ? 1 : -1 + ]); + } + return result; + }, [])); + this.collection.fetch({ + itemId: this.model.id, + sort: this.collection.sortField || 'created', + sortdir: -1 + }).done(() => { + this._fetchUsers(); + }); + }); }); + }, + + render() { + this.$el.html(annotationList({ + item: this.model, + accessLevel: this.model.getAccessLevel(), + creationAccess: this.createResp, + annotations: this.collection, + users: this.users, + canDraw: this._viewer && this._viewer.annotationAPI(), + drawn: this._drawn, + apiRoot: getApiRoot(), + confList: this._confList, + AccessType + })); return this; }, @@ -85,10 +131,14 @@ const AnnotationListWidget = View.extend({ }, _displayAnnotation(evt) { - const $el = $(evt.currentTarget); - const id = $el.parent().data('annotationId'); + if (!this._viewer || !this._viewer.annotationAPI()) { + return; + } + const $el = $(evt.currentTarget).closest('.g-annotation-row'); + const id = $el.data('annotationId'); const annotation = this.collection.get(id); - if ($el.find('input').prop('checked')) { + const startedOn = $el.find('.g-annotation-toggle-select i.icon-eye').length; + if (!startedOn) { this._drawn.add(id); annotation.fetch().then(() => { if (this._drawn.has(id)) { @@ -100,6 +150,36 @@ const AnnotationListWidget = View.extend({ this._drawn.delete(id); this._viewer.removeAnnotation(annotation); } + $el.find('.g-annotation-toggle-select i').toggleClass('icon-eye', !startedOn).toggleClass('icon-eye-off', !!startedOn); + const anyOn = this.collection.some((annotation) => this._drawn.has(annotation.id)); + this.$el.find('th.g-annotation-toggle i').toggleClass('icon-eye', !!anyOn).toggleClass('icon-eye-off', !anyOn); + }, + + _displayAllAnnotations(evt) { + if (!this._viewer || !this._viewer.annotationAPI()) { + return; + } + const anyOn = this.collection.some((annotation) => this._drawn.has(annotation.id)); + this.collection.forEach((annotation) => { + const id = annotation.id; + let isDrawn = this._drawn.has(annotation.id); + if (anyOn && isDrawn) { + this._drawn.delete(id); + this._viewer.removeAnnotation(annotation); + isDrawn = false; + } else if (!anyOn && !isDrawn) { + this._drawn.add(id); + annotation.fetch().then(() => { + if (this._drawn.has(id)) { + this._viewer.drawAnnotation(annotation); + } + return null; + }); + isDrawn = true; + } + this.$el.find(`.g-annotation-row[data-annotation-id="${id}"] .g-annotation-toggle-select i`).toggleClass('icon-eye', !!isDrawn).toggleClass('icon-eye-off', !isDrawn); + }); + this.$el.find('th.g-annotation-toggle i').toggleClass('icon-eye', !anyOn).toggleClass('icon-eye-off', !!anyOn); }, _deleteAnnotation(evt) { @@ -201,6 +281,7 @@ const AnnotationListWidget = View.extend({ _fetchUsers() { this.collection.each((model) => { this.users.add({'_id': model.get('creatorId')}); + this.users.add({'_id': model.get('updatedId')}); }); $.when.apply($, this.users.map((model) => { return model.fetch(); diff --git a/girder_annotation/test_annotation/web_client_specs/annotationListSpec.js b/girder_annotation/test_annotation/web_client_specs/annotationListSpec.js index 28952750d..179726a7c 100644 --- a/girder_annotation/test_annotation/web_client_specs/annotationListSpec.js +++ b/girder_annotation/test_annotation/web_client_specs/annotationListSpec.js @@ -186,7 +186,7 @@ describe('AnnotationListWidget', function () { runs(function () { expect($el.length).toBe(10); $el.each(function () { - expect($(this).find('.g-annotation-name').text()).toMatch(/annotation [0-9]+/); + expect($(this).find('.g-annotation-entry').eq(0).text()).toMatch(/annotation [0-9]+/); }); }); }); @@ -247,7 +247,7 @@ describe('AnnotationListWidget', function () { runs(function () { expect($el.length).toBe(9); $el.each(function () { - expect($(this).find('.g-annotation-name').text()).toMatch(/annotation [0-9]+/); + expect($(this).find('.g-annotation-entry').eq(0).text()).toMatch(/annotation [0-9]+/); }); }); }); @@ -265,20 +265,40 @@ describe('AnnotationListWidget', function () { expect($el.find('.g-annotation-permissions').length).toBe(0); }); it('check visibility checkbox tooltip', function () { - expect($('.g-annotation-list .g-annotation-toggle input:first').prop('title')).toBe( + expect($('.g-annotation-list .g-annotation-row .g-annotation-toggle a:first').prop('title')).toBe( 'Show annotation'); }); it('toggle annotation visibility', function () { - var id = $('.g-annotation-list .g-annotation-row:first').data('annotationId'); - $('.g-annotation-list .g-annotation-row:first').click(); - + var id; + runs(function () { + id = $('.g-annotation-list .g-annotation-row:first').data('annotationId'); + $('.g-annotation-list .g-annotation-row:first').click(); + }); waitsFor(function () { return drawnAnnotations[id]; }, 'annotation to draw'); + girderTest.waitForLoad(); runs(function () { - expect($('.g-annotation-list .g-annotation-toggle input:first').prop('checked')).toBe(true); + expect($('.g-annotation-list .g-annotation-toggle a:first i').hasClass('icon-eye')).toBe(true); $('.g-annotation-list .g-annotation-row:first').click(); - expect($('.g-annotation-list .g-annotation-toggle input:first').prop('checked')).toBe(false); + expect($('.g-annotation-list .g-annotation-toggle a:first i').hasClass('icon-eye')).toBe(false); + expect(drawnAnnotations[id]).toBeUndefined(); + }); + }); + it('toggle annotation visibility via view all button', function () { + var id; + runs(function () { + id = $('.g-annotation-list .g-annotation-row:first').data('annotationId'); + $('.g-annotation-list .g-annotation-toggle-all').click(); + }); + waitsFor(function () { + return drawnAnnotations[id]; + }, 'annotation to draw'); + girderTest.waitForLoad(); + runs(function () { + expect($('.g-annotation-list .g-annotation-toggle a:first i').hasClass('icon-eye')).toBe(true); + $('.g-annotation-list .g-annotation-toggle-all').click(); + expect($('.g-annotation-list .g-annotation-toggle a:first i').hasClass('icon-eye')).toBe(false); expect(drawnAnnotations[id]).toBeUndefined(); }); }); @@ -287,10 +307,16 @@ describe('AnnotationListWidget', function () { expect($('.g-annotation-list-header .g-annotation-delete').length).toBe(0); }); it('switch to a viewer that does not support annotations', function () { + var id; $('.g-item-image-viewer-select select').val('leaflet').trigger('change'); waitForLargeImageViewer('leaflet'); runs(function () { - expect($('.g-annotation-list .g-annotation-toggle input:first').prop('disabled')).toBe(true); + id = $('.g-annotation-list .g-annotation-row:first').data('annotationId'); + $('.g-annotation-list .g-annotation-row:first').click(); + }); + girderTest.waitForLoad(); + runs(function () { + expect(drawnAnnotations[id]).toBeUndefined(); }); }); });