todo_widget: Allow task list title to be set and edited by author.

Users can now name task lists by providing the task list title in the
`/todo` command on the same line. Example: `/todo School Work`. If no
title is provided by the user, "Task list" (which is also the
placeholder) is used as default.

The author of a task list can later edit / update the task list title
in the todo widget, just like the question in the poll widget.

Fixes part of #20213.
This commit is contained in:
N-Shar-ma 2022-07-29 07:55:31 +05:30 committed by Tim Abbott
parent b30eb4c4fc
commit 6df3ad251a
10 changed files with 390 additions and 21 deletions

View File

@ -489,6 +489,7 @@ export const slash_commands = [
text: $t({defaultMessage: "/todo (Create a collaborative to-do list)"}), text: $t({defaultMessage: "/todo (Create a collaborative to-do list)"}),
name: "todo", name: "todo",
aliases: "", aliases: "",
placeholder: $t({defaultMessage: "Task list"}),
}, },
]; ];

View File

@ -29,12 +29,21 @@ const poll_widget_extra_data_schema = z
}) })
.nullable(); .nullable();
export const todo_widget_extra_data_schema = z
.object({
task_list_title: z.string().optional(),
})
.nullable();
const widget_data_event_schema = z.object({ const widget_data_event_schema = z.object({
sender_id: z.number(), sender_id: z.number(),
data: z.discriminatedUnion("widget_type", [ data: z.discriminatedUnion("widget_type", [
z.object({widget_type: z.literal("poll"), extra_data: poll_widget_extra_data_schema}), z.object({widget_type: z.literal("poll"), extra_data: poll_widget_extra_data_schema}),
z.object({widget_type: z.literal("zform"), extra_data: zform_widget_extra_data_schema}), z.object({widget_type: z.literal("zform"), extra_data: zform_widget_extra_data_schema}),
z.object({widget_type: z.literal("todo"), extra_data: z.null()}), z.object({
widget_type: z.literal("todo"),
extra_data: todo_widget_extra_data_schema,
}),
]), ]),
}); });

View File

