Unified tag and member screen code

no issue

The tag and member screens share the same underlying UI/UX patterns but were using different code patterns. This brings both in line so that we have consistent code patterns that can be re-used for other screens.

- fixed cleanup of new tags by adding the `deactivate` hook to the `tag` route
- updated `member` and `member.new` route/controller setup to match tag route/controller setup
  - added `save` action to member controller so that Ctrl/Cmd+S works on member screen
  - updated tag route/controller to utilise the same instant display w/background refresh when accessing the tag details screen
- completed transition of non-component tag/members templates over to angle bracket component syntax
This commit is contained in:
Kevin Ansfield 2019-12-12 13:35:52 +00:00
parent 51ded01ed3
commit 5585a781b9
11 changed files with 205 additions and 374 deletions

View File

@ -8,13 +8,12 @@ import {task} from 'ember-concurrency';
export default Controller.extend({
members: controller(),
notifications: service(),
router: service(),
store: service(),
router: service(),
notifications: service(),
member: alias('model'),
subscribedAt: computed('member.createdAtUTC', function () {
let memberSince = moment(this.member.createdAtUTC).from(moment());
let createdDate = moment(this.member.createdAtUTC).format('MMM DD, YYYY');
@ -25,9 +24,15 @@ export default Controller.extend({
setProperty(propKey, value) {
this._saveMemberProperty(propKey, value);
},
toggleDeleteMemberModal() {
this.toggleProperty('showDeleteMemberModal');
},
save() {
return this.save.perform();
},
finaliseDeletion() {
// decrement the total member count manually so there's no flash
// when transitioning back to the members list
@ -62,21 +67,8 @@ export default Controller.extend({
},
leaveScreen() {
let transition = this.leaveScreenTransition;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
this.member.rollbackAttributes();
return transition.retry();
},
save() {
return this.save.perform();
return this.leaveScreenTransition.retry();
}
},
@ -98,11 +90,13 @@ export default Controller.extend({
fetchMember: task(function* (memberId) {
this.set('isLoading', true);
yield this.store.findRecord('member', memberId, {
reload: true
}).then((data) => {
this.set('member', data);
}).then((member) => {
this.set('member', member);
this.set('isLoading', false);
return member;
});
})

View File

@ -1,104 +0,0 @@
import Controller from '@ember/controller';
import moment from 'moment';
import {alias} from '@ember/object/computed';
import {computed} from '@ember/object';
import {inject as controller} from '@ember/controller';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default Controller.extend({
members: controller(),
store: service(),
router: service(),
notifications: service(),
member: alias('model'),
displayName: computed('member.{name,email}', function () {
return this.member.name || this.member.email || 'New member';
}),
subscribedAt: computed('member.createdAtUTC', function () {
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);
},
toggleDeleteMemberModal() {
this.toggleProperty('showDeleteMemberModal');
},
finaliseDeletion() {
// decrement the total member count manually so there's no flash
// when transitioning back to the members list
if (this.members.memberCount) {
this.members.decrementProperty('memberCount');
}
this.router.transitionTo('members');
},
toggleUnsavedChangesModal(transition) {
let leaveTransition = this.leaveScreenTransition;
if (!transition && this.showUnsavedChangesModal) {
this.set('leaveScreenTransition', null);
this.set('showUnsavedChangesModal', false);
return;
}
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() {
let transition = this.leaveScreenTransition;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
this.member.rollbackAttributes();
return transition.retry();
},
save() {
return this.save.perform();
}
},
save: task(function* () {
let member = this.member;
try {
return yield member.save();
} catch (error) {
if (error) {
this.notifications.showAPIError(error, {key: 'member.save'});
}
}
}).drop(),
_saveMemberProperty(propKey, newValue) {
let member = this.member;
member.set(propKey, newValue);
}
});

View File

@ -23,7 +23,11 @@ export default Controller.extend({
},
deleteTag() {
return this._deleteTag();
return this.tag.destroyRecord().then(() => {
return this.transitionToRoute('tags');
}, (error) => {
return this.notifications.showAPIError(error, {key: 'tag.delete'});
});
},
save() {
@ -55,17 +59,8 @@ export default Controller.extend({
},
leaveScreen() {
let transition = this.leaveScreenTransition;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
this.tag.rollbackAttributes();
return transition.retry();
return this.leaveScreenTransition.retry();
}
},
@ -93,6 +88,7 @@ export default Controller.extend({
}
tag.set('slug', slugValue);
}
// TODO: This is required until .validate/.save mark fields as validated
tag.get('hasValidated').addObject(propKey);
},
@ -125,11 +121,13 @@ export default Controller.extend({
}
}),
_deleteTag() {
return this.tag.destroyRecord().then(() => {
return this.transitionToRoute('tags');
}, (error) => {
return this.notifications.showAPIError(error, {key: 'tag.delete'});
fetchTag: task(function* (slug) {
this.set('isLoading', true);
yield this.store.queryRecord('tag', {slug}).then((tag) => {
this.set('tag', tag);
this.set('isLoading', false);
return tag;
});
}
})
});

View File

@ -3,9 +3,10 @@ import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
router: service(),
_requiresBackgroundRefresh: true,
init() {
this._super(...arguments);
this.router.on('routeWillChange', (transition) => {
@ -20,27 +21,29 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, {
},
model(params) {
this._isMemberUpdated = true;
return this.store.findRecord('member', params.member_id, {
reload: true
});
this._requiresBackgroundRefresh = false;
if (params.member_id) {
return this.store.findRecord('member', params.member_id, {reload: true});
} else {
return this.store.createRecord('member');
}
},
setupController(controller, model) {
setupController(controller, member) {
this._super(...arguments);
if (!this._isMemberUpdated) {
controller.fetchMember.perform(model.get('id'));
if (this._requiresBackgroundRefresh) {
controller.fetchMember.perform(member.get('id'));
}
},
deactivate() {
this._super(...arguments);
// clear the properties
let {controller} = this;
controller.model.rollbackAttributes();
this.set('controller.model', null);
this._isMemberUpdated = false;
// clean up newly created records and revert unsaved changes to existing
this.controller.member.rollbackAttributes();
this._requiresBackgroundRefresh = true;
},
actions: {
@ -54,10 +57,13 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, {
},
showUnsavedChangesModal(transition) {
if (transition.from && transition.from.name.match(/^member$/) && transition.targetName) {
if (transition.from && transition.from.name === this.routeName && transition.targetName) {
let {controller} = this;
if (!controller.member.isDeleted && controller.member.hasDirtyAttributes) {
// member.changedAttributes is always true for new members but number of changed attrs is reliable
let isChanged = Object.keys(controller.member.changedAttributes()).length > 0;
if (!controller.member.isDeleted && isChanged) {
transition.abort();
controller.send('toggleUnsavedChangesModal', transition);
return;

View File

@ -1,45 +1,6 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {isEmpty} from '@ember/utils';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
router: service(),
controllerName: 'member.new',
templateName: 'member/new',
init() {
this._super(...arguments);
this.router.on('routeWillChange', (transition) => {
this.showUnsavedChangesModal(transition);
});
},
model() {
return this.store.createRecord('member');
},
// reset the model so that mobile screens react to an empty selectedMember
deactivate() {
this._super(...arguments);
let {controller} = this;
controller.model.rollbackAttributes();
controller.set('model', null);
},
showUnsavedChangesModal(transition) {
if (transition.from && transition.from.name.match(/^members\.new/) && transition.targetName) {
let {controller} = this;
let isUnchanged = isEmpty(Object.keys(controller.member.changedAttributes()));
if (!controller.member.isDeleted && !isUnchanged) {
transition.abort();
controller.send('toggleUnsavedChangesModal', transition);
return;
}
}
}
import MemberRoute from '../member';
export default MemberRoute.extend({
controllerName: 'member',
templateName: 'member'
});

View File

@ -6,6 +6,8 @@ import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
router: service(),
_requiresBackgroundRefresh: true,
init() {
this._super(...arguments);
this.router.on('routeWillChange', (transition) => {
@ -20,6 +22,8 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, {
},
model(params) {
this._requiresBackgroundRefresh = false;
if (params.tag_slug) {
return this.store.queryRecord('tag', {slug: params.tag_slug});
} else {
@ -27,8 +31,24 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, {
}
},
serialize(model) {
return {tag_slug: model.get('slug')};
serialize(tag) {
return {tag_slug: tag.get('slug')};
},
setupController(controller, tag) {
this._super(...arguments);
if (this._requiresBackgroundRefresh) {
controller.fetchTag.perform(tag.get('slug'));
}
},
deactivate() {
this._super(...arguments);
// clean up newly created records and revert unsaved changes to existing
this.controller.tag.rollbackAttributes();
this._requiresBackgroundRefresh = true;
},
actions: {
@ -42,7 +62,9 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, {
let {controller} = this;
// tag.changedAttributes is always true for new tags but number of changed attrs is reliable
if (!controller.tag.isDeleted && Object.keys(controller.tag.changedAttributes()).length > 0) {
let isChanged = Object.keys(controller.tag.changedAttributes()).length > 0;
if (!controller.tag.isDeleted && isChanged) {
transition.abort();
controller.send('toggleUnsavedChangesModal', transition);
return;

View File

@ -2,58 +2,78 @@
<form class="mb10 member-basic-info-form">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
{{#link-to "members" data-test-link="members-back"}}Members{{/link-to}}
<LinkTo @route="members" data-test-link="members-back">Members</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
{{#if member.name}}
{{member.name}}
{{#if this.member.isNew}}
New member
{{else}}
{{member.email}}
{{or this.member.name this.member.email}}
{{/if}}
</h2>
<section class="view-actions">
{{gh-task-button type="button" task=save class="gh-btn gh-btn-blue gh-btn-icon" data-test-button="save"}}
<GhTaskButton @class="gh-btn gh-btn-blue gh-btn-icon" @type="button" @task={{save}} @data-test-button="save" />
</section>
</GhCanvasHeader>
<div class="flex items-center mb10 bt b--lightgrey-d1 pt8">
<GhMemberAvatar @member={{member}} @sizeClass={{if member.name 'f-subheadline fw4 lh-zero tracked-1' 'f-headline fw4 lh-zero tracked-1'}} @containerClass="w18 h18 mr4 gh-member-detail-avatar" />
{{#if (or member.name member.email)}}
<GhMemberAvatar
@member={{this.member}}
@sizeClass={{if this.member.name 'f-subheadline fw4 lh-zero tracked-1' 'f-headline fw4 lh-zero tracked-1'}}
@containerClass="w18 h18 mr4 gh-member-detail-avatar"
/>
{{else}}
<div class="flex items-center justify-center br-100 w18 h18 mr4 gh-new-member-avatar">
<span class="gh-member-avatar-label f-subheadline fw4 lh-zero tracked-1">N</span>
</div>
{{/if}}
<div>
<h3 class="f2 fw5 ma0 pa0">
{{or member.name member.email}}
{{or this.member.name this.member.email}}
</h3>
<p class="f6 pa0 ma0 midlightgrey-d1">
{{#if (and member.name member.email)}}
<span class="darkgrey fw5">{{member.email}}</span>
{{#if (and this.member.name this.member.email)}}
<span class="darkgrey fw5">{{this.member.email}}</span>
{{/if}}
Created on {{this.subscribedAt}}
{{#unless this.member.isNew}}
{{if (and member.name member.email) ""}}
Created on {{this.subscribedAt}}
{{/unless}}
</p>
</div>
</div>
{{gh-member-settings-form member=member
setProperty=(action "setProperty")
isLoading=this.isLoading
showDeleteTagModal=(action "toggleDeleteMemberModal")}}
<GhMemberSettingsForm
@member={{this.member}}
@setProperty={{action "setProperty"}}
@isLoading={{this.isLoading}}
@showDeleteTagModal={{action "toggleDeleteMemberModal"}} />
</form>
<button
type="button"
class="gh-btn gh-btn-red gh-btn-icon mt3"
{{action (toggle "showDeleteMemberModal" this)}}
data-test-button="delete-member"
>
<span>Delete member</span>
</button>
{{#unless this.member.isNew}}
<button
type="button"
class="gh-btn gh-btn-red gh-btn-icon mt3"
{{on "click" (action "toggleDeleteMemberModal")}}
data-test-button="delete-member"
>
<span>Delete member</span>
</button>
{{/unless}}
</section>
{{#if showUnsavedChangesModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveScreen")
close=(action "toggleUnsavedChangesModal")
modifier="action wide"}}
{{#if this.showUnsavedChangesModal}}
<GhFullscreenModal
@modal="leave-settings"
@confirm={{action "leaveScreen"}}
@close={{action "toggleUnsavedChangesModal"}}
@modifier="action wide" />
{{/if}}
{{#if showDeleteMemberModal}}
{{gh-fullscreen-modal "delete-member"
model=(hash member=member onSuccess=(action "finaliseDeletion"))
close=(action (toggle "showDeleteMemberModal" this))
modifier="action wide"}}
{{#if this.showDeleteMemberModal}}
<GhFullscreenModal
@modal="delete-member"
@model={{hash member=this.member onSuccess=(action "finaliseDeletion")}}
@close={{action "toggleDeleteMemberModal"}}
@modifier="action wide" />
{{/if}}

View File

@ -1,67 +0,0 @@
<section class="gh-canvas">
<form class="mb10 member-basic-info-form">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
{{#link-to "members" data-test-link="members-back"}}Members{{/link-to}}
<span>{{svg-jar "arrow-right"}}</span>{{displayName}}
</h2>
<section class="view-actions">
{{gh-task-button type="button" task=save class="gh-btn gh-btn-blue gh-btn-icon" data-test-button="save"}}
</section>
</GhCanvasHeader>
<div class="flex items-center mb10 bt b--lightgrey-d1 pt8">
{{#if (or member.name member.email)}}
<GhMemberAvatar @member={{member}} @sizeClass={{if member.name 'f-subheadline fw4 lh-zero tracked-1' 'f-headline fw4 lh-zero tracked-1'}} @containerClass="w18 h18 mr4 gh-member-detail-avatar" />
{{else}}
<div class="flex items-center justify-center br-100 w18 h18 mr4 gh-new-member-avatar">
<span class="gh-member-avatar-label f-subheadline fw4 lh-zero tracked-1">N</span>
</div>
{{/if}}
<div>
<h3 class="f2 fw5 ma0 pa0">
{{#if (or member.name member.email)}}
{{or member.name member.email}}
{{else}}
<span class="midlightgrey-d1">New member</span>
{{/if}}
</h3>
<p class="f6 pa0 ma0 midlightgrey-d1">
{{#if (and member.name member.email)}}
<span class="darkgrey fw5">{{member.email}}</span>
{{/if}}
{{#unless member.isNew}}
{{if (and member.name member.email) ""}}
Created on {{this.subscribedAt}}
{{/unless}}
</p>
</div>
</div>
{{gh-member-settings-form member=member
setProperty=(action "setProperty")
isLoading=this.isLoading
showDeleteTagModal=(action "toggleDeleteMemberModal")}}
</form>
<button
type="button"
class="gh-btn gh-btn-red gh-btn-icon mt3"
{{action (toggle "showDeleteMemberModal" this)}}
data-test-button="delete-member"
>
<span>Delete member</span>
</button>
</section>
{{#if showUnsavedChangesModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveScreen")
close=(action "toggleUnsavedChangesModal")
modifier="action wide"}}
{{/if}}
{{#if showDeleteMemberModal}}
{{gh-fullscreen-modal "delete-member"
model=(hash member=member onSuccess=(action "finaliseDeletion"))
close=(action (toggle "showDeleteMemberModal" this))
modifier="action wide"}}
{{/if}}

View File

@ -3,43 +3,57 @@
<h2 class="gh-canvas-title" data-test-screen-title>Members</h2>
<section class="view-actions">
<span class="dropdown">
{{#gh-dropdown-button dropdownName="members-actions-menu" classNames="gh-btn gh-btn-white gh-btn-icon only-has-icon gh-actions-cog" title="Members Actions" data-test-user-actions=true}}
<span>
{{svg-jar "settings"}}
<span class="hidden">Members Actions</span>
</span>
{{/gh-dropdown-button}}
{{#gh-dropdown name="members-actions-menu" tagName="ul" classNames="gh-member-actions-menu dropdown-menu dropdown-triangle-top-right"}}
<li>
{{#link-to "members.import" class="mr2" data-test-link="import-csv"}}
<span>Import CSV </span>
{{/link-to}}
</li>
<li>
<a href="#" {{action 'exportData'}} class="mr2">
<span>Export CSV </span>
</a>
</li>
{{/gh-dropdown}}
<GhDropdownButton
@dropdownName="members-actions-menu"
@classNames="gh-btn gh-btn-white gh-btn-icon only-has-icon gh-actions-cog"
@title="Members Actions"
@data-test-user-actions="true"
>
<span>
{{svg-jar "settings"}}
<span class="hidden">Members Actions</span>
</span>
</GhDropdownButton>
<GhDropdown
@name="members-actions-menu"
@tagName="ul"
@classNames="gh-member-actions-menu dropdown-menu dropdown-triangle-top-right"
>
<li>
<LinkTo @route="members.import" class="mr2" data-test-link="import-csv">
<span>Import CSV</span>
</LinkTo>
</li>
<li>
<a href="#" {{action 'exportData'}} class="mr2">
<span>Export CSV</span>
</a>
</li>
</GhDropdown>
</span>
<div class="relative gh-members-header-search">
{{svg-jar "search" class="gh-input-search-icon"}}
<GhTextInput placeholder="Search members..." @value={{this.searchText}} @input={{action (mut this.searchText) value="target.value"}} class="gh-members-list-searchfield {{if this.searchText "active"}}" />
<GhTextInput
placeholder="Search members..."
@value={{this.searchText}}
@input={{action (mut this.searchText) value="target.value"}}
class="gh-members-list-searchfield {{if this.searchText "active"}}" />
</div>
{{#link-to "member.new" class="gh-btn gh-btn-green" data-test-new-member-button="true"}}<span>New member</span>{{/link-to}}
<LinkTo @route="member.new" class="gh-btn gh-btn-green" data-test-new-member-button="true"><span>New member</span></LinkTo>
</section>
</GhCanvasHeader>
<section class="view-container">
<section class="content-list">
<ol class="members-list gh-list {{unless filteredMembers "no-posts"}}">
{{#if filteredMembers}}
<ol class="members-list gh-list {{unless this.filteredMembers "no-posts"}}">
{{#if this.filteredMembers}}
<li class="gh-list-row header">
<div class="gh-list-header">
{{#if this.searchText}}
Search result
{{else}}
{{#if this.fetchMembers.lastSuccessful}}
{{pluralize memberCount "member"}}
{{pluralize this.memberCount "member"}}
{{else}}
Loading...
{{/if}}
@ -48,19 +62,10 @@
<div class="gh-list-header gh-list-cellwidth-20 nowrap">Created</div>
<div class="gh-list-header gh-list-cellwidth-chevron"></div>
</li>
{{#vertical-collection
items=filteredMembers
key="id"
containerSelector=".gh-main"
estimateHeight=60
bufferSize=20
as |member|
}}
{{gh-members-list-item
member=member
data-test-member-id=member.id
}}
{{/vertical-collection}}
<VerticalCollection @items={{this.filteredMembers}} @key="id" @containerSelector=".gh-main" @estimateHeight=60 @bufferSize=20 as |member|>
<GhMembersListItem @member={{member}} @data-test-member-id={{member.id}} />
</VerticalCollection>
{{else}}
{{#if this.fetchMembers.isRunning}}
<div class="gh-content">
@ -84,4 +89,5 @@
</section>
</section>
</section>
{{outlet}}

View File

@ -2,37 +2,41 @@
<form class="mb15">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
{{#link-to "tags" data-test-link="tags-back"}}Tags{{/link-to}}
<LinkTo @route="tags" data-test-link="tags-back">Tags</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
{{if tag.isNew "New tag" tag.name}}
{{if this.tag.isNew "New tag" tag.name}}
</h2>
<section class="view-actions">
{{gh-task-button task=save type="button" class="gh-btn gh-btn-blue gh-btn-icon" data-test-button="save"}}
<GhTaskButton @task={{this.save}} @type="button" @class="gh-btn gh-btn-blue gh-btn-icon" @data-test-button="save" />
</section>
</GhCanvasHeader>
{{gh-tag-settings-form tag=tag
setProperty=(action "setProperty")
showDeleteTagModal=(action "toggleDeleteTagModal")}}
<GhTagSettingsForm
@tag={{this.tag}}
@setProperty={{action "setProperty"}}
@showDeleteTagModal={{action "toggleDeleteTagModal"}} />
</form>
{{#unless this.tag.isNew}}
<button class="gh-btn gh-btn-red gh-btn-icon mb15" {{action "toggleDeleteTagModal"}}>
<button type="button" class="gh-btn gh-btn-red gh-btn-icon mb15" {{on "click" (action "toggleDeleteTagModal")}}>
<span>Delete tag</span>
</button>
{{/unless}}
</section>
{{#if showUnsavedChangesModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveScreen")
close=(action "toggleUnsavedChangesModal")
modifier="action wide"}}
<GhFullscreenModal
@modal="leave-settings"
@confirm={{action "leaveScreen"}}
@close={{action "toggleUnsavedChangesModal"}}
@modifier="action wide" />
{{/if}}
{{#if showDeleteTagModal}}
{{gh-fullscreen-modal "delete-tag"
model=tag
confirm=(action "deleteTag")
close=(action "toggleDeleteTagModal")
modifier="action wide"}}
<GhFullscreenModal
@modal="delete-tag"
@model={{this.tag}}
@confirm={{action "deleteTag"}}
@close={{action "toggleDeleteTagModal"}}
@modifier="action wide"}} />
{{/if}}

View File

@ -3,42 +3,33 @@
<h2 class="gh-canvas-title" data-test-screen-title>Tags</h2>
<section class="view-actions">
<div class="gh-contentfilter gh-btn-group">
<button class="gh-btn {{if (eq type "public") "gh-btn-group-selected"}}" {{action "changeType" "public"}}><span>Public tags</span></button>
<button class="gh-btn {{if (eq type "internal") "gh-btn-group-selected"}}" {{action "changeType" "internal"}}><span>Internal tags</span></button>
<button class="gh-btn {{if (eq this.type "public") "gh-btn-group-selected"}}" {{action "changeType" "public"}}><span>Public tags</span></button>
<button class="gh-btn {{if (eq this.type "internal") "gh-btn-group-selected"}}" {{action "changeType" "internal"}}><span>Internal tags</span></button>
</div>
{{#link-to "tag.new" class="gh-btn gh-btn-green"}}<span>New tag</span>{{/link-to}}
<LinkTo @route="tag.new" class="gh-btn gh-btn-green"><span>New tag</span></LinkTo>
</section>
</GhCanvasHeader>
<section class="content-list">
<ol class="tags-list gh-list {{unless sortedTags "no-posts"}}">
{{#if sortedTags}}
<ol class="tags-list gh-list {{unless this.sortedTags "no-posts"}}">
{{#if this.sortedTags}}
<li class="gh-list-row header">
<div class="gh-list-header">Tag</div>
<div class="gh-list-header gh-list-cellwidth-10">Slug</div>
<div class="gh-list-header gh-list-cellwidth-10">No. of posts</div>
<div class="gh-list-header gh-list-cellwidth-min"></div>
</li>
{{#vertical-collection
items=sortedTags
key="id"
containerSelector=".gh-main"
estimateHeight=60
bufferSize=20
as |tag|
}}
{{gh-tags-list-item
tag=tag
data-test-tag-id=tag.id
}}
{{/vertical-collection}}
<VerticalCollection @items={{this.sortedTags}} @key="id" @containerSelector=".gh-main" @estimateHeight={{60}} @bufferSize={{20}} as |tag|>
<GhTagsListItem @tag={{tag}} @data-test-tag-id={{tag.id}} />
</VerticalCollection>
{{else}}
<li class="no-posts-box">
<div class="no-posts">
{{svg-jar "tags-placeholder" class="gh-tags-placeholder"}}
<h3>You haven't created any {{type}} tags yet!</h3>
{{#link-to "tag.new" class="gh-btn gh-btn-green gh-btn-lg"}}
<h3>You haven't created any {{this.type}} tags yet!</h3>
<LinkTo @route="tag.new" class="gh-btn gh-btn-green gh-btn-lg">
<span>Create a new tag</span>
{{/link-to}}
</LinkTo>
</div>
</li>
{{/if}}