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}}
+
+{{/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');
+ });
+});