Lists.test.jsx (17371B)
1 import React from "react"; 2 import { combineReducers, createStore } from "redux"; 3 import { Provider } from "react-redux"; 4 import { mount } from "enzyme"; 5 import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; 6 import { actionTypes as at } from "common/Actions.mjs"; 7 import { Lists } from "content-src/components/Widgets/Lists/Lists"; 8 9 const mockState = { 10 ...INITIAL_STATE, 11 ListsWidget: { 12 selected: "test-list", 13 lists: { 14 "test-list": { 15 label: "test", 16 tasks: [{ id: "1", value: "task", completed: false, isUrl: false }], 17 completed: [], 18 }, 19 }, 20 }, 21 }; 22 23 function WrapWithProvider({ children, state = INITIAL_STATE }) { 24 let store = createStore(combineReducers(reducers), state); 25 return <Provider store={store}>{children}</Provider>; 26 } 27 28 describe("<Lists>", () => { 29 let wrapper; 30 let sandbox; 31 let dispatch; 32 let handleUserInteraction; 33 34 beforeEach(() => { 35 sandbox = sinon.createSandbox(); 36 dispatch = sandbox.stub(); 37 handleUserInteraction = sandbox.stub(); 38 39 wrapper = mount( 40 <WrapWithProvider state={mockState}> 41 <Lists 42 dispatch={dispatch} 43 handleUserInteraction={handleUserInteraction} 44 /> 45 </WrapWithProvider> 46 ); 47 }); 48 49 afterEach(() => { 50 // If we defined what the activeElement should be, remove our override 51 delete document.activeElement; 52 }); 53 54 it("should render the component and selected list", () => { 55 assert.ok(wrapper.exists()); 56 assert.ok(wrapper.find(".lists").exists()); 57 assert.equal(wrapper.find("moz-option").length, 1); 58 assert.equal(wrapper.find(".task-item").length, 1); 59 }); 60 61 it("should update task input and add a new task on Enter key", () => { 62 const input = wrapper.find("input").at(0); 63 input.simulate("change", { target: { value: "nathan's cool task" } }); 64 65 // Override what the current active element so that the dispatch will trigger 66 Object.defineProperty(document, "activeElement", { 67 value: input.getDOMNode(), 68 configurable: true, 69 }); 70 71 input.simulate("keyDown", { key: "Enter" }); 72 73 assert.ok(dispatch.called, "Expected dispatch to be called"); 74 75 const [action] = dispatch.getCall(0).args; 76 assert.equal(action.type, at.WIDGETS_LISTS_UPDATE); 77 assert.ok( 78 action.data.lists["test-list"].tasks.some( 79 task => task.value === "nathan's cool task" 80 ) 81 ); 82 }); 83 84 it("should toggle task completion", () => { 85 const taskItem = wrapper.find(".task-item").at(0); 86 const checkbox = wrapper.find("input[type='checkbox']").at(0); 87 checkbox.simulate("change", { target: { checked: true } }); 88 // dispatch not called until transition has ended 89 assert.equal(dispatch.callCount, 0); 90 taskItem.simulate("transitionEnd", { propertyName: "opacity" }); 91 assert.ok(dispatch.calledTwice); 92 const [action] = dispatch.getCall(0).args; 93 assert.equal(action.type, at.WIDGETS_LISTS_UPDATE); 94 assert.ok(action.data.lists["test-list"].completed[0].completed); 95 }); 96 97 it("should not dispatch an action when input is empty and Enter is pressed", () => { 98 const input = wrapper.find("input").at(0); 99 input.simulate("change", { target: { value: "" } }); 100 // Override what the current active element so that the dispatch will trigger 101 Object.defineProperty(document, "activeElement", { 102 value: input.getDOMNode(), 103 configurable: true, 104 }); 105 input.simulate("keyDown", { key: "Enter" }); 106 107 assert.ok(dispatch.notCalled); 108 }); 109 110 it("should remove task when deleteTask is run from task item panel menu", () => { 111 // confirm that there is a task available to delete 112 const initialTasks = mockState.ListsWidget.lists["test-list"].tasks; 113 assert.equal(initialTasks.length, 1); 114 115 const deleteButton = wrapper.find("panel-item.delete-item").at(0); 116 deleteButton.props().onClick(); 117 118 assert.ok(dispatch.calledTwice); 119 const [action] = dispatch.getCall(0).args; 120 assert.equal(action.type, at.WIDGETS_LISTS_UPDATE); 121 122 // Check that the task list is now empty 123 const updatedTasks = action.data.lists["test-list"].tasks; 124 assert.equal(updatedTasks.length, 0, "Expected task to be removed"); 125 }); 126 127 it("should add a task with a valid URL and render it as a link", () => { 128 const input = wrapper.find("input").at(0); 129 const testUrl = "https://www.example.com"; 130 131 input.simulate("change", { target: { value: testUrl } }); 132 133 // Set activeElement for Enter key detection 134 Object.defineProperty(document, "activeElement", { 135 value: input.getDOMNode(), 136 configurable: true, 137 }); 138 139 input.simulate("keyDown", { key: "Enter" }); 140 141 assert.ok(dispatch.calledTwice, "Expected dispatch to be called"); 142 143 const [action] = dispatch.getCall(0).args; 144 assert.equal(action.type, at.WIDGETS_LISTS_UPDATE); 145 146 const newHyperlinkedTask = action.data.lists["test-list"].tasks.find( 147 t => t.value === testUrl 148 ); 149 150 assert.ok(newHyperlinkedTask, "Task with URL should be added"); 151 assert.ok(newHyperlinkedTask.isUrl, "Task should be marked as a URL"); 152 }); 153 154 it("should dispatch list change when dropdown selection changes", () => { 155 const select = wrapper.find("moz-select").getDOMNode(); 156 // need to create a new event since I couldnt figure out a way to 157 // trigger the change event to the moz-select component 158 const event = new Event("change", { bubbles: true }); 159 select.value = "test-list"; 160 select.dispatchEvent(event); 161 162 assert.ok(dispatch.calledOnce); 163 const [action] = dispatch.getCall(0).args; 164 assert.equal(action.type, at.WIDGETS_LISTS_CHANGE_SELECTED); 165 assert.equal(action.data, "test-list"); 166 }); 167 168 it("should delete list and select a fallback list", () => { 169 // Grab panel-item for deleting a list 170 const deleteList = wrapper.find("panel-item").at(2); 171 deleteList.props().onClick(); 172 173 assert.ok(dispatch.calledThrice); 174 assert.equal(dispatch.getCall(0).args[0].type, at.WIDGETS_LISTS_UPDATE); 175 assert.equal( 176 dispatch.getCall(1).args[0].type, 177 at.WIDGETS_LISTS_CHANGE_SELECTED 178 ); 179 }); 180 181 it("should update list name when edited and saved", () => { 182 // Grab panel-item for editing a list 183 const editList = wrapper.find("panel-item").at(0); 184 editList.props().onClick(); 185 wrapper.update(); 186 187 const editableInput = wrapper.find("input.edit-list"); 188 editableInput.simulate("change", { target: { value: "Updated List" } }); 189 editableInput.simulate("keyDown", { key: "Enter" }); 190 191 assert.ok(dispatch.calledTwice); 192 const [action] = dispatch.getCall(0).args; 193 assert.equal(action.type, at.WIDGETS_LISTS_UPDATE); 194 assert.equal(action.data.lists["test-list"].label, "Updated List"); 195 }); 196 197 it("should create a new list and dispatch update and select list actions", () => { 198 const createListBtn = wrapper.find("panel-item").at(1); // assumes "Create a new list" is at index 1 199 createListBtn.props().onClick(); 200 assert.ok(dispatch.calledThrice); 201 assert.equal(dispatch.getCall(0).args[0].type, at.WIDGETS_LISTS_UPDATE); 202 assert.equal( 203 dispatch.getCall(1).args[0].type, 204 at.WIDGETS_LISTS_CHANGE_SELECTED 205 ); 206 }); 207 208 it("should copy the current list to clipboard with correct formatting", () => { 209 // Set up task list with additional "completed" task 210 const task1 = { 211 id: "1", 212 value: "task 1", 213 completed: false, 214 isUrl: false, 215 }; 216 const task2 = { 217 id: "2", 218 value: "task 2", 219 completed: true, 220 isUrl: false, 221 }; 222 223 mockState.ListsWidget.lists["test-list"].tasks = [task1, task2]; 224 225 wrapper = mount( 226 <WrapWithProvider state={mockState}> 227 <Lists 228 dispatch={dispatch} 229 handleUserInteraction={handleUserInteraction} 230 /> 231 </WrapWithProvider> 232 ); 233 234 const clipboardWriteTextStub = sinon.stub(navigator.clipboard, "writeText"); 235 236 // Grab panel-item for copying a list 237 const copyList = wrapper.find("panel-item").at(3); 238 copyList.props().onClick(); 239 240 assert.ok( 241 clipboardWriteTextStub.calledOnce, 242 "Expected clipboard.writeText to be called" 243 ); 244 245 const [copiedText] = clipboardWriteTextStub.firstCall.args; 246 assert.include( 247 copiedText, 248 "List: test", 249 "Expected list title in copied text" 250 ); 251 assert.include( 252 copiedText, 253 "- [ ] task 1", 254 "- [x] task 2", 255 "Expected uncompleted and completed tasks in copied text" 256 ); 257 258 // Confirm WIDGETS_LISTS_USER_EVENT telemetry `list_copy` event 259 assert.ok(dispatch.calledOnce); 260 const [copyEvent] = dispatch.lastCall.args; 261 assert.equal(copyEvent.type, at.WIDGETS_LISTS_USER_EVENT); 262 assert.equal(copyEvent.data.userAction, "list_copy"); 263 264 clipboardWriteTextStub.restore(); 265 }); 266 267 it("should reorder tasks via reorder event", () => { 268 const task1 = { 269 id: "1", 270 value: "task 1", 271 completed: false, 272 isUrl: false, 273 }; 274 const task2 = { 275 id: "2", 276 value: "task 2", 277 completed: false, 278 isUrl: false, 279 }; 280 281 mockState.ListsWidget.lists["test-list"].tasks = [task1, task2]; 282 283 wrapper = mount( 284 <WrapWithProvider state={mockState}> 285 <Lists 286 dispatch={dispatch} 287 handleUserInteraction={handleUserInteraction} 288 /> 289 </WrapWithProvider> 290 ); 291 292 const reorderNode = wrapper.find("moz-reorderable-list").getDOMNode(); 293 294 // Simulate moving task2 before task1 295 const event = new CustomEvent("reorder", { 296 detail: { 297 draggedElement: { id: "2" }, 298 targetElement: { id: "1" }, 299 position: -1, 300 }, 301 bubbles: true, 302 }); 303 304 reorderNode.dispatchEvent(event); 305 306 assert.ok(dispatch.calledOnce); 307 const [action] = dispatch.getCall(0).args; 308 assert.equal(action.type, at.WIDGETS_LISTS_UPDATE); 309 310 const reorderedTasks = action.data.lists["test-list"].tasks; 311 assert.deepEqual(reorderedTasks, [task2, task1]); 312 }); 313 314 it("should dispatch OPEN_LINK when the Learn More option is clicked", () => { 315 const learnMoreItem = wrapper.find(".learn-more"); 316 learnMoreItem.props().onClick(); 317 318 assert.ok(dispatch.calledOnce); 319 const [action] = dispatch.getCall(0).args; 320 assert.equal(action.type, at.OPEN_LINK); 321 }); 322 323 it("disables Create new list action (in the panel list) when at the max lists limit", () => { 324 // Set temporary maximum list limit 325 const stateAtMax = { 326 ...mockState, 327 Prefs: { 328 ...INITIAL_STATE.Prefs, 329 values: { 330 ...INITIAL_STATE.Prefs.values, 331 "widgets.lists.maxLists": 1, 332 }, 333 }, 334 }; 335 336 const localWrapper = mount( 337 <WrapWithProvider state={stateAtMax}> 338 <Lists 339 dispatch={dispatch} 340 handleUserInteraction={handleUserInteraction} 341 /> 342 </WrapWithProvider> 343 ); 344 345 const createListBtn = localWrapper.find("panel-item.create-list").at(0); 346 assert.strictEqual(createListBtn.prop("disabled"), true); 347 }); 348 349 it("overrides `widgets.lists.maxLists` pref when below `1` value", () => { 350 const state = { 351 ...mockState, 352 Prefs: { 353 ...mockState.Prefs, 354 values: { 355 ...mockState.Prefs.values, 356 "widgets.lists.maxLists": 0, 357 }, 358 }, 359 }; 360 const localWrapper = mount( 361 <WrapWithProvider state={state}> 362 <Lists 363 dispatch={dispatch} 364 handleUserInteraction={handleUserInteraction} 365 /> 366 </WrapWithProvider> 367 ); 368 const createListBtn = localWrapper.find("panel-item.create-list").at(0); 369 // with 1 existing list, and maxLists coerced to 1, it should be disabled 370 assert.strictEqual(createListBtn.prop("disabled"), true); 371 }); 372 373 it("disables Create List option when at the maximum lists limit", () => { 374 const state = { 375 ...mockState, 376 ListsWidget: { 377 ...mockState.ListsWidget, 378 lists: { 379 "list-1": { label: "A", tasks: [], completed: [] }, 380 "list-2": { label: "B", tasks: [], completed: [] }, 381 }, 382 }, 383 Prefs: { 384 ...mockState.Prefs, 385 values: { 386 ...mockState.Prefs.values, 387 "widgets.lists.maxLists": 2, 388 }, 389 }, 390 }; 391 const localWrapper = mount( 392 <WrapWithProvider state={state}> 393 <Lists 394 dispatch={dispatch} 395 handleUserInteraction={handleUserInteraction} 396 /> 397 </WrapWithProvider> 398 ); 399 const createListBtn = localWrapper.find("panel-item.create-list").at(0); 400 // with 2 existing lists, and maxLists is set to 2, it should be disabled 401 assert.strictEqual(createListBtn.prop("disabled"), true); 402 }); 403 404 it("disables add-task input when at maximum list items limit", () => { 405 // total items = tasks + completed = 3 406 const state = { 407 ...mockState, 408 ListsWidget: { 409 selected: "test-list", 410 lists: { 411 "test-list": { 412 label: "test", 413 tasks: [ 414 { id: "1", value: "task 1", completed: false, isUrl: false }, 415 { id: "2", value: "task 2", completed: false, isUrl: false }, 416 ], 417 completed: [ 418 { id: "c1", value: "done", completed: true, isUrl: false }, 419 ], 420 }, 421 }, 422 }, 423 Prefs: { 424 ...mockState.Prefs, 425 values: { 426 ...mockState.Prefs?.values, 427 // At limit (3), so input should be disabled and icon greyed 428 "widgets.lists.maxListItems": 3, 429 }, 430 }, 431 }; 432 433 const localWrapper = mount( 434 <WrapWithProvider state={state}> 435 <Lists 436 dispatch={dispatch} 437 handleUserInteraction={handleUserInteraction} 438 /> 439 </WrapWithProvider> 440 ); 441 442 const input = localWrapper.find("input.add-task-input").at(0); 443 const addIcon = localWrapper 444 .find(".add-task-container .icon.icon-add") 445 .at(0); 446 447 assert.strictEqual( 448 input.prop("disabled"), 449 true, 450 "Expected add-task input to be disabled at the maximum list items limit" 451 ); 452 assert.strictEqual( 453 addIcon.hasClass("icon-disabled"), 454 true, 455 "Expected add icon to have icon-disabled class at the maximum list items limit" 456 ); 457 }); 458 459 it("enables add-task input when at maximum list items limit", () => { 460 // with 3 items in current list, and maxLists coerced to 1, it should be enabled 461 const state = { 462 ...mockState, 463 Prefs: { 464 ...mockState.Prefs, 465 values: { 466 ...mockState.Prefs?.values, 467 "widgets.lists.maxListItems": 5, 468 }, 469 }, 470 }; 471 472 const localWrapper = mount( 473 <WrapWithProvider state={state}> 474 <Lists 475 dispatch={dispatch} 476 handleUserInteraction={handleUserInteraction} 477 /> 478 </WrapWithProvider> 479 ); 480 481 const input = localWrapper.find("input.add-task-input").at(0); 482 const addIcon = localWrapper 483 .find(".add-task-container .icon.icon-add") 484 .at(0); 485 486 assert.strictEqual( 487 input.prop("disabled"), 488 false, 489 "Expected input to be enabled when under limit" 490 ); 491 assert.strictEqual( 492 addIcon.hasClass("icon-disabled"), 493 false, 494 "Expected add icon not to be greyed when under limit" 495 ); 496 }); 497 498 it("should cancel creating a new list when Escape key is pressed", () => { 499 const newListId = "new-list-id"; 500 501 // Provide a fallback list so CHANGE_SELECTED has somewhere to go after delete 502 const stateWithEmptyAndFallback = { 503 ...mockState, 504 ListsWidget: { 505 selected: newListId, 506 lists: { 507 [newListId]: { label: "", tasks: [], completed: [] }, // empty "new" list 508 "test-list": { label: "test", tasks: [], completed: [] }, // fallback 509 }, 510 }, 511 }; 512 513 const localWrapper = mount( 514 <WrapWithProvider state={stateWithEmptyAndFallback}> 515 <Lists 516 dispatch={dispatch} 517 handleUserInteraction={handleUserInteraction} 518 /> 519 </WrapWithProvider> 520 ); 521 522 const editableText = localWrapper.find("EditableText").at(0); 523 524 assert.ok(editableText.exists()); 525 editableText.props().setIsEditing(true); 526 localWrapper.update(); 527 528 let editableInput = localWrapper.find("input.edit-list"); 529 assert.ok(editableInput.exists()); 530 531 // Press Escape to cancel new-list creation 532 editableInput.simulate("keyDown", { key: "Escape" }); 533 localWrapper.update(); 534 535 // Test dispatches from handleCancelNewList 536 const types = dispatch.getCalls().map(call => call.args[0].type); 537 assert.include( 538 types, 539 at.WIDGETS_LISTS_UPDATE, 540 "Expected update dispatch on cancel" 541 ); 542 assert.include( 543 types, 544 at.WIDGETS_LISTS_CHANGE_SELECTED, 545 "Expected selected list to change after cancel" 546 ); 547 assert.include( 548 types, 549 at.WIDGETS_LISTS_USER_EVENT, 550 "Expected telemetry event on cancel" 551 ); 552 553 const listsState = localWrapper.find("Provider").prop("store").getState() 554 .ListsWidget.lists; 555 assert.strictEqual( 556 Object.keys(listsState).length, 557 2, 558 "expected total lists count to remain as 2 (no new list created on cancel)" 559 ); 560 561 // After cancelling, the input should be gone (editing ended / list removed) 562 editableInput = localWrapper.find("input.edit-list"); 563 assert.strictEqual(editableInput.exists(), false); 564 }); 565 });