Updated members routes/controllers with Octane idioms

no issue

- ran [`ember-native-class-codemod`](https://github.com/ember-codemods/ember-native-class-codemod) on members-related files
- updated files to remove need for `@classic` decorator where possible
    - switched to tracked properties
    - removed usage of `this.get/set/toggleProperty` etc
- swapped usage of `{{action 'foo'}}` for `{{this.foo}}`
This commit is contained in:
Kevin Ansfield 2020-05-20 14:55:41 +01:00
parent 9250d7939b
commit e80fa137db
14 changed files with 289 additions and 242 deletions

View File

@ -16,6 +16,10 @@ module.exports = {
'plugin:ghost/ember'
],
rules: {
// octane 🏎
'ghost/ember/classic-decorator-hooks': 'error',
'ghost/ember/classic-decorator-no-classic-methods': 'error',
// disable linting of `this.get` until there's a reliable autofix
'ghost/ember/use-ember-get-and-set': 'off',

View File

@ -1,6 +1,7 @@
/* global key */
import Component from '@ember/component';
import Ember from 'ember';
import classic from 'ember-classic-decorator';
import fallbackIfUndefined from '../utils/computed-fallback-if-undefined';
import {A, isArray} from '@ember/array';
import {action, computed, get} from '@ember/object';
@ -19,6 +20,7 @@ const {Handlebars} = Ember;
const BACKSPACE = 8;
const TAB = 9;
@classic
@tagName('')
class GhTokenInput extends Component {
// public attrs

View File

@ -50,7 +50,7 @@ export default class Trigger extends EmberPowerSelectMultipleTrigger {
if (typeof lastSelection === 'string') {
this.args.select.actions.search(lastSelection);
} else {
let searchField = this.get('searchField');
let searchField = this.searchField;
assert('`{{power-select-multiple}}` requires a `searchField` when the options are not strings to remove options using backspace', searchField);
this.args.select.actions.search(get(lastSelection, searchField));
}

View File

@ -1,95 +1,111 @@
import Controller from '@ember/controller';
import EmberObject from '@ember/object';
import Controller, {inject as controller} from '@ember/controller';
import EmberObject, {action, defineProperty} from '@ember/object';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import moment from 'moment';
import {alias} from '@ember/object/computed';
import {computed, defineProperty} from '@ember/object';
import {inject as controller} from '@ember/controller';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
const SCRATCH_PROPS = ['name', 'email', 'note'];
export default Controller.extend({
members: controller(),
session: service(),
dropdown: service(),
notifications: service(),
router: service(),
store: service(),
export default class MemberController extends Controller {
@controller members;
@service session;
@service dropdown;
@service notifications;
@service router;
@service store;
showImpersonateMemberModal: false,
@tracked isLoading = false;
@tracked showDeleteMemberModal = false;
@tracked showImpersonateMemberModal = false;
@tracked showUnsavedChangesModal = false;
member: alias('model'),
leaveScreenTransition = null;
scratchMember: computed('member', function () {
// Computed properties -----------------------------------------------------
@alias('model') member;
get scratchMember() {
let scratchMember = EmberObject.create({member: this.member});
SCRATCH_PROPS.forEach(prop => defineProperty(scratchMember, prop, boundOneWay(`member.${prop}`)));
return scratchMember;
}),
}
subscribedAt: computed('member.createdAtUTC', function () {
get subscribedAt() {
let memberSince = moment(this.member.createdAtUTC).from(moment());
let createdDate = moment(this.member.createdAtUTC).format('MMM DD, YYYY');
return `${createdDate} (${memberSince})`;
}),
}
actions: {
setProperty(propKey, value) {
this._saveMemberProperty(propKey, value);
},
// Actions -----------------------------------------------------------------
toggleDeleteMemberModal() {
this.toggleProperty('showDeleteMemberModal');
},
@action
setProperty(propKey, value) {
this._saveMemberProperty(propKey, value);
}
toggleImpersonateMemberModal() {
this.toggleProperty('showImpersonateMemberModal');
},
@action
toggleDeleteMemberModal() {
this.showDeleteMemberModal = !this.showDeleteMemberModal;
}
save() {
return this.save.perform();
},
@action
toggleImpersonateMemberModal() {
this.showImpersonateMemberModal = !this.showImpersonateMemberModal;
}
deleteMember() {
return this.member.destroyRecord().then(() => {
return this.transitionToRoute('members');
}, (error) => {
return this.notifications.showAPIError(error, {key: 'member.delete'});
});
},
@action
save() {
return this.saveTask.perform();
}
toggleUnsavedChangesModal(transition) {
let leaveTransition = this.leaveScreenTransition;
@action
deleteMember() {
return this.member.destroyRecord().then(() => {
return this.transitionToRoute('members');
}, (error) => {
return this.notifications.showAPIError(error, {key: 'member.delete'});
});
}
if (!transition && this.showUnsavedChangesModal) {
this.set('leaveScreenTransition', null);
this.set('showUnsavedChangesModal', false);
return;
}
@action
toggleUnsavedChangesModal(transition) {
let leaveTransition = this.leaveScreenTransition;
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveScreenTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.save.isRunning) {
return this.save.last.then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showUnsavedChangesModal', true);
}
},
leaveScreen() {
this.member.rollbackAttributes();
return this.leaveScreenTransition.retry();
if (!transition && this.showUnsavedChangesModal) {
this.leaveScreenTransition = null;
this.showUnsavedChangesModal = false;
return;
}
},
save: task(function* () {
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.leaveScreenTransition = transition;
// if a save is running, wait for it to finish then transition
if (this.save.isRunning) {
return this.save.last.then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.showUnsavedChangesModal = true;
}
}
@action
leaveScreen() {
this.member.rollbackAttributes();
return this.leaveScreenTransition.retry();
}
// Tasks -------------------------------------------------------------------
@task({drop: true})
*saveTask() {
let {member, scratchMember} = this;
// if Cmd+S is pressed before the field loses focus make sure we're
@ -109,18 +125,20 @@ export default Controller.extend({
this.notifications.showAPIError(error, {key: 'member.save'});
}
}
}).drop(),
}
fetchMember: task(function* (memberId) {
this.set('isLoading', true);
@task
*fetchMemberTask(memberId) {
this.isLoading = true;
let member = yield this.store.findRecord('member', memberId, {
this.member = yield this.store.findRecord('member', memberId, {
reload: true
});
this.set('member', member);
this.set('isLoading', false);
}),
this.isLoading = false;
}
// Private -----------------------------------------------------------------
_saveMemberProperty(propKey, newValue) {
let currentValue = this.member.get(propKey);
@ -136,4 +154,4 @@ export default Controller.extend({
this.member.set(propKey, newValue);
}
});
}

View File

@ -1,43 +1,46 @@
import Controller from '@ember/controller';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import moment from 'moment';
import {computed} from '@ember/object';
import {get} from '@ember/object';
import {A} from '@ember/array';
import {action} from '@ember/object';
import {pluralize} from 'ember-inflector';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
/* eslint-disable ghost/ember/alias-model-in-controller */
export default Controller.extend({
store: service(),
export default class MembersController extends Controller {
@service store;
queryParams: ['label'],
queryParams = ['label'];
label: null,
members: null,
searchText: '',
modalLabel: null,
showLabelModal: false,
@tracked searchText = '';
@tracked label = null;
@tracked members = null;
@tracked modalLabel = null;
@tracked showLabelModal = false;
_hasLoadedLabels: false,
_availableLabels: null,
@tracked _availableLabels = A([]);
init() {
this._super(...arguments);
this.set('members', this.store.peekAll('member'));
hasLoadedLabels = false;
constructor() {
super(...arguments);
this.members = this.store.peekAll('member');
this._availableLabels = this.store.peekAll('label');
},
}
showLoader: computed('filteredMembers.length', 'fetchMembers.isRunning', function () {
return (!this.get('filteredMembers.length') && this.get('fetchMembers.isRunning'));
}),
// Computed properties -----------------------------------------------------
listHeader: computed('selectedLabel', 'searchText', function () {
get showLoader() {
return (!this.filteredMembers.length && this.fetchMembersTask.isRunning);
}
get listHeader() {
let {searchText, selectedLabel, filteredMembers} = this;
if (searchText) {
return 'Search result';
}
if (this.fetchMembers.lastSuccessful) {
if (this.fetchMembersTask.lastSuccessful) {
let count = pluralize(filteredMembers.length, 'member');
if (selectedLabel && selectedLabel.slug) {
if (filteredMembers.length > 1) {
@ -49,44 +52,40 @@ export default Controller.extend({
return count;
}
return 'Loading...';
}),
}
showingAll: computed('label', 'searchText', function () {
let {searchText, label} = this;
get showingAll() {
return !this.searchText && !this.label;
}
return !searchText && !label;
}),
availableLabels: computed('_availableLabels.@each.isNew', function () {
get availableLabels() {
let labels = this._availableLabels
.filter(label => !label.get('isNew'))
.filter(label => label.get('id') !== null)
.filter(label => !label.isNew)
.filter(label => label.id !== null)
.sort((labelA, labelB) => labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true}));
let options = labels.toArray();
options.unshiftObject({name: 'All labels', slug: null});
return options;
}),
}
selectedLabel: computed('label', 'availableLabels', function () {
let label = this.get('label');
let labels = this.get('availableLabels');
get selectedLabel() {
let {label, availableLabels} = this;
return availableLabels.findBy('slug', label);
}
return labels.findBy('slug', label);
}),
labelModalData: computed('modalLabel', 'availableLabels', function () {
let label = this.get('modalLabel');
let labels = this.get('availableLabels');
get labelModalData() {
let label = this.modalLabel;
let labels = this.availableLabels;
return {
label,
labels
};
}),
}
filteredMembers: computed('members.@each.{name,email}', 'searchText', 'label', function () {
get filteredMembers() {
let {members, searchText, label} = this;
searchText = searchText.toLowerCase();
@ -106,59 +105,69 @@ export default Controller.extend({
return _label.slug === label;
});
}).sort((a, b) => {
return b.get('createdAtUTC').valueOf() - a.get('createdAtUTC').valueOf();
return b.createdAtUTC.valueOf() - a.createdAtUTC.valueOf();
});
return filtered;
}),
}
actions: {
exportData() {
let exportUrl = ghostPaths().url.api('members/csv');
let downloadURL = `${exportUrl}?limit=all`;
let iframe = document.getElementById('iframeDownload');
// Actions -----------------------------------------------------------------
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'iframeDownload';
iframe.style.display = 'none';
document.body.append(iframe);
}
iframe.setAttribute('src', downloadURL);
},
changeLabel(label, e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
this.set('label', get(label, 'slug'));
},
addLabel(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const newLabel = this.store.createRecord('label');
this.set('modalLabel', newLabel);
this.toggleProperty('showLabelModal');
},
editLabel(label, e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
let labels = this.get('availableLabels');
@action
exportData() {
let exportUrl = ghostPaths().url.api('members/csv');
let downloadURL = `${exportUrl}?limit=all`;
let iframe = document.getElementById('iframeDownload');
let modalLabel = labels.findBy('slug', label);
this.set('modalLabel', modalLabel);
this.toggleProperty('showLabelModal');
},
toggleLabelModal() {
this.toggleProperty('showLabelModal');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'iframeDownload';
iframe.style.display = 'none';
document.body.append(iframe);
}
},
iframe.setAttribute('src', downloadURL);
}
fetchMembers: task(function* () {
@action
changeLabel(label, e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
this.label = label.slug;
}
@action
addLabel(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const newLabel = this.store.createRecord('label');
this.modalLabel = newLabel;
this.showLabelModal = !this.showLabelModal;
}
@action
editLabel(label, e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
let modalLabel = this.availableLabels.findBy('slug', label);
this.modalLabel = modalLabel;
this.showLabelModal = !this.showLabelModal;
}
@action
toggleLabelModal() {
this.showLabelModal = !this.showLabelModal;
}
// Tasks -------------------------------------------------------------------
@task
*fetchMembersTask() {
let newFetchDate = new Date();
if (this._hasFetchedAll) {
@ -178,5 +187,5 @@ export default Controller.extend({
}
this._lastFetchDate = newFetchDate;
})
});
}
}

View File

@ -1,19 +1,19 @@
import Controller from '@ember/controller';
import {action} from '@ember/object';
import {inject as controller} from '@ember/controller';
import {inject as service} from '@ember/service';
/* eslint-disable ghost/ember/alias-model-in-controller */
export default Controller.extend({
members: controller(),
router: service(),
export default class ImportController extends Controller {
@controller members;
@service router;
actions: {
fetchNewMembers() {
this.members.fetchMembers.perform();
},
close() {
this.router.transitionTo('members');
}
@action
fetchNewMembers() {
this.members.fetchMembersTask.perform();
}
});
@action
close() {
this.router.transitionTo('members');
}
}

View File

@ -1,24 +1,26 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import classic from 'ember-classic-decorator';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
router: service(),
@classic
export default class MembersRoute extends AuthenticatedRoute.extend(CurrentUserSettings) {
@service router;
_requiresBackgroundRefresh: true,
_requiresBackgroundRefresh = true;
init() {
this._super(...arguments);
super.init(...arguments);
this.router.on('routeWillChange', (transition) => {
this.showUnsavedChangesModal(transition);
});
},
}
beforeModel() {
this._super(...arguments);
return this.get('session.user')
.then(this.transitionAuthor());
},
super.beforeModel(...arguments);
return this.session.user.then(this.transitionAuthor());
}
model(params) {
this._requiresBackgroundRefresh = false;
@ -28,33 +30,30 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, {
} else {
return this.store.createRecord('member');
}
},
}
setupController(controller, member) {
this._super(...arguments);
super.setupController(...arguments);
if (this._requiresBackgroundRefresh) {
controller.fetchMember.perform(member.get('id'));
controller.fetchMemberTask.perform(member.get('id'));
}
},
}
deactivate() {
this._super(...arguments);
super.deactivate(...arguments);
// clean up newly created records and revert unsaved changes to existing
this.controller.member.rollbackAttributes();
this._requiresBackgroundRefresh = true;
},
}
actions: {
save() {
this.controller.send('save');
}
},
@action
save() {
this.controller.save();
}
titleToken() {
return this.controller.get('member.name');
},
return this.controller.member.name;
}
showUnsavedChangesModal(transition) {
if (transition.from && transition.from.name === this.routeName && transition.targetName) {
@ -65,9 +64,9 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, {
if (!controller.member.isDeleted && isChanged) {
transition.abort();
controller.send('toggleUnsavedChangesModal', transition);
controller.toggleUnsavedChangesModal(transition);
return;
}
}
}
});
}

View File

@ -1,6 +1,6 @@
import MemberRoute from '../member';
export default MemberRoute.extend({
controllerName: 'member',
templateName: 'member'
});
export default class NewMemberRoute extends MemberRoute {
controllerName = 'member';
templateName = 'member';
}

View File

@ -1,45 +1,44 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend({
config: service(),
export default class MembersRoute extends AuthenticatedRoute {
@service config;
queryParams: {
queryParams = {
label: {refreshModel: true}
},
};
// redirect to posts screen if:
// - TODO: members is disabled?
// - logged in user isn't owner/admin
beforeModel() {
this._super(...arguments);
super.beforeModel(...arguments);
return this.session.user.then((user) => {
if (!user.isOwnerOrAdmin) {
return this.transitionTo('home');
}
});
},
}
// trigger a background load of labels for filter dropdown
setupController(controller) {
this._super(...arguments);
controller.fetchMembers.perform();
if (!controller._hasLoadedLabels) {
super.setupController(...arguments);
controller.fetchMembersTask.perform();
if (!controller.hasLoadedLabels) {
this.store.query('label', {limit: 'all'}).then(() => {
controller._hasLoadedLabels = true;
controller.hasLoadedLabels = true;
});
}
},
}
deactivate() {
this._super(...arguments);
super.deactivate(...arguments);
this.controller.modalLabel && this.controller.modalLabel.rollbackAttributes();
},
}
buildRouteInfoMetadata() {
return {
titleToken: 'Members'
};
}
});
}

View File

@ -1,4 +1,3 @@
import Route from '@ember/routing/route';
export default Route.extend({
});
export default class MembersImportRoute extends Route {}

View File

@ -15,13 +15,13 @@
{{#unless this.member.isNew}}
<button
class="gh-btn gh-btn-white gh-btn-icon mr2"
{{on "click" (action "toggleImpersonateMemberModal")}}>
{{on "click" this.toggleImpersonateMemberModal}}>
<span>Impersonate</span>
</button>
{{/unless}}
{{/if}}
<GhTaskButton @class="gh-btn gh-btn-blue gh-btn-icon" @type="button" @task={{this.save}} @autoReset={{true}} @data-test-button="save" />
<GhTaskButton @class="gh-btn gh-btn-blue gh-btn-icon" @type="button" @task={{this.saveTask}} @autoReset={{true}} @data-test-button="save" />
</section>
</GhCanvasHeader>
@ -67,7 +67,7 @@
<GhMemberSettingsForm
@member={{this.member}}
@scratchMember={{this.scratchMember}}
@setProperty={{action "setProperty"}}
@setProperty={{this.setProperty}}
@isLoading={{this.isLoading}} />
</form>
@ -75,7 +75,7 @@
<button
type="button"
class="gh-btn gh-btn-red gh-btn-icon mt3"
{{on "click" (action "toggleDeleteMemberModal")}}
{{on "click" this.toggleDeleteMemberModal}}
data-test-button="delete-member"
>
<span>Delete member</span>
@ -86,8 +86,8 @@
{{#if this.showUnsavedChangesModal}}
<GhFullscreenModal
@modal="leave-settings"
@confirm={{action "leaveScreen"}}
@close={{action "toggleUnsavedChangesModal"}}
@confirm={{this.leaveScreen}}
@close={{this.toggleUnsavedChangesModal}}
@modifier="action wide" />
{{/if}}
@ -95,8 +95,8 @@
<GhFullscreenModal
@modal="delete-member"
@model={{this.member}}
@confirm={{action "deleteMember"}}
@close={{action "toggleDeleteMemberModal"}}
@confirm={{this.deleteMember}}
@close={{this.toggleDeleteMemberModal}}
@modifier="action wide" />
{{/if}}
@ -104,6 +104,6 @@
<GhFullscreenModal
@modal="impersonate-member"
@model={{this.member}}
@close={{action "toggleImpersonateMemberModal"}}
@close={{this.toggleImpersonateMemberModal}}
@modifier="action wide" />
{{/if}}

View File

@ -5,9 +5,9 @@
<GhMembersContentfilter
@selectedLabel={{this.selectedLabel}}
@availableLabels={{this.availableLabels}}
@onLabelChange={{action "changeLabel"}}
@onLabelAdd={{action "addLabel"}}
@onLabelEdit={{action "editLabel"}}
@onLabelChange={{this.changeLabel}}
@onLabelAdd={{this.addLabel}}
@onLabelEdit={{this.editLabel}}
/>
<div class="relative gh-members-header-search">
{{svg-jar "search" class="gh-input-search-icon"}}
@ -34,9 +34,9 @@
</LinkTo>
</li>
<li>
<a href="#" {{action 'exportData'}} class="mr2">
<button class="mr2" {{on "click" this.exportData}}>
<span>Export all members</span>
</a>
</button>
</li>
</GhDropdown>
</span>
@ -93,7 +93,7 @@
<GhFullscreenModal
@modal="members-label-form"
@model={{this.labelModalData}}
@close={{action "toggleLabelModal"}}
@close={{this.toggleLabelModal}}
@modifier="action wide"
/>
{{/if}}

View File

@ -52,6 +52,7 @@
"ember-ajax": "5.0.0",
"ember-assign-helper": "0.2.0",
"ember-auto-import": "1.5.3",
"ember-classic-decorator": "1.0.8",
"ember-cli": "3.18.0",
"ember-cli-app-version": "3.2.0",
"ember-cli-babel": "7.20.0",

View File

@ -837,7 +837,7 @@
globals "^11.1.0"
lodash "^4.17.13"
"@babel/types@^7.1.6", "@babel/types@^7.3.2", "@babel/types@^7.3.4", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.7.2", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0", "@babel/types@^7.9.5", "@babel/types@^7.9.6":
"@babel/types@^7.1.6", "@babel/types@^7.3.2", "@babel/types@^7.3.4", "@babel/types@^7.4.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.7.2", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0", "@babel/types@^7.9.5", "@babel/types@^7.9.6":
version "7.9.6"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.6.tgz#2c5502b427251e9de1bd2dff95add646d95cc9f7"
integrity sha512-qxXzvBO//jO9ZnoasKF1uJzHd2+M6Q2ZPIVfnFps8JJvXy0ZBbwbNOmE6SGIY5XOY6d1Bo5lb9d9RJ8nv3WSeA==
@ -2269,6 +2269,14 @@ babel-plugin-ember-modules-api-polyfill@^2.13.0:
dependencies:
ember-rfc176-data "^0.3.13"
babel-plugin-filter-imports@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/babel-plugin-filter-imports/-/babel-plugin-filter-imports-3.0.0.tgz#a849683837ad29960da17492fb32789ab6b09a11"
integrity sha512-p/chjzVTgCxUqyLM0q/pfWVZS7IJTwGQMwNg0LOvuQpKiTftQgZDtkGB8XvETnUw19rRcL7bJCTopSwibTN2tA==
dependencies:
"@babel/types" "^7.4.0"
lodash "^4.17.11"
babel-plugin-filter-imports@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/babel-plugin-filter-imports/-/babel-plugin-filter-imports-4.0.0.tgz#068f8da15236a96a9602c36dc6f4a6eeca70a4f4"
@ -5195,6 +5203,14 @@ ember-basic-dropdown@^3.0.1:
ember-maybe-in-element "^0.4.0"
ember-truth-helpers "2.1.0"
ember-classic-decorator@1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/ember-classic-decorator/-/ember-classic-decorator-1.0.8.tgz#e290e5b0b1a31a569587a85a9c5c7a2f1242cabb"
integrity sha512-IsCDJ7rLsrFjYtgi9UXUmjzUQJaaJzmy/gKwGGtZ6kZwT5yhzSbScRi0P6Cb0guJPtlMMCE0sAQpJRbXmBb/gA==
dependencies:
babel-plugin-filter-imports "^3.0.0"
ember-cli-babel "^7.11.1"
ember-cli-app-version@3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/ember-cli-app-version/-/ember-cli-app-version-3.2.0.tgz#7b9ad0e1b63ae0518648356ee24c703e922bc26e"