Renaming date properties to contain UTC

closes TryGhost/Ghost#6985

- renames all date properties to end with `UTC`:
	- `publishedAt` in `post` model
	- `createdAt` in `post`, `role`, `subscriber`, `tag` and `user` model
	- `updatedAt` in `post`, `role`, `subscriber`, `tag` and `user` model
	- `unsubscribedAt` in `subscriber` model
	- `lastLogin` in `user` model
- adds `attrs` transforms in matching serializers `post`, `tag` and `user`
- new serializers files for `subscribers` and `role` to add `attr` transforms
- adds unit tests for all serializers
- use two variables in `post-settings-menu` controller to handle blog-timezone adjusted date as well as UTC date
This commit is contained in:
Aileen Nowak 2016-06-14 10:11:59 +02:00
parent 0028be64aa
commit d90ed28940
27 changed files with 235 additions and 54 deletions

View File

@ -24,9 +24,9 @@ export default Component.extend({
return htmlSafe(`background-image: url(${url})`); return htmlSafe(`background-image: url(${url})`);
}), }),
lastLogin: computed('user.lastLogin', function () { lastLoginUTC: computed('user.lastLoginUTC', function () {
let lastLogin = this.get('user.lastLogin'); let lastLoginUTC = this.get('user.lastLoginUTC');
return lastLogin ? moment(lastLogin).fromNow() : '(Never)'; return lastLoginUTC ? moment(lastLoginUTC).fromNow() : '(Never)';
}) })
}); });

View File

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

View File

