Skip to content
81 changes: 79 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, {ensureAllowedGroupsExist} 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,11 @@ 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 is edited while it is active, react immediately
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 +78,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 +119,7 @@ var DrawWidget = Panel.extend({
this.$el.html(drawWidget({
title: 'Draw',
elements: this.collection.models,
groups: this._groups,
groups: this._groupsForDisplay(),
style: this._style.id,
defaultGroup: this.parentView._defaultGroup,
highlighted: this._highlighted,
Expand Down Expand Up @@ -1071,10 +1077,81 @@ 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, 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.
*/
_ensureAllowedGroupsExist() {
const saves = ensureAllowedGroupsExist(
this._groups, this._getAllowedGroups(), this.parentView._defaultGroup);
if (!saves.length) {
return;
}
// Let other views know new groups exist after they're persisted so that a page refresh is
// not needed.
$.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.
*
* @returns {object[]} A list of plain style group attribute objects.
*/
_groupsForDisplay() {
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.
*/
_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
57 changes: 57 additions & 0 deletions histomicsui/web_client/utilities/allowedGroups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 strings. Any other value (missing, not an array, empty array, etc.) is
* treated as "unrestricted" and returns `null`.
*
* @param {AnnotationModel} annotation The annotation to check.
* @returns {string[]|null} The list of allowed group names, or null if there are no restrictions.
*/
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;
}

/**
* Ensure that every group in an `allowed_groups` restriction exists as a persisted style group.
* Create any that are missing by copying the style of the default group.
*
* Newly created groups are added to the collection synchronously and each is persisted
* asynchronously. The caller is responsible for reacting to the returned save promises.
*
* @param {StyleCollection} styles The style-group collection to populate.
* @param {string[]|null} allowed The validated `allowed_groups` restriction, or `null` for
* "unrestricted" (in which case nothing is created).
* @param {string} defaultGroupId The id of the default style group to copy.
* @returns {Array} The list of save promises for the newly created groups.
*/
function ensureAllowedGroupsExist(styles, allowed, defaultGroupId) {
if (!allowed) {
return [];
}
const missing = allowed.filter((groupId) => !styles.get(groupId));
if (!missing.length) {
return [];
}
// we assume the default group always exists; if it somehow does not, new groups are created
// with no inherited style rather than failing
const defaultGroup = styles.get(defaultGroupId);
const baseAttributes = defaultGroup ? _.omit(defaultGroup.toJSON(), 'id', 'group') : {};
return missing.map((groupId) => {
styles.add(Object.assign({}, baseAttributes, {id: groupId}));
return styles.get(groupId).save();
});
}

export default getAllowedGroups;
export {ensureAllowedGroupsExist};
6 changes: 5 additions & 1 deletion histomicsui/web_client/views/body/ImageView.js
Original file line number Diff line number Diff line change
Expand Up @@ -1553,8 +1553,12 @@ var ImageView = View.extend({
return;
}

var elementModel = this.selectedElements.add(element.attributes, options);
// Assign `originalAnnotation` before the element enters the collection. Setting it after
// `add` would leave that first render seeing an undefined reference and falling back to
// the panel's active annotation instead of the clicked one.
var elementModel = new this.selectedElements.model(element.attributes);
elementModel.originalAnnotation = annotation;
this.selectedElements.add(elementModel, options);
this.viewerWidget.highlightAnnotation(this.selectedAnnotation.id);
},

Expand Down
43 changes: 41 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, {ensureAllowedGroupsExist} from '../../utilities/allowedGroups';
import View from '../View';

import template from '../../templates/popover/annotationContextMenu.pug';
Expand All @@ -19,6 +20,8 @@ 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 is edited
this.listenTo(this.parentView.annotations, 'change:annotation', this.render);
},
render() {
this.$el.html(template({
Expand All @@ -28,7 +31,16 @@ const AnnotationContextMenu = View.extend({
return this;
},
refetchStyles() {
this.styles.fetch().done(() => this.render());
// Prevent race conditions when multiple fetches happen in quick succession.
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 @@ -96,7 +108,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, 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);
this._ensureAllowedGroupsExist(allowed);
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 All @@ -113,6 +134,24 @@ const AnnotationContextMenu = View.extend({
});
return groups;
},
/**
* Create any style groups required by the given `allowed_groups` restriction that don't
* already exist. Once the new groups are persisted, notify the other views so their style
* collections stay in sync.
*
* @param {string[]|null} allowed The validated `allowed_groups` restriction, or `null` when
* unrestricted.
*/
_ensureAllowedGroupsExist(allowed) {
const saves = ensureAllowedGroupsExist(
this.styles, allowed, this.parentView._defaultGroup);
if (!saves.length) {
return;
}
$.when(...saves).done(() => {
this.parentView.trigger('h:styleGroupsEdited', this.styles);
});
},
_setGroup(evt) {
evt.preventDefault();
evt.stopPropagation();
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