From 5694dc5c1e9a5e8d6f145f8374abee52b25b3214 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 20 Sep 2017 08:32:58 +0100 Subject: [PATCH] Custom Post Templates UI refs https://github.com/TryGhost/Ghost/issues/9060, requires https://github.com/TryGhost/Ghost/pull/9073 - add `{{gh-psm-template-select}}` component - allows selection of a custom template for a post if the active theme has custom templates - loads themes on render, only hitting the server if not already in the store - disables select if post slug matches a `post-*.hbs` or `page-*.hbs` template - adds `customTemplate` attr to `Post` model - adds `templates` attr to `Theme` model with CPs to pull out custom vs post/page override templates - add `.gh-select.disabled` styles to make disabled selects look visually disabled --- app/components/gh-psm-template-select.js | 75 +++++++++ app/models/post.js | 1 + app/models/setting.js | 8 +- app/models/theme.js | 23 ++- app/styles/patterns/forms.css | 9 ++ .../components/gh-post-settings-menu.hbs | 18 ++- .../components/gh-psm-template-select.hbs | 19 +++ .../acceptance/custom-post-templates-test.js | 151 ++++++++++++++++++ .../components/gh-psm-template-select-test.js | 87 ++++++++++ 9 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 app/components/gh-psm-template-select.js create mode 100644 app/templates/components/gh-psm-template-select.hbs create mode 100644 tests/acceptance/custom-post-templates-test.js create mode 100644 tests/integration/components/gh-psm-template-select-test.js diff --git a/app/components/gh-psm-template-select.js b/app/components/gh-psm-template-select.js new file mode 100644 index 0000000000..ca101295cf --- /dev/null +++ b/app/components/gh-psm-template-select.js @@ -0,0 +1,75 @@ +import Component from '@ember/component'; +import {computed} from '@ember/object'; +import {inject as injectService} from '@ember/service'; +import {isEmpty} from '@ember/utils'; +import {task} from 'ember-concurrency'; + +export default Component.extend({ + + store: injectService(), + + // public attributes + tagName: '', + post: null, + + // internal properties + activeTheme: null, + + // closure actions + onTemplateSelect() {}, + + // computed properties + customTemplates: computed('activeTheme.customTemplates.[]', function () { + let templates = this.get('activeTheme.customTemplates') || []; + let defaultTemplate = { + filename: '', + name: 'Default' + }; + + return isEmpty(templates) ? templates : [defaultTemplate, ...templates.sortBy('name')]; + }), + + matchedSlugTemplate: computed('post.{page,slug}', 'activeTheme.slugTemplates.[]', function () { + let slug = this.get('post.slug'); + let type = this.get('post.page') ? 'page' : 'post'; + + let [matchedTemplate] = this.get('activeTheme.slugTemplates').filter(function (template) { + return template.for.includes(type) && template.slug === slug; + }); + + return matchedTemplate; + }), + + selectedTemplate: computed('post.customTemplate', 'customTemplates.[]', function () { + let templates = this.get('customTemplates'); + let filename = this.get('post.customTemplate'); + + return templates.findBy('filename', filename); + }), + + // hooks + didInsertElement() { + this._super(...arguments); + this.get('loadActiveTheme').perform(); + }, + + // tasks + loadActiveTheme: task(function* () { + let store = this.get('store'); + let themes = yield store.peekAll('theme'); + + if (isEmpty(themes)) { + themes = yield store.findAll('theme'); + } + + let activeTheme = themes.filterBy('active', true).get('firstObject'); + + this.set('activeTheme', activeTheme); + }), + + actions: { + selectTemplate(template) { + this.onTemplateSelect(template.filename); + } + } +}); diff --git a/app/models/post.js b/app/models/post.js index 8d0aedc840..73f52ecf37 100644 --- a/app/models/post.js +++ b/app/models/post.js @@ -84,6 +84,7 @@ export default Model.extend(Comparable, ValidationEngine, { featureImage: attr('string'), codeinjectionFoot: attr('string', {defaultValue: ''}), codeinjectionHead: attr('string', {defaultValue: ''}), + customTemplate: attr('string'), ogImage: attr('string'), ogTitle: attr('string'), ogDescription: attr('string'), diff --git a/app/models/setting.js b/app/models/setting.js index 46f77b228e..b8afc0566b 100644 --- a/app/models/setting.js +++ b/app/models/setting.js @@ -26,7 +26,9 @@ export default Model.extend(ValidationEngine, { password: attr('string'), slack: attr('slack-settings'), amp: attr('boolean'), - unsplash: attr('unsplash-settings', {defaultValue() { - return {isActive: true}; - }}) + unsplash: attr('unsplash-settings', { + defaultValue() { + return {isActive: true}; + } + }) }); diff --git a/app/models/theme.js b/app/models/theme.js index d6b5a30545..69020a4f01 100644 --- a/app/models/theme.js +++ b/app/models/theme.js @@ -1,12 +1,31 @@ import Model from 'ember-data/model'; import attr from 'ember-data/attr'; +import {computed} from '@ember/object'; +import {isBlank} from '@ember/utils'; export default Model.extend({ + active: attr('boolean'), + errors: attr('raw'), name: attr('string'), package: attr('raw'), - active: attr('boolean'), + templates: attr('raw', {defaultValue: () => []}), warnings: attr('raw'), - errors: attr('raw'), + + customTemplates: computed('templates.[]', function () { + let templates = this.get('templates') || []; + + return templates.filter(function (template) { + return isBlank(template.slug); + }); + }), + + slugTemplates: computed('templates.[]', function () { + let templates = this.get('templates') || []; + + return templates.filter(function (template) { + return !isBlank(template.slug); + }); + }), activate() { let adapter = this.store.adapterFor(this.constructor.modelName); diff --git a/app/styles/patterns/forms.css b/app/styles/patterns/forms.css index 93a212f90d..3a9d4c79f8 100644 --- a/app/styles/patterns/forms.css +++ b/app/styles/patterns/forms.css @@ -332,6 +332,15 @@ textarea { text-shadow: 0 0 0 #000; } +.gh-select.disabled select { + color: color(var(--midgrey) l(+18%)); + cursor: default; +} + +.gh-select.disabled svg path { + stroke: color(var(--midgrey) l(+30%)); +} + /* File Uploads /* ---------------------------------------------------------- */ diff --git a/app/templates/components/gh-post-settings-menu.hbs b/app/templates/components/gh-post-settings-menu.hbs index 6e4836967f..b4907b646e 100644 --- a/app/templates/components/gh-post-settings-menu.hbs +++ b/app/templates/components/gh-post-settings-menu.hbs @@ -147,18 +147,32 @@
+ {{gh-psm-template-select + post=model + onTemplateSelect=(action (mut model.customTemplate))}} + {{#unless model.isNew}} {{/unless}} diff --git a/app/templates/components/gh-psm-template-select.hbs b/app/templates/components/gh-psm-template-select.hbs new file mode 100644 index 0000000000..55e6205d3e --- /dev/null +++ b/app/templates/components/gh-psm-template-select.hbs @@ -0,0 +1,19 @@ +{{#if customTemplates}} +
+ + + {{inline-svg "file-text-document"}} + + {{one-way-select selectedTemplate + options=customTemplates + optionValuePath="filename" + optionLabelPath="name" + update=(action "selectTemplate") + disabled=matchedSlugTemplate + data-test-select="custom-template"}} + {{inline-svg "arrow-down-small"}} + + +

Post URL matches {{matchedSlugTemplate.filename}}

+
+{{/if}} diff --git a/tests/acceptance/custom-post-templates-test.js b/tests/acceptance/custom-post-templates-test.js new file mode 100644 index 0000000000..a93417fc45 --- /dev/null +++ b/tests/acceptance/custom-post-templates-test.js @@ -0,0 +1,151 @@ +import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; +import destroyApp from 'ghost-admin/tests/helpers/destroy-app'; +import startApp from 'ghost-admin/tests/helpers/start-app'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import {authenticateSession} from 'ghost-admin/tests/helpers/ember-simple-auth'; +import {click, fillIn, find, keyEvent, visit} from 'ember-native-dom-helpers'; +import {expect} from 'chai'; + +// keyCodes +const KEY_S = 83; + +describe('Acceptance: Custom Post Templates', function() { + let application; + + beforeEach(function() { + application = startApp(); + + server.loadFixtures('settings'); + + let role = server.create('role', {name: 'Administrator'}); + server.create('user', {roles: [role]}); + + authenticateSession(application); + }); + + afterEach(function() { + destroyApp(application); + }); + + describe('with custom templates', function () { + beforeEach(function () { + server.create('theme', { + active: true, + name: 'example-theme', + package: { + name: 'Example Theme', + version: '0.1' + }, + templates: [ + { + filename: 'custom-news-bulletin.hbs', + name: 'News Bulletin', + for: ['post', 'page'], + slug: null + }, + { + filename: 'custom-big-images.hbs', + name: 'Big Images', + for: ['post', 'page'], + slug: null + }, + { + filename: 'post-one.hbs', + name: 'One', + for: ['post'], + slug: 'one' + }, + { + filename: 'page-about.hbs', + name: 'About', + for: ['page'], + slug: 'about' + } + ] + }); + }); + + it('can change selected template', async function () { + let post = server.create('post', {customTemplate: 'custom-news-bulletin.hbs'}); + + await visit('/editor/1'); + await click('[data-test-psm-trigger]'); + + // template form should be shown + expect(find('[data-test-custom-template-form]')).to.exist; + + // custom template should be selected + let select = find('[data-test-select="custom-template"]'); + expect(select.value, 'selected value').to.equal('custom-news-bulletin.hbs'); + + // templates list should contain default and custom templates in alphabetical order + expect(select.options.length).to.equal(3); + expect(select.options.item(0).value, 'default value').to.equal(''); + expect(select.options.item(0).text, 'default text').to.equal('Default'); + expect(select.options.item(1).value, 'first custom value').to.equal('custom-big-images.hbs'); + expect(select.options.item(1).text, 'first custom text').to.equal('Big Images'); + expect(select.options.item(2).value, 'second custom value').to.equal('custom-news-bulletin.hbs'); + expect(select.options.item(2).text, 'second custom text').to.equal('News Bulletin'); + + // select the default template + await fillIn(select, ''); + + // save then check server record + await keyEvent('.gh-app', 'keydown', KEY_S, { + metaKey: ctrlOrCmd === 'command', + ctrlKey: ctrlOrCmd === 'ctrl' + }); + + expect( + server.db.posts.find(post.id).customTemplate, + 'saved custom template' + ).to.equal(''); + }); + + it('disables template selector if slug matches slug-based template'); + + it('doesn\'t query themes endpoint unncessarily', async function () { + function themeRequests() { + return server.pretender.handledRequests.filter(function (request) { + return request.url.match(/\/themes\//); + }); + } + + server.create('post', {customTemplate: 'custom-news-bulletin.hbs'}); + + await visit('/editor/1'); + await click('[data-test-psm-trigger]'); + + expect(themeRequests().length, 'after first open').to.equal(1); + + await click('[data-test-psm-trigger]'); // hide + await click('[data-test-psm-trigger]'); // show + + expect(themeRequests().length, 'after second open').to.equal(1); + }); + }); + + describe('without custom templates', function () { + beforeEach(function () { + server.create('theme', { + active: true, + name: 'example-theme', + package: { + name: 'Example Theme', + version: '0.1' + }, + templates: [] + }); + }); + + it('doesn\'t show template selector', async function () { + server.create('post', {customTemplate: 'custom-news-bulletin.hbs'}); + + await visit('/editor/1'); + await click('[data-test-psm-trigger]'); + + // template form should be shown + expect(find('[data-test-custom-template-form]')).to.not.exist; + }); + }); +}); diff --git a/tests/integration/components/gh-psm-template-select-test.js b/tests/integration/components/gh-psm-template-select-test.js new file mode 100644 index 0000000000..d71ed2d358 --- /dev/null +++ b/tests/integration/components/gh-psm-template-select-test.js @@ -0,0 +1,87 @@ +import hbs from 'htmlbars-inline-precompile'; +import mockThemes from '../../../mirage/config/themes'; +import wait from 'ember-test-helpers/wait'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {find} from 'ember-native-dom-helpers'; +import {setupComponentTest} from 'ember-mocha'; +import {startMirage} from 'ghost-admin/initializers/ember-cli-mirage'; + +describe('Integration: Component: gh-psm-template-select', function() { + setupComponentTest('gh-psm-template-select', { + integration: true + }); + + let server; + + beforeEach(function () { + server = startMirage(); + + server.create('theme', { + active: true, + name: 'example-theme', + package: { + name: 'Example Theme', + version: '0.1' + }, + templates: [ + { + filename: 'custom-news-bulletin.hbs', + name: 'News Bulletin', + for: ['post', 'page'], + slug: null + }, + { + filename: 'custom-big-images.hbs', + name: 'Big Images', + for: ['post', 'page'], + slug: null + }, + { + filename: 'post-one.hbs', + name: 'One', + for: ['post'], + slug: 'one' + }, + { + filename: 'page-about.hbs', + name: 'About', + for: ['page'], + slug: 'about' + } + ] + }); + + mockThemes(server); + }); + + afterEach(function () { + server.shutdown(); + }); + + it('disables template selector if slug matches post template', async function () { + this.set('post', { + slug: 'one', + page: false + }); + + this.render(hbs`{{gh-psm-template-select post=post}}`); + await wait(); + + expect(find('select').disabled, 'select is disabled').to.be.true; + expect(find('p').textContent).to.have.string('post-one.hbs'); + }); + + it('disables template selector if slug matches page template', async function () { + this.set('post', { + slug: 'about', + page: true + }); + + this.render(hbs`{{gh-psm-template-select post=post}}`); + await wait(); + + expect(find('select').disabled, 'select is disabled').to.be.true; + expect(find('p').textContent).to.have.string('page-about.hbs'); + }); +});