From 2cf09602dfba9bbe08a38b2f813a3d63109639b6 Mon Sep 17 00:00:00 2001 From: Varun Singh Date: Sun, 11 Aug 2024 19:51:08 +0530 Subject: [PATCH] todo_widget: Convert module to TypeScript. --- tools/test-js-with-node | 2 +- web/src/submessage.ts | 8 +- web/src/{todo_widget.js => todo_widget.ts} | 348 +++++++++++++-------- 3 files changed, 225 insertions(+), 133 deletions(-) rename web/src/{todo_widget.js => todo_widget.ts} (65%) diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 70a99b80a2..f39031e353 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -255,7 +255,7 @@ EXEMPT_FILES = make_set( "web/src/thumbnail.ts", "web/src/timerender.ts", "web/src/tippyjs.ts", - "web/src/todo_widget.js", + "web/src/todo_widget.ts", "web/src/topic_list.ts", "web/src/topic_popover.js", "web/src/tutorial.js", diff --git a/web/src/submessage.ts b/web/src/submessage.ts index 9433c4faf5..74638eaeb6 100644 --- a/web/src/submessage.ts +++ b/web/src/submessage.ts @@ -5,6 +5,7 @@ import * as channel from "./channel"; import type {MessageList} from "./message_lists"; import * as message_store from "./message_store"; import type {Message} from "./message_store"; +import {todo_widget_extra_data_schema} from "./todo_widget"; import * as widgetize from "./widgetize"; export type Submessage = { @@ -37,13 +38,6 @@ const poll_widget_extra_data_schema = z }) .nullable(); -export const todo_widget_extra_data_schema = z - .object({ - task_list_title: z.string().optional(), - tasks: z.array(z.object({task: z.string(), desc: z.string()})).optional(), - }) - .nullable(); - const widget_data_event_schema = z.object({ sender_id: z.number(), data: z.discriminatedUnion("widget_type", [ diff --git a/web/src/todo_widget.js b/web/src/todo_widget.ts similarity index 65% rename from web/src/todo_widget.js rename to web/src/todo_widget.ts index 70e1708971..7f8068c056 100644 --- a/web/src/todo_widget.js +++ b/web/src/todo_widget.ts @@ -1,98 +1,88 @@ import $ from "jquery"; +import assert from "minimalistic-assert"; +import {z} from "zod"; import render_widgets_todo_widget from "../templates/widgets/todo_widget.hbs"; import render_widgets_todo_widget_tasks from "../templates/widgets/todo_widget_tasks.hbs"; import * as blueslip from "./blueslip"; import {$t} from "./i18n"; +import type {Message} from "./message_store"; import {page_params} from "./page_params"; import * as people from "./people"; -import {todo_widget_extra_data_schema} from "./submessage"; +import type {Event} from "./poll_widget"; // Any single user should send add a finite number of tasks // to a todo list. We arbitrarily pick this value. const MAX_IDX = 1000; +export const todo_widget_extra_data_schema = z + .object({ + task_list_title: z.string().optional(), + tasks: z.array(z.object({task: z.string(), desc: z.string()})).optional(), + }) + .nullable(); + +const todo_widget_inbound_data = z.intersection( + z.object({ + type: z.enum(["new_task", "new_task_list_title", "strike"]), + }), + z.record(z.string(), z.unknown()), +); + +const new_task_inbound_data_schema = z.object({ + type: z.literal("new_task"), + key: z.number().int().nonnegative().max(MAX_IDX), + task: z.string(), + desc: z.string(), + completed: z.boolean(), +}); + +type NewTaskOutboundData = z.output; + +type NewTaskTitleOutboundData = { + type: "new_task_list_title"; + title: string; +}; + +type TaskStrikeOutboundData = { + type: "strike"; + key: string; +}; + +type TodoTask = { + task: string; + desc: string; +}; + +type Task = { + task: string; + desc: string; + idx: number; + key: string; + completed: boolean; +}; + +export type TodoWidgetOutboundData = + | NewTaskTitleOutboundData + | NewTaskOutboundData + | TaskStrikeOutboundData; + export class TaskData { - task_map = new Map(); + message_sender_id: number; + me: number; + is_my_task_list: boolean; + input_mode: boolean; + report_error_function: (msg: string, more_info?: unknown) => void; + task_list_title: string; + task_map = new Map(); my_idx = 1; - constructor({ - message_sender_id, - current_user_id, - is_my_task_list, - task_list_title, - tasks, - report_error_function, - }) { - this.message_sender_id = message_sender_id; - this.me = current_user_id; - this.is_my_task_list = is_my_task_list; - // input_mode indicates if the task list title is being input currently - this.input_mode = is_my_task_list; // for now - this.report_error_function = report_error_function; - - if (task_list_title) { - this.set_task_list_title(task_list_title); - } else { - this.set_task_list_title($t({defaultMessage: "Task list"})); - } - - for (const [i, data] of tasks.entries()) { - this.handle.new_task.inbound("canned", { - key: i, - task: data.task, - desc: data.desc, - }); - } - } - - set_task_list_title(new_title) { - this.input_mode = false; - this.task_list_title = new_title; - } - - get_task_list_title() { - return this.task_list_title; - } - - set_input_mode() { - this.input_mode = true; - } - - clear_input_mode() { - this.input_mode = false; - } - - get_input_mode() { - return this.input_mode; - } - - get_widget_data() { - const all_tasks = [...this.task_map.values()]; - - const widget_data = { - all_tasks, - }; - - return widget_data; - } - - name_in_use(name) { - for (const item of this.task_map.values()) { - if (item.task === name) { - return true; - } - } - - return false; - } - handle = { new_task_list_title: { - outbound: (title) => { + outbound: (title: string): NewTaskTitleOutboundData | undefined => { const event = { - type: "new_task_list_title", + type: "new_task_list_title" as const, title, }; if (this.is_my_task_list) { @@ -101,8 +91,22 @@ export class TaskData { return undefined; }, - inbound: (sender_id, data) => { + inbound: (sender_id: number, raw_data: unknown): void => { // Only the message author can edit questions. + const new_task_title_inbound_data = z.object({ + type: z.literal("new_task_list_title"), + title: z.string(), + }); + const parsed = new_task_title_inbound_data.safeParse(raw_data); + + if (!parsed.success) { + this.report_error_function( + "todo widget: bad type for inbound task list title", + parsed.error, + ); + return; + } + const data = parsed.data; if (sender_id !== this.message_sender_id) { this.report_error_function( `user ${sender_id} is not allowed to edit the task list title`, @@ -110,20 +114,15 @@ export class TaskData { return; } - if (typeof data.title !== "string") { - this.report_error_function("todo widget: bad type for inbound task list title"); - return; - } - this.set_task_list_title(data.title); }, }, new_task: { - outbound: (task, desc) => { + outbound: (task: string, desc: string): NewTaskOutboundData | undefined => { this.my_idx += 1; const event = { - type: "new_task", + type: "new_task" as const, key: this.my_idx, task, desc, @@ -136,28 +135,21 @@ export class TaskData { return undefined; }, - inbound: (sender_id, data) => { + inbound: (sender_id: number | string, raw_data: unknown): void => { // All readers may add tasks. For legacy reasons, the // inbound idx is called key in the event. + + const parsed = new_task_inbound_data_schema.safeParse(raw_data); + if (!parsed.success) { + blueslip.warn("todo widget: bad type for inbound task data", parsed.error); + return; + } + + const data = parsed.data; const idx = data.key; const task = data.task; const desc = data.desc; - if (!Number.isInteger(idx) || idx < 0 || idx > MAX_IDX) { - blueslip.warn("todo widget: bad type for inbound task idx"); - return; - } - - if (typeof task !== "string") { - blueslip.warn("todo widget: bad type for inbound task title"); - return; - } - - if (typeof desc !== "string") { - blueslip.warn("todo widget: bad type for inbound task desc"); - return; - } - const key = idx + "," + sender_id; const completed = data.completed; @@ -181,23 +173,28 @@ export class TaskData { }, strike: { - outbound(key) { + outbound(key: string): TaskStrikeOutboundData { const event = { - type: "strike", + type: "strike" as const, key, }; return event; }, - inbound: (_sender_id, data) => { - // All message readers may strike/unstrike todo tasks. - const key = data.key; - if (typeof key !== "string") { - blueslip.warn("todo widget: bad type for inbound strike key"); + inbound: (_sender_id: number, raw_data: unknown): void => { + const task_strike_inbound_data_schema = z.object({ + type: z.literal("strike"), + key: z.string(), + }); + const parsed = task_strike_inbound_data_schema.safeParse(raw_data); + if (!parsed.success) { + blueslip.warn("todo widget: bad type for inbound strike key", parsed.error); return; } - + // All message readers may strike/unstrike todo tasks. + const data = parsed.data; + const key = data.key; const item = this.task_map.get(key); if (item === undefined) { @@ -210,9 +207,96 @@ export class TaskData { }, }; - handle_event(sender_id, data) { + constructor({ + message_sender_id, + current_user_id, + is_my_task_list, + task_list_title, + tasks, + report_error_function, + }: { + message_sender_id: number; + current_user_id: number; + is_my_task_list: boolean; + task_list_title: string; + tasks: TodoTask[]; + report_error_function: (msg: string, more_info?: unknown) => void; + }) { + this.message_sender_id = message_sender_id; + this.me = current_user_id; + this.is_my_task_list = is_my_task_list; + // input_mode indicates if the task list title is being input currently + this.input_mode = is_my_task_list; // for now + this.report_error_function = report_error_function; + this.task_list_title = ""; + if (task_list_title) { + this.set_task_list_title(task_list_title); + } else { + this.set_task_list_title($t({defaultMessage: "Task list"})); + } + + for (const [i, data] of tasks.entries()) { + this.handle.new_task.inbound("canned", { + key: i, + task: data.task, + desc: data.desc, + completed: false, + }); + } + } + + set_task_list_title(new_title: string): void { + this.input_mode = false; + this.task_list_title = new_title; + } + + get_task_list_title(): string { + return this.task_list_title; + } + + set_input_mode(): void { + this.input_mode = true; + } + + clear_input_mode(): void { + this.input_mode = false; + } + + get_input_mode(): boolean { + return this.input_mode; + } + + get_widget_data(): { + all_tasks: Task[]; + } { + const all_tasks = [...this.task_map.values()]; + + const widget_data = { + all_tasks, + }; + + return widget_data; + } + + name_in_use(name: string): boolean { + for (const item of this.task_map.values()) { + if (item.task === name) { + return true; + } + } + + return false; + } + + handle_event(sender_id: number, raw_data: unknown): void { + const parsed = todo_widget_inbound_data.safeParse(raw_data); + if (!parsed.success) { + return; + } + + const {data} = parsed; const type = data.type; - if (this.handle[type] && this.handle[type].inbound) { + if (this.handle[type]) { this.handle[type].inbound(sender_id, data); } else { blueslip.warn(`todo widget: unknown inbound type: ${type}`); @@ -220,14 +304,26 @@ export class TaskData { } } -export function activate({$elem, callback, extra_data, message}) { +export function activate({ + $elem, + callback, + extra_data, + message, +}: { + $elem: JQuery; + callback: (data: TodoWidgetOutboundData | undefined) => void; + extra_data: unknown; + message: Message; +}): (events: Event[]) => void { const parse_result = todo_widget_extra_data_schema.safeParse(extra_data); if (!parse_result.success) { blueslip.warn("invalid todo extra data", parse_result.error.issues); - return () => {}; + return () => { + /* we send a dummy function when extra data is invalid */ + }; } const {data} = parse_result; - const {task_list_title = "", tasks = []} = data || {}; + const {task_list_title = "", tasks = []} = data ?? {}; const is_my_task_list = people.is_my_user_id(message.sender_id); const task_data = new TaskData({ message_sender_id: message.sender_id, @@ -238,12 +334,13 @@ export function activate({$elem, callback, extra_data, message}) { report_error_function: blueslip.warn, }); - function update_edit_controls() { - const has_title = $elem.find("input.todo-task-list-title").val().trim() !== ""; + function update_edit_controls(): void { + const has_title = + $elem.find("input.todo-task-list-title").val()?.trim() !== ""; $elem.find("button.todo-task-list-title-check").toggle(has_title); } - function render_task_list_title() { + function render_task_list_title(): void { const task_list_title = task_data.get_task_list_title(); const input_mode = task_data.get_input_mode(); const can_edit = is_my_task_list && !input_mode; @@ -256,7 +353,7 @@ export function activate({$elem, callback, extra_data, message}) { $elem.find(".todo-task-list-title-bar").toggle(input_mode); } - function start_editing() { + function start_editing(): void { task_data.set_input_mode(); const task_list_title = task_data.get_task_list_title(); @@ -265,14 +362,14 @@ export function activate({$elem, callback, extra_data, message}) { $elem.find("input.todo-task-list-title").trigger("focus"); } - function abort_edit() { + function abort_edit(): void { task_data.clear_input_mode(); render_task_list_title(); } - function submit_task_list_title() { - const $task_list_title_input = $elem.find("input.todo-task-list-title"); - let new_task_list_title = $task_list_title_input.val().trim(); + function submit_task_list_title(): void { + const $task_list_title_input = $elem.find("input.todo-task-list-title"); + let new_task_list_title = $task_list_title_input.val()?.trim() ?? ""; const old_task_list_title = task_data.get_task_list_title(); // We should disable the button for blank task list title, @@ -295,7 +392,7 @@ export function activate({$elem, callback, extra_data, message}) { callback(data); } - function build_widget() { + function build_widget(): void { const html = render_widgets_todo_widget(); $elem.html(html); @@ -336,8 +433,8 @@ export function activate({$elem, callback, extra_data, message}) { $elem.find("button.add-task").on("click", (e) => { e.stopPropagation(); $elem.find(".widget-error").text(""); - const task = $elem.find("input.add-task").val().trim(); - const desc = $elem.find("input.add-desc").val().trim(); + const task = $elem.find("input.add-task").val()?.trim() ?? ""; + const desc = $elem.find("input.add-desc").val()?.trim() ?? ""; if (task === "") { return; @@ -357,7 +454,7 @@ export function activate({$elem, callback, extra_data, message}) { }); } - function render_results() { + function render_results(): void { const widget_data = task_data.get_widget_data(); const html = render_widgets_todo_widget_tasks(widget_data); $elem.find("ul.todo-widget").html(html); @@ -377,13 +474,14 @@ export function activate({$elem, callback, extra_data, message}) { return; } const key = $(e.target).attr("data-key"); + assert(key !== undefined); const data = task_data.handle.strike.outbound(key); callback(data); }); } - const handle_events = function (events) { + const handle_events = function (events: Event[]): void { for (const event of events) { task_data.handle_event(event.sender_id, event.data); }