1
0
Fork 0
mirror of https://github.com/TryGhost/Ghost-Admin.git synced 2023-12-14 02:33:04 +01:00

Timezones: Always use the timezone of blog setting

closes TryGhost/Ghost#6406

follow-up PR of #2

- adds a `timeZone` Service to provide the offset (=timezone reg. moment-timezone) of the users blog settings
- `gh-datetime-input` will read the offset of the timezone now and adjust the `publishedAt` date with it. This is the date which will be shown in the PSM 'Publish Date' field. When the user writes a new date/time, the offset is considered and will be deducted again before saving it to the model. This way, we always work with a UTC publish date except for this input field.
- gets `availableTimezones` from `configuration/timezones` API endpoint
- adds a `moment-utc` transform on all date attr (`createdAt`, `updatedAt`, `publishedAt`, `unsubscribedAt` and `lastLogin`) to only work with UTC times on serverside
- when switching the timezone in the select box, the user will be shown the local time of the selected timezone
- `createdAt`-property in `gh-user-invited` returns now `moment(createdAt).fromNow()` as `createdAt` is a moment date already
- added clock service to show actual time ticking below select box
- default timezone is '(GMT) Greenwich Mean Time : Dublin, Edinburgh, London'
- if no timezone is saved in the settings yet, the default value will be used
- shows the local time in 'Publish Date'  in PSM by default, until user overwrites it
- adds dependency `moment-timezone 0.5.4` to `bower.json`

---------

**Tests:**

- sets except for clock service in test env
- adds fixtures to mirage
- adds `service.ajax` and `service:ghostPaths` to navigation-test.js
- adds unit test for `gh-format-timeago` helper
- updates acceptance test `general-setting`
- adds acceptance test for `editor`
- adds integration tests for `services/config` and `services/time-zone`

---------

**Todos:**

- [ ] Integration tests: ~~`services/config`~~, ~~`services/time-zone`~~, `components/gh-datetime-input`
- [x] Acceptance test: `editor`
- [ ] Unit tests: `utils/date-formatting`
- [ ] write issue for renaming date properties (e. g. `createdAt` to `createdAtUTC`) and translate those for server side with serializers
This commit is contained in:
Aileen Nowak 2016-02-02 09:04:40 +02:00
parent f0af686c6d
commit be8b31ec85
37 changed files with 1086 additions and 69 deletions

View file

