Skip to content

Instantly share code, notes, and snippets.

@sukima
Forked from machty/components.async-button.js
Created January 5, 2017 12:57
Show Gist options
  • Save sukima/ed7a93648451c4c58f8267c21eeeac16 to your computer and use it in GitHub Desktop.
Save sukima/ed7a93648451c4c58f8267c21eeeac16 to your computer and use it in GitHub Desktop.
New Twiddle
import Ember from 'ember';
export default Ember.Component.extend({
tagName: '',
});
import Ember from 'ember';
export default Ember.Component.extend({
xs: ["", "success", "secondary", "alert"],
ys: ["", "active", "disabled"],
});
import Ember from 'ember';
import { task, timeout } from 'ember-concurrency';
export default Ember.Controller.extend({
myTask: task(function * () {
yield timeout(1500);
return { username: "machty" };
}).restartable(),
otherTask: task(function * () {
yield timeout(1500);
return { username: "trekkles" };
}).restartable(),
});
import Ember from 'ember';
import { task, timeout } from 'ember-concurrency';
const { computed, inject } = Ember;
const State = Ember.Object.extend({
tagName: '',
t: null,
task: null,
state: computed.or('task.state', '_defaultState'),
_defaultState: 'idle',
init() {
this._super();
this.perform = Ember.run.bind(this, '_perform');
},
_perform(...args) {
// TODO: how to compose "performability" when
// we decorate the underlying task with timeouts?
if (!this.get('task.isRunning')) {
this.get('_internalTask').cancelAll();
this.get('_internalTask').perform(...args);
}
},
isActive: computed.bool('_lastTaskInstance.isRunning'),
isDisabled: computed('_lastTaskInstance', 'task.last.isRunning', function() {
let lastTaskInstance = this.get('_lastTaskInstance');
let last = this.get('task.last');
return last && last.get('isRunning') && last !== lastTaskInstance;
}),
successValue: null,
isSuccess: false,
cooldown: 3000,
_internalTask: task(function * (...args) {
let ti = this.get('task').perform(...args);
this.set('_lastTaskInstance', ti);
let value = yield ti;
try {
this.set('isSuccess', true);
this.set('successValue', value);
yield timeout(this.get('cooldown'));
} finally {
this.set('isSuccess', false);
this.set('successValue', null);
}
}),
_lastTaskInstance: null,
});
export default Ember.Helper.extend({
compute(_, options) {
return State.create({
task: options.task,
t: options.t,
});
},
});
import Ember from 'ember';
import { task, timeout } from 'ember-concurrency';
const { computed, inject } = Ember;
export default Ember.Helper.extend({
i18n: inject.service(),
_stashedState: null,
state: null,
t: null,
default: computed('state.state', 'state.isDisabled', 'state.isSuccess', function() {
let state = this.get('state');
if (state.get('isSuccess')) {
return this._findFirstTranslation([
'success',
'idle'
], { value: state.get('successValue') });
} else if (state.get('isDisabled')) {
return this._findFirstTranslation(['idle']);
} else {
return this._findFirstTranslation([
state.get('state'),
'idle'
]);
}
}),
idleText: computed(function() {
return this._findFirstTranslation(['idle']);
}),
_findFirstTranslation(keys, data = {}) {
let prefix = this.get('t') || 'button.default';
let i18n = this.get('i18n');
for (let i = 0; i < keys.length; ++i) {
let key = `${prefix}.${keys[i]}`;
if (i18n.exists(key)) {
return i18n.t(key, data);
}
}
// TODO: better messaging
return `Missing Translations (${keys.join(', ')})`;
},
compute(_, { state, t }) {
this.set('state', state);
this.set('t', t);
return this;
},
});
export default {
'button': {
'default': {
'idle': 'Go',
'running': 'Please Wait...',
},
'add_user': {
'idle': 'Add a user',
'running': 'Adding User...',
'success': 'Added User {{value.username}}!',
},
'save_profile': {
'idle': 'Save Profile',
'running': 'Saving Profile...',
}
},
'loading-banner': {
'idle': 'Add a user',
'running': 'Adding User...',
'success': 'Added User {{value.username}}!',
}
};
body {
margin: 12px 16px !important;
}
.button {
position: relative;
}
.button.active::before {
content: ' ';
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 6px solid rgba(255,255,255,0.4);
}
table .button {
width: 100%;
margin: 0;
}
<h2>Tasks + i18n</h2>
<p>
Robust web apps feature most/all the following button/UI states:
</p>
{{button-table}}
<p>
It's usually very difficult to build out all these states, and
there's not a lot of Ember conventions to help/guide you, but
I feel like I'm getting pretty close to something that feels
pretty nice and composes well by way of combining Tasks with
ember-i18n. Give it a try and look at the code
in templates/application.hbs:
</p>
<div class="expanded button-group">
{{async-button t='button.add_user' task=myTask}}
{{async-button t='button.add_user' class='secondary'
task=myTask}}
{{#async-button t='button.save_profile' task=myTask as |h|}}
LOL, {{h.text.default}}
{{/async-button}}
</div>
<div class="expanded button-group">
{{async-button t='button.save_profile' task=otherTask}}
{{async-button t='button.save_profile' class='secondary'
task=otherTask}}
{{async-button t='button.save_profile' task=otherTask}}
</div>
<p>
Some niceties / possible conventions emerge:
</p>
<ul>
<li>
An "active" button is the one that you clicked that kicked off a task
</li>
<li>
A "disabled" button is one that can't be clicked right now because
the task that it would perform is already running (technically, the
only difference between an "active" and "disabled" button is that
you clicked the "active" one)
</li>
<li>
Built-in cooldown periods to display success messages (and the success result from the task is passed to the success translation template)
</li>
<li>
Graceful fallback to the most specific
translation that describes the success action.
</li>
</ul>
<h3>Composition</h3>
<p>
The behavior of the buttons above is encapsulated
in the async-button component, which itself
delegates most of its "work" and configuration
to two helpers: <strong>state-for</strong>
and <strong>text-for</strong>
</p>
<h4>state-for</h4>
<p>
This helper wraps a Task and exposes
additional state that's useful for driving
UI. In particular, the "cooldown" behavior
is essentially a decoration on top of the
underlying task, and the combined state
of the task and various decorations is
provided within the object returned from
state-for. (I just now realized that this
overlaps with the terminology in
ember-state-services; pretend this has
a different name).
</p>
<h4>text-for</h4>
<p>
text-for takes a state-for object and
translation locale path and returns a
utility object with lots of properties
for displaying text.
</p>
<p>
The whole goal of all of this is to land
on conventions that a) compose well with
other established conventions and b) themselves
decompose into reusable bits.
</p>
<br><br><br><br><br><br><br>
<br><br><br><br><br><br><br>
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/foundation/6.3.0/css/foundation-flex.css">
{{#with (state-for task=task) as |s|}}
{{#with (text-for state=s t=t) as |text|}}
<div class="button {{class}}
{{if s.isActive 'active'}}
{{if s.isDisabled 'disabled'}}
{{if s.isSuccess 'success'}}"
onclick={{action s.perform}}>
{{#if hasBlock}}
{{yield (hash text=text)}}
{{else}}
{{text.default}}
{{/if}}
</div>
{{/with}}
{{/with}}
<table style="width: 100%;">
<thead>
<tr>
<th></th>
{{#each xs as |x|}}
<th>
{{x}}
</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each ys as |y|}}
<tr>
<td>{{y}}</td>
{{#each xs as |x|}}
<td>
<div class="button {{x}} {{y}}">
{{if x x 'default'}}<br>{{y}}
</div>
</td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
{
"version": "0.10.1",
"EmberENV": {
"FEATURES": {}
},
"options": {
"use_pods": false,
"enable-testing": false
},
"dependencies": {
"jquery": "https://cdnjs.cloudflare.com/ajax/libs/jquery/1.11.3/jquery.js",
"ember": "2.6.0",
"ember-data": "2.6.1",
"ember-template-compiler": "2.6.0"
},
"addons": {
"ember-concurrency": "latest",
"ember-i18n": "latest"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment