mirror of https://github.com/zulip/zulip.git
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:
parent
83e1d78b7b
commit
833cc71181
|
@ -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;
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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");
|
||||||
|
|
Loading…
Reference in New Issue