invite_user_modal: Replaced email text_area with input_pill.

This makes the widget considerably more attractive, and probably a bit
more usable.

Fixes #29391.
This commit is contained in:
adnan-td 2024-03-24 00:06:45 +05:30 committed by Tim Abbott
parent 97cbf4e075
commit 87dee7a9b2
7 changed files with 152 additions and 14 deletions

View File

@ -102,6 +102,7 @@ EXEMPT_FILES = make_set(
"web/src/dropdown_widget.ts",
"web/src/echo.js",
"web/src/electron_bridge.d.ts",
"web/src/email_pill.ts",
"web/src/emoji_picker.js",
"web/src/emojisets.ts",
"web/src/favicon.ts",

57
web/src/email_pill.ts Normal file
View File

@ -0,0 +1,57 @@
import type {InputPillConfig, InputPillContainer, InputPillItem} from "./input_pill";
import * as input_pill from "./input_pill";
type EmailPill = {
email: string;
};
export type EmailPillWidget = InputPillContainer<EmailPill>;
const email_regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function create_item_from_email(
email: string,
current_items: InputPillItem<EmailPill>[],
): InputPillItem<EmailPill> | undefined {
if (!email_regex.test(email)) {
return undefined;
}
const existing_emails = current_items.map((item) => item.email);
if (existing_emails.includes(email)) {
return undefined;
}
return {
type: "email",
display_value: email,
email,
};
}
export function get_email_from_item(item: InputPillItem<EmailPill>): string {
return item.email;
}
export function get_current_email(
pill_container: input_pill.InputPillContainer<EmailPill>,
): string | null {
const current_text = pill_container.getCurrentText();
if (current_text !== null && email_regex.test(current_text)) {
return current_text;
}
return null;
}
export function create_pills(
$pill_container: JQuery,
pill_config?: InputPillConfig | undefined,
): input_pill.InputPillContainer<EmailPill> {
const pill_container = input_pill.create({
$container: $pill_container,
pill_config,
create_item_from_text: create_item_from_email,
get_text_from_item: get_email_from_item,
});
return pill_container;
}

View File

@ -43,6 +43,7 @@ type InputPill<T> = {
};
type InputPillStore<T> = {
onTextInputHook?: () => void;
pills: InputPill<T>[];
pill_config: InputPillCreateOptions<T>["pill_config"];
$parent: JQuery;
@ -72,9 +73,11 @@ export type InputPillContainer<T> = {
items: () => InputPillItem<T>[];
onPillCreate: (callback: () => void) => void;
onPillRemove: (callback: (pill: InputPill<T>) => void) => void;
onTextInputHook: (callback: () => void) => void;
createPillonPaste: (callback: () => void) => void;
clear: () => void;
clear_text: () => void;
getCurrentText: () => string | null;
is_pending: () => boolean;
_get_pills_for_testing: () => InputPill<T>[];
};
@ -109,6 +112,10 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
store.$input.text("");
},
getCurrentText() {
return store.$input.text();
},
is_pending() {
// This function returns true if we have text
// in out widget that hasn't been turned into
@ -328,7 +335,6 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
return;
}
const selection = window.getSelection();
// If no text is selected, and the cursor is just to the
// right of the last pill (with or without text in the
@ -364,6 +370,8 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
return;
}
store.onTextInputHook?.();
});
// handle events while hovering on ".pill" elements.
@ -403,7 +411,7 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
// get text representation of clipboard
assert(e.originalEvent instanceof ClipboardEvent);
const text = e.originalEvent.clipboardData?.getData("text/plain");
const text = e.originalEvent.clipboardData?.getData("text/plain").replaceAll("\n", ",");
// insert text manually
document.execCommand("insertText", false, text);
@ -445,6 +453,7 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
appendValidatedData: funcs.appendValidatedData.bind(funcs),
getByElement: funcs.getByElement.bind(funcs),
getCurrentText: funcs.getCurrentText.bind(funcs),
items: funcs.items.bind(funcs),
onPillCreate(callback) {
@ -455,6 +464,10 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
store.onPillRemove = callback;
},
onTextInputHook(callback) {
store.onTextInputHook = callback;
},
createPillonPaste(callback) {
store.createPillonPaste = callback;
},

View File

