mirror of https://github.com/zulip/zulip.git
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:
parent
97cbf4e075
commit
87dee7a9b2
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -141,6 +141,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
#invitee_emails_container .pill-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deactivated-pill {
|
||||
background-color: hsl(0deg 86% 86%) !important;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue