CNLearn Vue 9 - History + Debounced Input

Let’s add a history page to see what we’ve searched for. Why? At some point, we’d like to see just how many times we look for the same word/phrase and not learn it so probably should learn it at some point. The history page is the first attempt in doing that. Please keep in mind that for now, the history page will get cleared when you leave the site (saving it in IndexedDB/localStorage) is the next step. If you’re thinking “fine, I guess I kind of see the use of a history page” you might also be thinking: but why “debouncing our input”? What is the point of that? Why are you doing that now when implementing the history page? Other relevant question? Even more relevant questions?

Debouncing our Search Input

SearchInput Component Changes

By the way, what even is debouncing? Debouncing a function is essentially delaying it getting triggered until some time has passed. For example, once you click on a button, debouncing it might mean that the actual event is not sent until 2 seconds have passed. Obviously for a button that’s not really helpful, but for typing text, it can be quite useful.

There are two main reasons why I am adding a debouncing:

  1. The first reason is not related to the history page. For example, imagine I am typing “他们是我最好的朋友”. As I am typing it, 他 creates a server request, then 他们, then 【他们,是】, then 【他们,是, 我】,and so on. Surely it might make more sense to wait until the user has finished typing and only then segment the sentence, create our server requests and get the results.
  2. Similarly to the point above, if every time I type a character multiple requests are sent, then from the point of view of the application, I have requested that character/word multiple times and so its history would increase each time.

Those are the reasons why I am adding it now as I am adding the History page. I could have done it before, I should have done it before, but I know a person that is sometimes lazy and that’s me. What does the debounce function look like? Let’s look at the updated SearchInput component (src/components/SearchInput.vue).

In the script part,

...
import { debounce } from "lodash";
...
  data() {
    return {
      localString: ""
    };
  },
  watch: {
    localString(newValue) {
      this.debSearch(newValue);
    }
  },
  created() {
    this.debSearch = debounce(this.search, 500);
  },
  computed: {
    ...mapState("search", ["searchString"])
  },
  methods: {
    ...mapActions("search", ["search"])
  },
  beforeUnmount() {
    this.debSearch.cancel();
  }
  ...

For some reason, I decided rename localSearchString to localString (no idea why really). The more important parts are importing debounce from lodash, created(), watch() and beforeUnmount(). Now, when the component is first created(), we set its debSearch method to be a 500 milisecond debounced version of the search action. At the same time, before the component is unmounted, we also cancel any pending debounced functions. We need that because if, for example, we were to click on a word card we were interested it before some new search, we don’t want that function to run. Another thing I changed in the component is v-model="localString" (to reflect the renamed variable) and to call debSearch instead of search when watching the localString.

SearchInput Test Changes

Given I have added a debounced function, I need to change some tests. We’ll go through one of them and the change I made. So in the tests/unit/SearchInput.spec.js, I was initially going to wait until the debounce function runs but I couldn’t find a easy way to do that that didn’t involve installing a specific Jest version, a specific vue test utils version and so on, so I decided to do it easier:

const factory = () => {
  const store = createStore({
    modules: {
      search,
    },
  });
  const wrapper = mount(SearchInput, {
    global: {
      plugins: [PrimeVue, store],
    },
  });

  // also, I don't want debounced functions in there. they're a pain to test
  wrapper.vm.debSearch = wrapper.vm.search;
  return { store, wrapper };
};

There, I took the easy way out and problem solved :D In the component that gets loaded in the tests, I am replacing debSearch by the search method directly. No more debouncing, life is nice and easy.

History Page

Vuex Search Module Changes

Let’s start with the Vuex changes. When we search for a word, we need to either increment its search count by 1 if it’s already in our state, or we add it to our state. In our src/store/modules/search.js:

const search = {
  namespaced: true,
  state: () => ({
    ...
    searchHistory: new Map(),
    ...
  }),

We add a new Map to contain our searchHistory. Let’s create a mutation that will update the history for a word.

    UPDATE_HISTORY(state, word) {
      let wordHistory = state.searchHistory.get(word.simplified) || 0;
      wordHistory += 1;
      state.searchHistory.set(word.simplified, wordHistory);
    }

We get the word’s search count from our searchHistory or a default value of 0. We increment it by 1 and we set the new search count. :) Let’s also create a addToHistory action:

    addToHistory({ commit, state }, word) {
      // let's only do the commit if the word is not already in current words, otherwise
      // we incremenent it whenever a search string is extended
      const currentWordsArray = [...state.currentWords];

      if (!currentWordsArray.includes(word)) {
        commit("UPDATE_HISTORY", { simplified: word.simplified });
      }

We check what we have in our current Words. Why? Well, given that our findWords action gets called each time, there’s a chance we might accidentally count it multiple times. If the word is already in the currentWords as we are adding characters to our string, there’s no need to add it again. We only call the UPDATE_HISTORY mutation whenever there’s a new word added to our currentWords.

Finally, we modify our fndWords action to also dispatch the addToHistory action.

    async findWords({ dispatch, state }, words) {
      /* eslint-disable no-restricted-syntax */
      for (const word of words) {
        if (!state.cachedWords.has(word)) {
          try {
            /* eslint-disable no-await-in-loop */
            const response = await Dictionary.getWord(word);
            if (!(response.data.length === 0)) {
              const wordObject = {
                simplified: word,
                data: response.data
              };
              dispatch("addToCache", wordObject);
              dispatch("addToCurrentWords", wordObject.simplified);
              dispatch("addToHistory", wordObject);
            } else {
              await dispatch("findWords", [...word]);
            }
          } catch (error) {
            console.log(error);
          }
        } else {
          dispatch("addToCurrentWords", word);
          dispatch("addToHistory", { simplified: word });
        }
      }

These are all the modifications we need to make to the Vuex store. Let’s now look at the component itself, which is a pretty small one (for now) -> later we’ll add more interesting statistics in there.

History.vue Component

<script>
  import { mapState } from "vuex";
  export default {
    computed: {
      ...mapState("search", ["searchHistory"]),
    },
  };
</script>

In the actual script, we simply need access to the searchHistory state so we use the mapState helper from Vuex to accomplish that.

In the template part,

<template>
  <div class="history">
    <h1>This is your search history page.</h1>
    <div v-if="searchHistory.size > 0">
      <div v-for="word of searchHistory.keys()" :key="word">
        <router-link :to="{ name: 'wordDetail', params: { simplified: word } }">
          <p>{{ word }}: {{ searchHistory.get(word) }}</p>
        </router-link>
      </div>
    </div>
    <div v-else>
      <h4>It's empty. Go study!</h4>
    </div>
  </div>
</template>

we have a check to see if there’s anything in our searchHistory. If not, we display a motivational message. Otherwise, we iterate through each one and we use router-links to direct people to the wordDetail view with that word.

But Vlad, how do we even access this History page? Well, we create a route to it in our vue-router (src/router/index.js).

...
 {
    path: "/history",
    name: "history",
    component: History
  }
...

And that’s all. Except it’s not, because we all want tests. As I am writing this post, I realised I did not create the tests for this component. Oops. I go do that now and then continue the post. Then I also realised I did not write the tests for the new action and the new mutation.

History Page View Tests

I won’t go through everything, there’s no new learning here sadly.

describe("History view tests", () => {
  test("Checks if the view component got mounted and tells us to study", async () => {
    const initialSearchStore = {
      searchString: "",
      segmentedWords: new Map(),
      cachedWords: new Map(),
      currentWords: new Set(),
      searchHistory: new Map(),
    };
    const { store, wrapper, router } = await factory(initialSearchStore);
    await router.isReady();
    // test the header
    const h1 = wrapper.find("h1");
    expect(h1.exists()).toBe(true);
    expect(h1.text()).toBe("This is your search history page.");
    const h4 = wrapper.find("h4");
    expect(h4.exists()).toBe(true);
    expect(h4.text()).toBe("It's empty. Go study!");
  });
  test("Checks if the words from searchHistory appear as links", async () => {
    const wordsHistory = [
      ["我们", 1],
      ["说", 3],
      ["再见", 5],
    ];
    const initialSearchStore = {
      searchString: "",
      segmentedWords: new Map(),
      cachedWords: new Map(),
      currentWords: new Set(),
      searchHistory: new Map(wordsHistory),
    };
    const { store, wrapper, router } = await factory(initialSearchStore);
    await router.isReady();
    // test the header
    const h1 = wrapper.find("h1");
    expect(h1.exists()).toBe(true);
    expect(h1.text()).toBe("This is your search history page.");

    // let's check through the history
    const characterHistoryP = wrapper.findAll("p");
    const characterHistoryPAndHistory = zip(characterHistoryP, wordsHistory);
    characterHistoryPAndHistory.forEach(([histP, [simplified, histCount]]) => {
      expect(histP.text()).toEqual(`${simplified}: ${histCount}`);
    });
  });
});

We have two tests. One for the situation when there isn’t anything in searchHistory and one for when there is. That’s it!

searchHistory Vuex State Mutations and Actions

Lastly, let’s also test our new mutations and actions. we’ll add to the VuexSearchModuleActions.spec.js. We are going to test two cases for the action and 2 cases for the mutation.

1. The word is not in currentWords, and so it will get added to searchHistory or its history incremented

  test("Tests the addToHistory action when the word is not in currentWords", () => {
    const store = factory();
    expect(store.state.search.searchHistory.size).toEqual(0);
    expect(store.state.search.currentWords.size).toEqual(0);
    // now let's dispatch the action
    store.dispatch("search/addToHistory", { simplified: "我" });
    expect(store.state.search.searchHistory.size).toEqual(1);
    expect(store.state.search.searchHistory.get("我")).toEqual(1);
    // let's dispatch it again. since it's not in currentWords, its history will get increased by 1
    store.dispatch("search/addToHistory", { simplified: "我" });
    expect(store.state.search.searchHistory.size).toEqual(1);
    expect(store.state.search.searchHistory.get("我")).toEqual(2);
  });

We start with an empty state, check nothing is in there. Then we dispatch the addToHistory action and check that the word got added to searchHistory and that it’s history value is 1. Please note that we haven’t added to the currentWords array. Consequently, when we dispatch the action again, its history value gets increased by 1.

2. The word is in currentWords, and so it won’t get its history value incremented (or added to searchHistory)

  test("Tests the addToHistory action when the word is in currentWords", () => {
    const store = factory();
    store.state.search.currentWords.add("我");
    expect(store.state.search.searchHistory.size).toEqual(0);
    expect(store.state.search.currentWords.size).toEqual(1);
    // now let's dispatch the action
    store.dispatch("search/addToHistory", { simplified: "我" });
    // it shouldn't get added to searchHistory
    expect(store.state.search.searchHistory.size).toEqual(0);
  });

In this test, we start with an empty store but we add the character to the currentWords and check it’s not in history. We then check that if we dispatch the action, it does not get added to the searchHistory.

3. Tests UPDATE_HISTORY mutation when the word is not in searchHistory

  test("Tests the UPDATE_HISTORY mutation when the word is not in searchHistory", () => {
    const store = factory();
    expect(store.state.search.searchHistory.size).toEqual(0);
    // now let's commit the mutation
    store.commit("search/UPDATE_HISTORY", { simplified: "我" });
    expect(store.state.search.searchHistory.size).toEqual(1);
    expect(store.state.search.searchHistory.get("我")).toEqual(1);
  });

We start with an empty state, we check nothing is in there. When we commit the mutation, it sees that it’s not in the state and so gets a history value of 0. It gets incremented by 1 and added.

4. Tests UPDATE_HISTORY mutation when the word is in searchHistory

  test("Tests the UPDATE_HISTORY mutation when the word is in searchHistory", () => {
    const store = factory();
    store.state.search.searchHistory.set("我", 1);
    expect(store.state.search.searchHistory.size).toEqual(1);
    expect(store.state.search.searchHistory.get("我")).toEqual(1);
    // now let's commit the mutation
    store.commit("search/UPDATE_HISTORY", { simplified: "我" });
    expect(store.state.search.searchHistory.size).toEqual(1);
    expect(store.state.search.searchHistory.get("我")).toEqual(2);
  });

Similar to the test above, but we start with the word being in the searchHistory. When we commit the mutation, its history value gets incremented by 1.

And that is all for today :) I haven’t decided what the next post on the FE client will be, but I’m thinking it will have something to do with saving this data locally on the client. I’ve been looking at IndexedDB and so it will likely be on that. This was added to the repo in two commits:

  1. https://github.com/CNLearn/frontend/commit/b5c8e8dae984200c6dedc7234a9d19e0642238c7
  2. https://github.com/CNLearn/frontend/commit/43f191f544e61d7959a91a998a0bbad64f96b3ff