@ -1,4 +1,3 @@
import autosize from "autosize";
import ClipboardJS from "clipboard";
import {add} from "date-fns";
import $ from "jquery";
@ -16,7 +15,9 @@ import * as compose_banner from "./compose_banner";
import {show_copied_confirmation} from "./copied_tooltip";
import {csrf_token} from "./csrf";
import * as dialog_widget from "./dialog_widget";
import * as email_pill from "./email_pill";
import {$t, $t_html} from "./i18n";
import * as input_pill from "./input_pill";
import {page_params} from "./page_params";
import * as scroll_util from "./scroll_util";
import * as settings_config from "./settings_config";
@ -29,6 +30,7 @@ import * as util from "./util";
let custom_expiration_time_input = 10;
let custom_expiration_time_unit = "days";
let pills: email_pill.EmailPillWidget;
function reset_error_messages(): void {
$("#dialog_error").hide().text("").removeClass(common.status_classes);
@ -43,7 +45,7 @@ function get_common_invitation_data(): {
invite_as: number;
stream_ids: string;
invite_expires_in_minutes: string;
invitee_emails?: string;
invitee_emails: string;
} {
const invite_as = Number.parseInt(
$<HTMLSelectElement & {type: "select-one"}>("select:not([multiple])#invite_as").val()!,
@ -79,7 +81,19 @@ function get_common_invitation_data(): {
invite_as,
stream_ids: JSON.stringify(stream_ids),
invite_expires_in_minutes: JSON.stringify(expires_in),
invitee_emails: pills
.items()
.map((pill) => email_pill.get_email_from_item(pill))
.join(","),
};
const current_email = email_pill.get_current_email(pills);
if (current_email) {
if (pills.items().length === 0) {
data.invitee_emails = current_email;
} else {
data.invitee_emails += "," + current_email;
}
}
return data;
}
@ -100,16 +114,14 @@ function submit_invitation_form(): void {
"select:not([multiple])#expires_in",
);
const $invite_status = $("#dialog_error");
const $invitee_emails = $<HTMLTextAreaElement>("textarea#invitee_emails");
const data = get_common_invitation_data();
data.invitee_emails = $invitee_emails.val()!;
void channel.post({
url: "/json/invites",
data,
beforeSend,
success() {
const number_of_invites_sent = $invitee_emails.val()!.split(/[\n,]/).length;
const number_of_invites_sent = pills.items().length;
ui_report.success(
$t_html(
{
@ -120,7 +132,7 @@ function submit_invitation_form(): void {
),
$invite_status,
);
$invitee_emails.val("");
pills.clear();
if (page_params.development_environment) {
const rendered_email_msg = render_settings_dev_env_email_access();
@ -171,7 +183,9 @@ function submit_invitation_form(): void {
ui_report.message(error_response, $invite_status, "alert-error");
if (response_body.sent_invitations) {
$invitee_emails.val(invitee_emails_errored.join("\n"));
for (const email of invitee_emails_errored) {
pills.appendValue(email);
}
}
}
},
@ -327,7 +341,14 @@ function open_invite_user_modal(e: JQuery.ClickEvent<Document, undefined>): void
const $expires_in = $<HTMLSelectElement & {type: "select-one"}>(
"select:not([multiple])#expires_in",
);
const $invitee_emails = $<HTMLTextAreaElement>("textarea#invitee_emails");
const $pill_container = $("#invitee_emails_container .pill-container");
pills = input_pill.create({
$container: $pill_container,
create_item_from_text: email_pill.create_item_from_email,
get_text_from_item: email_pill.get_email_from_item,
});
const $pill_input = $("#invitee_emails_container .pill-container .input");
$pill_input.trigger("focus");
$("#invite-user-modal .dialog_submit_button").prop("disabled", true);
$("#email_invite_radio").prop("checked", true);
@ -340,8 +361,6 @@ function open_invite_user_modal(e: JQuery.ClickEvent<Document, undefined>): void
const user_has_email_set = !settings_data.user_email_not_configured();
autosize($invitee_emails.trigger("focus"));
set_custom_time_inputs_visibility();
set_expires_on_text();
set_streams_to_join_list_visibility();
@ -358,11 +377,16 @@ function open_invite_user_modal(e: JQuery.ClickEvent<Document, undefined>): void
function toggle_invite_submit_button(): void {
$("#invite-user-modal .dialog_submit_button").prop(
"disabled",
$invitee_emails.val()!.trim() === "" &&
pills.items().length === 0 &&
email_pill.get_current_email(pills) === null &&
!$("#generate_multiuse_invite_radio").is(":checked"),
);
}
pills.onPillCreate(toggle_invite_submit_button);
pills.onPillRemove(toggle_invite_submit_button);
pills.onTextInputHook(toggle_invite_submit_button);
$("#invite-user-modal").on("input", "input, textarea, select", () => {
toggle_invite_submit_button();
});

View File

@ -141,6 +141,10 @@
}
}
#invitee_emails_container .pill-container {
width: 100%;
}
.deactivated-pill {
background-color: hsl(0deg 86% 86%) !important;
}

View File

@ -29,7 +29,9 @@
</div>
<div id="invitee_emails_container">
<label for="invitee_emails">{{t "Emails (one on each line or comma-separated)" }}</label>
<textarea rows="2" id="invitee_emails" name="invitee_emails" class="invitee_emails" placeholder="{{t 'One or more email addresses...' }}"></textarea>
<div class="pill-container">
<div class="input" contenteditable="true"></div>
</div>
</div>
</div>
<div class="input-group">

View File

@ -639,3 +639,40 @@ run_test("appendValue/clear", ({mock_template}) => {
assert.deepEqual(removed_colors, ["blue", "yellow", "red"]);
assert.equal($pill_input[0].textContent, "");
});
run_test("getCurrentText/onTextInputHook", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
return html;
});
const info = set_up();
const config = info.config;
const items = info.items;
const $pill_input = info.$pill_input;
const $container = info.$container;
const widget = input_pill.create(config);
widget.appendValue("blue,red");
assert.deepEqual(widget.items(), [items.blue, items.red]);
const onTextInputHook = () => {
assert.deepEqual(widget.items(), [items.blue, items.red]);
};
widget.onTextInputHook(onTextInputHook);
$pill_input.text("yellow");
assert.equal(widget.getCurrentText(), "yellow");
const key_handler = $container.get_on_handler("keydown", ".input");
key_handler({
key: " ",
preventDefault: noop,
});
key_handler({
key: ",",
preventDefault: noop,
});
assert.deepEqual(widget.items(), [items.blue, items.red, items.yellow]);
});