Skip to content

Instantly share code, notes, and snippets.

@nightire
Forked from machty/components.async-button.js
Last active June 20, 2017 07:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nightire/cf5bc2d2df8a92b9ff841f42e2bec71e to your computer and use it in GitHub Desktop.
Save nightire/cf5bc2d2df8a92b9ff841f42e2bec71e to your computer and use it in GitHub Desktop.
Concurrency with i18n driven
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>
Here's a stab at an API to get a lot of the above
states for free using tasks and ember-i18n.
</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.12.0",
"ember-data": "2.12.1",
"ember-template-compiler": "2.12.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