diff --git a/web/e2e-tests/settings.test.ts b/web/e2e-tests/settings.test.ts index 1afa0bd03a..9b84018a91 100644 --- a/web/e2e-tests/settings.test.ts +++ b/web/e2e-tests/settings.test.ts @@ -34,7 +34,7 @@ async function open_settings(page: Page): Promise { } async function close_settings_and_date_picker(page: Page): Promise { - const date_picker_selector = ".custom_user_field_value.datepicker.form-control"; + const date_picker_selector = ".date-field-alt-input"; await page.click(date_picker_selector); await page.waitForSelector(".flatpickr-calendar", {visible: true}); diff --git a/web/src/custom_profile_fields_ui.ts b/web/src/custom_profile_fields_ui.ts index 3cc24a21c9..c26c449161 100644 --- a/web/src/custom_profile_fields_ui.ts +++ b/web/src/custom_profile_fields_ui.ts @@ -14,6 +14,7 @@ import * as settings_components from "./settings_components.ts"; import * as settings_ui from "./settings_ui.ts"; import {current_user, realm} from "./state_data.ts"; import * as typeahead_helper from "./typeahead_helper.ts"; +import * as ui_report from "./ui_report.ts"; import type {UserPillWidget} from "./user_pill.ts"; import * as user_pill from "./user_pill.ts"; @@ -200,19 +201,131 @@ export function initialize_custom_user_type_fields( return user_pills; } -export function initialize_custom_date_type_fields(element_id: string): void { +export function format_date(date: Date | undefined, format: string): string { + if (date === undefined || date.toString() === "Invalid Date") { + return "Invalid Date"; + } + + return flatpickr.formatDate(date, format); +} + +export function initialize_custom_date_type_fields(element_id: string, user_id: number): void { const $date_picker_elements = $(element_id).find(".custom_user_field .datepicker"); if ($date_picker_elements.length === 0) { return; } + function update_date(instance: flatpickr.Instance, date_str: string): void { + const $input_elem = $(instance.element); + const field_id = Number.parseInt($input_elem.attr("data-field-id")!, 10); + + if (date_str === "Invalid Date") { + // Date parses empty string to an invalid value but in + // our case it is a valid value when user does not want + // to set any value for the custom profile field. + if ($input_elem.parent().find(".date-field-alt-input").val() === "") { + if (user_id !== people.my_current_user_id()) { + // For "Manage user" modal, API request is made after + // clicking on "Save changes" button. + return; + } + update_user_custom_profile_fields([{id: field_id}], channel.del); + return; + } + + // Show "Invalid date value" message briefly and set + // the input to original value. + const $spinner_element = $input_elem + .closest(".custom_user_field") + .find(".custom-field-status"); + ui_report.error( + $t({defaultMessage: "Invalid date value"}), + undefined, + $spinner_element, + 1200, + ); + const original_value = people.get_custom_profile_data(user_id, field_id)?.value ?? ""; + instance.setDate(original_value); + if (user_id !== people.my_current_user_id()) { + // Trigger "input" event so that save button state can + // be toggled in "Manage user" modal. + $input_elem + .closest(".custom_user_field") + .find(".date-field-alt-input") + .trigger("input"); + } + return; + } + + if (user_id !== people.my_current_user_id()) { + // For "Manage user" modal, API request is made after + // clicking on "Save changes" button. + return; + } + + const fields = []; + if (date_str) { + fields.push({id: field_id, value: date_str}); + update_user_custom_profile_fields(fields, channel.patch); + } else { + fields.push({id: field_id}); + update_user_custom_profile_fields(fields, channel.del); + } + } + flatpickr($date_picker_elements, { altInput: true, + // We would need to handle the altInput separately + // than ".custom_user_field_value" elements to handle + // invalid values typed in the input. + altInputClass: "date-field-alt-input settings_text_input", altFormat: "F j, Y", allowInput: true, static: true, + // This helps us in accepting inputs in other formats + // like MM/DD/YYYY and basically any other format + // which is accepted by Date. + parseDate: (date_str) => new Date(date_str), + // We pass allowInvalidPreload as true because we handle + // invalid values typed in the input ourselves. Also, + // formatDate function is customized to handle "undefined" + // values, which are returned by parseDate for invalid + // values. + formatDate: format_date, + allowInvalidPreload: true, + onChange(_selected_dates, date_str, instance) { + update_date(instance, date_str); + }, }); + // This "change" event handler is needed to make sure that + // the date is successfully changed when typing a new value + // in the input and blurring the input by clicking outside + // while the calendar popover is opened, because onChange + // callback is not executed in such a scenario. + // + // https://github.com/flatpickr/flatpickr/issues/1551#issuecomment-1601830680 + // has explanation on why that happens. + // + // However, this leads to a problem in a couple of cases + // where both onChange callback and this "change" handlers + // are executed when changing the date by typing in the + // input. This occurs when pressing Enter while the input + // is focused, and also when blurring the input by clicking + // outside while the calendar popover is closed. + $(element_id) + .find("input.date-field-alt-input") + .on("change", function (this: HTMLInputElement) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const $datepicker = $(this).parent().find(".datepicker")[0] as HTMLInputElement & { + _flatpickr: flatpickr.Instance; + }; + const instance = $datepicker._flatpickr; + const date = new Date($(this).val()!); + const date_str = format_date(date, "Y-m-d"); + update_date(instance, date_str); + }); + // Enable the label associated to this field to open the datepicker when clicked. $(element_id) .find(".custom_user_field label.settings-field-label") @@ -234,6 +347,8 @@ export function initialize_custom_date_type_fields(element_id: string): void { .find(".custom_user_field .remove_date") .on("click", function () { const $custom_user_field = $(this).parent().find(".custom_user_field_value"); + const $displayed_input = $(this).parent().find(".date-field-alt-input"); + $displayed_input.val(""); $custom_user_field.val(""); $custom_user_field.trigger("input"); }); diff --git a/web/src/dialog_widget.ts b/web/src/dialog_widget.ts index 50f05855b4..cc388a0f5f 100644 --- a/web/src/dialog_widget.ts +++ b/web/src/dialog_widget.ts @@ -1,9 +1,11 @@ import $ from "jquery"; import _ from "lodash"; +import assert from "minimalistic-assert"; import render_dialog_widget from "../templates/dialog_widget.hbs"; import type {AjaxRequestHandler} from "./channel.ts"; +import * as custom_profile_fields_ui from "./custom_profile_fields_ui.ts"; import {$t_html} from "./i18n.ts"; import * as loading from "./loading.ts"; import * as modals from "./modals.ts"; @@ -135,6 +137,24 @@ export function get_current_values($inputs: JQuery): Record { current_values[property_name] = $(this).val(); } } + + if ($(this).hasClass("date-field-alt-input")) { + // For date type custom profile fields, we convert the + // input to the date format passed to the API. + const value = $(this).val()!; + const name = $(this).parent().find(".custom_user_field_value").attr("name")!; + + if (value === "") { + // This case is handled separately, because it will + // otherwise be parsed as an invalid date. + current_values[name] = value; + return; + } + + assert(typeof value === "string"); + const date_str = new Date(value); + current_values[name] = custom_profile_fields_ui.format_date(date_str, "Y-m-d"); + } }); return current_values; } diff --git a/web/src/settings_account.ts b/web/src/settings_account.ts index a0c565128f..b1dc45a4f6 100644 --- a/web/src/settings_account.ts +++ b/web/src/settings_account.ts @@ -233,7 +233,10 @@ export function add_custom_profile_fields_to_settings(): void { true, pill_update_handler, ); - custom_profile_fields_ui.initialize_custom_date_type_fields(element_id); + custom_profile_fields_ui.initialize_custom_date_type_fields( + element_id, + people.my_current_user_id(), + ); custom_profile_fields_ui.initialize_custom_pronouns_type_fields(element_id); } @@ -724,22 +727,26 @@ export function set_up(): void { }, ); - $("#profile-settings").on("change", ".custom_user_field_value", function (this: HTMLElement) { - const fields: CustomProfileFieldData[] = []; - const value = $(this).val()!; - assert(typeof value === "string"); - const field_id = Number.parseInt( - $(this).closest(".custom_user_field").attr("data-field-id")!, - 10, - ); - if (value) { - fields.push({id: field_id, value}); - custom_profile_fields_ui.update_user_custom_profile_fields(fields, channel.patch); - } else { - fields.push({id: field_id}); - custom_profile_fields_ui.update_user_custom_profile_fields(fields, channel.del); - } - }); + $("#profile-settings").on( + "change", + ".custom_user_field_value:not(.datepicker)", + function (this: HTMLElement) { + const fields: CustomProfileFieldData[] = []; + const value = $(this).val()!; + assert(typeof value === "string"); + const field_id = Number.parseInt( + $(this).closest(".custom_user_field").attr("data-field-id")!, + 10, + ); + if (value) { + fields.push({id: field_id, value}); + custom_profile_fields_ui.update_user_custom_profile_fields(fields, channel.patch); + } else { + fields.push({id: field_id}); + custom_profile_fields_ui.update_user_custom_profile_fields(fields, channel.del); + } + }, + ); $("#account-settings .deactivate_realm_button").on( "click", diff --git a/web/src/user_profile.ts b/web/src/user_profile.ts index c99bde195a..628a3156e3 100644 --- a/web/src/user_profile.ts +++ b/web/src/user_profile.ts @@ -1054,16 +1054,10 @@ function get_human_profile_data(fields_user_pills: Map & {user_id?: string | undefined} { const raw_current_values = dialog_widget.get_current_values( - $edit_form.find("input, select, textarea, button, .pill-container"), + $edit_form.find("input:not(.datepicker), select, textarea, button, .pill-container"), ); const schema = z.intersection( z.object({ @@ -1150,7 +1144,10 @@ export function show_edit_user_info_modal(user_id: number, $container: JQuery): custom_profile_field_form_selector, user_id, ); - custom_profile_fields_ui.initialize_custom_date_type_fields(custom_profile_field_form_selector); + custom_profile_fields_ui.initialize_custom_date_type_fields( + custom_profile_field_form_selector, + user_id, + ); custom_profile_fields_ui.initialize_custom_pronouns_type_fields( custom_profile_field_form_selector, ); diff --git a/web/styles/settings.css b/web/styles/settings.css index 174087e36c..23b9c77058 100644 --- a/web/styles/settings.css +++ b/web/styles/settings.css @@ -740,6 +740,18 @@ input[type="checkbox"] { } } +#edit-user-form .alert-notification.custom-field-status, +#profile-settings .alert-notification.custom-field-status, +#profile-settings .alert-notification.full-name-status, +#profile-settings .alert-notification.timezone-setting-status { + padding-top: 0; + padding-bottom: 0; + margin-top: 0; + padding-left: 0; + margin-left: 5px; + border: none; +} + #profile-settings { .custom-profile-fields-form .custom_user_field label, .full-name-change-container label, @@ -747,17 +759,6 @@ input[type="checkbox"] { min-width: fit-content; } - .alert-notification.custom-field-status, - .alert-notification.full-name-status, - .alert-notification.timezone-setting-status { - padding-top: 0; - padding-bottom: 0; - margin-top: 0; - padding-left: 0; - margin-left: 5px; - border: none; - } - .person_picker { /* Subtract 2 * (2px padding) + 2 * (1px border) */ min-width: calc(var(--modal-input-width) - 6px);