Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

[SPIKE] Nested tags UI #1068

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/gh-psm-tags-input.js
Expand Up @@ -15,7 +15,7 @@ export default Component.extend({
_availableTags: null,

availableTags: sort('_availableTags.[]', function (tagA, tagB) {
return tagA.name.localeCompare(tagB.name);
return tagA.nestedName.localeCompare(tagB.nestedName);
}),

availableTagNames: computed('availableTags.@each.name', function () {
Expand Down
22 changes: 21 additions & 1 deletion app/components/gh-tag-settings-form.js
Expand Up @@ -10,14 +10,17 @@ import {inject as service} from '@ember/service';
const {Handlebars} = Ember;

export default Component.extend({
feature: service(),
config: service(),
feature: service(),
mediaQueries: service(),
store: service(),

tag: null,

isViewingSubview: false,

_allTags: null,

// Allowed actions
setProperty: () => {},
showDeleteTagModal: () => {},
Expand All @@ -30,6 +33,18 @@ export default Component.extend({

isMobile: reads('mediaQueries.maxWidth600'),

availableParentTags: computed('tag.visibility', '_allTags.[]', function () {
// select all tags with the same visibility except the current tag
let filteredTags = this._allTags.filter((tag) => {
let sameVisibility = tag.visibility === this.tag.visibility;

return sameVisibility && tag.id !== this.tag.id;
});

// sort tags by name and tag heirarchy
return filteredTags.sort((tagA, tagB) => tagA.nestedName.localeCompare(tagB.nestedName));
}),

title: computed('tag.isNew', function () {
if (this.get('tag.isNew')) {
return 'New Tag';
Expand Down Expand Up @@ -86,6 +101,11 @@ export default Component.extend({
return metaDescription;
}),

init() {
this._super(...arguments);
this.set('_allTags', this.store.peekAll('tag'));
},

didReceiveAttrs() {
this._super(...arguments);

Expand Down
9 changes: 8 additions & 1 deletion app/components/gh-tag.js
@@ -1,5 +1,12 @@
import Component from '@ember/component';
import {computed} from '@ember/object';

export default Component.extend({
tagName: ''
tagName: '',

depthClass: computed('tag.slug', function () {
let depth = this.tag.slug.split('/').length - 1;

return `ml${depth * 4}`;
})
});
4 changes: 2 additions & 2 deletions app/controllers/posts.js
Expand Up @@ -81,10 +81,10 @@ export default Controller.extend({
availableTags: computed('_availableTags.[]', function () {
let tags = this.get('_availableTags')
.filter(tag => tag.get('id') !== null)
.sort((tagA, tagB) => tagA.name.localeCompare(tagB.name));
.sort((tagA, tagB) => tagA.nestedName.localeCompare(tagB.nestedName));
let options = tags.toArray();

options.unshiftObject({name: 'All tags', slug: null});
options.unshiftObject({nestedName: 'All tags', slug: null});

return options;
}),
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/settings/tags.js
Expand Up @@ -19,7 +19,7 @@ export default Controller.extend({

// tags are sorted by name
sortedTags: sort('filteredTags', function (tagA, tagB) {
return tagA.name.localeCompare(tagB.name);
return tagA.nestedName.localeCompare(tagB.nestedName);
}),

actions: {
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/settings/tags/tag.js
Expand Up @@ -32,7 +32,7 @@ export default Controller.extend({
let isNewTag = tag.get('isNew');
let currentValue = tag.get(propKey);

if (newValue) {
if (newValue && newValue.trim) {
newValue = newValue.trim();
}

Expand Down
35 changes: 33 additions & 2 deletions app/models/tag.js
@@ -1,16 +1,19 @@
import Model from 'ember-data/model';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import attr from 'ember-data/attr';
import {belongsTo, hasMany} from 'ember-data/relationships';
import {computed} from '@ember/object';
import {equal} from '@ember/object/computed';
import {inject as service} from '@ember/service';

export default Model.extend(ValidationEngine, {
feature: service(),

validationType: 'tag',

name: attr('string'),
slug: attr('string'),
description: attr('string'),
parent: attr('string'), // unused
metaTitle: attr('string'),
metaDescription: attr('string'),
featureImage: attr('string'),
Expand All @@ -21,10 +24,38 @@ export default Model.extend(ValidationEngine, {
updatedBy: attr('number'),
count: attr('raw'),

parent: belongsTo('tag', {inverse: 'children', async: false}),
children: hasMany('tag', {inverse: 'parent', async: false}),

isInternal: equal('visibility', 'internal'),
isPublic: equal('visibility', 'public'),

feature: service(),
// nestedName is used for sorting and display in dropdowns
//
// TODO: this is very inefficient. Luckily we always have all tags in
// memory when requesting the nested name, otherwise there would be a separate
// network request for each parent - we probably want to resolve this at the
// API level
nestedName: computed('parent', function () {
if (!this.belongsTo('parent').id()) {
return this.name;
}

let names = [this.name];
let parent = this.parent;

let count = 1;
while (parent && count <= 10) {
names.unshift(parent.name);
parent = parent.parent;
count += 1;
if (count >= 10) {
console.log('infinite loop escape', names);
}
}

return names.join('/');
}),

updateVisibility() {
let internalRegex = /^#.?/;
Expand Down
16 changes: 16 additions & 0 deletions app/serializers/tag.js
Expand Up @@ -29,6 +29,22 @@ export default ApplicationSerializer.extend({
delete payload[plural];
}
}

// TODO: revisit in nested tags API design
// API returns `parent` and `children` but Ember Data expects
// `parent_id` and `children_ids`
let tags = payload.tag ? [payload.tag] : payload.tags;
tags.forEach((tag) => {
if (tag.parent) {
tag.parent_id = tag.parent;
delete tag.parent;
}
if (tag.children) {
tag.children_ids = tag.children;
delete tag.children;
}
});

return this._super(...arguments);
}
});
2 changes: 2 additions & 0 deletions app/templates/components/gh-psm-tags-input.hbs
Expand Up @@ -9,4 +9,6 @@
selected=post.tags
showCreateWhen=(action "hideCreateOptionOnMatchingTag")
triggerId=triggerId
labelField="nestedName"
searchField="nestedName"
}}
19 changes: 19 additions & 0 deletions app/templates/components/gh-tag-settings-form.hbs
Expand Up @@ -27,6 +27,25 @@
{{gh-error-message errors=tag.errors property="name"}}
{{/gh-form-group}}

{{#gh-form-group class="for-select" errors=tag.errors hasValidated=tag.hasValidated property="parent"}}
<label for="tag-parent">Nested under tag</label>
{{#power-select
tagName="div"
triggerClass="gh-select"
selected=tag.parent
options=availableParentTags
searchField="nestedName"
searchPlaceholder="Search tags"
onchange=(action "setProperty" "parent")
optionsComponent="power-select-vertical-collection-options"
data-test-select="parent-tag"
as |selectedParent|
}}
{{selectedParent.nestedName}}
{{/power-select}}
{{gh-error-message errors=tag.errors property="parent"}}
{{/gh-form-group}}

{{#gh-form-group errors=tag.errors hasValidated=tag.hasValidated property="slug"}}
<label for="tag-slug">URL</label>
{{gh-text-input
Expand Down
4 changes: 2 additions & 2 deletions app/templates/components/gh-tag.hbs
@@ -1,4 +1,4 @@
<div class="settings-tag" id="gh-tag-{{tag.id}}" data-test-tag="{{tag.id}}">
<div class="settings-tag {{depthClass}}" id="gh-tag-{{tag.id}}" data-test-tag="{{tag.id}}">
{{#link-to 'settings.tags.tag' tag class="tag-edit-button"}}
<span class="tag-title" data-test-name>{{tag.name}}</span>
<span class="label label-default" data-test-slug>/{{tag.slug}}</span>
Expand All @@ -10,7 +10,7 @@
<p class="tag-description" data-test-description>{{tag.description}}</p>
<span class="tags-count" data-test-post-count>
{{#link-to "posts" (query-params type=null author=null tag=tag.slug order=null)}}
{{tag.count.posts}}
{{pluralize tag.count.posts "post"}}
{{/link-to}}
</span>
{{/link-to}}
Expand Down
4 changes: 2 additions & 2 deletions app/templates/posts.hbs
Expand Up @@ -48,7 +48,7 @@
{{#power-select
selected=selectedTag
options=availableTags
searchField="name"
searchField="nestedName"
onchange=(action "changeTag")
tagName="div"
classNames="gh-contentfilter-menu gh-contentfilter-tag"
Expand All @@ -60,7 +60,7 @@
data-test-tag-select=true
as |tag|
}}
{{tag.name}}
{{tag.nestedName}}
{{/power-select}}
{{/unless}}
</div>
Expand Down
2 changes: 1 addition & 1 deletion config/environment.js
Expand Up @@ -53,7 +53,7 @@ module.exports = function (environment) {

// Enable mirage here in order to mock API endpoints during development
ENV['ember-cli-mirage'] = {
enabled: false
enabled: true
};
}

Expand Down
10 changes: 6 additions & 4 deletions mirage/config.js
Expand Up @@ -25,9 +25,8 @@ export default function () {
// this.put('/posts/:id/', versionMismatchResponse);
// mockTags(this);
// this.loadFixtures('settings');
mockIntegrations(this);
mockApiKeys(this);
mockWebhooks(this);
mockPosts(this);
mockTags(this);

// keep this line, it allows all other API requests to hit the real server
this.passthrough();
Expand All @@ -43,11 +42,13 @@ export function testConfig() {
// this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server
this.namespace = '/ghost/api/v2/admin'; // make this `api`, for example, if your API is namespaced
// this.timing = 400; // delay for each request, automatically set to 0 during testing
// this.logging = true;
this.logging = true;

mockApiKeys(this);
mockAuthentication(this);
mockConfiguration(this);
mockInvites(this);
mockIntegrations(this);
mockPosts(this);
mockRoles(this);
mockSettings(this);
Expand All @@ -57,6 +58,7 @@ export function testConfig() {
mockThemes(this);
mockUploads(this);
mockUsers(this);
mockWebhooks(this);

/* Notifications -------------------------------------------------------- */

Expand Down
22 changes: 21 additions & 1 deletion mirage/config/tags.js
@@ -1,3 +1,4 @@
import moment from 'moment';
import {dasherize} from '@ember/string';
import {isBlank} from '@ember/utils';
import {paginatedResponse} from '../utils';
Expand All @@ -21,7 +22,26 @@ export default function mockTags(server) {
return tags.findBy({slug});
});

server.put('/tags/:id/');
server.put('/tags/:id/', function ({tags}, {params}) {
let attrs = this.normalizedRequestAttrs();
let tag = tags.find(params.id);
let parent = tags.find(attrs.parentId);

// tag's slug gets updated on parent change
if (attrs.parentId !== tag.parentId) {
let slug = attrs.slug.split('/').pop();

if (!attrs.parentId) {
attrs.slug = slug;
} else {
attrs.slug = `${parent.slug}/${slug}`;
}
}

attrs.updatedAt = moment.utc().toDate();

return tag.update(attrs);
});

server.del('/tags/:id/');
}
13 changes: 12 additions & 1 deletion mirage/factories/tag.js
@@ -1,4 +1,5 @@
import {Factory} from 'ember-cli-mirage';
import {dasherize} from '@ember/string';

export default Factory.extend({
createdAt: '2015-09-11T09:44:29.871Z',
Expand All @@ -10,7 +11,17 @@ export default Factory.extend({
metaTitle(i) { return `Meta Title for tag ${i}`; },
name(i) { return `Tag ${i}`; },
parent: null,
slug(i) { return `tag-${i}`; },
slug(i) {
let slug = this.name ? dasherize(this.name.toLowerCase()) : `tag-${i}`;

if (this.parent) {
let parts = this.parent.slug.split('/');
parts.push(slug);
return parts.join('/');
}

return slug;
},
updatedAt: '2015-10-19T16:25:07.756Z',
updatedBy: 1,
count() {
Expand Down
6 changes: 4 additions & 2 deletions mirage/models/tag.js
@@ -1,5 +1,7 @@
import {Model, hasMany} from 'ember-cli-mirage';
import {Model, belongsTo, hasMany} from 'ember-cli-mirage';

export default Model.extend({
posts: hasMany()
posts: hasMany(),
parent: belongsTo('tag', {inverse: 'children'}),
children: hasMany('tag', {inverse: 'parent'})
});
17 changes: 15 additions & 2 deletions mirage/scenarios/default.js
Expand Up @@ -5,7 +5,20 @@ export default function (server) {
// server.createList('contact', 10);

server.createList('subscriber', 125);
server.createList('tag', 100);

server.create('integration', {name: 'Demo'});

server.createList('tag', 20);
server.schema.tags.all().models.forEach((tag) => {
if (Math.random() < 0.5) {
let number = Math.ceil(Math.random() * 6);
server.createList('tag', number, {parent: tag});
}
});
let tagsWithParents = server.schema.tags.all().filter(tag => !!tag.parent);
tagsWithParents.models.forEach((tag) => {
if (Math.random() < 0.5) {
let number = Math.ceil(Math.random() * 3);
server.createList('tag', number, {parent: tag});
}
});
}