@ -290,11 +290,11 @@ export default Controller.extend(SettingsMenuMixin, {
* Action sent by post settings menu view. * Action sent by post settings menu view.
* (#1351) * (#1351)
*/ */
setPublishedAt(userInput) { setPublishedAtUTC(userInput) {
if (!userInput) { if (!userInput) {
// Clear out the publishedAt field for a draft // Clear out the publishedAtUTC field for a draft
if (this.get('model.isDraft')) { if (this.get('model.isDraft')) {
this.set('model.publishedAt', null); this.set('model.publishedAtUTC', null);
} }
return; return;
} }
@ -303,8 +303,9 @@ export default Controller.extend(SettingsMenuMixin, {
// we have to work with the timezone offset which we get from the timeZone Service. // we have to work with the timezone offset which we get from the timeZone Service.
this.get('timeZone.blogTimezone').then((blogTimezone) => { this.get('timeZone.blogTimezone').then((blogTimezone) => {
let newPublishedAt = parseDateString(userInput, blogTimezone); let newPublishedAt = parseDateString(userInput, blogTimezone);
let publishedAt = moment.utc(this.get('model.publishedAt')); let publishedAtUTC = moment.utc(this.get('model.publishedAtUTC'));
let errMessage = ''; let errMessage = '';
let newPublishedAtUTC;
// Clear previous errors // Clear previous errors
this.get('model.errors').remove('post-setting-date'); this.get('model.errors').remove('post-setting-date');
@ -316,13 +317,13 @@ export default Controller.extend(SettingsMenuMixin, {
} }
// Date is a valid date, so now make it UTC // Date is a valid date, so now make it UTC
newPublishedAt = moment.utc(newPublishedAt); newPublishedAtUTC = moment.utc(newPublishedAt);
if (newPublishedAt.diff(moment.utc(new Date()), 'hours', true) > 0) { if (newPublishedAtUTC.diff(moment.utc(new Date()), 'hours', true) > 0) {
// We have to check that the time from now is not shorter than 2 minutes, // We have to check that the time from now is not shorter than 2 minutes,
// otherwise we'll have issues with the serverside scheduling procedure // otherwise we'll have issues with the serverside scheduling procedure
if (newPublishedAt.diff(moment.utc(new Date()), 'minutes', true) < 2) { if (newPublishedAtUTC.diff(moment.utc(new Date()), 'minutes', true) < 2) {
errMessage = 'Must be at least 2 minutes from now.'; errMessage = 'Must be at least 2 minutes from now.';
} else { } else {
// in case the post is already published and the user sets the date // in case the post is already published and the user sets the date
@ -351,12 +352,12 @@ export default Controller.extend(SettingsMenuMixin, {
} }
// Do nothing if the user didn't actually change the date // Do nothing if the user didn't actually change the date
if (publishedAt && publishedAt.isSame(newPublishedAt)) { if (publishedAtUTC && publishedAtUTC.isSame(newPublishedAtUTC)) {
return; return;
} }
// Validation complete // Validation complete
this.set('model.publishedAt', newPublishedAt); this.set('model.publishedAtUTC', newPublishedAtUTC);
// If this is a new post. Don't save the model. Defer the save // If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button // to the user pressing the save button
@ -440,7 +441,7 @@ export default Controller.extend(SettingsMenuMixin, {
}, },
resetPubDate() { resetPubDate() {
this.set('publishedAtValue', ''); this.set('publishedAtUTCValue', '');
}, },
closeNavMenu() { closeNavMenu() {

View File

@ -10,12 +10,12 @@ const {equal} = computed;
// a custom sort function is needed in order to sort the posts list the same way the server would: // a custom sort function is needed in order to sort the posts list the same way the server would:
// status: scheduled, draft, published // status: scheduled, draft, published
// publishedAt: DESC // publishedAtUTC: DESC
// updatedAt: DESC // updatedAtUTC: DESC
// id: DESC // id: DESC
function comparator(item1, item2) { function comparator(item1, item2) {
let updated1 = item1.get('updatedAt'); let updated1 = item1.get('updatedAtUTC');
let updated2 = item2.get('updatedAt'); let updated2 = item2.get('updatedAtUTC');
let idResult, let idResult,
publishedAtResult, publishedAtResult,
statusResult, statusResult,
@ -85,8 +85,8 @@ function statusCompare(item1, item2) {
} }
function publishedAtCompare(item1, item2) { function publishedAtCompare(item1, item2) {
let published1 = item1.get('publishedAt'); let published1 = item1.get('publishedAtUTC');
let published2 = item2.get('publishedAt'); let published2 = item2.get('publishedAtUTC');
if (!published1 && !published2) { if (!published1 && !published2) {
return 0; return 0;
@ -112,7 +112,7 @@ export default Controller.extend({
postListFocused: equal('keyboardFocus', 'postList'), postListFocused: equal('keyboardFocus', 'postList'),
postContentFocused: equal('keyboardFocus', 'postContent'), postContentFocused: equal('keyboardFocus', 'postContent'),
sortedPosts: computed('model.@each.status', 'model.@each.publishedAt', 'model.@each.isNew', 'model.@each.updatedAt', function () { sortedPosts: computed('model.@each.status', 'model.@each.publishedAtUTC', 'model.@each.isNew', 'model.@each.updatedAtUTC', function () {
let postsArray = this.get('model').toArray(); let postsArray = this.get('model').toArray();
return postsArray.sort(comparator); return postsArray.sort(comparator);

View File

@ -57,7 +57,7 @@ export default Controller.extend(PaginationMixin, {
ascending: direction === 'asc' ascending: direction === 'asc'
}, { }, {
label: 'Subscription Date', label: 'Subscription Date',
valuePath: 'createdAt', valuePath: 'createdAtUTC',
format(value) { format(value) {
return value.format('MMMM DD, YYYY'); return value.format('MMMM DD, YYYY');
}, },
@ -111,7 +111,7 @@ export default Controller.extend(PaginationMixin, {
if (column.sorted) { if (column.sorted) {
this.setProperties({ this.setProperties({
order: column.get('valuePath').trim().underscore(), order: column.get('valuePath').trim().replace(/UTC$/, '').underscore(),
direction: column.ascending ? 'asc' : 'desc' direction: column.ascending ? 'asc' : 'desc'
}); });
table.setRows([]); table.setRows([]);

View File

@ -86,9 +86,9 @@ export default Mixin.create({
// countdown timer to show the time left until publish time for a scheduled post // countdown timer to show the time left until publish time for a scheduled post
// starts 15 minutes before scheduled time // starts 15 minutes before scheduled time
scheduleCountdown: computed('model.status', 'clock.second', 'model.publishedAt', 'model.timeScheduled', function () { scheduleCountdown: computed('model.status', 'clock.second', 'model.publishedAtUTC', 'model.timeScheduled', function () {
let status = this.get('model.status'); let status = this.get('model.status');
let publishTime = this.get('model.publishedAt'); let publishTime = this.get('model.publishedAtUTC');
this.get('clock.second'); this.get('clock.second');
@ -104,9 +104,9 @@ export default Mixin.create({
// dropdown menu, the save button gets the status 'isDangerous' to turn red and will only have the option to unschedule the post // dropdown menu, the save button gets the status 'isDangerous' to turn red and will only have the option to unschedule the post
// 2. when the scheduled time is reached we use a helper 'scheduledWillPublish' to pretend we're already dealing with a published post. // 2. when the scheduled time is reached we use a helper 'scheduledWillPublish' to pretend we're already dealing with a published post.
// This will take effect on the save button menu, the workflows and existing conditionals. // This will take effect on the save button menu, the workflows and existing conditionals.
statusFreeze: computed('model.status', 'clock.second', 'model.publishedAt', 'model.timeScheduled', function () { statusFreeze: computed('model.status', 'clock.second', 'model.publishedAtUTC', 'model.timeScheduled', function () {
let status = this.get('model.status'); let status = this.get('model.status');
let publishTime = this.get('model.publishedAt'); let publishTime = this.get('model.publishedAtUTC');
this.get('clock.second'); this.get('clock.second');

View File

@ -28,11 +28,11 @@ export default Model.extend(ValidationEngine, {
metaDescription: attr('string'), metaDescription: attr('string'),
author: belongsTo('user', {async: true}), author: belongsTo('user', {async: true}),
authorId: attr('number'), authorId: attr('number'),
updatedAt: attr('moment-utc'), updatedAtUTC: attr('moment-utc'),
updatedBy: attr(), updatedBy: attr(),
publishedAt: attr('moment-utc'), publishedAtUTC: attr('moment-utc'),
publishedBy: belongsTo('user', {async: true}), publishedBy: belongsTo('user', {async: true}),
createdAt: attr('moment-utc'), createdAtUTC: attr('moment-utc'),
createdBy: attr(), createdBy: attr(),
tags: hasMany('tag', { tags: hasMany('tag', {
embedded: 'always', embedded: 'always',
@ -74,11 +74,11 @@ export default Model.extend(ValidationEngine, {
// TODO: move this into gh-posts-list-item component // TODO: move this into gh-posts-list-item component
// Checks every second, if we reached the scheduled date // Checks every second, if we reached the scheduled date
timeScheduled: computed('publishedAt', 'clock.second', function () { timeScheduled: computed('publishedAtUTC', 'clock.second', function () {
let publishedAt = this.get('publishedAt') || moment.utc(new Date()); let publishedAtUTC = this.get('publishedAtUTC') || moment.utc(new Date());
this.get('clock.second'); this.get('clock.second');
return publishedAt.diff(moment.utc(new Date()), 'hours', true) > 0 ? true : false; return publishedAtUTC.diff(moment.utc(new Date()), 'hours', true) > 0 ? true : false;
}), }),
// remove client-generated tags, which have `id: null`. // remove client-generated tags, which have `id: null`.

View File

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

View File

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

View File

@ -24,8 +24,8 @@ export default Model.extend(ValidationEngine, {
metaDescription: attr('string'), metaDescription: attr('string'),
image: attr('string'), image: attr('string'),
visibility: attr('string', {defaultValue: 'public'}), visibility: attr('string', {defaultValue: 'public'}),
createdAt: attr('moment-utc'), createdAtUTC: attr('moment-utc'),
updatedAt: attr('moment-utc'), updatedAtUTC: attr('moment-utc'),
createdBy: attr(), createdBy: attr(),
updatedBy: attr(), updatedBy: attr(),
count: attr('raw'), count: attr('raw'),

View File

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

View File

@ -10,7 +10,10 @@ const {
export default ApplicationSerializer.extend(EmbeddedRecordsMixin, { export default ApplicationSerializer.extend(EmbeddedRecordsMixin, {
// settings for the EmbeddedRecordsMixin. // settings for the EmbeddedRecordsMixin.
attrs: { attrs: {
tags: {embedded: 'always'} tags: {embedded: 'always'},
publishedAtUTC: {key: 'published_at'},
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'}
}, },
normalize(model, hash, prop) { normalize(model, hash, prop) {

View File

@ -0,0 +1,8 @@
import ApplicationSerializer from 'ghost-admin/serializers/application';
export default ApplicationSerializer.extend({
attrs: {
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'}
}
});

View File

@ -0,0 +1,9 @@
import ApplicationSerializer from 'ghost-admin/serializers/application';
export default ApplicationSerializer.extend({
attrs: {
unsubscribedAtUTC: {key: 'unsubscribed_at'},
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'}
}
});

View File

@ -7,6 +7,11 @@ const {
} = Ember; } = Ember;
export default ApplicationSerializer.extend({ export default ApplicationSerializer.extend({
attrs: {
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'}
},
serializeIntoHash(hash, type, record, options) { serializeIntoHash(hash, type, record, options) {
options = options || {}; options = options || {};
options.includeId = true; options.includeId = true;

View File

@ -8,7 +8,10 @@ const {
export default ApplicationSerializer.extend(EmbeddedRecordsMixin, { export default ApplicationSerializer.extend(EmbeddedRecordsMixin, {
attrs: { attrs: {
roles: {embedded: 'always'} roles: {embedded: 'always'},
lastLoginUTC: {key: 'last_login'},
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'}
}, },
extractSingle(store, primaryType, payload) { extractSingle(store, primaryType, payload) {

View File

@ -7,7 +7,7 @@
{{user.name}} {{user.name}}
</span> </span>
<br> <br>
<span class="description">Last seen: {{component.lastLogin}}</span> <span class="description">Last seen: {{component.lastLoginUTC}}</span>
</div> </div>
<aside class="user-list-item-aside"> <aside class="user-list-item-aside">
{{#unless session.user.isAuthor}} {{#unless session.user.isAuthor}}

View File

@ -4,7 +4,7 @@
{{gh-trim-focus-input type="text" id="entry-title" placeholder="Your Post Title" value=model.titleScratch tabindex="1" focus=shouldFocusTitle focus-out="updateTitle" }} {{gh-trim-focus-input type="text" id="entry-title" placeholder="Your Post Title" value=model.titleScratch tabindex="1" focus=shouldFocusTitle focus-out="updateTitle" }}
{{/gh-view-title}} {{/gh-view-title}}
{{#if scheduleCountdown}} {{#if scheduleCountdown}}
<time datetime="{{post.publishedAt}}" class="gh-notification gh-notification-schedule"> <time datetime="{{post.publishedAtUTC}}" class="gh-notification gh-notification-schedule">
Post will be published {{scheduleCountdown}}. Post will be published {{scheduleCountdown}}.
</time> </time>
{{/if}} {{/if}}

View File

@ -36,8 +36,8 @@
{{else}} {{else}}
<label for="post-setting-date">Publish Date</label> <label for="post-setting-date">Publish Date</label>
{{/if}} {{/if}}
{{gh-datetime-input value=model.publishedAt {{gh-datetime-input value=model.publishedAtUTC
update=(action "setPublishedAt") update=(action "setPublishedAtUTC")
inputClass="post-setting-date" inputClass="post-setting-date"
inputId="post-setting-date" inputId="post-setting-date"
inputName="post-setting-date"}} inputName="post-setting-date"}}

View File

@ -24,14 +24,16 @@
{{#if post.page}} {{#if post.page}}
<span class="page">Page</span> <span class="page">Page</span>
{{else}} {{else}}
<time datetime="{{post.publishedAt}}" class="date published">{{gh-format-timeago post.publishedAt}} <time datetime="{{post.publishedAtUTC}}" class="date published">
Published {{gh-format-timeago post.publishedAtUTC}}
</time> </time>
{{/if}} {{/if}}
{{else}} {{else}}
{{#if component.isScheduled}} {{#if component.isScheduled}}
<span class="scheduled">Scheduled</span> <span class="scheduled">Scheduled</span>
<span>&ndash; <span>&ndash;
<time datetime="{{post.publishedAt}}" class-="date scheduled">{{gh-format-time-scheduled post.publishedAt component.blogTimezone}} <time datetime="{{post.publishedAtUTC}}" class-="date scheduled">
{{gh-format-time-scheduled post.publishedAtUTC component.blogTimezone}}
</time> </time>
</span> </span>
{{else}} {{else}}

View File

@ -38,7 +38,7 @@
</span> </span>
{{else}} {{else}}
<span class="description"> <span class="description">
Invitation sent: {{component.createdAt}} Invitation sent: {{component.createdAtUTC}}
</span> </span>
{{/if}} {{/if}}
</div> </div>

View File

@ -0,0 +1,23 @@
/* jshint expr:true */
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
describeModel(
'post',
'Unit:Serializer: post',
{
// Specify the other units that are required for this test.
needs: ['transform:moment-utc', 'model:user', 'model:tag']
},
function() {
// Replace this with your real tests.
it('serializes records', function() {
let record = this.subject();
let serializedRecord = record.serialize();
expect(record).to.be.ok;
});
}
);

View File

@ -0,0 +1,23 @@
/* jshint expr:true */
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
describeModel(
'role',
'Unit:Serializer: role',
{
// Specify the other units that are required for this test.
needs: ['transform:moment-utc']
},
function() {
// Replace this with your real tests.
it('serializes records', function() {
let record = this.subject();
let serializedRecord = record.serialize();
expect(record).to.be.ok;
});
}
);

View File

@ -0,0 +1,29 @@
/* jshint expr:true */
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
describeModel(
'setting',
'Unit:Serializer: setting',
{
// Specify the other units that are required for this test.
needs: [
'transform:moment-utc',
'transform:facebook-url-user',
'transform:twitter-url-user',
'transform:navigation-settings',
'transform:slack-settings'
]
},
function() {
// Replace this with your real tests.
it('serializes records', function() {
let record = this.subject();
let serializedRecord = record.serialize();
expect(record).to.be.ok;
});
}
);

View File

@ -0,0 +1,23 @@
/* jshint expr:true */
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
describeModel(
'subscriber',
'Unit:Serializer: subscriber',
{
// Specify the other units that are required for this test.
needs: ['model:post', 'transform:moment-utc']
},
function() {
// Replace this with your real tests.
it('serializes records', function() {
let record = this.subject();
let serializedRecord = record.serialize();
expect(record).to.be.ok;
});
}
);

View File

@ -0,0 +1,23 @@
/* jshint expr:true */
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
describeModel(
'tag',
'Unit:Serializer: tag',
{
// Specify the other units that are required for this test.
needs: ['transform:moment-utc', 'transform:raw']
},
function() {
// Replace this with your real tests.
it('serializes records', function() {
let record = this.subject();
let serializedRecord = record.serialize();
expect(record).to.be.ok;
});
}
);

View File

@ -0,0 +1,29 @@
/* jshint expr:true */
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
describeModel(
'user',
'Unit:Serializer: user',
{
// Specify the other units that are required for this test.
needs: [
'transform:moment-utc',
'transform:raw',
'transform:facebook-url-user',
'transform:twitter-url-user',
'model:role'
]
},
function() {
// Replace this with your real tests.
it('serializes records', function() {
let record = this.subject();
let serializedRecord = record.serialize();
expect(record).to.be.ok;
});
}
);