/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
web/src/custom_profile_fields_ui.ts
443 строки
17 KB
Anders Kaseorg
web: Use named import for jQuery.
29 июл 2026, 02:35
29 июл 2026, 02:35
52a10e8
Код
Авторство
О чём код?
import flatpickr from "flatpickr"; import {$} from "jquery"; import assert from "minimalistic-assert"; import * as z from "zod/mini"; import render_settings_custom_user_profile_field from "../templates/settings/custom_user_profile_field.hbs"; import render_user_display_only_pill from "../templates/user_display_only_pill.hbs"; import {Typeahead} from "./bootstrap_typeahead.ts"; import * as bootstrap_typeahead from "./bootstrap_typeahead.ts"; import * as channel from "./channel.ts"; import {$t} from "./i18n.ts"; import * as people from "./people.ts"; import * as pill_typeahead from "./pill_typeahead.ts"; 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"; import {the} from "./util.ts"; const user_value_schema = z.array(z.number()); export function append_custom_profile_fields(element_id: string, user_id: number): void { const person = people.get_by_user_id(user_id); if (person.is_bot) { return; } const all_custom_fields = realm.custom_profile_fields; const all_field_types = realm.custom_profile_field_types; const all_field_template_types = new Map([ [all_field_types.PARAGRAPH.id, "text"], [all_field_types.SHORT_TEXT.id, "text"], [all_field_types.DROPDOWN.id, "select"], [all_field_types.USER.id, "user"], [all_field_types.DATE.id, "date"], [all_field_types.EXTERNAL_ACCOUNT.id, "text"], [all_field_types.URL.id, "url"], [all_field_types.PRONOUNS.id, "text"], ]); for (const field of all_custom_fields) { const field_value = people.get_custom_profile_data(user_id, field.id) ?? { value: "", rendered_value: "", }; const editable_by_user = current_user.is_admin || field.editable_by_user; const is_dropdown_field = field.type === all_field_types.DROPDOWN.id; const field_choices = []; if (is_dropdown_field) { const field_choice_dict = settings_components.custom_profile_field_choices_schema.parse( JSON.parse(field.field_data), ); for (const [value, {order, text}] of Object.entries(field_choice_dict)) { field_choices[Number(order)] = { value, text, selected: value === field_value.value, }; } } const html = render_settings_custom_user_profile_field({ field, field_type: all_field_template_types.get(field.type), field_value, is_long_text_field: field.type === all_field_types.PARAGRAPH.id, is_user_field: field.type === all_field_types.USER.id, is_date_field: field.type === all_field_types.DATE.id, is_url_field: field.type === all_field_types.URL.id, is_pronouns_field: field.type === all_field_types.PRONOUNS.id, is_dropdown_field, field_choices, for_manage_user_modal: element_id === "#edit-user-form .custom-profile-field-form", is_empty_required_field: field.required && !field_value.value, editable_by_user, }); $(element_id).append($(html)); } } export type CustomProfileFieldData = { id: number; value?: number[] | string; }; function update_custom_profile_field( field: CustomProfileFieldData, method: channel.AjaxRequestHandler, ): void { let data; if (method === channel.del) { data = JSON.stringify([field.id]); } else { data = JSON.stringify([field]); } const $spinner_element = $( `.custom_user_field[data-field-id="${CSS.escape(field.id.toString())}"] .custom-field-status`, ).expectOne(); settings_ui.do_settings_change(method, "/json/users/me/profile_data", {data}, $spinner_element); } export function update_user_custom_profile_fields( fields: CustomProfileFieldData[], method: channel.AjaxRequestHandler, ): void { for (const field of fields) { update_custom_profile_field(field, method); } } export type PillUpdateField = { type: number; field_data: string; hint: string; id: number; name: string; order: number; required: boolean; display_in_profile_summary?: boolean | undefined; }; export function initialize_custom_user_type_fields( element_id: string, user_id: number, is_target_element_editable: boolean, pill_update_handler?: (field: PillUpdateField, pills: UserPillWidget) => void, ): Map<number, UserPillWidget> { const field_types = realm.custom_profile_field_types; const user_pills = new Map<number, UserPillWidget>(); const person = people.get_by_user_id(user_id); if (person.is_bot) { return user_pills; } for (const field of realm.custom_profile_fields) { const field_value_raw = people.get_custom_profile_data(user_id, field.id)?.value; // If we are not editing the field and field value is null, we don't expect // pill container for that field and proceed further if ( field.type === field_types.USER.id && (field_value_raw !== undefined || is_target_element_editable) ) { const $pill_container = $(element_id) .find( `.custom_user_field[data-field-id="${CSS.escape(`${field.id}`)}"] .pill-container`, ) .expectOne(); const pill_config = { exclude_inaccessible_users: is_target_element_editable, }; const pills = user_pill.create_pills($pill_container, pill_config); if (field_value_raw !== undefined) { const field_value = user_value_schema.parse(JSON.parse(field_value_raw)); for (const pill_user_id of field_value) { const user = people.get_user_by_id_assert_valid(pill_user_id); user_pill.append_user(user, pills); } } // We check and disable fields that this user doesn't have permission to edit. const is_disabled = $pill_container.hasClass("disabled"); if (is_target_element_editable && !is_disabled) { const $input = $pill_container.children(".input"); pill_typeahead.set_up_user($input, pills, {exclude_bots: true}); if (pill_update_handler) { pills.onPillCreate(() => { pill_update_handler(field, pills); }); pills.onPillRemove(() => { pill_update_handler(field, pills); }); } } user_pills.set(field.id, pills); } } // Enable the label associated to this field to focus on the input when clicked. $(element_id) .find(".custom_user_field label.settings-field-label") .on("click", function () { const $input_element = $(this) .closest(".custom_user_field") .find(".person_picker.pill-container .input"); $input_element.trigger("focus"); }); return user_pills; } export function initialize_profile_user_type_pills(user_id: number): void { const field_types = realm.custom_profile_field_types; for (const field of realm.custom_profile_fields) { if (field.type !== field_types.USER.id) { continue; } const raw_field_value = people.get_custom_profile_data(user_id, field.id)?.value; if (!raw_field_value) { continue; } const field_value = user_value_schema.parse(JSON.parse(raw_field_value)); const selector = `.custom_user_field[data-field-id="${field.id}"] .user-type-custom-field-pill-container`; const $pill_container = $("#user-profile-modal #content").find(selector).expectOne(); $pill_container.empty(); for (const user_id of field_value) { const user = people.get_user_by_id_assert_valid(user_id); const pill_html = render_user_display_only_pill({ display_value: user.full_name, user_id: user.user_id, img_src: people.small_avatar_url_for_person(user), is_active: people.is_person_active(user.user_id), is_current_user: people.is_my_user_id(user.user_id), is_bot: user.is_bot, }); $pill_container.append($(pill_html)); } } } 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); } // The "has-date" class controls the clear-date button's visibility, // so derive it from the visible alt input's value. function update_has_date_class($custom_user_field: JQuery): void { const has_value = $custom_user_field.find<HTMLInputElement>(".date-field-alt-input").val()!.length > 0; $custom_user_field.toggleClass("has-date", has_value); } export function initialize_custom_date_type_fields( element_id: string, user_id: number, for_profile_settings_panel = false, ): 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 (!for_profile_settings_panel) { // 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); update_has_date_class($input_elem.closest(".custom_user_field")); if (!for_profile_settings_panel) { // 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 (!for_profile_settings_panel) { // 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); } } let common_class_name = "modal_text_input"; if (for_profile_settings_panel) { common_class_name = "settings_text_input"; } 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 " + common_class_name, altFormat: "F j, Y", allowInput: 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, // Close the date picker on scroll, since flatpickr leaves the // open calendar in place and it would drift away from the input. onOpen(_selected_dates, _date_str, instance) { window.addEventListener( "scroll", () => { instance.close(); }, {capture: true, once: true}, ); }, onChange(_selected_dates, date_str, instance) { update_date(instance, date_str); update_has_date_class($(instance.element).closest(".custom_user_field")); }, }); // 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<HTMLInputElement>("input.date-field-alt-input") .on("change", function () { const instance = the($(this).parent().find(".datepicker"))._flatpickr; assert(instance !== undefined); 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") .on("click", function () { $(this).closest(".custom_user_field").find("input.datepicker").trigger("click"); }); $(element_id) .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(""); update_has_date_class($(this).closest(".custom_user_field")); $custom_user_field.trigger("input"); }); $(element_id) .find<HTMLInputElement>("input.date-field-alt-input") .on("input", function () { update_has_date_class($(this).closest(".custom_user_field")); }); $(element_id) .find(".custom_user_field .datepicker") .each(function () { update_has_date_class($(this).closest(".custom_user_field")); }); } export function initialize_custom_pronouns_type_fields(element_id: string): void { $(element_id) .find<HTMLInputElement>(".pronouns_type_field") .each((_index, pronoun_field) => { const commonly_used_pronouns = [ $t({defaultMessage: "he/him"}), $t({defaultMessage: "she/her"}), $t({defaultMessage: "they/them"}), ]; const bootstrap_typeahead_input = { $element: $(pronoun_field), type: "input" as const, }; new Typeahead(bootstrap_typeahead_input, { helpOnEmptyStrings: () => true, source() { return commonly_used_pronouns; }, sorter(items, query) { return bootstrap_typeahead.defaultSorter(items, query); }, item_html(_query: string) { return (item: string) => typeahead_helper.render_typeahead_item({primary: item}); }, }); }); }