@ -4,7 +4,11 @@ import boundOneWay from 'ghost/utils/bound-one-way';
import {formatDate} from 'ghost/utils/date-formatting';
import {invokeAction} from 'ember-invoke-action';
const {Component} = Ember;
const {
Component,
RSVP,
inject: {service}
} = Ember;
export default Component.extend(TextInputMixin, {
tagName: 'span',
@ -14,15 +18,21 @@ export default Component.extend(TextInputMixin, {
inputClass: null,
inputId: null,
inputName: null,
timeZone: service(),
didReceiveAttrs() {
let datetime = this.get('datetime') || moment();
let promises = {
datetime: RSVP.resolve(this.get('datetime') || moment.utc()),
offset: RSVP.resolve(this.get('timeZone.offset'))
};
if (!this.get('update')) {
throw new Error(`You must provide an \`update\` action to \`{{${this.templateName}}}\`.`);
}
this.set('datetime', formatDate(datetime));
RSVP.hash(promises).then((hash) => {
this.set('datetime', formatDate(hash.datetime || moment.utc(), hash.offset));
});
},
focusOut() {

View file

@ -26,6 +26,6 @@ export default Component.extend({
lastLogin: computed('user.lastLogin', function () {
let lastLogin = this.get('user.lastLogin');
return lastLogin ? lastLogin.fromNow() : '(Never)';
return lastLogin ? moment(lastLogin).fromNow() : '(Never)';
})
});

View file

@ -17,7 +17,7 @@ export default Component.extend({
createdAt: computed('user.createdAt', function () {
let createdAt = this.get('user.createdAt');
return createdAt ? createdAt.fromNow() : '';
return createdAt ? moment(createdAt).fromNow() : '';
}),
actions: {

View file

@ -31,6 +31,7 @@ export default Controller.extend(SettingsMenuMixin, {
notifications: service(),
session: service(),
slugGenerator: service(),
timeZone: service(),
initializeSelectedAuthor: observer('model', function () {
return this.get('model.author').then((author) => {
@ -294,48 +295,53 @@ export default Controller.extend(SettingsMenuMixin, {
if (this.get('model.isDraft')) {
this.set('model.publishedAt', null);
}
return;
}
this.get('timeZone.offset').then((offset) => {
let newPublishedAt = parseDateString(userInput, offset);
let publishedAt = moment.utc(this.get('model.publishedAt'));
let errMessage = '';
let newPublishedAt = parseDateString(userInput);
let publishedAt = moment(this.get('model.publishedAt'));
let errMessage = '';
// Clear previous errors
this.get('model.errors').remove('post-setting-date');
// Clear previous errors
this.get('model.errors').remove('post-setting-date');
// Validate new Published date
if (!newPublishedAt.isValid()) {
errMessage = 'Published Date must be a valid date with format: ' +
'DD MMM YY @ HH:mm (e.g. 6 Dec 14 @ 15:00)';
}
// Validate new Published date
if (!newPublishedAt.isValid()) {
errMessage = 'Published Date must be a valid date with format: ' +
'DD MMM YY @ HH:mm (e.g. 6 Dec 14 @ 15:00)';
} else if (newPublishedAt.diff(new Date(), 'h') > 0) {
errMessage = 'Published Date cannot currently be in the future.';
}
// Date is a valid date, so now make it UTC
newPublishedAt = moment.utc(newPublishedAt);
// If errors, notify and exit.
if (errMessage) {
this.get('model.errors').add('post-setting-date', errMessage);
return;
}
if (newPublishedAt.diff(moment.utc(new Date()), 'hours', true) > 0) {
errMessage = 'Published Date cannot currently be in the future.';
}
// Validation complete, update the view
this.set('model.publishedAt', newPublishedAt);
// If errors, notify and exit.
if (errMessage) {
this.get('model.errors').add('post-setting-date', errMessage);
return;
}
// Don't save the date if the user didn't actually changed the date
if (publishedAt && publishedAt.isSame(newPublishedAt)) {
return;
}
// Do nothing if the user didn't actually change the date
if (publishedAt && publishedAt.isSame(newPublishedAt)) {
return;
}
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (this.get('model.isNew')) {
return;
}
// Validation complete
this.set('model.publishedAt', newPublishedAt);
this.get('model').save().catch((errors) => {
this.showErrors(errors);
this.get('model').rollbackAttributes();
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (this.get('model.isNew')) {
return;
}
this.get('model').save().catch((errors) => {
this.showErrors(errors);
this.get('model').rollbackAttributes();
});
});
},

View file

@ -15,10 +15,13 @@ export default Controller.extend(SettingsSaveMixin, {
showUploadLogoModal: false,
showUploadCoverModal: false,
availableTimezones: null,
notifications: service(),
config: service(),
_scratchFacebook: null,
_scratchTwitter: null,
clock: service(),
selectedTheme: computed('model.activeTheme', 'themes', function () {
let activeTheme = this.get('model.activeTheme');
@ -34,6 +37,15 @@ export default Controller.extend(SettingsSaveMixin, {
return selectedTheme;
}),
selectedTimezone: computed('model.activeTimezone', 'availableTimezones', function () {
let activeTimezone = this.get('model.activeTimezone');
let availableTimezones = this.get('availableTimezones');
return availableTimezones
.filterBy('name', activeTimezone)
.get('firstObject');
}),
logoImageSource: computed('model.logo', function () {
return this.get('model.logo') || '';
}),
@ -42,6 +54,12 @@ export default Controller.extend(SettingsSaveMixin, {
return this.get('model.cover') || '';
}),
localTime: computed('selectedTimezone', 'clock.second', function () {
let timezone = this.get('selectedTimezone.name');
this.get('clock.second');
return timezone ? moment().tz(timezone).format('HH:mm:ss') : moment().utc().format('HH:mm:ss');
}),
isDatedPermalinks: computed('model.permalinks', {
set(key, value) {
this.set('model.permalinks', value ? '/:year/:month/:day/:slug/' : '/:slug/');
@ -82,7 +100,6 @@ export default Controller.extend(SettingsSaveMixin, {
save() {
let notifications = this.get('notifications');
let config = this.get('config');
return this.get('model').save().then((model) => {
config.set('blogTitle', model.get('title'));
@ -111,7 +128,9 @@ export default Controller.extend(SettingsSaveMixin, {
setTheme(theme) {
this.set('model.activeTheme', theme.name);
},
setTimezone(timezone) {
this.set('model.activeTimezone', timezone.name);
},
toggleUploadCoverModal() {
this.toggleProperty('showUploadCoverModal');
},

View file

@ -2,14 +2,18 @@ import Ember from 'ember';
const {Helper} = Ember;
export default Helper.helper(function (params) {
export function timeAgo(params) {
if (!params || !params.length) {
return;
}
let [timeago] = params;
let utc = moment.utc();
return moment(timeago).fromNow();
return moment(timeago).from(utc);
}
export default Helper.helper(function (params) {
return timeAgo(params);
// stefanpenner says cool for small number of timeagos.
// For large numbers moment sucks => single Ember.Object based clock better
// https://github.com/manuelmitasch/ghost-admin-ember-demo/commit/fba3ab0a59238290c85d4fa0d7c6ed1be2a8a82e#commitcomment-5396524

View file

@ -207,6 +207,27 @@ export function testConfig() {
return response;
});
this.get('/posts/:id', function (db, request) {
let {id} = request.params;
let post = db.posts.find(id);
return {
posts: [post]
};
});
this.put('/posts/:id/', function (db, request) {
let {id} = request.params;
let [attrs] = JSON.parse(request.requestBody).posts;
delete attrs.id;
let post = db.posts.update(id, attrs);
return {
posts: [post]
};
});
this.del('/posts/:id/', function (db, request) {
db.posts.remove(request.params.id);
@ -264,6 +285,16 @@ export function testConfig() {
return {};
});
/* Configuration -------------------------------------------------------- */
this.get('/configuration/timezones/', function (db) {
return {
configuration: [{
timezones: db.timezones
}]
};
});
/* Slugs ---------------------------------------------------------------- */
this.get('/slugs/post/:slug/', function (db, request) {

View file

@ -1,5 +1,5 @@
/* jscs:disable */
import Mirage from 'ember-cli-mirage';
import Mirage, {faker} from 'ember-cli-mirage';
export default Mirage.Factory.extend({
uuid(i) { return `post-${i}`; },
@ -10,14 +10,15 @@ export default Mirage.Factory.extend({
image(i) { return `/content/images/2015/10/post-${i}.jpg`; },
featured() { return false; },
page() { return false; },
status(i) { return `/content/images/2015/10/post-${i}.jpg`; },
status(i) { return faker.list.cycle('draft', 'published')(i); },
meta_description(i) { return `Meta description for post ${i}.`; },
meta_title(i) { return `Meta Title for post ${i}`; },
author_id() { return 1; },
updated_at() { return '2015-10-19T16:25:07.756Z'; },
updated_by() { return 1; },
published_at() { return '2015-10-19T16:25:07.756Z'; },
published_at() { return '2015-12-19T16:25:07.000Z'; },
published_by() { return 1; },
created_at() { return '2015-09-11T09:44:29.871Z'; },
created_by() { return 1; }
created_by() { return 1; },
tags() { return []; }
});

View file

@ -201,6 +201,17 @@ export default [
uuid: '5130441f-e4c7-4750-9692-a22d841ab049',
value: '@test'
},
{
created_at: '2015-09-11T09:44:30.810Z',
created_by: 1,
id: 16,
key: 'activeTimezone',
type: 'blog',
updated_at: '2015-09-23T13:32:49.868Z',
updated_by: 1,
uuid: '310c9169-9613-48b0-8bc4-d1e1c9be85b8',
value: 'Europe/Dublin'
},
{
key: 'availableThemes',
value: [

View file

@ -0,0 +1,327 @@
export default [
{
name: 'Pacific/Pago_Pago',
label: '(GMT -11:00) Midway Island, Samoa',
offset: -660
},
{
name: 'Pacific/Honolulu',
label: '(GMT -10:00) Hawaii',
offset: -600
},
{
name: 'America/Anchorage',
label: '(GMT -9:00) Alaska',
offset: -540
},
{
name: 'America/Tijuana',
label: '(GMT -8:00) Chihuahua, La Paz, Mazatlan',
offset: -480
},
{
name: 'America/Los_Angeles',
label: '(GMT -8:00) Pacific Time (US & Canada); Tijuana',
offset: -480
},
{
name: 'America/Phoenix',
label: '(GMT -7:00) Arizona',
offset: -420
},
{
name: 'America/Denver',
label: '(GMT -7:00) Mountain Time (US & Canada)',
offset: -420
},
{
name: 'America/Costa_Rica',
label: '(GMT -6:00) Central America',
offset: -360
},
{
name: 'America/Chicago',
label: '(GMT -6:00) Central Time (US & Canada)',
offset: -360
},
{
name: 'America/Mexico_City',
label: '(GMT -6:00) Guadalajara, Mexico City, Monterrey',
offset: -360
},
{
name: 'America/Regina',
label: '(GMT -6:00) Saskatchewan',
offset: -360
},
{
name: 'America/Bogota',
label: '(GMT -5:00) Bogota, Lima, Quito',
offset: -300
},
{
name: 'America/New_York',
label: '(GMT -5:00) Eastern Time (US & Canada)',
offset: -300
},
{
name: 'America/Fort_Wayne',
label: '(GMT -5:00) Indiana (East)',
offset: -300
},
{
name: 'America/Caracas',
label: '(GMT -4:30) Caracas, La Paz',
offset: -270
},
{
name: 'America/Halifax',
label: '(GMT -4:00) Atlantic Time (Canada); Brasilia, Greenland',
offset: -240
},
{
name: 'America/St_Johns',
label: '(GMT -3:30) Newfoundland',
offset: -210
},
{
name: 'America/Argentina/Buenos_Aires',
label: '(GMT -3:00) Buenos Aires, Georgetown',
offset: -180
},
{
name: 'America/Santiago',
label: '(GMT -3:00) Santiago',
offset: -180
},
{
name: 'America/Noronha',
label: '(GMT -2:00) Fernando de Noronha',
offset: -120
},
{
name: 'Atlantic/Azores',
label: '(GMT -1:00) Azores',
offset: -60
},
{
name: 'Atlantic/Cape_Verde',
label: '(GMT -1:00) Cape Verde Is.',
offset: -60
},
{
name: 'Africa/Casablanca',
label: '(GMT) Casablanca, Monrovia',
offset: 0
},
{
name: 'Europe/Dublin',
label: '(GMT) Greenwich Mean Time : Dublin, Edinburgh, London',
offset: 0
},
{
name: 'Europe/Amsterdam',
label: '(GMT +1:00) Amsterdam, Berlin, Rome, Stockholm, Vienna',
offset: 60
},
{
name: 'Europe/Prague',
label: '(GMT +1:00) Belgrade, Bratislava, Budapest, Prague',
offset: 60
},
{
name: 'Europe/Paris',
label: '(GMT +1:00) Brussels, Copenhagen, Madrid, Paris',
offset: 60
},
{
name: 'Europe/Warsaw',
label: '(GMT +1:00) Sarajevo, Skopje, Warsaw, Zagreb',
offset: 60
},
{
name: 'Africa/Lagos',
label: '(GMT +1:00) West Central Africa',
offset: 60
},
{
name: 'Europe/Istanbul',
label: '(GMT +2:00) Athens, Beirut, Bucharest, Istanbul',
offset: 120
},
{
name: 'Africa/Cairo',
label: '(GMT +2:00) Cairo, Egypt',
offset: 120
},
{
name: 'Africa/Maputo',
label: '(GMT +2:00) Harare',
offset: 120
},
{
name: 'Europe/Kiev',
label: '(GMT +2:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius',
offset: 120
},
{
name: 'Asia/Jerusalem',
label: '(GMT +2:00) Jerusalem',
offset: 120
},
{
name: 'Africa/Johannesburg',
label: '(GMT +2:00) Pretoria',
offset: 120
},
{
name: 'Asia/Baghdad',
label: '(GMT +3:00) Baghdad',
offset: 180
},
{
name: 'Asia/Riyadh',
label: '(GMT +3:00) Kuwait, Nairobi, Riyadh',
offset: 180
},
{
name: 'Asia/Tehran',
label: '(GMT +3:30) Tehran',
offset: 210
},
{
name: 'Asia/Dubai',
label: '(GMT +4:00) Abu Dhabi, Muscat',
offset: 240
},
{
name: 'Asia/Baku',
label: '(GMT +4:00) Baku, Tbilisi, Yerevan',
offset: 240
},
{
name: 'Europe/Moscow',
label: '(GMT +4:00) Moscow, St. Petersburg, Volgograd',
offset: 240
},
{
name: 'Asia/Kabul',
label: '(GMT +4:30) Kabul',
offset: 270
},
{
name: 'Asia/Karachi',
label: '(GMT +5:00) Islamabad, Karachi, Tashkent',
offset: 300
},
{
name: 'Asia/Kolkata',
label: '(GMT +5:30) Chennai, Calcutta, Mumbai, New Delhi',
offset: 330
},
{
name: 'Asia/Kathmandu',
label: '(GMT +5:45) Katmandu',
offset: 345
},
{
name: 'Asia/Almaty',
label: '(GMT +6:00) Almaty, Novosibirsk',
offset: 360
},
{
name: 'Asia/Dhaka',
label: '(GMT +6:00) Astana, Dhaka, Sri Jayawardenepura',
offset: 360
},
{
name: 'Asia/Yekaterinburg',
label: '(GMT +6:00) Yekaterinburg',
offset: 360
},
{
name: 'Asia/Rangoon',
label: '(GMT +6:30) Rangoon',
offset: 390
},
{
name: 'Asia/Bangkok',
label: '(GMT +7:00) Bangkok, Hanoi, Jakarta',
offset: 420
},
{
name: 'Asia/Hong_Kong',
label: '(GMT +8:00) Beijing, Chongqing, Hong Kong, Urumqi',
offset: 480
},
{
name: 'Asia/Krasnoyarsk',
label: '(GMT +8:00) Krasnoyarsk',
offset: 480
},
{
name: 'Asia/Singapore',
label: '(GMT +8:00) Kuala Lumpur, Perth, Singapore, Taipei',
offset: 480
},
{
name: 'Asia/Irkutsk',
label: '(GMT +9:00) Irkutsk, Ulaan Bataar',
offset: 540
},
{
name: 'Asia/Tokyo',
label: '(GMT +9:00) Osaka, Sapporo, Tokyo',
offset: 540
},
{
name: 'Asia/Seoul',
label: '(GMT +9:00) Seoul',
offset: 540
},
{
name: 'Australia/Darwin',
label: '(GMT +9:30) Darwin',
offset: 570
},
{
name: 'Australia/Brisbane',
label: '(GMT +10:00) Brisbane, Guam, Port Moresby',
offset: 600
},
{
name: 'Asia/Yakutsk',
label: '(GMT +10:00) Yakutsk',
offset: 600
},
{
name: 'Australia/Adelaide',
label: '(GMT +10:30) Adelaide',
offset: 630
},
{
name: 'Australia/Sydney',
label: '(GMT +11:00) Canberra, Hobart, Melbourne, Sydney, Vladivostok',
offset: 660
},
{
name: 'Pacific/Fiji',
label: '(GMT +12:00) Fiji, Kamchatka, Marshall Is.',
offset: 720
},
{
name: 'Pacific/Kwajalein',
label: '(GMT +12:00) International Date Line West',
offset: 720
},
{
name: 'Asia/Magadan',
label: '(GMT +12:00) Magadan, Soloman Is., New Caledonia',
offset: 720
},
{
name: 'Pacific/Auckland',
label: '(GMT +13:00) Auckland, Wellington',
offset: 780
}
];

View file

@ -28,11 +28,11 @@ export default Model.extend(ValidationEngine, {
metaDescription: attr('string'),
author: belongsTo('user', {async: true}),
authorId: attr('number'),
updatedAt: attr('moment-date'),
updatedAt: attr('moment-utc'),
updatedBy: attr(),
publishedAt: attr('moment-date'),
publishedAt: attr('moment-utc'),
publishedBy: belongsTo('user', {async: true}),
createdAt: attr('moment-date'),
createdAt: attr('moment-utc'),
createdBy: attr(),
tags: hasMany('tag', {
embedded: 'always',
@ -42,6 +42,7 @@ export default Model.extend(ValidationEngine, {
config: service(),
ghostPaths: service(),
timeZone: service(),
absoluteUrl: computed('url', 'ghostPaths.url', 'config.blogUrl', function () {
let blogUrl = this.get('config.blogUrl');

View file

@ -9,8 +9,8 @@ export default Model.extend({
uuid: attr('string'),
name: attr('string'),
description: attr('string'),
createdAt: attr('moment-date'),
updatedAt: attr('moment-date'),
createdAt: attr('moment-utc'),
updatedAt: attr('moment-utc'),
createdBy: attr(),
updatedBy: attr(),

View file

@ -16,6 +16,7 @@ export default Model.extend(ValidationEngine, {
permalinks: attr('string'),
activeTheme: attr('string'),
availableThemes: attr(),
activeTimezone: attr('string', {defaultValue: 'Europe/Dublin'}),
ghost_head: attr('string'),
ghost_foot: attr('string'),
facebook: attr('facebook-url-user'),

View file

@ -13,9 +13,9 @@ export default Model.extend(ValidationEngine, {
subscribedUrl: attr('string'),
subscribedReferrer: attr('string'),
unsubscribedUrl: attr('string'),
unsubscribedAt: attr('moment-date'),
createdAt: attr('moment-date'),
updatedAt: attr('moment-date'),
unsubscribedAt: attr('moment-utc'),
createdAt: attr('moment-utc'),
updatedAt: attr('moment-utc'),
createdBy: attr('number'),
updatedBy: attr('number'),

View file

@ -15,8 +15,8 @@ export default Model.extend(ValidationEngine, {
metaDescription: attr('string'),
image: attr('string'),
hidden: attr('boolean'),
createdAt: attr('moment-date'),
updatedAt: attr('moment-date'),
createdAt: attr('moment-utc'),
updatedAt: attr('moment-utc'),
createdBy: attr(),
updatedBy: attr(),
count: attr('raw')

View file

@ -28,10 +28,10 @@ export default Model.extend(ValidationEngine, {
language: attr('string', {defaultValue: 'en_US'}),
metaTitle: attr('string'),
metaDescription: attr('string'),
lastLogin: attr('moment-date'),
createdAt: attr('moment-date'),
lastLogin: attr('moment-utc'),
createdAt: attr('moment-utc'),
createdBy: attr('number'),
updatedAt: attr('moment-date'),
updatedAt: attr('moment-utc'),
updatedBy: attr('number'),
roles: hasMany('role', {
embedded: 'always',

View file

@ -1,12 +1,20 @@
import Ember from 'ember';
import AuthenticatedRoute from 'ghost/routes/authenticated';
import CurrentUserSettings from 'ghost/mixins/current-user-settings';
import styleBody from 'ghost/mixins/style-body';
const {
RSVP,
inject: {service}
} = Ember;
export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
titleToken: 'Settings - General',
classNames: ['settings-view-general'],
config: service(),
beforeModel() {
this._super(...arguments);
return this.get('session.user')
@ -15,7 +23,15 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
},
model() {
return this.store.queryRecord('setting', {type: 'blog,theme,private'});
return RSVP.hash({
settings: this.store.queryRecord('setting', {type: 'blog,theme,private'}),
availableTimezones: this.get('config.availableTimezones')
});
},
setupController(controller, models) {
controller.set('model', models.settings);
controller.set('availableTimezones', models.availableTimezones);
},
actions: {

37
app/services/clock.js Normal file
View file

@ -0,0 +1,37 @@
import Ember from 'ember';
const {
Service,
run
} = Ember;
const ONE_SECOND = 1000;
// Creates a clock service to run intervals.
export default Service.extend({
second: null,
minute: null,
hour: null,
init() {
this.tick();
},
tick() {
let now = moment().utc();
this.setProperties({
second: now.seconds(),
minute: now.minutes(),
hour: now.hours()
});
if (!Ember.testing) {
run.later(() => {
this.tick();
}, ONE_SECOND);
}
}
});

View file

@ -1,6 +1,11 @@
import Ember from 'ember';
const {Service, _ProxyMixin, computed} = Ember;
const {
Service,
_ProxyMixin,
computed,
inject: {service}
} = Ember;
function isNumeric(num) {
return Ember.$.isNumeric(num);
@ -25,6 +30,9 @@ function _mapType(val, type) {
}
export default Service.extend(_ProxyMixin, {
ajax: service(),
ghostPaths: service(),
content: computed(function () {
let metaConfigTags = Ember.$('meta[name^="env-"]');
let config = {};
@ -40,5 +48,17 @@ export default Service.extend(_ProxyMixin, {
});
return config;
}),
availableTimezones: computed(function() {
let timezonesUrl = this.get('ghostPaths.url').api('configuration', 'timezones');
return this.get('ajax').request(timezonesUrl).then((configTimezones) => {
let [ timezonesObj ] = configTimezones.configuration;
timezonesObj = timezonesObj.timezones;
return timezonesObj;
});
})
});

28
app/services/time-zone.js Normal file
View file

@ -0,0 +1,28 @@
import Ember from 'ember';
const {
Service,
computed,
inject: {service}
} = Ember;
export default Service.extend({
store: service(),
_parseTimezones(settings) {
let activeTimezone = settings.get('activeTimezone');
return activeTimezone;
},
_settings: computed(function () {
let store = this.get('store');
return store.queryRecord('setting', {type: 'blog,theme,private'});
}),
offset: computed('_settings.activeTimezone', function () {
return this.get('_settings').then((settings) => {
return this._parseTimezones(settings);
});
})
});

View file

@ -113,6 +113,22 @@
{{/gh-form-group}}
</div>
<div class="form-group for-select">
<label for="activeTimezone">Timezone</label>
<span class="gh-select" data-select-text="{{selectedTimezone.label}}" tabindex="0">
{{gh-select-native
id="activeTimezone"
name="general[activeTimezone]"
content=availableTimezones
optionValuePath="name"
optionLabelPath="label"
selection=selectedTimezone
action="setTimezone"
}}
</span>
<p>The local time here is currently {{localTime}}</p>
</div>
<div class="form-group for-checkbox">
<label for="isPrivate">Make this blog private</label>
<label class="checkbox" for="isPrivate">

View file

@ -0,0 +1,22 @@
/* global moment */
import Transform from 'ember-data/transform';
export default Transform.extend({
deserialize(serialized) {
if (serialized) {
return moment.utc(serialized);
}
return serialized;
},
serialize(deserialized) {
if (deserialized) {
try {
return deserialized.toJSON();
} catch (e) {
return deserialized;
}
}
return deserialized;
}
});

View file

@ -24,13 +24,18 @@ function verifyTimeStamp(dateString) {
}
// Parses a string to a Moment
function parseDateString(value) {
function parseDateString(value, offset) {
// We need the offset here, otherwise the date will be parsed
// in UTC timezone
moment.tz.setDefault(offset);
return value ? moment(verifyTimeStamp(value), parseDateFormats, true) : undefined;
}
// Formats a Date or Moment
function formatDate(value) {
return verifyTimeStamp(value ? moment(value).format(displayDateFormat) : '');
function formatDate(value, offset) {
// we output the date adjusted by the offset of the timezone set in the blog setting
return verifyTimeStamp(value ? moment(value).tz(offset).format(displayDateFormat) : '');
}
export {

View file

@ -19,6 +19,7 @@
"keymaster": "1.6.3",
"lodash": "3.7.0",
"moment": "2.13.0",
"moment-timezone": "0.5.4",
"normalize.css": "3.0.3",
"password-generator": "2.0.2",
"pretender": "1.1.0",

View file

@ -55,6 +55,7 @@ module.exports = function (defaults) {
app.import('bower_components/showdown-ghost/src/extensions/footnotes.js');
app.import('bower_components/showdown-ghost/src/extensions/highlight.js');
app.import('bower_components/moment/moment.js');
app.import('bower_components/moment-timezone/builds/moment-timezone-with-data.js');
app.import('bower_components/keymaster/keymaster.js');
app.import('bower_components/devicejs/lib/device.js');
app.import('bower_components/jquery-ui/jquery-ui.js');

View file

@ -27,7 +27,8 @@
"currentPath",
"currentRouteName",
"expect",
"fileUpload"
"fileUpload",
"moment"
],
"mocha": true,
"node": false,

View file

@ -0,0 +1,253 @@
/* jshint expr:true */
import {
describe,
it,
beforeEach,
afterEach
} from 'mocha';
import { expect } from 'chai';
import startApp from '../helpers/start-app';
import destroyApp from '../helpers/destroy-app';
import { invalidateSession, authenticateSession } from 'ghost/tests/helpers/ember-simple-auth';
import Mirage from 'ember-cli-mirage';
describe('Acceptance: Editor', function() {
let application;
beforeEach(function() {
application = startApp();
});
afterEach(function() {
destroyApp(application);
});
it('redirects to signin when not authenticated', function () {
invalidateSession(application);
visit('/editor/1');
andThen(function() {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
});
it('does not redirect to team page when authenticated as author', function () {
let role = server.create('role', {name: 'Author'});
let user = server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
visit('/editor/1');
andThen(() => {
expect(currentURL(), 'currentURL').to.equal('/editor/1');
});
});
it('does not redirect to team page when authenticated as editor', function () {
let role = server.create('role', {name: 'Editor'});
let user = server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
visit('/editor/1');
andThen(() => {
expect(currentURL(), 'currentURL').to.equal('/editor/1');
});
});
describe('when logged in', function () {
beforeEach(function () {
let role = server.create('role', {name: 'Administrator'});
let user = server.create('user', {roles: [role]});
server.loadFixtures();
return authenticateSession(application);
});
it('renders the editor correctly, PSM Publish Date and Save Button', function () {
let posts = server.createList('post', 3);
// post id 1 is a draft, checking for draft behaviour now
visit('/editor/1');
andThen(() => {
expect(currentURL(), 'currentURL')
.to.equal('/editor/1');
});
// should error, if the date input is in a wrong format
fillIn('input[name="post-setting-date"]', 'testdate');
triggerEvent('input[name="post-setting-date"]', 'blur');
andThen(() => {
expect(find('.ember-view.response').text().trim(), 'inline error response for invalid date')
.to.equal('Published Date must be a valid date with format: DD MMM YY @ HH:mm (e.g. 6 Dec 14 @ 15:00)');
});
// saves the post with the new date
fillIn('input[name="post-setting-date"]', '10 May 16 @ 10:00');
triggerEvent('input[name="post-setting-date"]', 'blur');
// saving
click('.view-header .btn.btn-sm.js-publish-button');
andThen(() => {
expect(find('input[name="post-setting-date"]').val(), 'date after saving')
.to.equal('10 May 16 @ 10:00');
});
// should not do anything if the input date is not different
fillIn('input[name="post-setting-date"]', '10 May 16 @ 10:00');
triggerEvent('input[name="post-setting-date"]', 'blur');
andThen(() => {
expect(find('input[name="post-setting-date"]').val(), 'date didn\'t change')
.to.equal('10 May 16 @ 10:00');
});
// checking the flow of the saving button for a draft
andThen(() => {
expect(find('.view-header .btn.btn-sm.js-publish-button').hasClass('btn-red'), 'no red button expected')
.to.be.false;
expect(find('.view-header .btn.btn-sm.js-publish-button').text().trim(), 'text in save button')
.to.equal('Save Draft');
expect(find('.post-save-draft').hasClass('active'), 'highlights the default active button state for a draft')
.to.be.true;
});
// click on publish now
click('.post-save-publish a');
andThen(() => {
expect(find('.post-save-publish').hasClass('active'), 'highlights the selected active button state')
.to.be.true;
expect(find('.view-header .btn.btn-sm.js-publish-button').hasClass('btn-red'), 'red button to change from draft to published')
.to.be.true;
expect(find('.view-header .btn.btn-sm.js-publish-button').text().trim(), 'text in save button after click on \'publish now\'')
.to.equal('Publish Now');
});
// Publish the post
click('.view-header .btn.btn-sm.js-publish-button');
andThen(() => {
expect(find('.view-header .btn.btn-sm.js-publish-button').text().trim(), 'text in save button after publishing')
.to.equal('Update Post');
expect(find('.post-save-publish').hasClass('active'), 'highlights the default active button state for a published post')
.to.be.true;
expect(find('.view-header .btn.btn-sm.js-publish-button').hasClass('btn-red'), 'no red button expected')
.to.be.false;
});
// post id 2 is a published post, checking for published post behaviour now
visit('/editor/2');
andThen(() => {
expect(currentURL(), 'currentURL').to.equal('/editor/2');
expect(find('input[name="post-setting-date"]').val()).to.equal('19 Dec 15 @ 16:25');
});
// should reset the date if the input field is blank
fillIn('input[name="post-setting-date"]', '');
triggerEvent('input[name="post-setting-date"]', 'blur');
andThen(() => {
expect(find('input[name="post-setting-date"]').val(), 'empty date input')
.to.equal('');
});
// saving
click('.view-header .btn.btn-sm.js-publish-button');
andThen(() => {
expect(find('input[name="post-setting-date"]').val(), 'date value restored')
.to.equal('19 Dec 15 @ 16:25');
});
// saves the post with a new date
fillIn('input[name="post-setting-date"]', '10 May 16 @ 10:00');
triggerEvent('input[name="post-setting-date"]', 'blur');
// saving
click('.view-header .btn.btn-sm.js-publish-button');
andThen(() => {
expect(find('input[name="post-setting-date"]').val(), 'new date after saving')
.to.equal('10 May 16 @ 10:00');
});
// should not do anything if the input date is not different
fillIn('input[name="post-setting-date"]', '10 May 16 @ 10:00');
triggerEvent('input[name="post-setting-date"]', 'blur');
andThen(() => {
expect(find('input[name="post-setting-date"]').val(), 'date didn\'t change')
.to.equal('10 May 16 @ 10:00');
});
andThen(() => {
expect(currentURL(), 'currentURL').to.equal('/editor/2');
expect(find('.view-header .btn.btn-sm.js-publish-button').hasClass('btn-red'), 'no red button expected')
.to.be.false;
expect(find('.view-header .btn.btn-sm.js-publish-button').text().trim(), 'text in save button for published post')
.to.equal('Update Post');
expect(find('.post-save-publish').hasClass('active'), 'highlights the default active button state for a published post')
.to.be.true;
});
// click on unpublish
click('.post-save-draft a');
andThen(() => {
expect(find('.post-save-draft').hasClass('active'), 'highlights the active button state for a draft')
.to.be.true;
expect(find('.view-header .btn.btn-sm.js-publish-button').hasClass('btn-red'), 'red button to change from published to draft')
.to.be.true;
expect(find('.view-header .btn.btn-sm.js-publish-button').text().trim(), 'text in save button for post to unpublish')
.to.equal('Unpublish');
});
// Unpublish the post
click('.view-header .btn.btn-sm.js-publish-button');
andThen(() => {
expect(find('.view-header .btn.btn-sm.js-publish-button').text().trim(), 'text in save button for draft')
.to.equal('Save Draft');
expect(find('.post-save-draft').hasClass('active'), 'highlights the default active button state for a draft')
.to.be.true;
expect(find('.view-header .btn.btn-sm.js-publish-button').hasClass('btn-red'), 'no red button expected')
.to.be.false;
});
// go to settings to change the timezone
visit('/settings/general');
andThen(() => {
expect(currentURL(), 'currentURL for settings')
.to.equal('/settings/general');
expect(find('#activeTimezone option:selected').text().trim(), 'default timezone')
.to.equal('(GMT) Greenwich Mean Time : Dublin, Edinburgh, London');
// select a new timezone
find('#activeTimezone option[value="Pacific/Auckland"]').prop('selected', true);
});
triggerEvent('#activeTimezone select', 'change');
// save the settings
click('.view-header .btn.btn-blue');
andThen(() => {
expect(find('#activeTimezone option:selected').text().trim(), 'new timezone after saving')
.to.equal('(GMT +13:00) Auckland, Wellington');
});
// and now go back to the editor
visit('/editor/2');
andThen(() => {
expect(currentURL(), 'currentURL in editor')
.to.equal('/editor/2');
expect(find('input[name="post-setting-date"]').val(), 'date with timezone offset')
.to.equal('10 May 16 @ 21:00');
});
});
});
});

View file

@ -47,9 +47,20 @@ describe('Acceptance: Posts - Post', function() {
// if we're in "desktop" size, we should redirect and highlight
if (find('.content-preview:visible').length) {
expect(currentURL(), 'currentURL').to.equal(`/${posts[0].id}`);
expect(find('.posts-list li').first().hasClass('active'), 'highlights latest post').to.be.true;
// expect(find('.posts-list li').first().hasClass('active'), 'highlights latest post').to.be.true;
}
});
// check if we can edit the post
click('.post-edit');
andThen(() => {
expect(currentURL(), 'currentURL to editor')
.to.equal('/editor/1');
});
// TODO: test the right order of the listes posts
// and fix the faker import to ensure correct ordering
});
it('redirects to 404 when post does not exist', function () {

View file

@ -132,6 +132,29 @@ describe('Acceptance: Settings - General', function () {
expect(find('#activeTheme select option').length, 'available themes').to.equal(1);
expect(find('#activeTheme select option').text().trim()).to.equal('Blog - 1.0');
});
});
it('renders timezone selector correctly', function () {
visit('/settings/general');
andThen(() => {
expect(currentURL(), 'currentURL').to.equal('/settings/general');
expect(find('#activeTimezone select option').length, 'available timezones').to.equal(65);
expect(find('#activeTimezone option:selected').text().trim()).to.equal('(GMT) Greenwich Mean Time : Dublin, Edinburgh, London');
find('#activeTimezone option[value="Africa/Cairo"]').prop('selected', true);
});
triggerEvent('#activeTimezone select', 'change');
click('.view-header .btn.btn-blue');
andThen(() => {
expect(find('#activeTimezone option:selected').text().trim()).to.equal('(GMT +2:00) Cairo, Egypt');
});
});
it('handles private blog settings correctly', function () {
visit('/settings/general');
// handles private blog settings correctly
andThen(() => {

View file

@ -174,7 +174,6 @@ describe('Acceptance: Settings - Tags', function () {
// trigger save
fillIn('.tag-settings-pane input[name="name"]', 'New Name');
triggerEvent('.tag-settings-pane input[name="name"]', 'blur');
andThen(() => {
// check we update with the data returned from the server
expect(find('.settings-tags .settings-tag:last .tag-title').text(), 'tag list updates on save')

View file

@ -0,0 +1,26 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import Ember from 'ember';
const {run} = Ember;
describeComponent(
'gh-datetime-input',
'Integration: Component: gh-datetime-input',
{
integration: true
},
function () {
it('renders', function () {
// renders the component on the page
// this.render(hbs`{{gh-datetime-input}}`);
//
// expect(this.$('.ember-text-field gh-input')).to.have.length(1);
});
}
);

View file

@ -0,0 +1,63 @@
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
import Pretender from 'pretender';
import Ember from 'ember';
function stubAvailableTimezonesEndpoint(server) {
server.get('/ghost/api/v0.1/configuration/timezones', function (request) {
return [
200,
{'Content-Type': 'application/json'},
JSON.stringify({
configuration: [{
timezones: [{
label: '(GMT -11:00) Midway Island, Samoa',
name: 'Pacific/Pago_Pago',
offset: -660
},
{
label: '(GMT) Greenwich Mean Time : Dublin, Edinburgh, London',
name: 'Europe/Dublin',
offset: 0
}]
}]
})
];
});
}
describeModule(
'service:config',
'Integration: Service: config',
{
integration: true
},
function () {
let server;
beforeEach(function () {
server = new Pretender();
});
afterEach(function () {
server.shutdown();
});
it('returns a list of timezones in the expected format', function (done) {
let service = this.subject();
stubAvailableTimezonesEndpoint(server);
service.get('availableTimezones').then(function (timezones) {
expect(timezones.length).to.equal(2);
expect(timezones[0].name).to.equal('Pacific/Pago_Pago');
expect(timezones[0].label).to.equal('(GMT -11:00) Midway Island, Samoa');
expect(timezones[1].name).to.equal('Europe/Dublin');
expect(timezones[1].label).to.equal('(GMT) Greenwich Mean Time : Dublin, Edinburgh, London');
done();
});
});
}
);

View file

@ -0,0 +1,52 @@
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
import Pretender from 'pretender';
import Ember from 'ember';
function settingsStub(server) {
let settings = [
{
id: '1',
type: 'blog',
key: 'activeTimezone',
value: 'Africa/Cairo'
}
];
server.get('/ghost/api/v0.1/settings/', function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({settings})];
});
}
describeModule(
'service:time-zone',
'Integration: Service: time-zone',
{
integration: true
},
function () {
let server;
beforeEach(function () {
server = new Pretender();
});
afterEach(function () {
server.shutdown();
});
it('should return a timezone offset', function (done) {
let service = this.subject();
settingsStub(server);
service.get('offset').then(function (offset) {
expect(offset).to.equal('Africa/Cairo');
done();
});
});
}
);

View file

@ -4,6 +4,7 @@ import {
describeModule,
it
} from 'ember-mocha';
import sinon from 'sinon';
const {run} = Ember;
@ -15,7 +16,7 @@ describeModule(
'controller:post-settings-menu',
'Unit: Controller: post-settings-menu',
{
needs: ['controller:application', 'service:notifications', 'service:slug-generator']
needs: ['controller:application', 'service:notifications', 'service:slug-generator', 'service:timeZone']
},
function () {

View file

@ -22,7 +22,7 @@ describeModule(
'Unit: Controller: settings/navigation',
{
// Specify the other units that are required for this test.
needs: ['service:config', 'service:notifications', 'model:navigation-item']
needs: ['service:config', 'service:notifications', 'model:navigation-item', 'service:ajax', 'service:ghostPaths']
},
function () {
it('blogUrl: captures config and ensures trailing slash', function () {

View file

@ -0,0 +1,25 @@
/* jshint expr:true */
import {expect} from 'chai';
import {
describe,
it
} from 'mocha';
import {
timeAgo
} from 'ghost/helpers/gh-format-timeago';
import sinon from 'sinon';
describe('Unit: Helper: gh-format-timeago', function () {
let mockDate;
let utcStub;
it('calculates the correct time difference', function () {
mockDate = '2016-05-30T10:00:00.000Z';
utcStub = sinon.stub(moment, 'utc').returns('2016-05-30T11:00:00.000Z');
let result = timeAgo([mockDate]);
expect(result).to.be.equal('an hour ago');
moment.utc.restore();
});
});

View file

@ -0,0 +1,6 @@
import {formatDate, parseDateString} from 'ghost/utils/date-formatting';
describe('Unit: Util: date-formatting', function () {
it('parses a string into a moment');
it('formats a date or moment');
});