tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 });