diff --git a/app/assets/javascripts/discourse/components/date-input.js.es6 b/app/assets/javascripts/discourse/components/date-input.js.es6 new file mode 100644 index 00000000000..d29962c02c4 --- /dev/null +++ b/app/assets/javascripts/discourse/components/date-input.js.es6 @@ -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; + } +}); diff --git a/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 b/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 new file mode 100644 index 00000000000..3754be93cd2 --- /dev/null +++ b/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 @@ -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); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/date-time-input.js.es6 b/app/assets/javascripts/discourse/components/date-time-input.js.es6 new file mode 100644 index 00000000000..ce173e3d422 --- /dev/null +++ b/app/assets/javascripts/discourse/components/date-time-input.js.es6 @@ -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) + ); + } + } + } +}); diff --git a/app/assets/javascripts/discourse/components/time-input.js.es6 b/app/assets/javascripts/discourse/components/time-input.js.es6 new file mode 100644 index 00000000000..bdeb1b3a722 --- /dev/null +++ b/app/assets/javascripts/discourse/components/time-input.js.es6 @@ -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 + }); + } +}); diff --git a/app/assets/javascripts/discourse/templates/components/date-input.hbs b/app/assets/javascripts/discourse/templates/components/date-input.hbs new file mode 100644 index 00000000000..e29eb0e73af --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/date-input.hbs @@ -0,0 +1,5 @@ +{{input + type=inputType + class="date-picker" + placeholder=placeholder + value=value}} diff --git a/app/assets/javascripts/discourse/templates/components/date-time-input-range.hbs b/app/assets/javascripts/discourse/templates/components/date-time-input-range.hbs new file mode 100644 index 00000000000..e4476ae9486 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/date-time-input-range.hbs @@ -0,0 +1,34 @@ + + +{{#if error}} +
{{error}}
+{{/if}} + +
+ {{date-time-input + date=from + onChange=(action "_onChange" (hash prop="from")) + showTime=showFromTime + }} +
+ +
+ {{date-time-input + date=to + onChange=(action "_onChange" (hash prop="to")) + showTime=showToTime + }} +
diff --git a/app/assets/javascripts/discourse/templates/components/date-time-input.hbs b/app/assets/javascripts/discourse/templates/components/date-time-input.hbs new file mode 100644 index 00000000000..a2da31c28c8 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/date-time-input.hbs @@ -0,0 +1,9 @@ +{{date-input date=date onChange=(action "onChangeDate")}} + +{{#if showTime}} + {{time-input + hours=_hours + minutes=_minutes + onChange=(action "onChangeTime") + }} +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/time-input.hbs b/app/assets/javascripts/discourse/templates/components/time-input.hbs new file mode 100644 index 00000000000..51ada91bf64 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/time-input.hbs @@ -0,0 +1,40 @@ +
+ {{#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") + }} + +
:
+ + {{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}} +
diff --git a/app/assets/javascripts/polyfills.js b/app/assets/javascripts/polyfills.js index ab97aebb351..5c115208f74 100644 --- a/app/assets/javascripts/polyfills.js +++ b/app/assets/javascripts/polyfills.js @@ -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 */ diff --git a/app/assets/stylesheets/common/components/date-input.scss b/app/assets/stylesheets/common/components/date-input.scss new file mode 100644 index 00000000000..583039a0017 --- /dev/null +++ b/app/assets/stylesheets/common/components/date-input.scss @@ -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; + } +} diff --git a/app/assets/stylesheets/common/components/date-time-input-range.scss b/app/assets/stylesheets/common/components/date-time-input-range.scss new file mode 100644 index 00000000000..438de7742e0 --- /dev/null +++ b/app/assets/stylesheets/common/components/date-time-input-range.scss @@ -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; + } + } +} diff --git a/app/assets/stylesheets/common/components/date-time-input.scss b/app/assets/stylesheets/common/components/date-time-input.scss new file mode 100644 index 00000000000..634bc219610 --- /dev/null +++ b/app/assets/stylesheets/common/components/date-time-input.scss @@ -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; + } +} diff --git a/app/assets/stylesheets/common/components/time-input.scss b/app/assets/stylesheets/common/components/time-input.scss new file mode 100644 index 00000000000..d11e2b7c7d9 --- /dev/null +++ b/app/assets/stylesheets/common/components/time-input.scss @@ -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; + } + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 032cbe7f366..a728f185d2a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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 diff --git a/test/javascripts/components/date-input-test.js.es6 b/test/javascripts/components/date-input-test.js.es6 new file mode 100644 index 00000000000..64686aba06d --- /dev/null +++ b/test/javascripts/components/date-input-test.js.es6 @@ -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()); + } +}); diff --git a/test/javascripts/components/date-time-input-range-test.js.es6 b/test/javascripts/components/date-time-input-range-test.js.es6 new file mode 100644 index 00000000000..8176943b991 --- /dev/null +++ b/test/javascripts/components/date-time-input-range-test.js.es6 @@ -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()); + } +}); diff --git a/test/javascripts/components/date-time-input-test.js.es6 b/test/javascripts/components/date-time-input-test.js.es6 new file mode 100644 index 00000000000..2dd4ac8fcf4 --- /dev/null +++ b/test/javascripts/components/date-time-input-test.js.es6 @@ -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())); + } +}); diff --git a/test/javascripts/components/time-input-test.js.es6 b/test/javascripts/components/time-input-test.js.es6 new file mode 100644 index 00000000000..edbe7574c82 --- /dev/null +++ b/test/javascripts/components/time-input-test.js.es6 @@ -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"); + } +});