ts: Convert input_pill.js to TypeScript.

Migrated input_pill subsystem to TypeScript, used generics to make
it a generic module so that it works with different implementations
like stream_pill or user_pill.
This commit is contained in:
Lalit 2023-04-08 13:18:21 +05:30 committed by Tim Abbott
parent 83e1d78b7b
commit 833cc71181
3 changed files with 102 additions and 41 deletions

View File

@ -54,7 +54,7 @@ type EmojiDict = {
}; };
// Details needed by template to render an emoji. // Details needed by template to render an emoji.
type EmojiRenderingDetails = { export type EmojiRenderingDetails = {
emoji_name: string; emoji_name: string;
reaction_type: string; reaction_type: string;
emoji_code: string | number; emoji_code: string | number;

View File

@ -5,12 +5,74 @@ import $ from "jquery";
import render_input_pill from "../templates/input_pill.hbs"; import render_input_pill from "../templates/input_pill.hbs";
import * as blueslip from "./blueslip"; import * as blueslip from "./blueslip";
import type {EmojiRenderingDetails} from "./emoji";
import * as keydown_util from "./keydown_util"; import * as keydown_util from "./keydown_util";
import * as ui_util from "./ui_util"; import * as ui_util from "./ui_util";
// See https://zulip.readthedocs.io/en/latest/subsystems/input-pills.html // See https://zulip.readthedocs.io/en/latest/subsystems/input-pills.html
export function create(opts) { export type InputPillItem<T> = {
display_value: string;
type: string;
img_src?: string;
deactivated?: boolean;
status_emoji_info?: EmojiRenderingDetails & {emoji_alt_code: boolean}; // TODO: Move this in user_status.js
} & T;
type InputPillCreateOptions<T> = {
$container: JQuery;
pill_config?: {
show_user_status_emoji?: boolean;
};
create_item_from_text: (
text: string,
existing_items: InputPillItem<T>[],
) => InputPillItem<T> | undefined;
get_text_from_item: (item: InputPillItem<T>) => string;
};
type InputPill<T> = {
item: InputPillItem<T>;
$element: JQuery;
};
type InputPillStore<T> = {
pills: InputPill<T>[];
pill_config: InputPillCreateOptions<T>["pill_config"];
$parent: JQuery;
$input: JQuery;
create_item_from_text: InputPillCreateOptions<T>["create_item_from_text"];
get_text_from_item: InputPillCreateOptions<T>["get_text_from_item"];
onPillCreate?: () => void;
removePillFunction?: (pill: InputPill<T>) => void;
createPillonPaste?: () => void;
};
type InputPillRenderingDetails = {
display_value: string;
has_image: boolean;
img_src?: string;
deactivated?: boolean;
has_status?: boolean;
status_emoji_info?: EmojiRenderingDetails & {emoji_alt_code: boolean};
};
// These are the functions that are exposed to other modules.
type InputPillContainer<T> = {
appendValue: (text: string) => void;
appendValidatedData: (item: InputPillItem<T>) => void;
getByElement: (element: HTMLElement) => InputPill<T> | undefined;
items: () => InputPillItem<T>[];
onPillCreate: (callback: () => void) => void;
onPillRemove: (callback: (pill: InputPill<T>) => void) => void;
createPillonPaste: (callback: () => void) => void;
clear: () => void;
clear_text: () => void;
is_pending: () => boolean;
_get_pills_for_testing: () => InputPill<T>[];
};
export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T> | undefined {
if (!opts.$container) { if (!opts.$container) {
blueslip.error("Pill needs container."); blueslip.error("Pill needs container.");
return undefined; return undefined;
@ -28,7 +90,7 @@ export function create(opts) {
// a stateful object of this `pill_container` instance. // a stateful object of this `pill_container` instance.
// all unique instance information is stored in here. // all unique instance information is stored in here.
const store = { const store: InputPillStore<T> = {
pills: [], pills: [],
pill_config: opts.pill_config, pill_config: opts.pill_config,
$parent: opts.$container, $parent: opts.$container,
@ -42,12 +104,12 @@ export function create(opts) {
// of the `this` arg in the `Function.prototype.bind` use in the prototype. // of the `this` arg in the `Function.prototype.bind` use in the prototype.
const funcs = { const funcs = {
// return the value of the contenteditable input form. // return the value of the contenteditable input form.
value(input_elem) { value(input_elem: HTMLElement) {
return input_elem.textContent; return input_elem.textContent ?? "";
}, },
// clear the value of the input form. // clear the value of the input form.
clear(input_elem) { clear(input_elem: HTMLElement) {
input_elem.textContent = ""; input_elem.textContent = "";
}, },
@ -63,11 +125,11 @@ export function create(opts) {
return store.$input.text().trim() !== ""; return store.$input.text().trim() !== "";
}, },
create_item(text) { create_item(text: string) {
const existing_items = funcs.items(); const existing_items = funcs.items();
const item = store.create_item_from_text(text, existing_items); const item = store.create_item_from_text(text, existing_items);
if (!item || !item.display_value) { if (!item?.display_value) {
store.$input.addClass("shake"); store.$input.addClass("shake");
return undefined; return undefined;
} }
@ -77,7 +139,7 @@ export function create(opts) {
// This is generally called by typeahead logic, where we have all // This is generally called by typeahead logic, where we have all
// the data we need (as opposed to, say, just a user-typed email). // the data we need (as opposed to, say, just a user-typed email).
appendValidatedData(item) { appendValidatedData(item: InputPillItem<T>) {
if (!item.display_value) { if (!item.display_value) {
blueslip.error("no display_value returned"); blueslip.error("no display_value returned");
return; return;
@ -90,7 +152,7 @@ export function create(opts) {
const has_image = item.img_src !== undefined; const has_image = item.img_src !== undefined;
const opts = { const opts: InputPillRenderingDetails = {
display_value: item.display_value, display_value: item.display_value,
has_image, has_image,
deactivated: item.deactivated, deactivated: item.deactivated,
@ -108,12 +170,12 @@ export function create(opts) {
opts.has_status = has_status; opts.has_status = has_status;
} }
if (typeof store.onPillCreate === "function") { if (store.onPillCreate !== undefined) {
store.onPillCreate(); store.onPillCreate();
} }
const pill_html = render_input_pill(opts); const pill_html = render_input_pill(opts);
const payload = { const payload: InputPill<T> = {
item, item,
$element: $(pill_html), $element: $(pill_html),
}; };
@ -124,7 +186,7 @@ export function create(opts) {
// this appends a pill to the end of the container but before the // this appends a pill to the end of the container but before the
// input block. // input block.
appendPill(value) { appendPill(value: string) {
if (value.length === 0) { if (value.length === 0) {
return true; return true;
} }
@ -136,7 +198,7 @@ export function create(opts) {
const payload = this.create_item(value); const payload = this.create_item(value);
// if the pill object is undefined, then it means the pill was // if the pill object is undefined, then it means the pill was
// rejected so we should return out of this. // rejected so we should return out of this.
if (!payload) { if (payload === undefined) {
return false; return false;
} }
@ -148,19 +210,19 @@ export function create(opts) {
// from the DOM, removes it from the array and returns it. // from the DOM, removes it from the array and returns it.
// this would generally be used for DOM-provoked actions, such as a user // this would generally be used for DOM-provoked actions, such as a user
// clicking on a pill to remove it. // clicking on a pill to remove it.
removePill(element) { removePill(element: HTMLElement) {
let idx; let idx: number | undefined;
for (let x = 0; x < store.pills.length; x += 1) { for (let x = 0; x < store.pills.length; x += 1) {
if (store.pills[x].$element[0] === element) { if (store.pills[x].$element[0] === element) {
idx = x; idx = x;
} }
} }
if (typeof idx === "number") { if (idx !== undefined) {
store.pills[idx].$element.remove(); store.pills[idx].$element.remove();
const pill = store.pills.splice(idx, 1); const pill = store.pills.splice(idx, 1);
if (typeof store.removePillFunction === "function") { if (store.removePillFunction !== undefined) {
store.removePillFunction(pill); store.removePillFunction(pill[0]);
} }
// This is needed to run the "change" event handler registered in // This is needed to run the "change" event handler registered in
@ -180,18 +242,18 @@ export function create(opts) {
// If quiet is a truthy value, the event handler associated with the // If quiet is a truthy value, the event handler associated with the
// pill will not be evaluated. This is useful when using clear to reset // pill will not be evaluated. This is useful when using clear to reset
// the pills. // the pills.
removeLastPill(quiet) { removeLastPill(quiet?: boolean) {
const pill = store.pills.pop(); const pill = store.pills.pop();
if (pill) { if (pill) {
pill.$element.remove(); pill.$element.remove();
if (!quiet && typeof store.removePillFunction === "function") { if (!quiet && store.removePillFunction !== undefined) {
store.removePillFunction(pill); store.removePillFunction(pill);
} }
} }
}, },
removeAllPills(quiet) { removeAllPills(quiet?: boolean) {
while (store.pills.length > 0) { while (store.pills.length > 0) {
this.removeLastPill(quiet); this.removeLastPill(quiet);
} }
@ -199,7 +261,7 @@ export function create(opts) {
this.clear(store.$input[0]); this.clear(store.$input[0]);
}, },
insertManyPills(pills) { insertManyPills(pills: string | string[]) {
if (typeof pills === "string") { if (typeof pills === "string") {
pills = pills.split(/,/g).map((pill) => pill.trim()); pills = pills.split(/,/g).map((pill) => pill.trim());
} }
@ -210,7 +272,7 @@ export function create(opts) {
(pill) => (pill) =>
// if this returns `false`, it errored and we should push it to // if this returns `false`, it errored and we should push it to
// the draft pills. // the draft pills.
funcs.appendPill(pill) === false, !funcs.appendPill(pill),
); );
store.$input.text(drafts.join(", ")); store.$input.text(drafts.join(", "));
@ -225,7 +287,7 @@ export function create(opts) {
return drafts.length === 0; return drafts.length === 0;
}, },
getByElement(element) { getByElement(element: HTMLElement) {
return store.pills.find((pill) => pill.$element[0] === element); return store.pills.find((pill) => pill.$element[0] === element);
}, },
@ -238,7 +300,7 @@ export function create(opts) {
}, },
createPillonPaste() { createPillonPaste() {
if (typeof store.createPillonPaste === "function") { if (store.createPillonPaste !== undefined) {
return store.createPillonPaste(); return store.createPillonPaste();
} }
return true; return true;
@ -263,7 +325,7 @@ export function create(opts) {
// if the pill to append was rejected, no need to clear the // if the pill to append was rejected, no need to clear the
// input; it may have just been a typo or something close but // input; it may have just been a typo or something close but
// incorrect. // incorrect.
if (ret !== false) { if (ret) {
// clear the input. // clear the input.
funcs.clear(e.target); funcs.clear(e.target);
e.stopPropagation(); e.stopPropagation();
@ -277,7 +339,7 @@ export function create(opts) {
// deletion, otherwise delete the last pill in the sequence. // deletion, otherwise delete the last pill in the sequence.
if ( if (
e.key === "Backspace" && e.key === "Backspace" &&
(funcs.value(e.target).length === 0 || window.getSelection().anchorOffset === 0) (funcs.value(e.target).length === 0 || window.getSelection()?.anchorOffset === 0)
) { ) {
e.preventDefault(); e.preventDefault();
funcs.removeLastPill(); funcs.removeLastPill();
@ -289,7 +351,7 @@ export function create(opts) {
// should switch to focus the last pill in the list. // should switch to focus the last pill in the list.
// the rest of the events then will be taken care of in the function // the rest of the events then will be taken care of in the function
// below that handles events on the ".pill" class. // below that handles events on the ".pill" class.
if (e.key === "ArrowLeft" && window.getSelection().anchorOffset === 0) { if (e.key === "ArrowLeft" && window.getSelection()?.anchorOffset === 0) {
store.$parent.find(".pill").last().trigger("focus"); store.$parent.find(".pill").last().trigger("focus");
} }
@ -298,7 +360,7 @@ export function create(opts) {
if (e.key === ",") { if (e.key === ",") {
// if the pill is successful, it will create the pill and clear // if the pill is successful, it will create the pill and clear
// the input. // the input.
if (funcs.appendPill(store.$input.text().trim()) !== false) { if (funcs.appendPill(store.$input.text().trim())) {
funcs.clear(store.$input[0]); funcs.clear(store.$input[0]);
} }
e.preventDefault(); e.preventDefault();
@ -343,7 +405,9 @@ export function create(opts) {
e.preventDefault(); e.preventDefault();
// get text representation of clipboard // get text representation of clipboard
const text = (e.originalEvent || e).clipboardData.getData("text/plain"); const text = ((e.originalEvent ?? e) as ClipboardEvent).clipboardData?.getData(
"text/plain",
);
// insert text manually // insert text manually
document.execCommand("insertText", false, text); document.execCommand("insertText", false, text);
@ -371,18 +435,18 @@ export function create(opts) {
}); });
store.$parent.on("copy", ".pill", (e) => { store.$parent.on("copy", ".pill", (e) => {
const $element = store.$parent.find(":focus"); const $element = e.currentTarget as HTMLElement;
const data = funcs.getByElement($element[0]); const {item} = funcs.getByElement($element)!;
e.originalEvent.clipboardData.setData( (e.originalEvent as ClipboardEvent).clipboardData?.setData(
"text/plain", "text/plain",
store.get_text_from_item(data.item), store.get_text_from_item(item),
); );
e.preventDefault(); e.preventDefault();
}); });
} }
// the external, user-accessible prototype. // the external, user-accessible prototype.
const prototype = { const prototype: InputPillContainer<T> = {
appendValue: funcs.appendPill.bind(funcs), appendValue: funcs.appendPill.bind(funcs),
appendValidatedData: funcs.appendValidatedData.bind(funcs), appendValidatedData: funcs.appendValidatedData.bind(funcs),

View File

@ -161,11 +161,10 @@ run_test("copy from pill", ({mock_template}) => {
let copied_text; let copied_text;
const $pill_stub = { const $pill_stub = "<pill-stub RED>";
[0]: "<pill-stub RED>",
};
const e = { const e = {
currentTarget: $pill_stub,
originalEvent: { originalEvent: {
clipboardData: { clipboardData: {
setData(format, text) { setData(format, text) {
@ -177,8 +176,6 @@ run_test("copy from pill", ({mock_template}) => {
preventDefault: noop, preventDefault: noop,
}; };
$container.set_find_results(":focus", $pill_stub);
copy_handler(e); copy_handler(e);
assert.equal(copied_text, "RED"); assert.equal(copied_text, "RED");