DEV: Move calendar date + time picker from local dates into core component (#23023)

This commit moves the calendar date and time picker shown in
the local dates modal into a core component that can be reused
in other places. Also add system specs to make sure there isn't
any breakages with this feature, and a section to the styleguide.
This commit is contained in:
Martin Brennan 2023-08-11 13:05:44 +10:00 committed by GitHub
parent 0187ad0d37
commit fb36af7799
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 463 additions and 200 deletions

View File

@ -0,0 +1,24 @@
<div
class="calendar-date-time-input"
{{did-insert this.setupInternalDateTime}}
{{did-insert this.setupPikaday}}
{{did-update this.changeMinDate @minDate}}
{{did-update this.changeDate @date}}
{{did-update this.changeTime @time}}
>
<Input class="fake-input" />
<div class="date-picker" id="picker-container-{{@datePickerId}}"></div>
<div class="time-pickers">
{{d-icon "far-clock"}}
<Input
maxlength={{5}}
placeholder="hh:mm"
@type="time"
@value={{this._time}}
class="time-picker"
{{on "input" (action this.onChangeTime)}}
/>
</div>
</div>

View File

@ -0,0 +1,109 @@
/* global Pikaday:true */
import { isEmpty } from "@ember/utils";
import Component from "@glimmer/component";
import I18n from "I18n";
import loadScript from "discourse/lib/load-script";
import { Promise } from "rsvp";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
export default class CalendarDateTimeInput extends Component {
_timeFormat = this.args.timeFormat || "HH:mm:ss";
_dateFormat = this.args.dateFormat || "YYYY-MM-DD";
_dateTimeFormat = this.args.dateTimeFormat || "YYYY-MM-DD HH:mm:ss";
_picker = null;
@tracked _time;
@tracked _date;
@action
setupInternalDateTime() {
this._time = this.args.time;
this._date = this.args.date;
}
@action
setupPikaday(element) {
this.#setupPicker(element).then((picker) => {
this._picker = picker;
});
}
@action
onChangeTime(event) {
this._time = event.target.value;
this.args.onChangeTime(this._time);
}
@action
changeDate() {
if (moment(this.args.date, this._dateFormat).isValid()) {
this._date = this.args.date;
this._picker.setDate(
moment.utc(this._date).format(this._dateFormat),
true
);
} else {
this._date = null;
this._picker.setDate(null);
}
}
@action
changeTime() {
if (isEmpty(this.args.time)) {
this._time = null;
return;
}
if (/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/.test(this.args.time)) {
this._time = this.args.time;
}
}
@action
changeMinDate() {
if (
this.args.minDate &&
moment(this.args.minDate, this._dateFormat).isValid()
) {
this._picker.setMinDate(
moment(this.args.minDate, this._dateFormat).toDate()
);
} else {
this._picker.setMinDate(null);
}
}
#setupPicker(element) {
return new Promise((resolve) => {
loadScript("/javascripts/pikaday.js").then(() => {
const options = {
field: element.querySelector(".fake-input"),
container: element.querySelector(
`#picker-container-${this.args.datePickerId}`
),
bound: false,
format: "YYYY-MM-DD",
reposition: false,
firstDay: 1,
setDefaultDate: true,
keyboardInput: false,
i18n: {
previousMonth: I18n.t("dates.previous_month"),
nextMonth: I18n.t("dates.next_month"),
months: moment.months(),
weekdays: moment.weekdays(),
weekdaysShort: moment.weekdaysMin(),
},
onSelect: (date) => {
const formattedDate = moment(date).format("YYYY-MM-DD");
this.args.onChangeDate(formattedDate);
},
};
resolve(new Pikaday(options));
});
});
}
}

View File

@ -6,6 +6,7 @@
@import "color-input";
@import "char-counter";
@import "conditional-loading-section";
@import "calendar-date-time-input";
@import "convert-to-public-topic-modal";
@import "d-lightbox";
@import "d-tooltip";

View File

