Skip to content

Instantly share code, notes, and snippets.

@jdickey
Created December 11, 2012 06:10
Show Gist options
  • Save jdickey/4256272 to your computer and use it in GitHub Desktop.
Save jdickey/4256272 to your computer and use it in GitHub Desktop.
Support for jQuery-contextMenu Issue #94; see https://github.com/medialize/jQuery-contextMenu/issues/94

What IS All This?

"All this" is some CoffeeScript specs (using Jasmine via Jasminerice) and code to exercise the Medialize ContextMenu jQuery plugin, through a thin wrapper class.

Specifically, this Gist contains source and spec files demonstrating the main problem that led to the filing of this issue on the jQuery-contextMenu project.

What's the problem?

Seemingly logically enough, during spec setup we want to set up and display the menu, and during (later) spec teardown we want to remove any of the effects induced by showing and acting upon it. Therein lies the current problem: as noted in the comments within the spec snippet (immediately following), I've tried half a dozen ways to restore the state of the menu <ul> container and contents to its original state. None have worked. Witbout being able to "clean up" after ourselves, many things we would like to do in tests using Jasmine (or other similar frameworks) become unwieldy at best.

Simply put, we need to be able to restore the DOM to its initial state and prove it.

What's been tried?

  • Calls to $.contextMenu('hide'), as per the docs
  • Triggering another contextmenu DOM event (theory being that since triggering that event shows the menu, it might act as a toggle. Nope.)
  • Triggering a mousedown event, or a mousedown event followed by a mouseup event; both events being sent to the document body.
  • Triggering a keypress event on the document body

What else can you tell me?

Following this introduction are three files that should help anyone trying to help solve the problem:

Not included here is the source of the actual ContextMenu plugin; it may be retrieved from here or here.

Hang on; why not just directly code to the plugin?

