Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Datetime formats #4539

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
44 changes: 26 additions & 18 deletions fields/types/datetime/DatetimeField.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,33 @@ module.exports = Field.create({

focusTargetRef: 'dateInput',

// default input formats
dateInputFormat: 'YYYY-MM-DD',
timeInputFormat: 'h:mm:ss a',
tzOffsetInputFormat: 'Z',

// parse formats (duplicated from lib/fieldTypes/datetime.js)
parseFormats: ['YYYY-MM-DD', 'YYYY-MM-DD h:m:s a', 'YYYY-MM-DD h:m a', 'YYYY-MM-DD H:m:s', 'YYYY-MM-DD H:m'],

getInitialState () {
return {
dateValue: this.props.value && this.moment(this.props.value).format(this.dateInputFormat),
timeValue: this.props.value && this.moment(this.props.value).format(this.timeInputFormat),
tzOffsetValue: this.props.value ? this.moment(this.props.value).format(this.tzOffsetInputFormat) : this.moment().format(this.tzOffsetInputFormat),
dateValue: this.props.value && this.moment(this.props.value).format(this.getDateInputFormat()),
timeValue: this.props.value && this.moment(this.props.value).format(this.getTimeInputFormat()),
tzOffsetValue: this.props.value ? this.moment(this.props.value).format(this.getTzInputFormat()) : this.moment().format(this.getTzInputFormat()),
};
},

getDateInputFormat () {
return this.props.formatDateString;
},

getTimeInputFormat () {
return this.props.formatTimeString;
},

getTzInputFormat () {
return this.props.formatTzString;
},

getDefaultProps () {
return {
formatString: 'Do MMM YYYY, h:mm:ss a',
formatDateString: 'YYYY-MM-DD',
formatTimeString: 'h:mm:ss a',
};
},

Expand All @@ -54,22 +62,22 @@ module.exports = Field.create({

// TODO: Move format() so we can share with server-side code
format (value, format) {
format = format || this.dateInputFormat + ' ' + this.timeInputFormat;
format = format || this.getDateInputFormat() + ' ' + this.getTimeInputFormat();
return value ? this.moment(value).format(format) : '';
},

handleChange (dateValue, timeValue, tzOffsetValue) {
var value = dateValue + ' ' + timeValue;
var datetimeFormat = this.dateInputFormat + ' ' + this.timeInputFormat;
var datetimeFormat = this.getDateInputFormat() + ' ' + this.getTimeInputFormat();

// if the change included a timezone offset, include that in the calculation (so NOW works correctly during DST changes)
if (typeof tzOffsetValue !== 'undefined') {
value += ' ' + tzOffsetValue;
datetimeFormat += ' ' + this.tzOffsetInputFormat;
datetimeFormat += ' ' + this.getTzInputFormat();
}
// if not, calculate the timezone offset based on the date (respect different DST values)
else {
this.setState({ tzOffsetValue: this.moment(value, datetimeFormat).format(this.tzOffsetInputFormat) });
this.setState({ tzOffsetValue: this.moment(value, datetimeFormat).format(this.getTzInputFormat()) });
}

this.props.onChange({
Expand All @@ -89,9 +97,9 @@ module.exports = Field.create({
},

setNow () {
var dateValue = this.moment().format(this.dateInputFormat);
var timeValue = this.moment().format(this.timeInputFormat);
var tzOffsetValue = this.moment().format(this.tzOffsetInputFormat);
var dateValue = this.moment().format(this.getDateInputFormat());
var timeValue = this.moment().format(this.getTimeInputFormat());
var tzOffsetValue = this.moment().format(this.getTzInputFormat());
this.setState({
dateValue: dateValue,
timeValue: timeValue,
Expand All @@ -113,7 +121,7 @@ module.exports = Field.create({
<Group>
<Section grow>
<DateInput
format={this.dateInputFormat}
format={this.getDateInputFormat()}
name={this.getInputName(this.props.paths.date)}
onChange={this.dateChanged}
ref="dateInput"
Expand All @@ -125,7 +133,7 @@ module.exports = Field.create({
autoComplete="off"
name={this.getInputName(this.props.paths.time)}
onChange={this.timeChanged}
placeholder="HH:MM:SS am/pm"
placeholder={this.getTimeInputFormat()}
value={this.state.timeValue}
/>
</Section>
Expand Down
47 changes: 40 additions & 7 deletions fields/types/datetime/DatetimeType.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ var utils = require('keystone-utils');

// ISO_8601 is needed for the automatically created createdAt and updatedAt fields
var parseFormats = ['YYYY-MM-DD', 'YYYY-MM-DD h:m:s a', 'YYYY-MM-DD h:m a', 'YYYY-MM-DD H:m:s', 'YYYY-MM-DD H:m', 'YYYY-MM-DD h:mm:s a Z', moment.ISO_8601];

/**
* DateTime FieldType Constructor
* @extends Field
Expand All @@ -15,21 +16,52 @@ function datetime (list, path, options) {
this._nativeType = Date;
this._underscoreMethods = ['format', 'moment', 'parse'];
this._fixedSize = 'full';
this._properties = ['formatString', 'isUTC'];
this._properties = ['formatDateString', 'formatTimeString', 'formatTzString', 'isUTC'];
this.typeDescription = 'date and time';
this.parseFormatString = options.parseFormat || parseFormats;
this.formatString = (options.format === false) ? false : (options.format || 'YYYY-MM-DD h:mm:ss a');
this.parseFormatString = parseFormats.slice(0);
this.formatDateString = (options.dateFormat === false) ? false : (options.dateFormat || 'YYYY-MM-DD');
this.formatTimeString = (options.timeFormat === false) ? false : (options.timeFormat || 'h:mm:ss a');
this.formatTzString = (options.tzFormat === false) ? false : (options.tzFormat || 'Z');
this.isUTC = options.utc || false;
if (this.formatString && typeof this.formatString !== 'string') {
throw new Error('FieldType.DateTime: options.format must be a string.');
if (this.formatDateString && typeof this.formatDateString !== 'string') {
throw new Error('FieldType.DateTime: options.dateFormat must be a string.');
}
if (this.formatTimeString && typeof this.formatTimeString !== 'string') {
throw new Error('FieldType.DateTime: options.timeFormat must be a string.');
}
if (this.formatTzString && typeof this.formatTzString !== 'string') {
throw new Error('FieldType.DateTime: options.tzFormat must be a string.');
}

// For backward compatibility, if parseFormat option is specified, add it to the parseFormatString array
if (options.parseFormat) {
if (Array.isArray(options.parseFormat)) {
this.parseFormatString = this.parseFormatString.concat(options.parseFormat);
} else if (typeof options.parseFormat === 'string') {
this.parseFormatString.push(options.parseFormat);
}
}

// If a custom format is specified by the user, it should be added to the parseFormatString array to ensure
// successful validation
if (this.formatDateString || this.formatTimeString || this.formatTzString) {
let customFormat = [];

if (this.formatDateString) customFormat.push(this.formatDateString);
if (this.formatTimeString) customFormat.push(this.formatTimeString);
if (this.formatTzString) customFormat.push(this.formatTzString);

this.parseFormatString.push(customFormat.join(' '));
}

datetime.super_.call(this, list, path, options);
this.paths = {
date: this.path + '_date',
time: this.path + '_time',
tzOffset: this.path + '_tzOffset',
};
}

datetime.properName = 'Datetime';
util.inherits(datetime, FieldType);

Expand Down Expand Up @@ -57,13 +89,13 @@ datetime.prototype.getInputFromData = function (data) {
return this.getValueFromData(data);
};


datetime.prototype.validateRequiredInput = function (item, data, callback) {
var value = this.getInputFromData(data);
var result = !!value;
if (value === undefined && item.get(this.path)) {
result = true;
}

utils.defer(callback, result);
};

Expand All @@ -79,6 +111,7 @@ datetime.prototype.validateInput = function (data, callback) {
if (value) {
result = this.parse(value, this.parseFormatString, true).isValid();
}

utils.defer(callback, result);
};

Expand Down Expand Up @@ -114,7 +147,7 @@ datetime.prototype.updateItem = function (item, data, callback) {
if (!item.get(this.path) || !newValue.isSame(item.get(this.path))) {
item.set(this.path, newValue.toDate());
}
// If it's null or empty string, clear it out
// If it's null or empty string, clear it out
} else {
item.set(this.path, null);
}
Expand Down
26 changes: 21 additions & 5 deletions fields/types/datetime/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Internally uses [moment.js](http://momentjs.com/) to manage date parsing, format

If the `utc` option is set, `moment(value).utc()` is called in all methods to enable moment's utc mode.

String parsing with moment will be done using the `parseFormat` option, which defaults to `"'YYYY-MM-DD h:m:s a'"`.
String parsing with moment will be done using the `dateFormat`, `timeFormat` and `tzFormat` options which default to
`'YYYY-MM-DD'`, `'h:mm:ss a'` and `'Z'` respectively.

## Example

Expand All @@ -19,18 +20,33 @@ String parsing with moment will be done using the `parseFormat` option, which de

* `parseFormat` `string`

The default pattern to read in values with. Defaults to an array of values to try:
The default pattern to read in values with. This pattern is added to the below array of default values along with the
format specified in the `dateFormat`, `timeFormat` and `tzFormat` options.

This option option need only be specified if you require format(s) that don't appear below and don't match the display
format.

`['YYYY-MM-DD', 'YYYY-MM-DD h:m:s a', 'YYYY-MM-DD h:m a', 'YYYY-MM-DD H:m:s', 'YYYY-MM-DD H:m', 'YYYY-MM-DD h:mm:s a Z', moment.ISO_8601]`

* `dateFormat` `string`

The default format pattern to use when displaying the date portion of the value. Defaults to `YYYY-MM-DD`

See the [momentjs format docs](http://momentjs.com/docs/#/displaying/format/) for information on the supported formats and options.

* `timeFormat` `string`

The default format pattern to use when displaying the time portion of the value. Defaults to `h:mm:ss a`

See the [momentjs format docs](http://momentjs.com/docs/#/displaying/format/) for information on the supported formats and options.

* `format` `string`
* `dateFormat` `string`

The default format pattern to use when display the information. Defaults to `Do MMM YYYY hh:mm:ss a`
The default format pattern to use when displaying the timezone offset portion of the value. Defaults to `Z`

See the [momentjs format docs](http://momentjs.com/docs/#/displaying/format/) for information on the supported formats and options.

`utc` `boolean`
* `utc` `boolean`

Sets whether the string should be displayed in the admin UI in UTC time or local time. Defaults to `false`.

Expand Down
58 changes: 53 additions & 5 deletions fields/types/datetime/test/type.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ var DatetimeType = require('../DatetimeType');
exports.initList = function (List) {
List.add({
datetime: DatetimeType,
customDisplayFormat: {
type: DatetimeType,
dateFormat: 'D MMM YYYY',
timeFormat: 'HH:mm',
},
customFormat: {
type: DatetimeType,
parseFormat: 'DD.MM.YY h:m a',
Expand All @@ -18,13 +23,47 @@ exports.initList = function (List) {

exports.testFieldType = function (List) {
describe('invalid options', function () {
it('should throw when format is not a string', function (done) {
it('should throw when dateFormat is not a string', function (done) {
try {
List.add({
invalidFormatOption: { type: DatetimeType, dateFormat: /aregexp/ },
});

// If control reaches here, exception has not been thrown. Test failed.
demand(true).not.eql(true);
done();
} catch (err) {
demand(err.message).eql('FieldType.DateTime: options.dateFormat must be a string.');
done();
}
});

it('should throw when timeFormat is not a string', function (done) {
try {
List.add({
invalidFormatOption: { type: DatetimeType, timeFormat: /aregexp/ },
});

// If control reaches here, exception has not been thrown. Test failed.
demand(true).not.eql(true);
done();
} catch (err) {
demand(err.message).eql('FieldType.DateTime: options.timeFormat must be a string.');
done();
}
});

it('should throw when tzFormat is not a string', function (done) {
try {
List.add({
invalidFormatOption: { type: DatetimeType, format: /aregexp/ },
invalidFormatOption: { type: DatetimeType, tzFormat: /aregexp/ },
});

// If control reaches here, exception has not been thrown. Test failed.
demand(true).not.eql(true);
done();
} catch (err) {
demand(err.message).eql('FieldType.DateTime: options.format must be a string.');
demand(err.message).eql('FieldType.DateTime: options.tzFormat must be a string.');
done();
}
});
Expand Down Expand Up @@ -169,6 +208,15 @@ exports.testFieldType = function (List) {
});
});

it('should validate a date time string in a custom format when a custom display format is specified', function (done) {
List.fields.customDisplayFormat.validateInput({
customDisplayFormat: '20 Jan 2018 14:00 +00:00',
}, function (result) {
demand(result).be.true();
done();
});
});

it('should validate a date time string in a custom format when specified', function (done) {
List.fields.customFormat.validateInput({
customFormat: '25.02.16 04:45 am',
Expand All @@ -187,11 +235,11 @@ exports.testFieldType = function (List) {
});
});

it('should invalidate a date time string in the default format when a custom one is specified', function (done) {
it('should validate a date time string in the default format when a custom one is specified', function (done) {
List.fields.customFormat.validateInput({
customFormat: '2016-02-25 04:45:00 am',
}, function (result) {
demand(result).be.false();
demand(result).be.true();
done();
});
});
Expand Down