Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions histomicsui/web_client/panels/DrawWidget.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import StyleCollection from '../collections/StyleCollection';
import StyleModel from '../models/StyleModel';
import editElement from '../dialogs/editElement';
import editStyleGroups from '../dialogs/editStyleGroups';
import getAllowedGroups from '../utilities/allowedGroups';
import drawWidget from '../templates/panels/drawWidget.pug';
import drawWidgetElement from '../templates/panels/drawWidgetElement.pug';
import '../stylesheets/panels/drawWidget.styl';
Expand Down Expand Up @@ -64,9 +65,13 @@ var DrawWidget = Panel.extend({
this._groups = new StyleCollection();
this._style = new StyleModel({id: this.parentView._defaultGroup});
this.listenTo(this._groups, 'add change', this._handleStyleGroupsUpdate);
this.listenTo(this._groups, 'remove', this.render);
this.listenTo(this._groups, 'remove', this._handleStyleGroupsRemoved);
this.listenTo(this.collection, 'add remove reset', this._recalculateGroupAggregation);
this.listenTo(this.collection, 'change update reset', this.render);
// if the annotation's metadata (including `allowed_groups`) is
// edited while this annotation is active, react immediately instead
// of requiring the annotation to be reselected or the page reloaded
this.listenTo(this.annotation, 'change:annotation', this._handleAnnotationAttributesChange);
this._groups.fetch().done(() => {
// ensure the default style exists
if (this._groups.has(this.parentView._defaultGroup)) {
Expand All @@ -75,9 +80,12 @@ var DrawWidget = Panel.extend({
this._groups.add(this._style.toJSON());
this._groups.get(this._style.id).save();
}
this._ensureAllowedGroupsExist();
if (this._editOptions.style && this._groups.get(this._editOptions.style)) {
this._setStyleGroup(this._groups.get(this._editOptions.style).toJSON());
}
this._restrictStyleToAllowedGroups();
this._debounceRender();
});
this.on('h:mouseon', (model) => {
if (model && model.id) {
Expand Down Expand Up @@ -113,7 +121,7 @@ var DrawWidget = Panel.extend({
this.$el.html(drawWidget({
title: 'Draw',
elements: this.collection.models,
groups: this._groups,
groups: this._visibleGroups(),
style: this._style.id,
defaultGroup: this.parentView._defaultGroup,
highlighted: this._highlighted,
Expand Down Expand Up @@ -1071,10 +1079,99 @@ var DrawWidget = Panel.extend({
},

_handleStyleGroupsUpdate() {
this._restrictStyleToAllowedGroups();
this._debounceRender();
this.trigger('h:styleGroupsUpdated', this._groups);
},

_handleStyleGroupsRemoved() {
this._restrictStyleToAllowedGroups();
this.render();
},

/**
* Get the current annotation's `allowed_groups` metadata, if any.
*
* @returns {string[]|null} The list of allowed group names, or `null` if
* the current annotation has no valid restriction.
*/
_getAllowedGroups() {
return getAllowedGroups(this.annotation);
},

/**
* Respond to the active annotation's metadata being edited (e.g. via the
* "Edit annotation" dialog), which may have changed its `allowed_groups`
* restriction.
*/
_handleAnnotationAttributesChange() {
this._ensureAllowedGroupsExist();
this._restrictStyleToAllowedGroups();
this._debounceRender();
},

/**
* If the current annotation restricts its elements to a set of
* `allowed_groups`, create any of those groups that don't already
* exist, copying the current default group's style (but not its name).
*/
_ensureAllowedGroupsExist() {
const allowed = this._getAllowedGroups();
if (!allowed) {
return;
}
const missing = allowed.filter((groupId) => !this._groups.has(groupId));
if (!missing.length) {
return;
}
const defaultGroup = this._groups.get(this.parentView._defaultGroup);
const baseAttributes = defaultGroup ? _.omit(defaultGroup.toJSON(), 'id', 'group') : {};
const saves = missing.map((groupId) => {
this._groups.add(Object.assign({}, baseAttributes, {id: groupId}));
return this._groups.get(groupId).save();
});
// other views (e.g. the annotation context menu) keep their own
// fetched copy of the style groups, so let them know new groups
// exist without requiring a page refresh; wait until the new
// groups are actually persisted before notifying, otherwise a
// listener's refetch may race with these saves and read stale data
$.when(...saves).done(() => {
this.parentView.trigger('h:styleGroupsEdited', this._groups);
});
},

/**
* Return the style groups that should be offered to the user given the
* current annotation's `allowed_groups` restriction, if any, sorted
* alphabetically by id (matching the style-group dropdown's ordering).
*
* @returns {object[]} A list of plain style group attribute objects.
*/
_visibleGroups() {
const allowed = this._getAllowedGroups();
const groups = allowed ? this._groups.filter((group) => allowed.includes(group.id)) : this._groups.models;
return _.sortBy(groups, 'id').map((group) => group.toJSON());
},

/**
* If the current annotation restricts its elements to a set of
* `allowed_groups` and the currently selected style is not one of them,
* switch to the first allowed group that exists, using the same
* alphabetical ordering as the style-group dropdown.
*/
_restrictStyleToAllowedGroups() {
const allowed = this._getAllowedGroups();
if (!allowed || allowed.includes(this._style.id)) {
return;
}
const candidates = this._groups.filter((group) => allowed.includes(group.id))
.map((group) => group.id)
.sort();
if (candidates.length) {
this._setStyleGroup(this._groups.get(candidates[0]).toJSON());
}
},

_highlightElement(evt) {
const id = $(evt.currentTarget).data('id');
const annotType = this.collection._byId[id].get('type');
Expand Down
2 changes: 1 addition & 1 deletion histomicsui/web_client/templates/panels/drawWidget.pug
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ block title
block content
.input-group.input-group-sm.h-style-group-row
select.form-control.h-style-group
each group in groups.sortBy('id')
each group in groups
option(value=group.id, selected=group.id === style)
= group.id
.input-group-btn
Expand Down
29 changes: 29 additions & 0 deletions histomicsui/web_client/utilities/allowedGroups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import _ from 'underscore';

/**
* Read and validate the `allowed_groups` metadata on an annotation.
*
* The value is expected to live at
* `annotation.get('annotation').attributes.allowed_groups` and be an array of
* non-empty strings. Any other value (missing, not an array, empty array, or
* an array with no valid strings) is treated as "unrestricted" and this
* returns `null`.
*
* @param {AnnotationModel} annotation The annotation (layer) to check.
* @returns {string[]|null} The de-duplicated list of allowed group names, in
* their original order, or `null` if there is no valid restriction.
*/
function getAllowedGroups(annotation) {
if (!annotation) {
return null;
}
const attributes = (annotation.get('annotation') || {}).attributes || {};
const allowedGroups = attributes.allowed_groups;
if (!_.isArray(allowedGroups)) {
return null;
}
const filtered = _.uniq(allowedGroups.filter((group) => _.isString(group) && group.length));
return filtered.length ? filtered : null;
}

export default getAllowedGroups;
34 changes: 32 additions & 2 deletions histomicsui/web_client/views/popover/AnnotationContextMenu.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import $ from 'jquery';

import StyleCollection from '../../collections/StyleCollection';
import getAllowedGroups from '../../utilities/allowedGroups';
import View from '../View';

import template from '../../templates/popover/annotationContextMenu.pug';
Expand All @@ -19,6 +20,10 @@ const AnnotationContextMenu = View.extend({
this.styles = new StyleCollection();
this.styles.fetch().done(() => this.render());
this.listenTo(this.collection, 'add remove reset', this.render);
// react immediately if any annotation's metadata (including
// `allowed_groups`) is edited, rather than requiring a new
// selection or a page reload
this.listenTo(this.parentView.annotations, 'change:annotation', this.render);
},
render() {
this.$el.html(template({
Expand All @@ -28,7 +33,23 @@ const AnnotationContextMenu = View.extend({
return this;
},
refetchStyles() {
this.styles.fetch().done(() => this.render());
// guard against overlapping fetches: if two refetches are in
// flight (e.g. triggered in quick succession by multiple
// `h:styleGroupsEdited` events), only apply the result of the most
// recently issued one, so a slower, stale response can't clobber
// the collection with outdated style-group data. Overriding
// `success` here replaces Backbone's default handler (which would
// otherwise unconditionally call `set()`), so we perform the set
// ourselves only when this is still the latest outstanding request.
const requestId = (this._styleFetchRequestId = (this._styleFetchRequestId || 0) + 1);
this.styles.fetch({
success: (collection, resp, options) => {
if (requestId === this._styleFetchRequestId) {
collection.set(resp, options);
this.render();
}
}
});
},
setGroupCount(groupCount) {
this._cachedGroupCount = groupCount;
Expand Down Expand Up @@ -95,7 +116,16 @@ const AnnotationContextMenu = View.extend({
}
},
_getAnnotationGroups() {
const groups = this.styles.map((style) => style.id);
// restrict to the allowed groups of the annotation that owns the
// selected/right-clicked element(s), not whichever annotation
// happens to be active in the Annotations panel
const referenceElement = this.collection.at(0);
const referenceAnnotation = (referenceElement && referenceElement.originalAnnotation) || this.parentView.activeAnnotation;
const allowed = getAllowedGroups(referenceAnnotation);
let groups = this.styles.map((style) => style.id);
if (allowed) {
groups = groups.filter((groupId) => allowed.includes(groupId));
}
groups.sort((a, b) => {
const countA = this._cachedGroupCount[a] || 0;
const countB = this._cachedGroupCount[b] || 0;
Expand Down
1 change: 1 addition & 0 deletions tests/test_web_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def testAnalysisRun(self, params):

@pytest.mark.plugin('histomicsui')
@pytest.mark.parametrize('spec', [
'allowedGroupsSpec.js',
'analysisSpec.js',
'annotationSpec.js',
'girderUISpec.js',
Expand Down
Loading