FEATURE: new date/time components (#7898)

This commit is contained in:
Joffrey JAFFEUX 2019-07-18 17:29:41 +02:00 committed by GitHub
parent 194a2b612f
commit 95ad4f9077
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 847 additions and 0 deletions

View File

@ -0,0 +1,101 @@
/* global Pikaday:true */
import loadScript from "discourse/lib/load-script";
import {
default as computed,
on
} from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({
classNames: ["d-date-input"],
date: null,
_picker: null,
@computed("site.mobileView")
inputType(mobileView) {
return mobileView ? "date" : "text";
},
@on("didInsertElement")
_loadDatePicker() {
const container = this.element.querySelector(`#${this.containerId}`);
if (this.site.mobileView) {
this._loadNativePicker(container);
} else {
this._loadPikadayPicker(container);
}
},
didUpdateAttrs() {
this._super(...arguments);
if (this._picker) {
this._picker.setDate(this.date, true);
}
},
_loadPikadayPicker(container) {
loadScript("/javascripts/pikaday.js").then(() => {
Ember.run.next(() => {
const default_opts = {
field: this.element.querySelector(".date-picker"),
container: container || this.element,
bound: container === null,
format: "LL",
firstDay: 1,
i18n: {
previousMonth: I18n.t("dates.previous_month"),
nextMonth: I18n.t("dates.next_month"),
months: moment.months(),
weekdays: moment.weekdays(),
weekdaysShort: moment.weekdaysShort()
},
onSelect: date => this._handleSelection(date)
};
this._picker = new Pikaday(Object.assign(default_opts, this._opts()));
this._picker.setDate(this.date, true);
});
});
},
_loadNativePicker(container) {
const wrapper = container || this.element;
const picker = wrapper.querySelector("input.date-picker");
picker.onchange = () => this._handleSelection(picker.value);
picker.hide = () => {
/* do nothing for native */
};
picker.destroy = () => {
/* do nothing for native */
};
this._picker = picker;
},
_handleSelection(value) {
if (!this.element || this.isDestroying || this.isDestroyed) return;
this._picker && this._picker.hide();
if (this.onChange) {
this.onChange(moment(value).toDate());
}
},
@on("willDestroyElement")
_destroy() {
if (this._picker) {
this._picker.destroy();
}
this._picker = null;
},
@computed()
placeholder() {
return I18n.t("dates.placeholder");
},
_opts() {
return null;
}
});

View File

@ -0,0 +1,51 @@
export default Ember.Component.extend({
classNames: ["d-date-time-input-range"],
from: null,
to: null,
onChangeTo: null,
onChangeFrom: null,
currentPanel: "from",
showFromTime: true,
showToTime: true,
error: null,
fromPanelActive: Ember.computed.equal("currentPanel", "from"),
toPanelActive: Ember.computed.equal("currentPanel", "to"),
_valid(state) {
if (state.to < state.from) {
return I18n.t("date_time_picker.errors.to_before_from");
}
return true;
},
actions: {
_onChange(options, value) {
if (this.onChange) {
const state = {
from: this.from,
to: this.to
};
const diff = {};
diff[options.prop] = value;
const newState = Object.assign(state, diff);
const validation = this._valid(newState);
if (validation === true) {
this.set("error", null);
this.onChange(newState);
} else {
this.set("error", validation);
}
}
},
onChangePanel(panel) {
this.set("currentPanel", panel);
}
}
});

View File

@ -0,0 +1,35 @@
export default Ember.Component.extend({
classNames: ["d-date-time-input"],
date: null,
showTime: true,
_hours: Ember.computed("date", function() {
return this.date ? this.date.getHours() : null;
}),
_minutes: Ember.computed("date", function() {
return this.date ? this.date.getMinutes() : null;
}),
actions: {
onChangeTime(time) {
if (this.onChange) {
const year = this.date.getFullYear();
const month = this.date.getMonth();
const day = this.date.getDate();
this.onChange(new Date(year, month, day, time.hours, time.minutes));
}
},
onChangeDate(date) {
if (this.onChange) {
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
this.onChange(
new Date(year, month, day, this._hours || 0, this._minutes || 0)
);
}
}
}
});

View File

@ -0,0 +1,73 @@
import { isNumeric } from "discourse/lib/utilities";
export default Ember.Component.extend({
classNames: ["d-time-input"],
hours: null,
minutes: null,
_hours: Ember.computed.oneWay("hours"),
_minutes: Ember.computed.oneWay("minutes"),
isSafari: Ember.computed.oneWay("capabilities.isSafari"),
isMobile: Ember.computed.oneWay("site.mobileView"),
nativePicker: Ember.computed.or("isSafari", "isMobile"),
actions: {
onInput(options, event) {
event.preventDefault();
if (this.onChange) {
let value = event.target.value;
if (!isNumeric(value)) {
value = 0;
} else {
value = parseInt(value, 10);
}
if (options.prop === "hours") {
value = Math.max(0, Math.min(value, 23))
.toString()
.padStart(2, "0");
this._processHoursChange(value);
} else {
value = Math.max(0, Math.min(value, 59))
.toString()
.padStart(2, "0");
this._processMinutesChange(value);
}
Ember.run.schedule("afterRender", () => (event.target.value = value));
}
},
onFocusIn(value, event) {
if (value && event.target) {
event.target.select();
}
},
onChangeTime(event) {
const time = event.target.value;
if (time && this.onChange) {
this.onChange({
hours: time.split(":")[0],
minutes: time.split(":")[1]
});
}
}
},
_processHoursChange(hours) {
this.onChange({
hours,
minutes: this._minutes || "00"
});
},
_processMinutesChange(minutes) {
this.onChange({
hours: this._hours || "00",
minutes
});
}
});

View File

@ -0,0 +1,5 @@
{{input
type=inputType
class="date-picker"
placeholder=placeholder
value=value}}

View File

@ -0,0 +1,34 @@
<ul class="panels {{currentPanel}}">
<li>
{{d-button
label="date_time_picker.from"
class="from-panel"
action=(action "onChangePanel" "from")}}
</li>
<li>
{{d-button
label="date_time_picker.to"
class="to-panel"
action=(action "onChangePanel" "to")}}
</li>
</ul>
{{#if error}}
<div class="alert error">{{error}}</div>
{{/if}}
<div class="panel from {{if fromPanelActive 'visible'}}">
{{date-time-input
date=from
onChange=(action "_onChange" (hash prop="from"))
showTime=showFromTime
}}
</div>
<div class="panel to {{if toPanelActive 'visible'}}">
{{date-time-input
date=to
onChange=(action "_onChange" (hash prop="to"))
showTime=showToTime
}}
</div>

View File

@ -0,0 +1,9 @@
{{date-input date=date onChange=(action "onChangeDate")}}
{{#if showTime}}
{{time-input
hours=_hours
minutes=_minutes
onChange=(action "onChangeTime")
}}
{{/if}}

View File

@ -0,0 +1,40 @@
<div class="fields">
{{#if nativePicker}}
{{input
class="field time"
type="time"
value=(concat _hours ":" _minutes)
change=(action "onChangeTime")
}}
{{else}}
{{input
class="field hours"
type="number"
title="Hours"
minlength=2
maxlength=2
max="23"
min="0"
placeholder="00"
value=_hours
input=(action "onInput" (hash prop="hours"))
focus-in=(action "onFocusIn")
}}
<div class="separator">:</div>
{{input
class="field minutes"
title="Minutes"
type="number"
minlength=2
maxlength=2
max="59"
min="0"
placeholder="00"
value=_minutes
input=(action "onInput" (hash prop="minutes"))
focus-in=(action "onFocusIn")
}}
{{/if}}
</div>

View File

@ -189,4 +189,40 @@ if (RegExp.prototype.flags === undefined) {
});
}
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
if (!String.prototype.padStart) {
String.prototype.padStart = function padStart(targetLength, padString) {
targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0;
padString = String(typeof padString !== "undefined" ? padString : " ");
if (this.length >= targetLength) {
return String(this);
} else {
targetLength = targetLength - this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
}
return padString.slice(0, targetLength) + String(this);
}
};
}
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padEnd
if (!String.prototype.padEnd) {
String.prototype.padEnd = function padEnd(targetLength, padString) {
targetLength = targetLength >> 0; //floor if number or convert non-number to 0;
padString = String(typeof padString !== "undefined" ? padString : " ");
if (this.length > targetLength) {
return String(this);
} else {
targetLength = targetLength - this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
}
return String(this) + padString.slice(0, targetLength);
}
};
}
/* eslint-enable */

View File

@ -0,0 +1,13 @@
.d-date-input {
.date-picker {
margin: 0;
text-align: left;
width: 100%;
outline: none;
box-shadow: none !important;
}
.pika-single {
margin-left: -1px;
}
}

View File

@ -0,0 +1,42 @@
.d-date-time-input-range {
padding: 0.5em;
background: whitesmole;
border: 1px solid $primary-low;
width: 300px;
display: flex;
flex-direction: column;
.panels {
display: inline-flex;
list-style: none;
margin: 0 0 0.5em 0;
flex: 1;
&.from {
.from-panel {
background: $danger;
color: $secondary;
}
}
&.to {
.to-panel {
background: $danger;
color: $secondary;
}
}
.btn {
margin-right: 0.5em;
}
}
.panel {
display: none;
flex: 1;
&.visible {
display: flex;
}
}
}

View File

@ -0,0 +1,15 @@
.d-date-time-input {
display: flex;
align-items: center;
border: 1px solid $primary-low;
width: 258px;
box-sizing: border-box;
position: relative;
flex: 1;
justify-content: space-between;
.date-picker,
.fields {
border: 0;
}
}

View File

@ -0,0 +1,39 @@
.d-time-input {
box-sizing: border-box;
.fields {
display: flex;
align-items: center;
border: 1px solid $primary-low;
.field {
text-align: center;
width: auto;
margin: 0;
border: none;
outline: none;
box-shadow: none;
width: 32px;
&.time {
width: 100%;
text-align: left;
}
&.hours,
&.minutes {
text-align: center;
width: 45px;
}
&.hours {
padding-right: 0;
}
&.minutes {
padding-left: 10px;
width: 55px;
}
}
}
}

View File

@ -1518,6 +1518,12 @@ en:
one: "Select at least {{count}} item."
other: "Select at least {{count}} items."
date_time_picker:
from: From
to: To
errors:
to_before_from: "To date must be later than from date."
emoji_picker:
filter_placeholder: Search for emoji
smileys_&_emotion: Smileys and Emotion

View File

@ -0,0 +1,65 @@
import componentTest from "helpers/component-test";
moduleForComponent("date-input", { integration: true });
function dateInput() {
return find(".date-picker");
}
function setDate(date) {
this.set("date", date);
}
async function pika(year, month, day) {
await click(
`.pika-button.pika-day[data-pika-year="${year}"][data-pika-month="${month}"][data-pika-day="${day}"]`
);
}
function noop() {}
const DEFAULT_DATE = new Date(2019, 0, 29);
componentTest("default", {
template: `{{date-input date=date}}`,
beforeEach() {
this.setProperties({ date: DEFAULT_DATE });
},
test(assert) {
assert.equal(dateInput().val(), "January 29, 2019");
}
});
componentTest("prevents mutations", {
template: `{{date-input date=date onChange=onChange}}`,
beforeEach() {
this.setProperties({ date: DEFAULT_DATE });
this.set("onChange", noop);
},
async test(assert) {
await click(dateInput());
await pika(2019, 0, 2);
assert.ok(this.date.getTime() === DEFAULT_DATE.getTime());
}
});
componentTest("allows mutations through actions", {
template: `{{date-input date=date onChange=onChange}}`,
beforeEach() {
this.setProperties({ date: DEFAULT_DATE });
this.set("onChange", setDate);
},
async test(assert) {
await click(dateInput());
await pika(2019, 0, 2);
assert.ok(this.date.getTime() === new Date(2019, 0, 2).getTime());
}
});

View File

@ -0,0 +1,102 @@
import componentTest from "helpers/component-test";
moduleForComponent("date-time-input-range", { integration: true });
function fromDateInput() {
return find(".from .date-picker");
}
function fromHoursInput() {
return find(".from .field.hours");
}
function fromMinutesInput() {
return find(".from .field.minutes");
}
function toDateInput() {
return find(".to .date-picker");
}
function toHoursInput() {
return find(".to .field.hours");
}
function toMinutesInput() {
return find(".to .field.minutes");
}
function setDates(dates) {
this.setProperties(dates);
}
async function pika(year, month, day) {
await click(
`.pika-button.pika-day[data-pika-year="${year}"][data-pika-month="${month}"][data-pika-day="${day}"]`
);
}
const DEFAULT_DATE_TIME = new Date(2019, 0, 29, 14, 45);
componentTest("default", {
template: `{{date-time-input-range from=date to=to}}`,
beforeEach() {
this.setProperties({ date: DEFAULT_DATE_TIME, to: null });
},
test(assert) {
assert.equal(fromDateInput().val(), "January 29, 2019");
assert.equal(fromHoursInput().val(), "14");
assert.equal(fromMinutesInput().val(), "45");
assert.equal(toDateInput().val(), "");
assert.equal(toHoursInput().val(), "");
assert.equal(toMinutesInput().val(), "");
}
});
componentTest("can switch panels", {
template: `{{date-time-input-range}}`,
async test(assert) {
assert.ok(exists(".panel.from.visible"));
assert.notOk(exists(".panel.to.visible"));
await click(".panels .to-panel");
assert.ok(exists(".panel.to.visible"));
assert.notOk(exists(".panel.from.visible"));
}
});
componentTest("prevents toDate to be before fromDate", {
template: `{{date-time-input-range from=from to=to onChange=onChange}}`,
beforeEach() {
this.setProperties({
from: DEFAULT_DATE_TIME,
to: DEFAULT_DATE_TIME,
onChange: setDates
});
},
async test(assert) {
assert.notOk(exists(".error"));
await click(toDateInput());
await pika(2019, 0, 1);
assert.ok(exists(".error"));
assert.ok(
this.to.getTime() === DEFAULT_DATE_TIME.getTime(),
"it didnt trigger a mutation"
);
await click(toDateInput());
await pika(2019, 0, 30);
assert.notOk(exists(".error"));
assert.ok(this.to.getTime() === new Date(2019, 0, 30, 14, 45).getTime());
}
});

View File

@ -0,0 +1,84 @@
import componentTest from "helpers/component-test";
moduleForComponent("date-time-input", { integration: true });
function dateInput() {
return find(".date-picker");
}
function hoursInput() {
return find(".field.hours");
}
function minutesInput() {
return find(".field.minutes");
}
function setDate(date) {
this.set("date", date);
}
async function pika(year, month, day) {
await click(
`.pika-button.pika-day[data-pika-year="${year}"][data-pika-month="${month}"][data-pika-day="${day}"]`
);
}
const DEFAULT_DATE_TIME = new Date(2019, 0, 29, 14, 45);
componentTest("default", {
template: `{{date-time-input date=date}}`,
beforeEach() {
this.setProperties({ date: DEFAULT_DATE_TIME });
},
test(assert) {
assert.equal(dateInput().val(), "January 29, 2019");
assert.equal(hoursInput().val(), "14");
assert.equal(minutesInput().val(), "45");
}
});
componentTest("prevents mutations", {
template: `{{date-time-input date=date}}`,
beforeEach() {
this.setProperties({ date: DEFAULT_DATE_TIME });
},
async test(assert) {
await click(dateInput());
await pika(2019, 0, 2);
assert.ok(this.date.getTime() === DEFAULT_DATE_TIME.getTime());
}
});
componentTest("allows mutations through actions", {
template: `{{date-time-input date=date onChange=onChange}}`,
beforeEach() {
this.setProperties({ date: DEFAULT_DATE_TIME });
this.set("onChange", setDate);
},
async test(assert) {
await click(dateInput());
await pika(2019, 0, 2);
assert.ok(this.date.getTime() === new Date(2019, 0, 2, 14, 45).getTime());
}
});
componentTest("can hide time", {
template: `{{date-time-input date=date showTime=false}}`,
beforeEach() {
this.setProperties({ date: DEFAULT_DATE_TIME });
},
async test(assert) {
assert.notOk(exists(hoursInput()));
}
});

View File

@ -0,0 +1,97 @@
import componentTest from "helpers/component-test";
moduleForComponent("time-input", { integration: true });
function hoursInput() {
return find(".field.hours");
}
function minutesInput() {
return find(".field.minutes");
}
function setTime(time) {
this.setProperties(time);
}
function noop() {}
componentTest("default", {
template: `{{time-input hours=hours minutes=minutes}}`,
beforeEach() {
this.setProperties({ hours: "14", minutes: "58" });
},
test(assert) {
assert.equal(hoursInput().val(), "14");
assert.equal(minutesInput().val(), "58");
}
});
componentTest("prevents mutations", {
template: `{{time-input hours=hours minutes=minutes}}`,
beforeEach() {
this.setProperties({ hours: "14", minutes: "58" });
},
async test(assert) {
await fillIn(hoursInput(), "12");
assert.ok(this.hours === "14");
await fillIn(minutesInput(), "36");
assert.ok(this.minutes === "58");
}
});
componentTest("allows mutations through actions", {
template: `{{time-input hours=hours minutes=minutes onChange=onChange}}`,
beforeEach() {
this.setProperties({ hours: "14", minutes: "58" });
this.set("onChange", setTime);
},
async test(assert) {
await fillIn(hoursInput(), "12");
assert.ok(this.hours === "12");
await fillIn(minutesInput(), "36");
assert.ok(this.minutes === "36");
}
});
componentTest("hours and minutes have boundaries", {
template: `{{time-input hours=14 minutes=58 onChange=onChange}}`,
beforeEach() {
this.set("onChange", noop);
},
async test(assert) {
await fillIn(hoursInput(), "2");
assert.equal(hoursInput().val(), "02");
await fillIn(hoursInput(), "@");
assert.equal(hoursInput().val(), "00");
await fillIn(hoursInput(), "24");
assert.equal(hoursInput().val(), "23");
await fillIn(hoursInput(), "-1");
assert.equal(hoursInput().val(), "00");
await fillIn(minutesInput(), "@");
assert.equal(minutesInput().val(), "00");
await fillIn(minutesInput(), "2");
assert.equal(minutesInput().val(), "02");
await fillIn(minutesInput(), "60");
assert.equal(minutesInput().val(), "59");
await fillIn(minutesInput(), "-1");
assert.equal(minutesInput().val(), "00");
}
});