@ -7,6 +7,7 @@ import * as blueslip from "./blueslip";
import {$t} from "./i18n"; import {$t} from "./i18n";
import {page_params} from "./page_params"; import {page_params} from "./page_params";
import * as people from "./people"; import * as people from "./people";
import {todo_widget_extra_data_schema} from "./submessage";
// Any single user should send add a finite number of tasks // Any single user should send add a finite number of tasks
// to a todo list. We arbitrarily pick this value. // to a todo list. We arbitrarily pick this value.
@ -16,8 +17,46 @@ export class TaskData {
task_map = new Map(); task_map = new Map();
my_idx = 1; my_idx = 1;
constructor({current_user_id}) { constructor({
message_sender_id,
current_user_id,
is_my_task_list,
task_list_title,
report_error_function,
}) {
this.message_sender_id = message_sender_id;
this.me = current_user_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"}));
}
}
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() { get_widget_data() {
@ -41,6 +80,36 @@ export class TaskData {
} }
handle = { handle = {
new_task_list_title: {
outbound: (title) => {
const event = {
type: "new_task_list_title",
title,
};
if (this.is_my_task_list) {
return event;
}
return undefined;
},
inbound: (sender_id, data) => {
// Only the message author can edit questions.
if (sender_id !== this.message_sender_id) {
this.report_error_function(
`user ${sender_id} is not allowed to edit the task list title`,
);
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: { new_task: {
outbound: (task, desc) => { outbound: (task, desc) => {
this.my_idx += 1; this.my_idx += 1;
@ -143,18 +212,118 @@ export class TaskData {
} }
} }
export function activate(opts) { export function activate({$elem, callback, extra_data, message}) {
const $elem = opts.$elem; const parse_result = todo_widget_extra_data_schema.safeParse(extra_data);
const callback = opts.callback; if (!parse_result.success) {
blueslip.warn("invalid todo extra data", parse_result.error.issues);
return;
}
const {data} = parse_result;
const {task_list_title = ""} = data || {};
const is_my_task_list = people.is_my_user_id(message.sender_id);
const task_data = new TaskData({ const task_data = new TaskData({
message_sender_id: message.sender_id,
current_user_id: people.my_current_user_id(), current_user_id: people.my_current_user_id(),
is_my_task_list,
task_list_title,
report_error_function: blueslip.warn,
}); });
function render() { function update_edit_controls() {
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() {
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;
$elem.find(".todo-task-list-title-header").toggle(!input_mode);
$elem.find(".todo-task-list-title-header").text(task_list_title);
$elem.find(".todo-edit-task-list-title").toggle(can_edit);
update_edit_controls();
$elem.find(".todo-task-list-title-bar").toggle(input_mode);
}
function start_editing() {
task_data.set_input_mode();
const task_list_title = task_data.get_task_list_title();
$elem.find("input.todo-task-list-title").val(task_list_title);
render_task_list_title();
$elem.find("input.todo-task-list-title").trigger("focus");
}
function abort_edit() {
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();
const old_task_list_title = task_data.get_task_list_title();
// We should disable the button for blank task list title,
// so this is just defensive code.
if (new_task_list_title.trim() === "") {
new_task_list_title = old_task_list_title;
}
// Optimistically set the task list title locally.
task_data.set_task_list_title(new_task_list_title);
render_task_list_title();
// If there were no actual edits, we can exit now.
if (new_task_list_title === old_task_list_title) {
return;
}
// Broadcast the new task list title to our peers.
const data = task_data.handle.new_task_list_title.outbound(new_task_list_title);
callback(data);
}
function build_widget() {
const html = render_widgets_todo_widget(); const html = render_widgets_todo_widget();
$elem.html(html); $elem.html(html);
$elem.find("input.todo-task-list-title").on("keyup", (e) => {
e.stopPropagation();
update_edit_controls();
});
$elem.find("input.todo-task-list-title").on("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") {
submit_task_list_title();
return;
}
if (e.key === "Escape") {
abort_edit();
return;
}
});
$elem.find(".todo-edit-task-list-title").on("click", (e) => {
e.stopPropagation();
start_editing();
});
$elem.find("button.todo-task-list-title-check").on("click", (e) => {
e.stopPropagation();
submit_task_list_title();
});
$elem.find("button.todo-task-list-title-remove").on("click", (e) => {
e.stopPropagation();
abort_edit();
});
$elem.find("button.add-task").on("click", (e) => { $elem.find("button.add-task").on("click", (e) => {
e.stopPropagation(); e.stopPropagation();
$elem.find(".widget-error").text(""); $elem.find(".widget-error").text("");
@ -210,9 +379,11 @@ export function activate(opts) {
task_data.handle_event(event.sender_id, event.data); task_data.handle_event(event.sender_id, event.data);
} }
render_task_list_title();
render_results(); render_results();
}; };
render(); build_widget();
render_task_list_title();
render_results(); render_results();
} }

View File

@ -12,7 +12,12 @@ type ZFormExtraData = {
choices: {type: string; reply: string; long_name: string; short_name: string}[]; choices: {type: string; reply: string; long_name: string; short_name: string}[];
}; };
type WidgetExtraData = PollWidgetExtraData | ZFormExtraData | null; // TODO: This TodoWidgetExtraData type should be moved to web/src/todo_widget.js when it will be migrated
type TodoWidgetExtraData = {
task_list_title?: string;
};
type WidgetExtraData = PollWidgetExtraData | TodoWidgetExtraData | ZFormExtraData | null;
type WidgetOptions = { type WidgetOptions = {
widget_type: string; widget_type: string;

View File

@ -216,7 +216,8 @@ button {
&.add-task, &.add-task,
&.add-desc, &.add-desc,
&.poll-option, &.poll-option,
&.poll-question { &.poll-question,
&.todo-task-list-title {
padding: 4px 6px; padding: 4px 6px;
margin: 2px 0; margin: 2px 0;
} }
@ -228,7 +229,9 @@ button {
} }
.poll-question-check, .poll-question-check,
.poll-question-remove { .poll-question-remove,
.todo-task-list-title-check,
.todo-task-list-title-remove {
width: 28px !important; width: 28px !important;
height: 28px; height: 28px;
border-radius: 3px; border-radius: 3px;
@ -240,7 +243,8 @@ button {
} }
} }
.poll-edit-question { .poll-edit-question,
.todo-edit-task-list-title {
opacity: 0.4; opacity: 0.4;
display: inline-block; display: inline-block;
margin-left: 5px; margin-left: 5px;
@ -250,7 +254,8 @@ button {
} }
} }
.poll-question-header { .poll-question-header,
.todo-task-list-title-header {
display: inline; display: inline;
} }

View File

@ -1,5 +1,11 @@
<div class="todo-widget"> <div class="todo-widget">
<h4>{{t "Task list" }}</h4> <h4 class="todo-task-list-title-header">{{t "Task list" }}</h4>
<i class="fa fa-pencil todo-edit-task-list-title"></i>
<div class="todo-task-list-title-bar">
<input type="text" class="todo-task-list-title" placeholder="{{t 'Add todo task list title'}}" />
<button class="todo-task-list-title-remove"><i class="fa fa-remove"></i></button>
<button class="todo-task-list-title-check"><i class="fa fa-check"></i></button>
</div>
<div class="add-task-bar"> <div class="add-task-bar">
<input type="text" class="add-task" placeholder="{{t 'New task'}}" /> <input type="text" class="add-task" placeholder="{{t 'New task'}}" />
<input type="text" class="add-desc" placeholder="{{t 'Description'}}" /> <input type="text" class="add-desc" placeholder="{{t 'Description'}}" />

View File

@ -547,7 +547,7 @@ def validate_poll_data(poll_data: object, is_widget_author: bool) -> None:
raise ValidationError(f"Unknown type for poll data: {poll_data['type']}") raise ValidationError(f"Unknown type for poll data: {poll_data['type']}")
def validate_todo_data(todo_data: object) -> None: def validate_todo_data(todo_data: object, is_widget_author: bool) -> None:
check_dict([("type", check_string)])("todo data", todo_data) check_dict([("type", check_string)])("todo data", todo_data)
assert isinstance(todo_data, dict) assert isinstance(todo_data, dict)
@ -575,6 +575,19 @@ def validate_todo_data(todo_data: object) -> None:
checker("todo data", todo_data) checker("todo data", todo_data)
return return
if todo_data["type"] == "new_task_list_title":
if not is_widget_author:
raise ValidationError("You can't edit the task list title unless you are the author.")
checker = check_dict_only(
[
("type", check_string),
("title", check_string),
]
)
checker("todo data", todo_data)
return
raise ValidationError(f"Unknown type for todo data: {todo_data['type']}") raise ValidationError(f"Unknown type for todo data: {todo_data['type']}")

View File

@ -21,6 +21,56 @@ def get_widget_data(content: str) -> Tuple[Optional[str], Optional[str]]:
return None, None return None, None
def parse_poll_extra_data(content: str) -> Any:
# This is used to extract the question from the poll command.
# The command '/poll question' will pre-set the question in the poll
lines = content.splitlines()
question = ""
options = []
if lines and lines[0]:
question = lines.pop(0).strip()
for line in lines:
# If someone is using the list syntax, we remove it
# before adding an option.
option = re.sub(r"(\s*[-*]?\s*)", "", line.strip(), count=1)
if len(option) > 0:
options.append(option)
extra_data = {
"question": question,
"options": options,
}
return extra_data
def parse_todo_extra_data(content: str) -> Any:
# This is used to extract the task list title from the todo command.
# The command '/todo Title' will pre-set the task list title
lines = content.splitlines()
task_list_title = ""
if lines and lines[0]:
task_list_title = lines.pop(0).strip()
tasks = []
for line in lines:
# If someone is using the list syntax, we remove it
# before adding a task.
task_data = re.sub(r"(\s*[-*]?\s*)", "", line.strip(), count=1)
if len(task_data) > 0:
# a task and its description (optional) are separated
# by the (first) `:` character
task_data_array = task_data.split(":", 1)
tasks.append(
{
"task": task_data_array[0].strip(),
"desc": task_data_array[1].strip() if len(task_data_array) > 1 else "",
}
)
extra_data = {
"task_list_title": task_list_title,
"tasks": tasks,
}
return extra_data
def get_extra_data_from_widget_type(content: str, widget_type: Optional[str]) -> Any: def get_extra_data_from_widget_type(content: str, widget_type: Optional[str]) -> Any:
if widget_type == "poll": if widget_type == "poll":
# This is used to extract the question from the poll command. # This is used to extract the question from the poll command.
@ -41,7 +91,15 @@ def get_extra_data_from_widget_type(content: str, widget_type: Optional[str]) ->
"options": options, "options": options,
} }
return extra_data return extra_data
return None else:
# This is used to extract the task list title from the todo command.
# The command '/todo Title' will pre-set the task list title
lines = content.splitlines()
task_list_title = ""
if lines and lines[0]:
task_list_title = lines.pop(0).strip()
extra_data = {"task_list_title": task_list_title}
return extra_data
def do_widget_post_save_actions(send_request: SendMessageRequest) -> None: def do_widget_post_save_actions(send_request: SendMessageRequest) -> None:

View File

@ -98,10 +98,14 @@ class WidgetContentTestCase(ZulipTestCase):
self.assertEqual(get_widget_data(content=message), (None, None)) self.assertEqual(get_widget_data(content=message), (None, None))
# Add positive checks for context # Add positive checks for context
self.assertEqual(get_widget_data(content="/todo"), ("todo", None)) self.assertEqual(get_widget_data(content="/todo"), ("todo", {"task_list_title": ""}))
self.assertEqual(get_widget_data(content="/todo ignore"), ("todo", None)) self.assertEqual(
get_widget_data(content="/todo Title"), ("todo", {"task_list_title": "Title"})
)
# Test tokenization on newline character # Test tokenization on newline character
self.assertEqual(get_widget_data(content="/todo\nignore"), ("todo", None)) self.assertEqual(
get_widget_data(content="/todo\nignore"), ("todo", {"task_list_title": ""})
)
def test_explicit_widget_content(self) -> None: def test_explicit_widget_content(self) -> None:
# Users can send widget_content directly on messages # Users can send widget_content directly on messages
@ -167,7 +171,24 @@ class WidgetContentTestCase(ZulipTestCase):
expected_submessage_content = dict( expected_submessage_content = dict(
widget_type="todo", widget_type="todo",
extra_data=None, extra_data={"task_list_title": ""},
)
submessage = SubMessage.objects.get(message_id=message.id)
self.assertEqual(submessage.msg_type, "widget")
self.assertEqual(orjson.loads(submessage.content), expected_submessage_content)
content = "/todo Example Task List Title"
payload["content"] = content
result = self.api_post(sender, "/api/v1/messages", payload)
self.assert_json_success(result)
message = self.get_last_message()
self.assertEqual(message.content, content)
expected_submessage_content = dict(
widget_type="todo",
extra_data={"task_list_title": "Example Task List Title"},
) )
submessage = SubMessage.objects.get(message_id=message.id) submessage = SubMessage.objects.get(message_id=message.id)
@ -226,6 +247,55 @@ class WidgetContentTestCase(ZulipTestCase):
self.assertEqual(submessage.msg_type, "widget") self.assertEqual(submessage.msg_type, "widget")
self.assertEqual(orjson.loads(submessage.content), expected_submessage_content) self.assertEqual(orjson.loads(submessage.content), expected_submessage_content)
def test_todo_command_extra_data(self) -> None:
sender = self.example_user("cordelia")
stream_name = "Verona"
# We test for leading spaces.
content = "/todo School Work"
payload = dict(
type="stream",
to=orjson.dumps(stream_name).decode(),
topic="whatever",
content=content,
)
result = self.api_post(sender, "/api/v1/messages", payload)
self.assert_json_success(result)
message = self.get_last_message()
self.assertEqual(message.content, content)
expected_submessage_content = dict(
widget_type="todo",
extra_data=dict(
task_list_title="School Work",
),
)
submessage = SubMessage.objects.get(message_id=message.id)
self.assertEqual(submessage.msg_type, "widget")
self.assertEqual(orjson.loads(submessage.content), expected_submessage_content)
# Now don't supply a task list title.
content = "/todo"
payload["content"] = content
result = self.api_post(sender, "/api/v1/messages", payload)
self.assert_json_success(result)
expected_submessage_content = dict(
widget_type="todo",
extra_data=dict(
task_list_title="",
),
)
message = self.get_last_message()
self.assertEqual(message.content, content)
submessage = SubMessage.objects.get(message_id=message.id)
self.assertEqual(submessage.msg_type, "widget")
self.assertEqual(orjson.loads(submessage.content), expected_submessage_content)
def test_poll_permissions(self) -> None: def test_poll_permissions(self) -> None:
cordelia = self.example_user("cordelia") cordelia = self.example_user("cordelia")
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
@ -255,6 +325,37 @@ class WidgetContentTestCase(ZulipTestCase):
result = post(hamlet, dict(type="question", question="Tabs or spaces?")) result = post(hamlet, dict(type="question", question="Tabs or spaces?"))
self.assert_json_error(result, "You can't edit a question unless you are the author.") self.assert_json_error(result, "You can't edit a question unless you are the author.")
def test_todo_permissions(self) -> None:
cordelia = self.example_user("cordelia")
hamlet = self.example_user("hamlet")
stream_name = "Verona"
content = "/todo School Work"
payload = dict(
type="stream",
to=orjson.dumps(stream_name).decode(),
topic="whatever",
content=content,
)
result = self.api_post(cordelia, "/api/v1/messages", payload)
self.assert_json_success(result)
message = self.get_last_message()
def post(sender: UserProfile, data: Dict[str, object]) -> "TestHttpResponse":
payload = dict(
message_id=message.id, msg_type="widget", content=orjson.dumps(data).decode()
)
return self.api_post(sender, "/api/v1/submessage", payload)
result = post(cordelia, dict(type="new_task_list_title", title="School Work"))
self.assert_json_success(result)
result = post(hamlet, dict(type="new_task_list_title", title="School Work"))
self.assert_json_error(
result, "You can't edit the task list title unless you are the author."
)
def test_poll_type_validation(self) -> None: def test_poll_type_validation(self) -> None:
sender = self.example_user("cordelia") sender = self.example_user("cordelia")
stream_name = "Verona" stream_name = "Verona"

View File

@ -49,7 +49,7 @@ def process_submessage(
if widget_type == "todo": if widget_type == "todo":
try: try:
validate_todo_data(todo_data=widget_data) validate_todo_data(todo_data=widget_data, is_widget_author=is_widget_author)
except ValidationError as error: except ValidationError as error:
raise JsonableError(error.message) raise JsonableError(error.message)