Last active
August 29, 2015 14:25
-
-
Save lamchau/a8de8024b71a2db70cff to your computer and use it in GitHub Desktop.
{{inline-edit}} for Ember
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Inline Edit Component | |
* | |
* @author lamchau | |
*/ | |
import Ember from "ember"; | |
import KeyCodes from "utils/key-codes"; | |
const DOUBLE_CLICK_COUNT = 2; | |
const SINGLE_CLICK_COUNT = 1; | |
export default Ember.Component.extend({ | |
initialWidth: 0, | |
// data backed value (object to delete) [optional] | |
data: null, | |
isEditing: false, | |
isValid: true, | |
inputElement: null, | |
previousValue: null, | |
// rendered object | |
value: null, | |
// options | |
autoResize: false, | |
clickCount: DOUBLE_CLICK_COUNT, | |
selectAllOnFocus: true, | |
showDeleteButton: true, | |
actions: { | |
cancel() { | |
this.trigger("onCancel"); | |
}, | |
delete() { | |
this.trigger("onDelete"); | |
}, | |
edit() { | |
this.trigger("onEdit"); | |
}, | |
save() { | |
this.trigger("onSave"); | |
} | |
}, | |
_initialize: Ember.on("init", function() { | |
// convenience method to set multiple properties | |
let options = this.get("options"), | |
placeholder = this.get("placeholder"), | |
isEditing = this.get("isEditing"); | |
this.setProperties(options); | |
// TOOD: remove and switch to ember subexpressions | |
if (placeholder && I18n.lookup(placeholder)) { | |
this.set("placeholder", I18n.t(placeholder)); | |
} | |
if (isEditing) { | |
this.onEdit(); | |
} | |
}), | |
click() { | |
let clickCount = this.get("clickCount"), | |
isEditing = this.get("isEditing"); | |
if (clickCount === SINGLE_CLICK_COUNT && !isEditing) { | |
this.onEdit(); | |
} | |
}, | |
doubleClick() { | |
let clickCount = this.get("clickCount"), | |
isEditing = this.get("isEditing"); | |
if (clickCount !== SINGLE_CLICK_COUNT && !isEditing) { | |
this.onEdit(); | |
} | |
}, | |
focusInputArea() { | |
let input = this.getInputElement(), | |
value = input.val(); | |
// set cursor position for firefox and ie | |
input.focus(); | |
input.val(""); | |
input.val(value); | |
this.resizeInput(); | |
if (this.get("selectAllOnFocus")) { | |
this.selectAll(); | |
} | |
}, | |
focusOut() { | |
this.onCancel(); | |
}, | |
getActionContext() { | |
let actionContext = this.get("data"); | |
if (_.isUndefined(actionContext) || _.isNull(actionContext)) { | |
actionContext = this.getValue(); | |
} | |
return actionContext; | |
}, | |
// all keypress events | |
input() { | |
this.resizeInput(); | |
}, | |
getInputElement() { | |
let element = this.get("inputElement"); | |
if (!_.isElement(element)) { | |
element = this.$("input"); | |
this.set("inputElement", element); | |
} | |
return element; | |
}, | |
getValue() { | |
let value = this.get("value"); | |
return _.trim(value); | |
}, | |
keyUp(event) { | |
switch (event.which) { | |
case KeyCodes.ENTER: | |
this.onSave(); | |
break; | |
case KeyCodes.ESCAPE: | |
this.onCancel(); | |
break; | |
} | |
}, | |
onCancel() { | |
if (!this.get("isEditing")) { | |
return; | |
} | |
this.setEditMode(false); | |
this.setProperties({ | |
value: this.get("previousValue"), | |
previousValue: null | |
}); | |
}, | |
onDelete() { | |
if (this.get("showDeleteButton")) { | |
this.sendActionContext("delete"); | |
} | |
}, | |
onEdit() { | |
if (this.get("isEditing")) { | |
return; | |
} | |
let value = this.getValue(); | |
this.setProperties({ | |
value: value, | |
previousValue: value | |
}); | |
this.setEditMode(true); | |
Ember.run.scheduleOnce("afterRender", this, this.focusInputArea); | |
}, | |
onSave() { | |
let value = this.getValue(), | |
isValid = this.validate(value); | |
this.set("value", value); | |
this.set("isValid", isValid); | |
if (isValid) { | |
this.setEditMode(false); | |
this.sendActionContext("save"); | |
} | |
}, | |
resizeInput() { | |
if (!this.get("autoResize")) { | |
return; | |
} | |
let input = this.getInputElement(), | |
initialWidth = this.get("initialWidth"), | |
currentWidth = input.prop("scrollWidth"); | |
// store and/or set initial width, then resize to content | |
if (!initialWidth) { | |
this.set("initialWidth", currentWidth); | |
initialWidth = currentWidth; | |
} | |
input.css("width", initialWidth); | |
// calculate new width from scroll width | |
// TODO: IE does not correctly report scrollWidth | |
currentWidth = input.prop("scrollWidth"); | |
input.css("width", currentWidth); | |
}, | |
selectAll() { | |
let input = this.getInputElement(); | |
input.select(); | |
}, | |
sendActionContext(actionName) { | |
let actionContext = this.getActionContext(); | |
if (_.isUndefined(actionContext) || _.isNull(actionContext)) { | |
return; | |
} | |
this.triggerAction({ | |
action: actionName, | |
actionContext: actionContext | |
}); | |
}, | |
setEditMode(isEditing) { | |
this.set("isEditing", isEditing); | |
}, | |
validate(value) { | |
return true; | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$borderWidth: 1px; | |
.inline-text { | |
@include user-select(none); | |
display: inline-block; | |
&__button { | |
cursor: pointer; | |
display: none; | |
} | |
&__input { | |
max-width: 500px; | |
border: $borderWidth solid silver; | |
margin: -2px; | |
@include placeholder { | |
color: $etch-color__action--nu; | |
} | |
} | |
&:hover { | |
cursor: pointer; | |
border: $borderWidth solid silver; | |
margin: -$borderWidth; | |
.inline-text { | |
&__button { | |
display: inline-block; | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
moduleForComponent, | |
test | |
} from "ember-qunit"; | |
import { skip } from "qunit"; | |
import KeyCodes from "utils/key-codes"; | |
let component, | |
context, | |
sandbox; | |
function createEvent(keyCode) { | |
let keyEvent = $.Event("keypress"); | |
keyEvent.charCode = keyCode; | |
keyEvent.which = keyCode; | |
return keyEvent; | |
} | |
moduleForComponent("inline-edit", { | |
needs:[], | |
beforeEach() { | |
component = this.subject(); | |
context = this; | |
sandbox = sinon.sandbox.create(); | |
}, | |
afterEach() { | |
component = null; | |
context = null; | |
sandbox.restore(); | |
} | |
}); | |
test("it renders", assert => { | |
assert.equal(component._state, "preRender"); | |
context.render(); | |
assert.equal(component._state, "inDOM"); | |
}); | |
test("actions", assert => { | |
function testAction(action, methodName) { | |
let stub = sandbox.stub(component, methodName), | |
subscriptionCalled = false; | |
component.one(methodName, () => { | |
subscriptionCalled = true; | |
}); | |
component.send(action); | |
assert.ok(stub.calledOnce, | |
`should call '${methodName}' when the '${action}' action is triggered`); | |
assert.ok(subscriptionCalled, | |
`should also broadcast an event for '${methodName}'`); | |
} | |
testAction("cancel", "onCancel"); | |
testAction("delete", "onDelete"); | |
testAction("edit", "onEdit"); | |
testAction("save", "onSave"); | |
}); | |
test("click", assert => { | |
let stub = sandbox.stub(component, "focusInputArea"); | |
Ember.run(() => component.click()); | |
Ember.run(() => component.doubleClick()); | |
assert.ok(stub.calledOnce, "should trigger 'onEdit' only once"); | |
}); | |
test("doubleClick", assert => { | |
let stub = sandbox.stub(component, "focusInputArea"); | |
Ember.run(() => component.doubleClick()); | |
Ember.run(() => component.doubleClick()); | |
assert.ok(stub.calledOnce, "should trigger 'onEdit' only once"); | |
}); | |
test("focusOut", assert => { | |
let stub = sandbox.stub(component, "onCancel"); | |
component.focusOut(); | |
assert.ok(stub.calledOnce, "should trigger the 'cancel' on focus out"); | |
}); | |
test("getActionContext", assert => { | |
let data = component.get("data"), | |
value = component.get("value"), | |
actionContext; | |
assert.ok(_.isNull(data), "should be null for 'data'"); | |
assert.ok(_.isNull(value), "should be null for 'value'"); | |
component.set("value", "foo"); | |
actionContext = component.getActionContext(); | |
assert.strictEqual(actionContext, "foo", "should return value if 'data' does not exist"); | |
component.set("data", "bar"); | |
actionContext = component.getActionContext(); | |
assert.strictEqual(actionContext, "bar", "should return data if both 'value' and 'data' exist"); | |
}); | |
test("focusInputArea", assert => { | |
let resizeInput = sandbox.stub(component, "resizeInput"), | |
selectAll = sandbox.stub(component, "selectAll"), | |
focusCalled = sandbox.stub(); | |
sandbox.stub(component, "getInputElement").returns({ | |
focus: focusCalled, | |
val: _.noop | |
}); | |
component.set("selectAllOnFocus", false); | |
component.focusInputArea(); | |
assert.ok(resizeInput.calledOnce, "should call resize input"); | |
assert.ok(focusCalled, "should call the focus method on an input"); | |
assert.ok(selectAll.notCalled, "should not call the select all"); | |
component.set("selectAllOnFocus", true); | |
component.focusInputArea(); | |
assert.ok(selectAll.calledOnce, "should call the select all"); | |
}); | |
test("getValue", assert => { | |
component.set("value", " hello world "); | |
let actual = component.getValue(), | |
expected = "hello world"; | |
assert.strictEqual(actual, expected, "should return the trimmed value"); | |
}); | |
test("keyUp", assert => { | |
function testKeyUp(keyCode, methodName, message) { | |
let mockEvent = createEvent(keyCode), | |
stub = sandbox.stub(component, methodName); | |
component.keyUp(mockEvent); | |
assert.ok(stub.calledOnce, message); | |
stub.restore(); | |
} | |
testKeyUp(KeyCodes.ESCAPE, "onCancel", "should call 'cancel' when the escape key is pressed"); | |
testKeyUp(KeyCodes.ENTER, "onSave", "should call 'save' when the enter key is pressed"); | |
}); | |
test("onCancel", assert => { | |
function testValue(expected, message) { | |
assert.strictEqual(component.get("value"), expected, message); | |
} | |
component.set("value", "foo"); | |
component.set("isEditing", false); | |
component.onCancel(); | |
testValue("foo", "should not restore the value when not editing"); | |
component.set("value", "foo"); | |
component.set("previousValue", "hello world"); | |
component.set("isEditing", true); | |
component.onCancel(); | |
testValue("hello world", "should restore the previous value"); | |
assert.ok(!component.get("isEditing"), "should not be in edit mode"); | |
assert.ok(_.isNull(component.get("previousValue")), "should clear the previous value"); | |
}); | |
test("onDelete", assert => { | |
(function() { | |
let mock = sandbox.mock(component); | |
mock.expects("triggerAction").never(); | |
component.onDelete(); | |
assert.ok(mock.verify(), "should not be called if actionContext does not exist"); | |
})(); | |
(function() { | |
let mock = sandbox.mock(component), | |
expected = { | |
name: "bob" | |
}; | |
mock.expects("triggerAction").once().withArgs({ | |
action: "delete", | |
actionContext: expected | |
}); | |
component.set("data", expected); | |
component.onDelete(); | |
assert.ok(mock.verify(), "should only be called if actionContext exists"); | |
})(); | |
(function() { | |
let mock = sandbox.mock(component), | |
expected = { | |
name: "bob" | |
}; | |
mock.expects("triggerAction").never(); | |
component.set("showDeleteButton", false); | |
component.set("data", expected); | |
component.onDelete(); | |
assert.ok(mock.verify(), "should not be called if 'showDeleteButton' is false"); | |
})(); | |
}); | |
test("onEdit", assert => { | |
let stub = sandbox.stub(component, "focusInputArea"); | |
assert.ok(!component.get("isEditing"), "should enter into 'value mode' by default"); | |
Ember.run(() => component.onEdit()); | |
assert.ok(component.get("isEditing"), "should enter into 'edit mode'"); | |
assert.ok(stub.calledOnce, "should focus when entering into 'edit mode'"); | |
Ember.run(() => component.onEdit()); | |
assert.ok(component.get("isEditing"), "should stay in 'edit mode' on subsequent calls"); | |
assert.ok(stub.calledOnce, "should not focus again once when already in 'edit mode'"); | |
}); | |
test("onSave", assert => { | |
component.onSave(); | |
assert.ok(!component.get("isEditing"), "should not be in 'edit mode' after saving"); | |
component.onSave(); | |
assert.ok(!component.get("isEditing"), "should stay in 'value mode' after saving"); | |
let trimStub = sandbox.stub(_, "trim"); | |
component.onSave(); | |
assert.ok(trimStub.called, "should call the trim function when saving"); | |
let stub = sandbox.stub(component, "sendActionContext"); | |
sandbox.stub(component, "validate").returns(false); | |
component.onSave(); | |
assert.ok(stub.notCalled, "should not send the action context if not valid"); | |
}); | |
test("resizeInput", assert => { | |
let stub = sandbox.stub(); | |
sandbox.stub(component, "getInputElement").returns({ | |
prop: _.noop, | |
css: stub | |
}); | |
component.set("autoResize", false); | |
component.resizeInput(); | |
assert.ok(stub.notCalled, "should not update the element if 'autoResize' is false"); | |
component.set("autoResize", true); | |
component.resizeInput(); | |
assert.ok(stub.called, "should update the element if 'autoResize' is true"); | |
}); | |
skip("selectAll", assert => { | |
assert.ok(true); | |
}); | |
test("setEditMode", assert => { | |
component.setEditMode(true); | |
assert.ok(component.get("isEditing"), "should be in 'edit mode' when the value is 'true'"); | |
component.setEditMode(false); | |
assert.ok(!component.get("isEditing"), "should not be in 'edit mode' when the value is 'false'"); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export default { | |
BACKSPACE: 8, | |
TAB: 9, | |
ENTER: 13, | |
SHIFT: 16, | |
CTRL: 17, | |
ALT: 18, | |
ESCAPE: 27, | |
PAGEUP: 33, | |
PAGEDOWN: 34, | |
END: 35, | |
HOME: 36, | |
LEFT: 37, | |
UP: 38, | |
RIGHT: 39, | |
DOWN: 40, | |
DELETE: 46 | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment