diff --git a/tools/test-js-with-node b/tools/test-js-with-node index ef41588df8..43198932b4 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -163,6 +163,7 @@ EXEMPT_FILES = make_set( "web/src/plotly.js.d.ts", "web/src/pm_list.js", "web/src/pm_list_dom.ts", + "web/src/poll_modal.js", "web/src/poll_widget.ts", "web/src/popover_menus.js", "web/src/popover_menus_data.js", diff --git a/web/shared/icons/grip-vertical.svg b/web/shared/icons/grip-vertical.svg new file mode 100644 index 0000000000..24579a9966 Binary files /dev/null and b/web/shared/icons/grip-vertical.svg differ diff --git a/web/src/compose.js b/web/src/compose.js index fa16b25239..263df67f8d 100644 --- a/web/src/compose.js +++ b/web/src/compose.js @@ -117,6 +117,7 @@ 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"); } export function send_message_success(request, data) { diff --git a/web/src/compose_setup.js b/web/src/compose_setup.js index 875118221b..433d8b19da 100644 --- a/web/src/compose_setup.js +++ b/web/src/compose_setup.js @@ -1,5 +1,7 @@ import $ from "jquery"; +import render_add_poll_modal from "../templates/add_poll_modal.hbs"; + import * as compose from "./compose"; import * as compose_actions from "./compose_actions"; import * as compose_banner from "./compose_banner"; @@ -9,10 +11,13 @@ import * as compose_recipient from "./compose_recipient"; import * as compose_state from "./compose_state"; import * as compose_ui from "./compose_ui"; import * as compose_validate from "./compose_validate"; +import * as dialog_widget from "./dialog_widget"; import * as flatpickr from "./flatpickr"; +import {$t_html} from "./i18n"; import * as message_edit from "./message_edit"; import * as narrow from "./narrow"; import {page_params} from "./page_params"; +import * as poll_modal from "./poll_modal"; import * as popovers from "./popovers"; import * as resize from "./resize"; import * as rows from "./rows"; @@ -23,6 +28,7 @@ import * as stream_settings_components from "./stream_settings_components"; import * as sub_store from "./sub_store"; import * as subscriber_api from "./subscriber_api"; import {get_timestamp_for_flatpickr} from "./timerender"; +import * as ui_report from "./ui_report"; import * as upload from "./upload"; import * as user_topics from "./user_topics"; @@ -74,6 +80,13 @@ export function initialize() { } else { $("#compose_close").attr("data-tooltip-template-id", "compose_close_tooltip_template"); } + + // The poll widget requires an empty compose box. + if (compose_text_length > 0) { + $(".add-poll").parent().addClass("disabled-on-hover"); + } else { + $(".add-poll").parent().removeClass("disabled-on-hover"); + } }); $("#compose form").on("submit", (e) => { @@ -325,6 +338,44 @@ export function initialize() { } }); + $("body").on("click", ".compose_control_button_container:not(.disabled) .add-poll", (e) => { + e.preventDefault(); + e.stopPropagation(); + + function validate_input() { + const question = $("#poll-question-input").val().trim(); + + if (question === "") { + ui_report.error( + $t_html({defaultMessage: "Please enter a question."}), + undefined, + $("#dialog_error"), + ); + return false; + } + return true; + } + + dialog_widget.launch({ + html_heading: $t_html({defaultMessage: "Create a poll"}), + html_body: render_add_poll_modal(), + html_submit_button: $t_html({defaultMessage: "Add poll"}), + 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 poll_message_content = poll_modal.frame_poll_message_content(); + compose_ui.insert_syntax_and_focus(poll_message_content); + }, + validate_input, + form_id: "add-poll-form", + id: "add-poll-modal", + post_render: poll_modal.poll_options_setup, + help_link: "https://zulip.com/help/create-a-poll", + }); + }); + $("#compose").on("click", ".markdown_preview", (e) => { e.preventDefault(); e.stopPropagation(); diff --git a/web/src/poll_modal.js b/web/src/poll_modal.js new file mode 100644 index 0000000000..316535f25c --- /dev/null +++ b/web/src/poll_modal.js @@ -0,0 +1,68 @@ +import $ from "jquery"; +import {Sortable} from "sortablejs"; + +import render_poll_modal_option from "../templates/poll_modal_option.hbs"; + +function create_option_row($last_option_row_input) { + const row = render_poll_modal_option(); + const $row_container = $last_option_row_input.closest(".simplebar-content"); + $row_container.append(row); +} + +function add_option_row(e) { + // 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 + // This handles a case when the next empty input row is already + // added and user is updating the above row(s). + if ($(e.target).closest(".option-row").next().hasClass("option-row")) { + return; + } + create_option_row($(e.target)); +} + +function delete_option_row(e) { + const $row = $(e.target).closest(".option-row"); + $row.remove(); +} + +export function poll_options_setup() { + const $poll_options_list = $("#add-poll-form .poll-options-list"); + const $submit_button = $("#add-poll-modal .dialog_submit_button"); + const $question_input = $("#add-poll-form #poll-question-input"); + + // Disable the submit button if the question is empty. + $submit_button.prop("disabled", true); + $question_input.on("input", () => { + if ($question_input.val().trim() !== "") { + $submit_button.prop("disabled", false); + } else { + $submit_button.prop("disabled", true); + } + }); + + $poll_options_list.on("input", "input.poll-option-input", add_option_row); + $poll_options_list.on("click", "button.delete-option", delete_option_row); + + // setTimeout is needed to here to give time for simplebar to initialise + setTimeout(() => { + Sortable.create($("#add-poll-form .poll-options-list .simplebar-content")[0], { + onUpdate() {}, + // 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 frame_poll_message_content() { + const question = $("#poll-question-input").val().trim(); + const options = $(".poll-option-input") + .map(function () { + return $(this).val().trim(); + }) + .toArray() + .filter(Boolean); + return "/poll " + question + "\n" + options.join("\n"); +} diff --git a/web/src/tippyjs.ts b/web/src/tippyjs.ts index 50c4217099..955e95cb8e 100644 --- a/web/src/tippyjs.ts +++ b/web/src/tippyjs.ts @@ -209,6 +209,22 @@ export function initialize(): void { }, }); + delegate("body", { + target: "#add-poll-modal .dialog_submit_button_container", + appendTo: () => document.body, + onShow(instance) { + const content = $t({defaultMessage: "Please enter a question."}); + const $elem = $(instance.reference); + // Show tooltip to enter question only if submit button is disabled + // (due to question field being empty). + if ($elem.find(".dialog_submit_button").is(":disabled")) { + instance.setContent(content); + return undefined; + } + return false; + }, + }); + $("body").on( "blur", ".message_control_button, .delete-selected-drafts-button-container", diff --git a/web/styles/compose.css b/web/styles/compose.css index 9937a7a052..d55cb0fed0 100644 --- a/web/styles/compose.css +++ b/web/styles/compose.css @@ -994,6 +994,15 @@ textarea.new_message_textarea, } } + .compose_control_button_container.disabled-on-hover:hover { + opacity: 0.3; + cursor: not-allowed; + + .compose_control_button { + pointer-events: none; + } + } + .fa-eye { position: relative; top: -0.7px; diff --git a/web/styles/modal.css b/web/styles/modal.css index 7bf594b7a4..4c71e2e455 100644 --- a/web/styles/modal.css +++ b/web/styles/modal.css @@ -385,3 +385,81 @@ 0 0 8px hsl(206deg 80% 62% / 60%); } } + +#add-poll-modal { + /* this height allows 3-4 option rows + to fit in without need for scrolling */ + height: 450px; + overflow: hidden; + + .modal__content { + flex-grow: 1; + + .simplebar-content { + box-sizing: border-box; + height: 100%; + } + } + + #add-poll-form { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + + .poll-label { + font-weight: bold; + margin: 5px 0; + } + + .poll-question-input-container { + display: flex; + margin-bottom: 10px; + + #poll-question-input { + flex-grow: 1; + } + } + + .poll-options-list { + margin: 0; + height: 0; + overflow: auto; + flex-grow: 1; + + .option-row { + list-style-type: none; + cursor: move; + margin-top: 10px; + padding: 0; + display: flex; + align-items: center; + gap: 10px; + + .drag-icon { + color: hsl(0deg 0% 75%); + } + + .poll-option-input { + flex-grow: 1; + } + } + + .option-row:first-child { + margin-top: 0; + } + + .option-row:last-child { + cursor: default; + + .delete-option { + visibility: hidden; + } + + .drag-icon { + visibility: hidden; + } + } + } + } +} diff --git a/web/templates/add_poll_modal.hbs b/web/templates/add_poll_modal.hbs new file mode 100644 index 0000000000..ade571f1e0 --- /dev/null +++ b/web/templates/add_poll_modal.hbs @@ -0,0 +1,13 @@ +
diff --git a/web/templates/compose_control_buttons.hbs b/web/templates/compose_control_buttons.hbs index 25868f470d..a4fbf0a9a3 100644 --- a/web/templates/compose_control_buttons.hbs +++ b/web/templates/compose_control_buttons.hbs @@ -24,6 +24,11 @@ + {{#unless message_id}} + + {{/unless}} diff --git a/web/templates/poll_modal_option.hbs b/web/templates/poll_modal_option.hbs new file mode 100644 index 0000000000..e838404163 --- /dev/null +++ b/web/templates/poll_modal_option.hbs @@ -0,0 +1,7 @@ +