From 88c09f32a33d8fd4759f75a5d9c59a49fa1d7673 Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 21 Jul 2026 08:49:41 -0400 Subject: [PATCH 1/6] Add allowedGroups utility to read/validate allowed_groups metadata --- .../web_client/utilities/allowedGroups.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 histomicsui/web_client/utilities/allowedGroups.js diff --git a/histomicsui/web_client/utilities/allowedGroups.js b/histomicsui/web_client/utilities/allowedGroups.js new file mode 100644 index 00000000..472ccfc2 --- /dev/null +++ b/histomicsui/web_client/utilities/allowedGroups.js @@ -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; From aaf456adfbbd6e5523604209b0bfb24ec8949e0d Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 21 Jul 2026 08:55:38 -0400 Subject: [PATCH 2/6] Restrict Draw panel to allowed groups --- histomicsui/web_client/panels/DrawWidget.js | 47 ++++++++++++++++++- .../templates/panels/drawWidget.pug | 2 +- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/histomicsui/web_client/panels/DrawWidget.js b/histomicsui/web_client/panels/DrawWidget.js index 5f85572c..dd6ac06b 100644 --- a/histomicsui/web_client/panels/DrawWidget.js +++ b/histomicsui/web_client/panels/DrawWidget.js @@ -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'; @@ -78,6 +79,7 @@ var DrawWidget = Panel.extend({ if (this._editOptions.style && this._groups.get(this._editOptions.style)) { this._setStyleGroup(this._groups.get(this._editOptions.style).toJSON()); } + this._restrictStyleToAllowedGroups(); }); this.on('h:mouseon', (model) => { if (model && model.id) { @@ -113,7 +115,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, @@ -1071,10 +1073,53 @@ var DrawWidget = Panel.extend({ }, _handleStyleGroupsUpdate() { + this._restrictStyleToAllowedGroups(); this._debounceRender(); this.trigger('h:styleGroupsUpdated', this._groups); }, + /** + * 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); + }, + + /** + * 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'); diff --git a/histomicsui/web_client/templates/panels/drawWidget.pug b/histomicsui/web_client/templates/panels/drawWidget.pug index 6a2374d9..407742ef 100644 --- a/histomicsui/web_client/templates/panels/drawWidget.pug +++ b/histomicsui/web_client/templates/panels/drawWidget.pug @@ -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 From a19f66db4bbabbe37007e689532726525de6984e Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 21 Jul 2026 08:58:45 -0400 Subject: [PATCH 3/6] Auto-create missing allowed groups --- histomicsui/web_client/panels/DrawWidget.js | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/histomicsui/web_client/panels/DrawWidget.js b/histomicsui/web_client/panels/DrawWidget.js index dd6ac06b..ee0f7804 100644 --- a/histomicsui/web_client/panels/DrawWidget.js +++ b/histomicsui/web_client/panels/DrawWidget.js @@ -76,6 +76,7 @@ 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()); } @@ -1088,6 +1089,36 @@ var DrawWidget = Panel.extend({ return getAllowedGroups(this.annotation); }, + /** + * 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 From 0b33ee2a6fde4d9e147ac7395b9b071ffdd02984 Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 21 Jul 2026 08:59:57 -0400 Subject: [PATCH 4/6] React live to annotation metadata edits --- histomicsui/web_client/panels/DrawWidget.js | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/histomicsui/web_client/panels/DrawWidget.js b/histomicsui/web_client/panels/DrawWidget.js index ee0f7804..2ef43aa4 100644 --- a/histomicsui/web_client/panels/DrawWidget.js +++ b/histomicsui/web_client/panels/DrawWidget.js @@ -65,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)) { @@ -81,6 +85,7 @@ var DrawWidget = Panel.extend({ this._setStyleGroup(this._groups.get(this._editOptions.style).toJSON()); } this._restrictStyleToAllowedGroups(); + this._debounceRender(); }); this.on('h:mouseon', (model) => { if (model && model.id) { @@ -1079,6 +1084,11 @@ var DrawWidget = Panel.extend({ this.trigger('h:styleGroupsUpdated', this._groups); }, + _handleStyleGroupsRemoved() { + this._restrictStyleToAllowedGroups(); + this.render(); + }, + /** * Get the current annotation's `allowed_groups` metadata, if any. * @@ -1089,6 +1099,17 @@ var DrawWidget = Panel.extend({ 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 From 61a9302758356f6771cada40d4eeae4901080f2f Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 21 Jul 2026 09:01:07 -0400 Subject: [PATCH 5/6] Restrict context menu + guard overlapping style refetches --- .../views/popover/AnnotationContextMenu.js | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/histomicsui/web_client/views/popover/AnnotationContextMenu.js b/histomicsui/web_client/views/popover/AnnotationContextMenu.js index 49319f1c..218654c2 100644 --- a/histomicsui/web_client/views/popover/AnnotationContextMenu.js +++ b/histomicsui/web_client/views/popover/AnnotationContextMenu.js @@ -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'; @@ -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({ @@ -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; @@ -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; From 42e6086392da1a0811581e39a76b2d2d0e615ea6 Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 21 Jul 2026 09:01:38 -0400 Subject: [PATCH 6/6] Add allowed_groups web client tests --- tests/test_web_client.py | 1 + tests/web_client_specs/allowedGroupsSpec.js | 315 ++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 tests/web_client_specs/allowedGroupsSpec.js diff --git a/tests/test_web_client.py b/tests/test_web_client.py index 577aa040..27a17cf5 100644 --- a/tests/test_web_client.py +++ b/tests/test_web_client.py @@ -133,6 +133,7 @@ def testAnalysisRun(self, params): @pytest.mark.plugin('histomicsui') @pytest.mark.parametrize('spec', [ + 'allowedGroupsSpec.js', 'analysisSpec.js', 'annotationSpec.js', 'girderUISpec.js', diff --git a/tests/web_client_specs/allowedGroupsSpec.js b/tests/web_client_specs/allowedGroupsSpec.js new file mode 100644 index 00000000..aaf7a159 --- /dev/null +++ b/tests/web_client_specs/allowedGroupsSpec.js @@ -0,0 +1,315 @@ +/* globals describe, it, expect, waitsFor, runs, huiTest, girderTest */ + +girderTest.importPlugin('jobs', 'large_image', 'large_image_annotation', 'slicer_cli_web', 'histomicsui'); +girderTest.addScripts([ + '/static/built/plugins/histomicsui/huiTest.js', + '/static/built/plugins/histomicsui/extra/sinon.js' +]); + +girderTest.promise.done(function () { + huiTest.startApp(); + + describe('allowed_groups annotation metadata tests', function () { + var girder, largeImageAnnotation, histomicsUI; + + /** + * POST a new annotation with the given name/attributes/elements and + * fetch it back into a fresh AnnotationModel, storing the result on + * `result.annotation` once ready. Uses the girderTest runs/waitsFor + * idiom so it can be called directly inside an `it`. + */ + function createAnnotation(name, attributes, elements, result) { + var annotationId; + runs(function () { + girder.rest.restRequest({ + url: 'annotation?itemId=' + huiTest.imageId(), + contentType: 'application/json', + processData: false, + type: 'POST', + data: JSON.stringify({ + name: name, + attributes: attributes, + elements: elements + }) + }).then(function (resp) { + annotationId = resp._id; + return null; + }); + }); + waitsFor(function () { + return annotationId !== undefined; + }); + girderTest.waitForLoad(); + runs(function () { + result.annotation = new largeImageAnnotation.models.AnnotationModel({ + _id: annotationId + }); + result.fetched = false; + result.annotation.fetch().then(function () { + result.fetched = true; + return null; + }); + }); + waitsFor(function () { + return result.fetched; + }); + } + + function rectangleElement(x, y) { + return {type: 'rectangle', center: [x, y, 0], width: 4, height: 4}; + } + + describe('setup', function () { + it('login', function () { + huiTest.login(); + }); + + it('open image', function () { + huiTest.openImage('image'); + }); + + it('access plugin namespaces', function () { + girder = window.girder; + largeImageAnnotation = girder.plugins.large_image_annotation; + histomicsUI = girder.plugins.histomicsui; + }); + }); + + describe('#1/#2: no restriction (missing or empty allowed_groups)', function () { + var result = {}; + var drawWidget; + + it('creates an annotation with no allowed_groups key', function () { + createAnnotation('unrestricted annotation', {}, [rectangleElement(0, 0)], result); + }); + + it('offers every existing group in the Draw panel dropdown', function () { + var bodyView = huiTest.app.bodyView; + bodyView.annotations.add(result.annotation); + bodyView._editAnnotation(result.annotation); + drawWidget = bodyView.drawWidget; + waitsFor(function () { + return !!drawWidget._groups.length; + }); + runs(function () { + expect(drawWidget._getAllowedGroups()).toBe(null); + var expectedIds = drawWidget._groups.map(function (m) { return m.id; }).sort(); + var values = drawWidget.$('.h-style-group option').map(function () { + return this.value; + }).get().sort(); + expect(values).toEqual(expectedIds); + }); + }); + + it('offers every existing group in the context menu', function () { + var bodyView = huiTest.app.bodyView; + var element = result.annotation.elements().first(); + bodyView._resetSelection(); + bodyView._selectElement(element); + var groups = bodyView.contextMenu._getAnnotationGroups(); + var expectedIds = bodyView.contextMenu.styles.map(function (m) { return m.id; }); + expect(groups.sort()).toEqual(expectedIds.sort()); + }); + + it('treats an empty allowed_groups list the same as no restriction', function () { + result.annotation.get('annotation').attributes = {allowed_groups: []}; + expect(drawWidget._getAllowedGroups()).toBe(null); + result.annotation.get('annotation').attributes = {allowed_groups: 'not-an-array'}; + expect(drawWidget._getAllowedGroups()).toBe(null); + }); + }); + + describe('#3/#4: restricted annotation, including auto-created groups', function () { + var result = {}; + var drawWidget; + var defaultStyle; + + it('records the default group style for comparison', function () { + // StyleModel has no url/urlRoot of its own; it is only + // persisted through backbone.localStorage, which is patched + // onto the *collection*. A bare model must therefore be + // fetched via a StyleCollection rather than fetched directly. + var styles = new histomicsUI.collections.StyleCollection(); + var fetched = false; + runs(function () { + styles.fetch().always(function () { + fetched = true; + }); + }); + waitsFor(function () { + return fetched; + }); + runs(function () { + defaultStyle = styles.get('default'); + }); + }); + + it('creates an annotation restricted to a mix of missing groups', function () { + createAnnotation('restricted annotation', { + allowed_groups: ['groupA', 'groupB'] + }, [rectangleElement(10, 10)], result); + }); + + it('#4a/#4b: auto-creates every missing allowed group using the default style', function () { + var bodyView = huiTest.app.bodyView; + bodyView.annotations.add(result.annotation); + bodyView._editAnnotation(result.annotation); + drawWidget = bodyView.drawWidget; + waitsFor(function () { + return drawWidget._groups.has('groupA') && drawWidget._groups.has('groupB'); + }); + runs(function () { + ['groupA', 'groupB'].forEach(function (groupId) { + var created = drawWidget._groups.get(groupId).toJSON(); + expect(created.fillColor).toBe(defaultStyle.get('fillColor')); + expect(created.lineColor).toBe(defaultStyle.get('lineColor')); + expect(created.lineWidth).toBe(defaultStyle.get('lineWidth')); + expect(created.pattern).toBe(defaultStyle.get('pattern')); + }); + // the active style switches to the first allowed group + expect(drawWidget._style.id).toBe('groupA'); + }); + }); + + it('#3/#4c: restriction is immediately reflected in the Draw panel dropdown', function () { + var values = drawWidget.$('.h-style-group option').map(function () { + return this.value; + }).get(); + expect(values).toEqual(['groupA', 'groupB']); + }); + + it('#3/#4c: restriction is immediately reflected in the context menu', function () { + var bodyView = huiTest.app.bodyView; + // select an element of the restricted annotation so the context + // menu's group list reflects it, rather than a stale selection + // left over from a previous describe block + var element = result.annotation.elements().first(); + bodyView._resetSelection(); + bodyView._selectElement(element); + + var groups = bodyView.contextMenu._getAnnotationGroups(); + expect(groups.sort()).toEqual(['groupA', 'groupB']); + }); + }); + + describe('#5: context menu ignores active selection, uses the clicked annotation', function () { + var restricted = {}; + var unrestricted = {}; + + it('creates a restricted and an unrestricted annotation', function () { + createAnnotation('restricted annotation for #5', { + allowed_groups: ['groupE', 'groupF'] + }, [rectangleElement(30, 30)], restricted); + }); + + it('creates the unrestricted annotation', function () { + createAnnotation('unrestricted annotation for #5', {}, [rectangleElement(40, 40)], unrestricted); + }); + + it('auto-creates the restricted annotation\'s allowed groups (groupE/groupF) as styles', function () { + // _getAnnotationGroups() only ever returns groups that already + // exist as StyleModels; it never auto-creates them. Auto-creation + // only happens via the Draw panel's _editAnnotation() flow, so + // briefly edit the restricted annotation here to create groupE + // and groupF before switching the active annotation below. + var bodyView = huiTest.app.bodyView; + bodyView.annotations.add(restricted.annotation); + bodyView._editAnnotation(restricted.annotation); + waitsFor(function () { + return !!bodyView.drawWidget && + bodyView.drawWidget._groups.has('groupE') && + bodyView.drawWidget._groups.has('groupF'); + }); + }); + + it('restricts the context menu to the clicked element\'s annotation regardless of the active annotation/group/shape', function () { + var bodyView = huiTest.app.bodyView; + + runs(function () { + // make the *unrestricted* annotation active in the Annotations/Draw panel + bodyView.annotations.add(unrestricted.annotation); + bodyView._editAnnotation(unrestricted.annotation); + }); + waitsFor(function () { + return !!bodyView.drawWidget && !!bodyView.drawWidget._groups.length; + }); + runs(function () { + // pick an arbitrary style group in the Draw panel to prove the + // context menu restriction is independent of it + bodyView.drawWidget.setStyleGroupById(bodyView.drawWidget._groups.first().id); + + // but select (as if right-clicked) an element from the *restricted* annotation + var element = restricted.annotation.elements().first(); + bodyView._resetSelection(); + bodyView._selectElement(element); + + var groups = bodyView.contextMenu._getAnnotationGroups(); + expect(groups.sort()).toEqual(['groupE', 'groupF']); + }); + }); + }); + + describe('#3/#5: live metadata updates without reselecting or reloading', function () { + var result = {}; + var drawWidget; + + it('creates an annotation with no restriction', function () { + createAnnotation('live-update annotation', {}, [rectangleElement(50, 50)], result); + }); + + it('opens the annotation while unrestricted', function () { + var bodyView = huiTest.app.bodyView; + bodyView.annotations.add(result.annotation); + bodyView._editAnnotation(result.annotation); + drawWidget = bodyView.drawWidget; + waitsFor(function () { + return !!drawWidget._groups.length; + }); + runs(function () { + expect(drawWidget._getAllowedGroups()).toBe(null); + }); + }); + + it('immediately restricts the Draw panel and context menu when allowed_groups is added, without reselecting', function () { + var bodyView = huiTest.app.bodyView; + + // select an element of this annotation so the context menu reflects it + var element = result.annotation.elements().first(); + bodyView._resetSelection(); + bodyView._selectElement(element); + + // simulate editing the annotation's metadata via the "Edit annotation" + // dialog, which mutates attributes directly and triggers + // 'change:annotation' rather than calling .set() + result.annotation.get('annotation').attributes = {allowed_groups: ['groupG', 'groupH']}; + result.annotation.trigger('change:annotation', result.annotation, {}); + + waitsFor(function () { + // the Draw panel's `_groups` collection updates synchronously, + // but its dropdown DOM is refreshed via a debounced render, so + // also wait for the DOM to catch up before asserting on it + return drawWidget._groups.has('groupG') && drawWidget._groups.has('groupH') && + drawWidget.$('.h-style-group option').length === 2; + }); + runs(function () { + expect(drawWidget._style.id).toBe('groupG'); + var drawValues = drawWidget.$('.h-style-group option').map(function () { + return this.value; + }).get(); + expect(drawValues).toEqual(['groupG', 'groupH']); + }); + // the context menu keeps its own StyleCollection, refetched + // asynchronously via the 'h:styleGroupsEdited' event once the + // Draw panel auto-creates the newly allowed groups; wait for + // that refetch to complete before checking it + waitsFor(function () { + return bodyView.contextMenu.styles.get('groupG') && bodyView.contextMenu.styles.get('groupH'); + }); + runs(function () { + var contextGroups = bodyView.contextMenu._getAnnotationGroups(); + expect(contextGroups.sort()).toEqual(['groupG', 'groupH']); + }); + }); + }); + }); +});