diff --git a/help/collaborative-to-do-lists.md b/help/collaborative-to-do-lists.md index dcc3295d86..72a55adecf 100644 --- a/help/collaborative-to-do-lists.md +++ b/help/collaborative-to-do-lists.md @@ -6,6 +6,31 @@ {start_tabs} +{tab|via-compose-box-buttons} + +{!start-composing.md!} + +1. Make sure the compose box is empty. + +1. Click the **Add to-do list** () icon at + the bottom of the compose box. + +1. Fill out todo-list information as desired, and click **Create to-do list** to insert todo-list + formatting. + +1. Click the **Send** () button, or + use a [keyboard shortcut](/help/configure-send-message-keys) to send your + message. + +!!! tip "" + + To reorder the list of todos, click and drag the **vertical dots** + () to the left of each + option. To delete an option, click the **trash** + () icon to the right of it. + +{tab|via-markdown} + {!start-composing.md!} 1. Make sure the compose box is empty. diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 031e740422..3552b00af4 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -177,7 +177,6 @@ EXEMPT_FILES = make_set( "web/src/playground_links_popover.ts", "web/src/pm_list.ts", "web/src/pm_list_dom.ts", - "web/src/poll_modal.ts", "web/src/poll_widget.ts", "web/src/popover_menus.ts", "web/src/popover_menus_data.ts", @@ -291,6 +290,7 @@ EXEMPT_FILES = make_set( "web/src/user_topics.ts", "web/src/user_topics_ui.ts", "web/src/views_util.ts", + "web/src/widget_modal.ts", "web/src/zcommand.ts", "web/src/zform.js", "web/src/zulip_test.ts", diff --git a/web/shared/icons/todo-list.svg b/web/shared/icons/todo-list.svg new file mode 100644 index 0000000000..362b7337b8 --- /dev/null +++ b/web/shared/icons/todo-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/compose.js b/web/src/compose.js index 10b7c1c97e..bf70856541 100644 --- a/web/src/compose.js +++ b/web/src/compose.js @@ -132,7 +132,9 @@ export function clear_compose_box() { compose_banner.clear_uploads(); compose_ui.hide_compose_spinner(); scheduled_messages.reset_selected_schedule_timestamp(); - $(".compose_control_button_container:has(.add-poll)").removeClass("disabled-on-hover"); + $(".compose_control_button_container:has(.needs-empty-compose)").removeClass( + "disabled-on-hover", + ); } export function send_message_success(request, data) { diff --git a/web/src/compose_actions.ts b/web/src/compose_actions.ts index d6693c13bd..61446f7c14 100644 --- a/web/src/compose_actions.ts +++ b/web/src/compose_actions.ts @@ -141,7 +141,9 @@ function clear_box(): void { compose_banner.clear_errors(); compose_banner.clear_warnings(); compose_banner.clear_uploads(); - $(".compose_control_button_container:has(.add-poll)").removeClass("disabled-on-hover"); + $(".compose_control_button_container:has(.needs-empty-compose)").removeClass( + "disabled-on-hover", + ); } let autosize_callback_opts: ComposeActionsStartOpts; @@ -388,7 +390,9 @@ export let start = (raw_opts: ComposeActionsStartOpts): void => { false, replace_all_without_undo_support, ); - $(".compose_control_button_container:has(.add-poll)").addClass("disabled-on-hover"); + $(".compose_control_button_container:has(.needs-empty-compose)").addClass( + "disabled-on-hover", + ); // If we were provided with message content, we might need to // display that it's too long. compose_validate.check_overflow_text($("#send_message_form")); diff --git a/web/src/compose_setup.js b/web/src/compose_setup.js index 0a4a82952f..e2fbe81423 100644 --- a/web/src/compose_setup.js +++ b/web/src/compose_setup.js @@ -3,6 +3,7 @@ import _ from "lodash"; import {unresolve_name} from "../shared/src/resolved_topic.ts"; import render_add_poll_modal from "../templates/add_poll_modal.hbs"; +import render_add_todo_list_modal from "../templates/add_todo_list_modal.hbs"; import * as compose from "./compose.js"; import * as compose_actions from "./compose_actions.ts"; @@ -23,7 +24,6 @@ import * as message_view from "./message_view.ts"; import * as narrow_state from "./narrow_state.ts"; import * as onboarding_steps from "./onboarding_steps.ts"; import {page_params} from "./page_params.ts"; -import * as poll_modal from "./poll_modal.ts"; import * as popovers from "./popovers.ts"; import * as resize from "./resize.ts"; import * as rows from "./rows.ts"; @@ -36,6 +36,7 @@ import {get_timestamp_for_flatpickr} from "./timerender.ts"; import * as ui_report from "./ui_report.ts"; import * as upload from "./upload.ts"; import * as user_topics from "./user_topics.ts"; +import * as widget_modal from "./widget_modal.ts"; export function abort_xhr() { $("#compose-send-button").prop("disabled", false); @@ -88,9 +89,9 @@ export function initialize() { // The poll widget requires an empty compose box. if (compose_text_length > 0) { - $(".add-poll").parent().addClass("disabled-on-hover"); + $(".needs-empty-compose").parent().addClass("disabled-on-hover"); } else { - $(".add-poll").parent().removeClass("disabled-on-hover"); + $(".needs-empty-compose").parent().removeClass("disabled-on-hover"); } if (compose_state.get_is_content_unedited_restored_draft()) { @@ -449,7 +450,7 @@ export function initialize() { // frame a message using data input in modal, then populate the compose textarea with it e.preventDefault(); e.stopPropagation(); - const poll_message_content = poll_modal.frame_poll_message_content(); + const poll_message_content = widget_modal.frame_poll_message_content(); compose_ui.insert_syntax_and_focus(poll_message_content); }, on_show() { @@ -460,11 +461,74 @@ export function initialize() { validate_input, form_id: "add-poll-form", id: "add-poll-modal", - post_render: poll_modal.poll_options_setup, + post_render: widget_modal.poll_options_setup, help_link: "https://zulip.com/help/create-a-poll", }); }); + $("body").on("input", "#add-todo-modal .todo-input", (e) => { + e.preventDefault(); + e.stopPropagation(); + + $(".option-row").each(function () { + const todo_name = $(this).find(".todo-input").val(); + const $todo_description = $(this).find(".todo-description-input"); + $todo_description.prop("disabled", !todo_name); + }); + }); + + $("body").on( + "click", + ".compose_control_button_container:not(.disabled) .add-todo-list", + (e) => { + e.preventDefault(); + e.stopPropagation(); + + function validate_input(e) { + let is_valid = true; + e.preventDefault(); + e.stopPropagation(); + $(".option-row").each(function () { + const todo_name = $(this).find(".todo-input").val(); + const todo_description = $(this).find(".todo-description-input").val(); + if (!todo_name && todo_description) { + ui_report.error( + $t_html({defaultMessage: "Please enter task title."}), + undefined, + $("#dialog_error"), + ); + is_valid = false; + } + }); + return is_valid; + } + + dialog_widget.launch({ + html_heading: $t_html({defaultMessage: "Create a collaborative to-do list"}), + html_body: render_add_todo_list_modal(), + html_submit_button: $t_html({defaultMessage: "Create to-do list"}), + close_on_submit: true, + on_click(e) { + // frame a message using data input in modal, then populate the compose textarea with it + e.preventDefault(); + e.stopPropagation(); + const todo_message_content = widget_modal.frame_todo_message_content(); + compose_ui.insert_syntax_and_focus(todo_message_content); + }, + on_show() { + setTimeout(() => { + $("#todo-title-input").trigger("select"); + }, 0); + }, + form_id: "add-todo-form", + validate_input, + id: "add-todo-modal", + post_render: widget_modal.todo_list_tasks_setup, + help_link: "https://zulip.com/help/collaborative-to-do-lists", + }); + }, + ); + $("#compose").on("click", ".markdown_preview", (e) => { e.preventDefault(); e.stopPropagation(); diff --git a/web/src/tippyjs.ts b/web/src/tippyjs.ts index a090778901..40b52259f9 100644 --- a/web/src/tippyjs.ts +++ b/web/src/tippyjs.ts @@ -231,6 +231,29 @@ export function initialize(): void { }, }); + tippy.delegate("body", { + target: "#add-todo-modal .todo-description-container", + onShow(instance) { + const $elem = $(instance.reference); + + /* Due to height: 0, data-reference-hidden for tooltip is set on the tooltip and can + cause the tooltip to hide. We should use .show-when-reference-hidden here too since we + want data-reference-hidden to work when user scrolls here.*/ + $(instance.popper).find(".tippy-box").addClass("show-when-reference-hidden"); + + if ($elem.find(".todo-description-input").is(":disabled")) { + instance.setContent( + $t({ + defaultMessage: "Enter a task before adding a description.", + }), + ); + return undefined; + } + return false; + }, + appendTo: () => document.body, + }); + $("body").on( "blur", ".message_control_button, .delete-selected-drafts-button-container", diff --git a/web/src/poll_modal.ts b/web/src/widget_modal.ts similarity index 53% rename from web/src/poll_modal.ts rename to web/src/widget_modal.ts index 18d95d2667..01a838fb43 100644 --- a/web/src/poll_modal.ts +++ b/web/src/widget_modal.ts @@ -2,16 +2,20 @@ import $ from "jquery"; import SortableJS from "sortablejs"; import render_poll_modal_option from "../templates/poll_modal_option.hbs"; +import render_todo_modal_task from "../templates/todo_modal_task.hbs"; import * as util from "./util.ts"; -function create_option_row($last_option_row_input: JQuery): void { - const row_html = render_poll_modal_option(); +function create_option_row( + $last_option_row_input: JQuery, + template: (context?: unknown) => string, +): void { + const row_html = template(); const $row_container = $last_option_row_input.closest(".simplebar-content"); $row_container.append($(row_html)); } -function add_option_row(this: HTMLElement): void { +function add_option_row(this: HTMLElement, widget_type: string): void { // if the option triggering the input event e is not the last, // that is, its next sibling has the class `option-row`, we // do not add a new option row and return from this function @@ -20,7 +24,8 @@ function add_option_row(this: HTMLElement): void { if ($(this).closest(".option-row").next().hasClass("option-row")) { return; } - create_option_row($(this)); + const template = widget_type === "POLL" ? render_poll_modal_option : render_todo_modal_task; + create_option_row($(this), template); } function delete_option_row(this: HTMLElement): void { @@ -28,6 +33,21 @@ function delete_option_row(this: HTMLElement): void { $row.remove(); } +function setup_sortable_list(selector: string): void { + // setTimeout is needed to here to give time for simplebar to initialise + setTimeout(() => { + SortableJS.create(util.the($($(selector + " .simplebar-content"))), { + onUpdate() { + // Do nothing on drag; the order is only processed on submission. + }, + // We don't want the last (empty) row to be draggable, as a new row + // is added on input event of the last row. + filter: "input, .option-row:last-child", + preventOnFilter: false, + }); + }, 0); +} + export function poll_options_setup(): void { const $poll_options_list = $("#add-poll-form .poll-options-list"); const $submit_button = $("#add-poll-modal .dialog_submit_button"); @@ -43,21 +63,22 @@ export function poll_options_setup(): void { } }); - $poll_options_list.on("input", "input.poll-option-input", add_option_row); + $poll_options_list.on("input", "input.poll-option-input", function (this: HTMLElement) { + add_option_row.call(this, "POLL"); + }); $poll_options_list.on("click", "button.delete-option", delete_option_row); - // setTimeout is needed to here to give time for simplebar to initialise - setTimeout(() => { - SortableJS.create(util.the($("#add-poll-form .poll-options-list .simplebar-content")), { - onUpdate() { - // Do nothing on drag; the order is only processed on submission. - }, - // We don't want the last (empty) row to be draggable, as a new row - // is added on input event of the last row. - filter: "input, .option-row:last-child", - preventOnFilter: false, - }); - }, 0); + setup_sortable_list("#add-poll-form .poll-options-list"); +} + +export function todo_list_tasks_setup(): void { + const $todo_options_list = $("#add-todo-form .todo-options-list"); + $todo_options_list.on("input", "input.todo-input", function (this: HTMLElement) { + add_option_row.call(this, "TODO"); + }); + $todo_options_list.on("click", "button.delete-option", delete_option_row); + + setup_sortable_list("#add-todo-form .todo-options-list"); } export function frame_poll_message_content(): string { @@ -70,3 +91,33 @@ export function frame_poll_message_content(): string { .filter(Boolean); return "/poll " + question + "\n" + options.join("\n"); } + +export function frame_todo_message_content(): string { + let title = $("input#todo-title-input").val()?.trim(); + + if (title === "") { + title = "Task list"; + } + const todo_str = `/todo ${title}\n`; + + const todos: string[] = []; + + $(".option-row").each(function () { + const todo_name = $(this).find(".todo-input").val()?.toString().trim() ?? ""; + const todo_description = + $(this).find(".todo-description-input").val()?.toString().trim() ?? ""; + + if (todo_name) { + let todo = ""; + + if (todo_name && todo_description) { + todo = `${todo_name}: ${todo_description}`; + } else if (todo_name && !todo_description) { + todo = todo_name; + } + todos.push(todo); + } + }); + + return todo_str + todos.join("\n"); +} diff --git a/web/styles/modal.css b/web/styles/modal.css index e31ade7db0..269677967b 100644 --- a/web/styles/modal.css +++ b/web/styles/modal.css @@ -466,7 +466,8 @@ width: 14em; } -#add-poll-modal { +#add-poll-modal, +#add-todo-modal { /* this height allows 3-4 option rows to fit in without need for scrolling */ height: 32.1428em; /* 450px / 14px em */ @@ -481,27 +482,32 @@ } } - #add-poll-form { + #add-poll-form, + #add-todo-form { display: flex; flex-direction: column; overflow: hidden; height: 100%; - .poll-label { + .poll-label, + .todo-label { font-weight: bold; margin: 5px 0; } - .poll-question-input-container { + .poll-question-input-container, + .todo-title-input-container { display: flex; margin-bottom: 10px; - #poll-question-input { + #poll-question-input, + #todo-title-input { flex-grow: 1; } } - .poll-options-list { + .poll-options-list, + .todo-options-list { margin: 0; height: 0; overflow: auto; @@ -525,6 +531,22 @@ margin-bottom: 0; flex-grow: 1; } + + .todo-input { + flex: 1 1 auto; + margin-bottom: 0; + max-width: 30%; + } + + .todo-description-container { + flex: 1 1 auto; + display: flex; + + .todo-description-input { + flex-grow: 1; + margin-bottom: 0; + } + } } .option-row:first-child { @@ -543,6 +565,22 @@ } } } + + @media (max-width: $sm_min) { + .option-row { + .todo-input { + width: 100%; + min-width: 90px; + } + + .todo-description-container { + .todo-description-input { + width: 100%; + min-width: 90px; + } + } + } + } } } diff --git a/web/templates/add_todo_list_modal.hbs b/web/templates/add_todo_list_modal.hbs new file mode 100644 index 0000000000..25c654e36a --- /dev/null +++ b/web/templates/add_todo_list_modal.hbs @@ -0,0 +1,13 @@ +
+ +
+ +
+ +

{{t "Anyone can add more tasks after the to-do list is posted."}}

+ +
diff --git a/web/templates/compose_control_buttons.hbs b/web/templates/compose_control_buttons.hbs index de5a90e7e0..c757320f0c 100644 --- a/web/templates/compose_control_buttons.hbs +++ b/web/templates/compose_control_buttons.hbs @@ -56,7 +56,10 @@
{{#unless message_id}}
- + +
+
+
{{/unless}} diff --git a/web/templates/todo_modal_task.hbs b/web/templates/todo_modal_task.hbs new file mode 100644 index 0000000000..21288ac660 --- /dev/null +++ b/web/templates/todo_modal_task.hbs @@ -0,0 +1,10 @@ +
  • + + +
    + +
    + +
  • diff --git a/web/templates/tooltip_templates.hbs b/web/templates/tooltip_templates.hbs index 7786d133ba..74269f7ddc 100644 --- a/web/templates/tooltip_templates.hbs +++ b/web/templates/tooltip_templates.hbs @@ -68,6 +68,12 @@ {{t "A poll must be an entire message." }} +