Skip to content

Instantly share code, notes, and snippets.

@lamchau
Last active August 29, 2015 14:25
Show Gist options
  • Save lamchau/a8de8024b71a2db70cff to your computer and use it in GitHub Desktop.
Save lamchau/a8de8024b71a2db70cff to your computer and use it in GitHub Desktop.
{{inline-edit}} for Ember
/**
* 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;
}
});
$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;
}
}
}
}
<span class="inline-text">
{{#if isEditing}}
{{input class="inline-text__input"
value=value
focusOut="focusOut"
on="input"
placeholder=placeholder}}
{{else}}
<span class="inline-text__value {{if isValid '' 'inline-text__value--error'}}"
on={{doubleClick}} on={{click}}>
{{#if value}}
{{value}}
{{else}}
{{placeholder}}
{{/if}}
</span>
{{!--
TODO: position this to the left so it's easier to delete
consecutive links without moving the mouse (has hovering issues)
--}}
{{#if showDeleteButton}}
<span class="inline-text__button inline-text__button-delete" {{action "delete"}}>
<i class="fa fa-times"></i>
</span>
{{/if}}
{{/if}}
</span>
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'");
});
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