@ -0,0 +1,50 @@
.calendar-date-time-input {
.fake-input {
display: none;
}
padding: 5px;
border: 1px solid var(--primary-low);
z-index: 1;
background: var(--secondary);
width: 200px;
box-sizing: border-box;
margin-left: 1em;
.date-picker {
display: flex;
flex-direction: column;
width: auto;
box-sizing: border-box;
.pika-single {
position: relative !important;
flex: 1;
display: flex;
border: 0;
}
}
.time-pickers {
display: flex;
justify-content: center;
flex: 1;
margin-top: 1em;
align-items: center;
padding: 0.25em;
border-top: 1px solid var(--primary-low-mid);
box-sizing: border-box;
.d-icon {
color: var(--primary-medium);
margin-right: 0.5em;
}
.time-picker {
box-shadow: none;
margin: 0;
box-sizing: border-box;
width: 100%;
}
}
}

View File

@ -1,4 +1,3 @@
/* global Pikaday:true */
import computed, {
debounce,
observes,
@ -7,10 +6,7 @@ import Component from "@ember/component";
import EmberObject, { action } from "@ember/object";
import I18n from "I18n";
import { INPUT_DELAY } from "discourse-common/config/environment";
import { Promise } from "rsvp";
import { cookAsync } from "discourse/lib/text";
import { isEmpty } from "@ember/utils";
import loadScript from "discourse/lib/load-script";
import { notEmpty } from "@ember/object/computed";
import { propertyNotEqual } from "discourse/lib/computed";
import { schedule } from "@ember/runloop";
@ -46,18 +42,14 @@ export default Component.extend({
formats: (this.siteSettings.discourse_local_dates_default_formats || "")
.split("|")
.filter((f) => f),
timezone: moment.tz.guess(),
timezone: this.currentUserTimezone,
date: moment().format(this.dateFormat),
});
},
didInsertElement() {
this._super(...arguments);
this._setupPicker().then((picker) => {
this._picker = picker;
this.send("focusFrom");
});
this.send("focusFrom");
},
@observes("computedConfig.{from,to,options}", "options", "isValid", "isRange")
@ -194,7 +186,7 @@ export default Component.extend({
@computed
currentUserTimezone() {
return moment.tz.guess();
return this.currentUser.user_option.timezone || moment.tz.guess();
},
@computed
@ -312,118 +304,79 @@ export default Component.extend({
this.set("format", format);
},
actions: {
setTime(event) {
this._setTimeIfValid(event.target.value, "time");
},
@computed("fromSelected", "toSelected")
selectedDate(fromSelected) {
return fromSelected ? this.date : this.toDate;
},
setToTime(event) {
this._setTimeIfValid(event.target.value, "toTime");
},
@computed("fromSelected", "toSelected")
selectedTime(fromSelected) {
return fromSelected ? this.time : this.toTime;
},
eraseToDateTime() {
this.setProperties({ toDate: null, toTime: null });
this._setPickerDate(null);
},
@action
changeSelectedDate(date) {
if (this.fromSelected) {
this.set("date", date);
} else {
this.set("toDate", date);
}
},
focusFrom() {
this.setProperties({ fromSelected: true, toSelected: false });
this._setPickerDate(this.get("fromConfig.date"));
this._setPickerMinDate(null);
},
@action
changeSelectedTime(time) {
if (this.fromSelected) {
this.set("time", time);
} else {
this.set("toTime", time);
}
},
focusTo() {
this.setProperties({ toSelected: true, fromSelected: false });
this._setPickerDate(this.get("toConfig.date"));
this._setPickerMinDate(this.get("fromConfig.date"));
},
@action
eraseToDateTime() {
this.setProperties({
toDate: null,
toTime: null,
});
this.focusFrom();
},
advancedMode() {
this.toggleProperty("advancedMode");
},
@action
focusFrom() {
this.setProperties({
fromSelected: true,
toSelected: false,
minDate: null,
});
},
save() {
const markup = this.markup;
@action
focusTo() {
this.setProperties({
toSelected: true,
fromSelected: false,
minDate: this.get("fromConfig.date"),
});
},
if (markup) {
this._closeModal();
this.insertDate(markup);
}
},
@action
toggleAdvancedMode() {
this.toggleProperty("advancedMode");
},
cancel() {
@action
save() {
const markup = this.markup;
if (markup) {
this._closeModal();
},
},
_setTimeIfValid(time, key) {
if (isEmpty(time)) {
this.set(key, null);
return;
}
if (/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/.test(time)) {
this.set(key, time);
this.insertDate(markup);
}
},
_setupPicker() {
return new Promise((resolve) => {
loadScript("/javascripts/pikaday.js").then(() => {
const options = {
field: this.element.querySelector(".fake-input"),
container: this.element.querySelector(
`#picker-container-${this.elementId}`
),
bound: false,
format: "YYYY-MM-DD",
reposition: false,
firstDay: 1,
setDefaultDate: true,
keyboardInput: false,
i18n: {
previousMonth: I18n.t("dates.previous_month"),
nextMonth: I18n.t("dates.next_month"),
months: moment.months(),
weekdays: moment.weekdays(),
weekdaysShort: moment.weekdaysMin(),
},
onSelect: (date) => {
const formattedDate = moment(date).format("YYYY-MM-DD");
if (this.fromSelected) {
this.set("date", formattedDate);
}
if (this.toSelected) {
this.set("toDate", formattedDate);
}
},
};
resolve(new Pikaday(options));
});
});
},
_setPickerMinDate(date) {
schedule("afterRender", () => {
if (moment(date, this.dateFormat).isValid()) {
this._picker.setMinDate(moment(date, this.dateFormat).toDate());
} else {
this._picker.setMinDate(null);
}
});
},
_setPickerDate(date) {
schedule("afterRender", () => {
if (moment(date, this.dateFormat).isValid()) {
this._picker.setDate(moment.utc(date), true);
} else {
this._picker.setDate(null);
}
});
@action
cancel() {
this._closeModal();
},
_closeModal() {

View File

@ -66,38 +66,16 @@
</div>
<div class="picker-panel">
<Input class="fake-input" />
<div class="date-picker" id="picker-container-{{this.elementId}}"></div>
{{#if this.fromSelected}}
<div class="time-pickers">
{{d-icon "far-clock"}}
<Input
maxlength={{5}}
placeholder="hh:mm"
@type="time"
@value={{this.time}}
class="time-picker"
{{on "input" (action "setTime")}}
/>
</div>
{{/if}}
{{#if this.toSelected}}
{{#if this.toDate}}
<div class="time-pickers">
{{d-icon "far-clock"}}
<Input
maxlength={{5}}
placeholder="hh:mm"
@type="time"
@value={{this.toTime}}
class="time-picker"
{{on "input" (action "setToTime")}}
/>
</div>
{{/if}}
{{/if}}
<CalendarDateTimeInput
@datePickerId="local-date-create-form"
@date={{this.selectedDate}}
@time={{this.selectedTime}}
@minDate={{this.minDate}}
@timeFormat={{this.timeFormat}}
@dateFormat={{this.dateFormat}}
@onChangeDate={{action this.changeSelectedDate}}
@onChangeTime={{action this.changeSelectedTime}}
/>
</div>
{{#if this.site.mobileView}}
@ -210,7 +188,7 @@
<DButton
@class="btn-default advanced-mode-btn"
@action={{action "advancedMode"}}
@action={{action "toggleAdvancedMode"}}
@icon="cog"
@label={{this.toggleModeBtnLabel}}
/>

View File

@ -70,25 +70,6 @@ div[data-tippy-root] {
flex-direction: row;
padding: 0.5em;
.picker-panel {
padding: 5px;
border: 1px solid var(--primary-low);
}
.date-picker {
display: flex;
flex-direction: column;
width: auto;
box-sizing: border-box;
.pika-single {
position: relative !important;
flex: 1;
display: flex;
border: 0;
}
}
.form {
flex: 1 0 0px;
@ -210,37 +191,6 @@ div[data-tippy-root] {
.inputs-panel {
flex: 1;
}
.picker-panel {
z-index: 1;
background: var(--secondary);
width: 200px;
box-sizing: border-box;
margin-left: 1em;
}
.time-pickers {
display: flex;
justify-content: center;
flex: 1;
margin-top: 1em;
align-items: center;
padding: 0.25em;
border-top: 1px solid var(--primary-low-mid);
box-sizing: border-box;
.d-icon {
color: var(--primary-medium);
margin-right: 0.5em;
}
.time-picker {
box-shadow: none;
margin: 0;
box-sizing: border-box;
width: 100%;
}
}
}
.preview {
@ -318,17 +268,17 @@ html.mobile-view {
flex-direction: column;
}
.picker-panel {
.calendar-date-time-input {
width: 100%;
margin: 0 0 1em 0;
.pika-single {
justify-content: center;
}
}
.time-picker {
padding-top: 6px;
.time-picker {
padding-top: 6px;
}
}
}
}

View File

@ -4,7 +4,10 @@ describe "Local dates", type: :system do
fab!(:topic) { Fabricate(:topic) }
fab!(:current_user) { Fabricate(:user) }
let(:year) { Time.zone.now.year + 1 }
let(:month) { Time.zone.now.month }
let(:bookmark_modal) { PageObjects::Modals::Bookmark.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:insert_datetime_modal) { PageObjects::Modals::InsertDateTime.new }
before do
create_post(user: current_user, topic: topic, title: "Date range test post", raw: <<~RAW)
@ -69,6 +72,85 @@ describe "Local dates", type: :system do
end
end
describe "insert modal" do
let(:timezone) { "Australia/Brisbane" }
before do
current_user.user_option.update!(timezone: timezone)
sign_in(current_user)
end
it "allows selecting a date without a time and inserts into the post" do
topic_page.visit_topic_and_open_composer(topic)
expect(topic_page).to have_expanded_composer
composer.click_toolbar_button("local-dates")
expect(insert_datetime_modal).to be_open
insert_datetime_modal.calendar_date_time_picker.select_year(year)
insert_datetime_modal.calendar_date_time_picker.select_day(16)
insert_datetime_modal.click_primary_button
expect(composer.composer_input.value).to have_content(
"[date=#{Date.parse("#{year}-#{month}-16").strftime("%Y-%m-%d")} timezone=\"#{timezone}\"]",
)
end
it "allows selecting a date with a time and inserts into the post" do
topic_page.visit_topic_and_open_composer(topic)
expect(topic_page).to have_expanded_composer
composer.click_toolbar_button("local-dates")
expect(insert_datetime_modal).to be_open
insert_datetime_modal.calendar_date_time_picker.select_year(year)
insert_datetime_modal.calendar_date_time_picker.select_day(16)
insert_datetime_modal.calendar_date_time_picker.fill_time("11:45am")
insert_datetime_modal.click_primary_button
expect(composer.composer_input.value).to have_content(
"[date=#{Date.parse("#{year}-#{month}-16").strftime("%Y-%m-%d")} time=11:45:00 timezone=\"#{timezone}\"]",
)
end
it "allows selecting a start date and time and an end date and time" do
topic_page.visit_topic_and_open_composer(topic)
expect(topic_page).to have_expanded_composer
composer.click_toolbar_button("local-dates")
expect(insert_datetime_modal).to be_open
insert_datetime_modal.calendar_date_time_picker.select_year(year)
insert_datetime_modal.calendar_date_time_picker.select_day(16)
insert_datetime_modal.calendar_date_time_picker.fill_time("11:45am")
insert_datetime_modal.select_to
insert_datetime_modal.calendar_date_time_picker.select_year(year)
insert_datetime_modal.calendar_date_time_picker.select_day(23)
insert_datetime_modal.calendar_date_time_picker.fill_time("12:45pm")
insert_datetime_modal.click_primary_button
expect(composer.composer_input.value).to have_content(
"[date-range from=#{Date.parse("#{year}-#{month}-16").strftime("%Y-%m-%d")}T11:45:00 to=#{Date.parse("#{year}-#{month}-23").strftime("%Y-%m-%d")}T12:45:00 timezone=\"#{timezone}\"]",
)
end
it "allows clearing the end date and time" do
topic_page.visit_topic_and_open_composer(topic)
expect(topic_page).to have_expanded_composer
composer.click_toolbar_button("local-dates")
expect(insert_datetime_modal).to be_open
insert_datetime_modal.calendar_date_time_picker.select_year(year)
insert_datetime_modal.calendar_date_time_picker.select_day(16)
insert_datetime_modal.calendar_date_time_picker.fill_time("11:45am")
insert_datetime_modal.select_to
insert_datetime_modal.calendar_date_time_picker.select_year(year)
insert_datetime_modal.calendar_date_time_picker.select_day(23)
insert_datetime_modal.calendar_date_time_picker.fill_time("12:45pm")
insert_datetime_modal.delete_to
insert_datetime_modal.click_primary_button
expect(composer.composer_input.value).to have_content(
"[date=#{Date.parse("#{year}-#{month}-16").strftime("%Y-%m-%d")} time=11:45:00 timezone=\"#{timezone}\"]",
)
end
end
describe "bookmarks" do
before do
current_user.user_option.update!(timezone: "Asia/Singapore")

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
module PageObjects
module Modals
class InsertDateTime < PageObjects::Modals::Base
MODAL_CSS_CLASS = ".discourse-local-dates-create-modal"
def calendar_date_time_picker
@calendar_date_time_picker ||=
PageObjects::Components::CalendarDateTimePicker.new(MODAL_CSS_CLASS)
end
def select_to
find(".date-time-control.to").click
end
def select_from
find(".date-time-control.from").click
end
def delete_to
find(".delete-to-date").click
end
end
end
end

View File

@ -130,7 +130,7 @@ acceptance("Local Dates - composer", function (needs) {
await click(".delete-to-date");
assert.notOk(
query(".pika-table .is-selected"),
query(".date-time-control.to.is-selected"),
"deleting selected TO date works"
);

View File

@ -25,3 +25,5 @@
<StyleguideExample @title="<DatePicker>">
<DatePicker @defaultDate="YYYY-MM-DD" />
</StyleguideExample>
<Styleguide::CalendarDateTimeInput />

View File

@ -0,0 +1,34 @@
<StyleguideExample @title="<CalendarDateTimeInput>">
<Styleguide::Component>
<CalendarDateTimeInput
@datePickerId="styleguide"
@date={{this.date}}
@time={{this.time}}
@minDate={{this.minDate}}
@timeFormat={{this.timeFormat}}
@dateFormat={{this.dateFormat}}
@onChangeDate={{action this.changeDate}}
@onChangeTime={{action this.changeTime}}
/>
</Styleguide::Component>
<Styleguide::Controls>
<Styleguide::Controls::Row @name="Min date">
<DatePicker @defaultDate="YYYY-MM-DD" @value={{this.minDate}} />
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="Date">
<DatePicker @defaultDate="YYYY-MM-DD" @value={{this.date}} />
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="Time">
<Input
maxlength={{5}}
placeholder="hh:mm"
@type="time"
@value={{this.time}}
class="time-picker"
/>
</Styleguide::Controls::Row>
</Styleguide::Controls>
</StyleguideExample>

View File

@ -0,0 +1,24 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class StyleguideCalendarDateTimeInput extends Component {
@service currentUser;
@tracked dateFormat = "YYYY-MM-DD";
@tracked timeFormat = "HH:mm:ss";
@tracked date = null;
@tracked time = null;
@tracked minDate = null;
@action
changeDate(date) {
this.date = date;
}
@action
changeTime(time) {
this.time = time;
}
}

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
module PageObjects
module Components
class CalendarDateTimePicker < PageObjects::Components::Base
def initialize(context)
@context = context
end
def component
find(@context)
end
def select_day(day_number)
component.find("button.pika-button.pika-day[data-pika-day='#{day_number}']").click
end
def select_year(year)
component
.find(".pika-select-year", visible: false)
.find("option[value='#{year}']")
.select_option
end
def fill_time(time)
component.find(".time-picker").fill_in(with: time)
end
end
end
end