mirror of
https://github.com/zulip/zulip.git
synced 2025-11-14 02:48:00 +00:00
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:
@@ -102,6 +102,7 @@ EXEMPT_FILES = make_set(
|
|||||||
"web/src/dropdown_widget.ts",
|
"web/src/dropdown_widget.ts",
|
||||||
"web/src/echo.js",
|
"web/src/echo.js",
|
||||||
"web/src/electron_bridge.d.ts",
|
"web/src/electron_bridge.d.ts",
|
||||||
|
"web/src/email_pill.ts",
|
||||||
"web/src/emoji_picker.js",
|
"web/src/emoji_picker.js",
|
||||||
"web/src/emojisets.ts",
|
"web/src/emojisets.ts",
|
||||||
"web/src/favicon.ts",
|
"web/src/favicon.ts",
|
||||||
|
|||||||
57
web/src/email_pill.ts
Normal file
57
web/src/email_pill.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ type InputPill<T> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type InputPillStore<T> = {
|
type InputPillStore<T> = {
|
||||||
|
onTextInputHook?: () => void;
|
||||||
pills: InputPill<T>[];
|
pills: InputPill<T>[];
|
||||||
pill_config: InputPillCreateOptions<T>["pill_config"];
|
pill_config: InputPillCreateOptions<T>["pill_config"];
|
||||||
$parent: JQuery;
|
$parent: JQuery;
|
||||||
@@ -72,9 +73,11 @@ export type InputPillContainer<T> = {
|
|||||||
items: () => InputPillItem<T>[];
|
items: () => InputPillItem<T>[];
|
||||||
onPillCreate: (callback: () => void) => void;
|
onPillCreate: (callback: () => void) => void;
|
||||||
onPillRemove: (callback: (pill: InputPill<T>) => void) => void;
|
onPillRemove: (callback: (pill: InputPill<T>) => void) => void;
|
||||||
|
onTextInputHook: (callback: () => void) => void;
|
||||||
createPillonPaste: (callback: () => void) => void;
|
createPillonPaste: (callback: () => void) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
clear_text: () => void;
|
clear_text: () => void;
|
||||||
|
getCurrentText: () => string | null;
|
||||||
is_pending: () => boolean;
|
is_pending: () => boolean;
|
||||||
_get_pills_for_testing: () => InputPill<T>[];
|
_get_pills_for_testing: () => InputPill<T>[];
|
||||||
};
|
};
|
||||||
@@ -109,6 +112,10 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
|
|||||||
store.$input.text("");
|
store.$input.text("");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getCurrentText() {
|
||||||
|
return store.$input.text();
|
||||||
|
},
|
||||||
|
|
||||||
is_pending() {
|
is_pending() {
|
||||||
// This function returns true if we have text
|
// This function returns true if we have text
|
||||||
// in out widget that hasn't been turned into
|
// in out widget that hasn't been turned into
|
||||||
@@ -328,7 +335,6 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
// If no text is selected, and the cursor is just to the
|
// If no text is selected, and the cursor is just to the
|
||||||
// right of the last pill (with or without text in 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
store.onTextInputHook?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
// handle events while hovering on ".pill" elements.
|
// 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
|
// get text representation of clipboard
|
||||||
assert(e.originalEvent instanceof ClipboardEvent);
|
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
|
// insert text manually
|
||||||
document.execCommand("insertText", false, text);
|
document.execCommand("insertText", false, text);
|
||||||
@@ -445,6 +453,7 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
|
|||||||
appendValidatedData: funcs.appendValidatedData.bind(funcs),
|
appendValidatedData: funcs.appendValidatedData.bind(funcs),
|
||||||
|
|
||||||
getByElement: funcs.getByElement.bind(funcs),
|
getByElement: funcs.getByElement.bind(funcs),
|
||||||
|
getCurrentText: funcs.getCurrentText.bind(funcs),
|
||||||
items: funcs.items.bind(funcs),
|
items: funcs.items.bind(funcs),
|
||||||
|
|
||||||
onPillCreate(callback) {
|
onPillCreate(callback) {
|
||||||
@@ -455,6 +464,10 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
|
|||||||
store.onPillRemove = callback;
|
store.onPillRemove = callback;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onTextInputHook(callback) {
|
||||||
|
store.onTextInputHook = callback;
|
||||||
|
},
|
||||||
|
|
||||||
createPillonPaste(callback) {
|
createPillonPaste(callback) {
|
||||||
store.createPillonPaste = callback;
|
store.createPillonPaste = callback;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import autosize from "autosize";
|
|
||||||
import ClipboardJS from "clipboard";
|
import ClipboardJS from "clipboard";
|
||||||
import {add} from "date-fns";
|
import {add} from "date-fns";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
@@ -16,7 +15,9 @@ import * as compose_banner from "./compose_banner";
|
|||||||
import {show_copied_confirmation} from "./copied_tooltip";
|
import {show_copied_confirmation} from "./copied_tooltip";
|
||||||
import {csrf_token} from "./csrf";
|
import {csrf_token} from "./csrf";
|
||||||
import * as dialog_widget from "./dialog_widget";
|
import * as dialog_widget from "./dialog_widget";
|
||||||
|
import * as email_pill from "./email_pill";
|
||||||
import {$t, $t_html} from "./i18n";
|
import {$t, $t_html} from "./i18n";
|
||||||
|
import * as input_pill from "./input_pill";
|
||||||
import {page_params} from "./page_params";
|
import {page_params} from "./page_params";
|
||||||
import * as scroll_util from "./scroll_util";
|
import * as scroll_util from "./scroll_util";
|
||||||
import * as settings_config from "./settings_config";
|
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_input = 10;
|
||||||
let custom_expiration_time_unit = "days";
|
let custom_expiration_time_unit = "days";
|
||||||
|
let pills: email_pill.EmailPillWidget;
|
||||||
|
|
||||||
function reset_error_messages(): void {
|
function reset_error_messages(): void {
|
||||||
$("#dialog_error").hide().text("").removeClass(common.status_classes);
|
$("#dialog_error").hide().text("").removeClass(common.status_classes);
|
||||||
@@ -43,7 +45,7 @@ function get_common_invitation_data(): {
|
|||||||
invite_as: number;
|
invite_as: number;
|
||||||
stream_ids: string;
|
stream_ids: string;
|
||||||
invite_expires_in_minutes: string;
|
invite_expires_in_minutes: string;
|
||||||
invitee_emails?: string;
|
invitee_emails: string;
|
||||||
} {
|
} {
|
||||||
const invite_as = Number.parseInt(
|
const invite_as = Number.parseInt(
|
||||||
$<HTMLSelectElement & {type: "select-one"}>("select:not([multiple])#invite_as").val()!,
|
$<HTMLSelectElement & {type: "select-one"}>("select:not([multiple])#invite_as").val()!,
|
||||||
@@ -79,7 +81,19 @@ function get_common_invitation_data(): {
|
|||||||
invite_as,
|
invite_as,
|
||||||
stream_ids: JSON.stringify(stream_ids),
|
stream_ids: JSON.stringify(stream_ids),
|
||||||
invite_expires_in_minutes: JSON.stringify(expires_in),
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,16 +114,14 @@ function submit_invitation_form(): void {
|
|||||||
"select:not([multiple])#expires_in",
|
"select:not([multiple])#expires_in",
|
||||||
);
|
);
|
||||||
const $invite_status = $("#dialog_error");
|
const $invite_status = $("#dialog_error");
|
||||||
const $invitee_emails = $<HTMLTextAreaElement>("textarea#invitee_emails");
|
|
||||||
const data = get_common_invitation_data();
|
const data = get_common_invitation_data();
|
||||||
data.invitee_emails = $invitee_emails.val()!;
|
|
||||||
|
|
||||||
void channel.post({
|
void channel.post({
|
||||||
url: "/json/invites",
|
url: "/json/invites",
|
||||||
data,
|
data,
|
||||||
beforeSend,
|
beforeSend,
|
||||||
success() {
|
success() {
|
||||||
const number_of_invites_sent = $invitee_emails.val()!.split(/[\n,]/).length;
|
const number_of_invites_sent = pills.items().length;
|
||||||
ui_report.success(
|
ui_report.success(
|
||||||
$t_html(
|
$t_html(
|
||||||
{
|
{
|
||||||
@@ -120,7 +132,7 @@ function submit_invitation_form(): void {
|
|||||||
),
|
),
|
||||||
$invite_status,
|
$invite_status,
|
||||||
);
|
);
|
||||||
$invitee_emails.val("");
|
pills.clear();
|
||||||
|
|
||||||
if (page_params.development_environment) {
|
if (page_params.development_environment) {
|
||||||
const rendered_email_msg = render_settings_dev_env_email_access();
|
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");
|
ui_report.message(error_response, $invite_status, "alert-error");
|
||||||
|
|
||||||
if (response_body.sent_invitations) {
|
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"}>(
|
const $expires_in = $<HTMLSelectElement & {type: "select-one"}>(
|
||||||
"select:not([multiple])#expires_in",
|
"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);
|
$("#invite-user-modal .dialog_submit_button").prop("disabled", true);
|
||||||
$("#email_invite_radio").prop("checked", 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();
|
const user_has_email_set = !settings_data.user_email_not_configured();
|
||||||
|
|
||||||
autosize($invitee_emails.trigger("focus"));
|
|
||||||
|
|
||||||
set_custom_time_inputs_visibility();
|
set_custom_time_inputs_visibility();
|
||||||
set_expires_on_text();
|
set_expires_on_text();
|
||||||
set_streams_to_join_list_visibility();
|
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 {
|
function toggle_invite_submit_button(): void {
|
||||||
$("#invite-user-modal .dialog_submit_button").prop(
|
$("#invite-user-modal .dialog_submit_button").prop(
|
||||||
"disabled",
|
"disabled",
|
||||||
$invitee_emails.val()!.trim() === "" &&
|
pills.items().length === 0 &&
|
||||||
|
email_pill.get_current_email(pills) === null &&
|
||||||
!$("#generate_multiuse_invite_radio").is(":checked"),
|
!$("#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", () => {
|
$("#invite-user-modal").on("input", "input, textarea, select", () => {
|
||||||
toggle_invite_submit_button();
|
toggle_invite_submit_button();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -141,6 +141,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#invitee_emails_container .pill-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.deactivated-pill {
|
.deactivated-pill {
|
||||||
background-color: hsl(0deg 86% 86%) !important;
|
background-color: hsl(0deg 86% 86%) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="invitee_emails_container">
|
<div id="invitee_emails_container">
|
||||||
<label for="invitee_emails">{{t "Emails (one on each line or comma-separated)" }}</label>
|
<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>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
|
|||||||
@@ -639,3 +639,40 @@ run_test("appendValue/clear", ({mock_template}) => {
|
|||||||
assert.deepEqual(removed_colors, ["blue", "yellow", "red"]);
|
assert.deepEqual(removed_colors, ["blue", "yellow", "red"]);
|
||||||
assert.equal($pill_input[0].textContent, "");
|
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]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user