Why bother wrapping a CoffeeScript class around the thing? I wasn't always using this plugin for my context menu; putting it in was a direct result of ripping out a previous plugin that had proven unsuitable (precisely which one isn't currently relevant). My long-held coding practices, which heavily inform our evolving shop standards, require that replacing an outside component requires wrapping it in a class or module for that project, so that any later changes to yet another outside component should require no code changes outside that code (and its specs, of course). Therefore, wrapping it in a class allows me to do two things that aren't nearly as practical:

  1. Define and enforce the usage I would make of the wrapped component; and
  2. Trivially prove that later releases of the wrapped component (or its replacement) would have no impact on my code (since it's written to my wrapper's API, not the component's).

Neither of these can be reliably done without some form of home-grown encapsulation/abstraction.

One last question: why has this Gist been revised so many times?

Because Gists, unlike issue reports, have no Preview button. All changes after this Gist was created have been to this description, not to any of the attached code.

# encoding: utf-8
#= require jquery.contextMenu
window.meldd = {} unless window.meldd
window.meldd.Util = {} unless window.meldd.Util
class window.meldd.Util.ContextMenu
_buildAnItem = (itemName) ->
{
name: itemName.capitalize()
icon: itemName
disabled: (key, options) ->
false
}
_buildCallParamsFrom = (params) ->
@params = {}
@params.selector = params.selector
@params.items = {}
@params.items[keyword] = _buildAnItem(keyword) for keyword in [
'edit',
'challenge',
'inquiry'
]
@params.callback = (key, options) ->
# FIXME We haven't REALLY specced this yet; trying to get menu show/hide
# working first. This really IS the whole point of the exercise, yes?
console.log 'In callback', key, options
message = 'meldd.contextMenu.' + key
new window.meldd.Server.CallbackMapper().publish message, null
@params
_validateCtorParams = (params) ->
errorMessage = (which) ->
message = 'Util.ContextMenu constructor requires '
tails = {
selector: 'a valid "selector" parameter'
selectorNotInDom: 'that the selector exist in the DOM'
}
message + tails[which]
throw errorMessage('selector') if typeof params.selector == 'undefined'
throw errorMessage('selectorNotInDom') unless $(params.selector).length
true
constructor: (params) ->
_validateCtorParams.call @, params
_buildCallParamsFrom.call @, params
destroy: ->
$.contextMenu 'destroy'
to$: (paramsIn = {}) ->
params = Object.clone @params
Object.merge params, paramsIn # any overrides?
$.contextMenu params
# encoding: utf-8
window.meldd = {} unless window.meldd
window.meldd.Util = {} unless window.meldd.Util
#= require Util/ContextMenu
buildCtorParamsExcept = (what) ->
ret = {
selector: '#whatever'
}
delete ret[what]
ret
initObject = (klass, omitted = 'ignored') ->
params = buildCtorParamsExcept omitted
affix params.selector
$('#whatever').html '<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>'
new klass params
menuRootNode = ->
$('.context-menu-root')
describe 'Util/ContextMenu', ->
beforeEach ->
@klass = window.meldd.Util.ContextMenu
@addMatchers {
toHaveTopSpec: ->
# This and #toHaveZIndexSpec *could* stand some DRYing up. However, IMO
# the value of the documenting an otherwise minimally-scrutable regex
# outweighs the value of non-repetition.
toHaveWidthSpec: ->
regex = /// ^
.* # anything or nothing at all, followed by
[^\-]? # anything BUT a dash, or nothing at all, followed by
width\: # the word 'width' and a colon, followed by
\s+ # one or more whitespace characters, followed by
\d+ # one or more decimal digits, followed by
\w+ # one or more word characters (e.g., 'px', 'em'), followed by
; # a semicolon, followed by
.*$ # anything or nothing at all, until the end of the line
///
@actual.attr('style').search(regex) != -1
toHaveZIndexSpec: ->
regex = /^.*z\-index\:\s+\d+;.*$/ # see #toHaveWidthSpec
@actual.attr('style').search(regex) != -1
toHaveDisplayNone: ->
regex = /.*display:\s+none;.*$/
@actual.attr('style').search(regex) != -1
}
describe 'verifying that the menu actually displays as expected', ->
beforeEach ->
@obj = initObject @klass
@obj.to$()
afterEach ->
@obj.destroy()
it 'changing state in response to "contextmenu" event', ->
expect(menuRootNode()).toHaveDisplayNone()
$('#whatever').trigger({type: 'contextmenu'})
expect(menuRootNode()).not.toHaveDisplayNone()
expect(menuRootNode()).toHaveWidthSpec()
expect(menuRootNode()).toHaveZIndexSpec()
# We could go on and spec the rest of the ad-hoc style attributes,
# but you get the drift: Stuff Changed.
# Now to cancel the context menu. IRL this would be done either by a mouse
# click outside the menu, hitting the Escape key or such, but (as usual)
# speccing it is a bit harder. On 73rd thought, nevermind. I copied and
# pasted a "carbon copy" copy of this spec immediately after this one,
# including verifying that display: none; is set at the beginning, and it
# passed, too. Yeah, we *should* be able to programmatically close the
# menu; it says so in the docs; see http://j.mp/jQctxtmenuClose and see if
# I'm missing something.
#
# EDIT: Of *course* the carbon-copy passed; Jasmine guarantees a
# reinitialised DOM between specs. So that didn't really prove what I
# needed. *sigh*
#
# Seemingly-plausible attempts that fail:
#
# $('#whatever').contextmenu 'hide' # per doc, see
# menuRootNode().contextMenu 'hide'
# $('#whatever').trigger({type: 'contextmenu'})
# $('body').mousedown().mouseup()
# $('body').keydown().keyup()
# $('body').keypress()
# FIXME: EXPECT FAILURE until we get the menu-close initiator right
expect(menuRootNode()).not.toHaveDisplayNone()
# encoding: utf-8
window.meldd = {} unless window.meldd
window.meldd.Util = {} unless window.meldd.Util
#= require Util/ContextMenu
buildCtorParamsExcept = (what) ->
ret = {
selector: '#whatever'
}
delete ret[what]
ret
initObject = (klass, omitted = 'ignored') ->
params = buildCtorParamsExcept omitted
affix params.selector
$('#whatever').html '<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>'
new klass params
menuRootNode = ->
$('.context-menu-root')
describe 'Util/ContextMenu', ->
beforeEach ->
@klass = window.meldd.Util.ContextMenu
@addMatchers {
toHaveTopSpec: ->
# This and #toHaveZIndexSpec *could* stand some DRYing up. However, IMO
# the value of the documenting an otherwise minimally-scrutable regex
# outweighs the value of non-repetition.
toHaveWidthSpec: ->
regex = /// ^
.* # anything or nothing at all, followed by
[^\-]? # anything BUT a dash, or nothing at all, followed by
width\: # the word 'width' and a colon, followed by
\s+ # one or more whitespace characters, followed by
\d+ # one or more decimal digits, followed by
\w+ # one or more word characters (e.g., 'px', 'em'), followed by
; # a semicolon, followed by
.*$ # anything or nothing at all, until the end of the line
///
@actual.attr('style').search(regex) != -1
toHaveZIndexSpec: ->
regex = /^.*z\-index\:\s+\d+;.*$/ # see #toHaveWidthSpec
@actual.attr('style').search(regex) != -1
toHaveDisplayNone: ->
regex = /.*display:\s+none;.*$/
@actual.attr('style').search(regex) != -1
}
describe 'has a basic API consisting of ', ->
it 'a constructor with one parameter', ->
expect(typeof(@klass)).toEqual 'function'
expect(@klass.length).toEqual 1
describe 'instance methods, including', ->
beforeEach ->
@obj = initObject @klass
it 'a #to$ method with one parameter', ->
expect(typeof @obj.to$).toEqual 'function'
expect(@obj.to$.length).toEqual 1
it 'a #destroy method with no parameters', ->
expect(typeof @obj.destroy).toEqual 'function'
expect(@obj.destroy.length).toEqual 0
describe 'has constructor-parameter requirements for', ->
describe '"selector"', ->
beforeEach ->
@messageStart = 'Util.ContextMenu constructor requires '
it 'that must be specified', ->
params = buildCtorParamsExcept 'selector'
message = @messageStart + 'a valid "selector" parameter'
expect( => new @klass(params)).toThrow message
it 'that must exist in the DOM', ->
params = buildCtorParamsExcept 'ignored'
message = @messageStart + 'that the selector exist in the DOM'
expect( => new @klass(params)).toThrow message
describe 'calling the #destroy method', ->
beforeEach ->
@obj = initObject @klass
it 'after calling #to$ removes the added menu from the DOM', ->
@obj.to$()
expect(menuRootNode()).toExist()
@obj.destroy()
expect(menuRootNode()).not.toExist()
it 'without having called #to$ has no effect on the DOM', ->
expect(menuRootNode()).not.toExist()
existing = $('html').outerHTML()
@obj.destroy()
expect(existing).toEqual $('html').outerHTML()
describe 'calling the #to$ method', ->
describe 'with the parameter default value', ->
describe 'adds the menu to the DOM', ->
beforeEach ->
@obj = initObject @klass
@obj.to$()
afterEach ->
@obj.destroy()
describe 'with the menu root node set up correctly, including', ->
it 'with the .context-menu-root CSS class', ->
expect(menuRootNode()).toExist()
it 'as a semantically-correct unordered list', ->
expect(menuRootNode().get(0).tagName).toEqual 'UL'
it 'with a .context-menu-list CSS class', ->
expect(menuRootNode()).toHaveClass 'context-menu-list'
it 'and initially hides it by setting "display" to "none"', ->
expect(menuRootNode().css 'display').toEqual 'none'
it 'with three menu items', ->
expect(menuRootNode().find('.context-menu-item').length).toEqual 3
describe 'with each menu item', ->
it 'being an LI element', ->
menuRootNode().children().each (index) ->
expect(this.tagName).toEqual 'LI'
it 'having the .context-menu-item CSS class', ->
menuRootNode().children().each (index) ->
expect($(this)).toHaveClass 'context-menu-item'
it 'having a single child element', ->
menuRootNode().children().each (index) ->
expect($(this).children().length).toEqual 1
it 'having a SPAN child element', ->
menuRootNode().children().each (index) ->
expect($(this).children().first().get(0).tagName).toEqual 'SPAN'
describe 'with the correct menu item data, including', ->
it 'the correct item-text strings', ->
items = ['Edit', 'Challenge', 'Inquiry']
menuRootNode().children().each (index) ->
expect(items[index]).toEqual $(this).children().first().text()
it 'the primary "icon" CSS style', ->
menuRootNode().children().each (index) ->
expect($(this)).toHaveClass 'icon'
it 'the correct icon-naming CSS style', ->
iconStyles = ['icon-edit', 'icon-challenge', 'icon-inquiry']
menuRootNode().children().each (index) ->
expect($(this)).toHaveClass iconStyles[index]
describe 'verifying that the menu actually displays as expected', ->
beforeEach ->
@obj = initObject @klass
@obj.to$()
afterEach ->
@obj.destroy()
it 'changing state in response to "contextmenu" event', ->
expect(menuRootNode()).toHaveDisplayNone()
$('#whatever').trigger({type: 'contextmenu'})
expect(menuRootNode()).not.toHaveDisplayNone()
expect(menuRootNode()).toHaveWidthSpec()
expect(menuRootNode()).toHaveZIndexSpec()
# We could go on and spec the rest of the ad-hoc style attributes,
# but you get the drift: Stuff Changed.
# Now to cancel the context menu. IRL this would be done either by a mouse
# click outside the menu, hitting the Escape key or such, but (as usual)
# speccing it is a bit harder. On 73rd thought, nevermind. I copied and
# pasted a "carbon copy" copy of this spec immediately after this one,
# including verifying that display: none; is set at the beginning, and it
# passed, too. Yeah, we *should* be able to programmatically close the
# menu; it says so in the docs; see http://j.mp/jQctxtmenuClose and see if
# I'm missing something.
#
# EDIT: Of *course* the carbon-copy passed; Jasmine guarantees a
# reinitialised DOM between specs. So that didn't really prove what I
# needed. *sigh*
#
# Seemingly-plausible attempts that fail:
#
# $('#whatever').contextmenu 'hide' # per doc, see
# menuRootNode().contextMenu 'hide'
# $('#whatever').trigger({type: 'contextmenu'})
# $('body').mousedown().mouseup()
# $('body').keydown().keyup()
# $('body').keypress()
# FIXME: EXPECT FAILURE until we get the menu-close initiator right
expect(menuRootNode()).not.toHaveDisplayNone()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment