diff --git a/.eslintrc.json b/.eslintrc.json index 3fe678f0ff..690f159892 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -178,6 +178,7 @@ "user_events": false, "voting_widget": false, "tictactoe_widget": false, + "todo_widget": false, "zform": false, "widgetize": false, "submessage": false, diff --git a/frontend_tests/node_tests/widgetize.js b/frontend_tests/node_tests/widgetize.js index f4c830d048..59a9d7c384 100644 --- a/frontend_tests/node_tests/widgetize.js +++ b/frontend_tests/node_tests/widgetize.js @@ -1,6 +1,7 @@ set_global('$', global.make_zjquery()); set_global('voting_widget', {}); set_global('tictactoe_widget', {}); +set_global('todo_widget', {}); set_global('zform', {}); set_global('document', 'document-stub'); diff --git a/static/js/bundles/app.js b/static/js/bundles/app.js index 9b79c0c778..c70e8ff496 100644 --- a/static/js/bundles/app.js +++ b/static/js/bundles/app.js @@ -65,6 +65,7 @@ import "js/top_left_corner.js"; import "js/stream_list.js"; import "js/filter.js"; import 'js/voting_widget.js'; +import 'js/todo_widget.js'; import 'js/tictactoe_widget.js'; import 'js/zform.js'; import 'js/widgetize.js'; diff --git a/static/js/todo_widget.js b/static/js/todo_widget.js new file mode 100644 index 0000000000..009c99bcc4 --- /dev/null +++ b/static/js/todo_widget.js @@ -0,0 +1,192 @@ +var todo_widget = (function () { + +var exports = {}; + +exports.task_data_holder = function (is_my_task_list) { + var self = {}; + + var all_tasks = []; + var pending_tasks = []; + var completed_tasks = []; + var my_idx = 0; + + self.get_widget_data = function () { + + var widget_data = { + pending_tasks: pending_tasks, + completed_tasks: completed_tasks, + }; + + return widget_data; + }; + + self.check_task = { + task_exists: function (task) { + var task_exists = _.any(all_tasks, function (item) { + return item.task === task; + }); + return task_exists; + }, + }; + + self.handle = { + new_task: { + outbound: function (task) { + var event = { + type: 'new_task', + key: my_idx, + task: task, + completed: false, + }; + my_idx += 1; + + if (is_my_task_list && !self.check_task.task_exists(task)) { + return event; + } + return; + }, + + inbound: function (sender_id, data) { + var idx = data.key; + var task = data.task; + var completed = data.completed; + + var task_data = { + task: task, + user_id: sender_id, + key: idx, + completed: completed, + }; + + if (!self.check_task.task_exists(task)) { + pending_tasks.push(task_data); + all_tasks.push(task_data); + + if (my_idx <= idx) { + my_idx = idx + 1; + } + } + }, + }, + + strike: { + outbound: function (key) { + var event = { + type: 'strike', + key: key, + }; + + if (is_my_task_list) { + return event; + } + return; + }, + + inbound: function (sender_id, data) { + var key = data.key; + var task = all_tasks[key]; + var index; + + if (task === undefined) { + blueslip.error('unknown key for tasks: ' + key); + return; + } + + all_tasks[key].completed = !all_tasks[key].completed; + + // toggle + if (task.completed) { + index = pending_tasks.indexOf(task); + pending_tasks.splice(index, 1); + completed_tasks.unshift(task); + } else { + index = completed_tasks.indexOf(task); + completed_tasks.splice(index, 1); + pending_tasks.push(task); + } + }, + }, + }; + + self.handle_event = function (sender_id, data) { + var type = data.type; + if (self.handle[type]) { + self.handle[type].inbound(sender_id, data); + } + }; + + return self; +}; + +exports.activate = function (opts) { + var elem = opts.elem; + var callback = opts.callback; + + var is_my_task_list = people.is_my_user_id(opts.message.sender_id); + var task_data = exports.task_data_holder(is_my_task_list); + + function render() { + var html = templates.render('todo-widget'); + elem.html(html); + + elem.find("button.add-task").on('click', function (e) { + e.stopPropagation(); + elem.find(".widget-error").text(''); + var task = elem.find("input.add-task").val().trim(); + + if (task === '') { + return; + } + + elem.find(".add-task").val('').focus(); + + var task_exists = task_data.check_task.task_exists(task); + if (task_exists) { + elem.find(".widget-error").text(i18n.t('Task already exists')); + return; + } + + var data = task_data.handle.new_task.outbound(task); + callback(data); + }); + } + + function render_results() { + var widget_data = task_data.get_widget_data(); + var html = templates.render('todo-widget-tasks', widget_data); + elem.find('ul.todo-widget').html(html); + elem.find(".widget-error").text(''); + + if (!is_my_task_list) { + elem.find(".add-task-bar").hide(); + elem.find("button.task").attr('disabled', true); + } + + elem.find("button.task").on('click', function (e) { + e.stopPropagation(); + var key = $(e.target).attr('data-key'); + + var data = task_data.handle.strike.outbound(key); + callback(data); + }); + } + + elem.handle_events = function (events) { + _.each(events, function (event) { + task_data.handle_event(event.sender_id, event.data); + }); + render_results(); + }; + + render(); + render_results(); +}; + +return exports; + +}()); +if (typeof module !== 'undefined') { + module.exports = todo_widget; +} + +window.todo_widget = todo_widget; diff --git a/static/js/widgetize.js b/static/js/widgetize.js index 37245c199c..cdc6326ff5 100644 --- a/static/js/widgetize.js +++ b/static/js/widgetize.js @@ -6,6 +6,7 @@ var widgets = {}; widgets.poll = voting_widget; widgets.tictactoe = tictactoe_widget; +widgets.todo = todo_widget; widgets.zform = zform; var widget_contents = {}; diff --git a/static/styles/widgets.scss b/static/styles/widgets.scss index 70667011a3..3235225367 100644 --- a/static/styles/widgets.scss +++ b/static/styles/widgets.scss @@ -16,23 +16,26 @@ font-weight: 600; } +.todo-widget h4, .poll-widget h4 { - font-size: 14px; + font-size: 18px; font-weight: 600; } +.todo-widget li, .poll-widget li { list-style: none; margin: 2px 2px 2px 0px; } +.todo-widget ul, .poll-widget ul { margin: 0px 0px 5px 0px; padding: 0; } -.poll-widget .poll-names { - font-size: 10px; +.poll-widget .poll-names .todo-widget { + font-size: 14px; color: green; } @@ -41,17 +44,40 @@ } .poll-vote { - border-radius: 2px; color: #3c906e; border-color: #9dc8b7; margin-right: 4px; background-color: white; } +button.task { + height: 20px; + width: 20px; + background-color: transparent; + border-color: #9dc8b7; + margin-right: 4px; + border-radius: 3px; +} + +button.task:hover { + border: 1px solid #2988a4; +} + +button.task-completed { + border-color: #b9cec6; + padding: 0px; +} + +img.task-completed { + width: 15px; +} + .poll-vote:hover { border-color: #59a687; } +input.add-task, +button.add-task, input.poll-comment, button.poll-comment, input.poll-question, @@ -60,13 +86,22 @@ button.poll-question { margin: 2px 0px 2px 0px; } -button.poll-comment, button.poll-question { +button.add-task, +button.poll-comment, +button.poll-question { border-radius: 3px; border: 1px solid #cccccc; background-color: white; width: 100px; } -button.poll-comment:hover, button.poll-question:hover { +button.add-task:hover, +button.poll-comment:hover, +button.poll-question:hover { border-color: #999999; } + +.widget-error { + color: #b94a48; + font-size: 12px; +} diff --git a/static/templates/widgets/todo-widget-tasks.handlebars b/static/templates/widgets/todo-widget-tasks.handlebars new file mode 100644 index 0000000000..4b7c335d41 --- /dev/null +++ b/static/templates/widgets/todo-widget-tasks.handlebars @@ -0,0 +1,16 @@ +
+{{#each pending_tasks}} +
  • + + {{ task }} +
  • +{{/each}} +{{#each completed_tasks}} +
  • + + {{ task }} +
  • +{{/each}} diff --git a/static/templates/widgets/todo-widget.handlebars b/static/templates/widgets/todo-widget.handlebars new file mode 100644 index 0000000000..b2fd3b7ee2 --- /dev/null +++ b/static/templates/widgets/todo-widget.handlebars @@ -0,0 +1,10 @@ +
    +

    Task list

    +
    + + +
    +
    + +
    diff --git a/zerver/lib/widget.py b/zerver/lib/widget.py index 39d78c8fcc..f2b1f38ef9 100644 --- a/zerver/lib/widget.py +++ b/zerver/lib/widget.py @@ -18,7 +18,7 @@ def do_widget_pre_save_actions(message: MutableMapping[str, Any]) -> None: return def get_widget_data(content: str) -> Tuple[Optional[str], Optional[str]]: - valid_widget_types = ['tictactoe', 'poll'] + valid_widget_types = ['tictactoe', 'poll', 'todo'] tokens = content.split(' ') if not tokens: return None, None