Web CNLearn - Vue 3 - Creating (Some Of) the Search Functionality and More

I just realised how I titled my posts..Vue 1, Vue 2, Vue 3, …I am not referring to Vue version numbers. I would change them now but can’t wait until 10 years from now, somebody finds my Vue 99 post and gets confused. So I’m doing it for them! So what’s on the schedule for today?

  1. Create some utilities for Chinese text (to only allow Chinese characters to be entered -> obviously we will change that later)
  2. Install a Chinese string segmenter: we will use segmentit for now.
  3. Create a few more Vuex actions/mutations.
  4. Test them!

(Also, at some point, I want to create a static version of this website -> just search functionality and nothing else and host it on GH Pages. Inspired by this. Basically saving this here as a reminder for myself.)

Utilities for Chinese text

In the src folder, let’s create an utilities directory (yea i know…) and then create a chinese.js file. In there, let’s add the following:

export function extractChineseCharacters(sentence) {
    const re = new RegExp('[\\u4e00-\\u9fa5]', 'g')
    // the following returns an iterator
    const matches = sentence.matchAll(re)
    // now let's create a set to store the characters occurring
    const characters = new Set()
    for (const match of matches) {
        characters.add(match[0])
    }
    return characters
};

export function extractChineseCharactersToString(sentence) {
    const re = new RegExp('[\\u4e00-\\u9fa5]', 'g')
    // the following returns an iterator
    const matches = sentence.matchAll(re)
    // now let's create a set to store the characters occurring
    const characters = Array.from(matches)
    const chineseString = characters.join('')
    return chineseString
};

The first function creates a set that stores all the unique Chinese characters in a string. The second function takes in a strong and returns another string that strips everything besides Chinese characters. The first one we will not use in this post, but the second one will be used

Chinese Segmentation

Let’s install the package we will use.

npm install segmentit

Ok this task maybe didn’t require it’s own subheading.

Creating Vuex mutations/actions/state

Ok let’s think of the flow and then determine what state, mutations and actions we need.

  1. User enters string
  2. Chinese characters are extracted from the string
  3. Commit mutation to set the search string in the state.
  4. The search string gets segmented
  5. The segmented words get saved somewhere -> let’s create something in the state where we can store them -> a segmentedWords Map
  6. The segmented words are returned to a newly created Search.vue view component.

Ok let’s create a search action in the search vuex module.

search({ dispatch }, searchString) {
      const chineseString = extractChineseCharactersToString(searchString);
      dispatch('setSearch', chineseString);
      // segment the words
      const segmentedWordsArray = segmentit.doSegment(chineseString, {
        simple: true,
        stripPunctuation: true,
      });
      dispatch('addToSegmentedWords', segmentedWordsArray);
      // then do the actual word search

We will change the SearchInput component so that when the text field changes, it calls this action from the Vuex store. Please note that after we finish this we will refactor it in TypeScript one component at a time (still learning some TypeScript things hence why we are not doing it yet). The search action gets dispatched. It then extracts the chinese characters from the string and saves that as chineseString. We then dispatch the setSearch action with the chineseString we just extracted from the user-inputted string. The setSearch action is the same it was here. We then segment the Chinese string using the segmentit package we installed. In order to do so, we first import it in our store and initialise it.

import { Segment, useDefault } from 'segmentit';

const segmentit = useDefault(new Segment());

Then we used it in our search action by doing the following:

      const segmentedWordsArray = segmentit.doSegment(chineseString, {
        simple: true,
        stripPunctuation: true,
      });

The parameters are library specific ones and we won’t go in a lot of detail here. If interested, please head to segmentit. What is the segmentedWordsArray though? It’s an array of strings. For example, if the searchString was “我们是你们的朋友”, segmentedWordsArray would be [ "我们", "是", "你们", "的", "朋友" ]. What do we do with the segmentedWordsArray then? We add them to our segmentedWords state. What is our segmentedWords state you ask?

  state: () => ({
    searchString: '',
    segmentedWords: new Map(),
  }),

It’s a Map where the key will be the position of the word in the string and the value will be the simplified word. Why a map and not an array? Well, loading each word’s data from the backend API will be async, and I ran into some issues where the order was not preserved. This way, when I display the search results, they will be in the same order as they appeared in the original search string.

How do we add them to segmentedWords then? We have an addToSegmentedWords action obviously. What does the action do? First of all, it clears the segmentedWords from the previous search. Then for each word from segmentedWordsArray, we commit the ‘ADD_TO_SEGMENTED_WORDS’ mutation while passing in an object with position and simplified.

    addToSegmentedWords({ commit }, segmentedWordsArray) {
      // clear the current segmented words
      commit('CLEAR_SEGMENTED_WORDS');
      segmentedWordsArray.forEach((word, index) => (
        commit('ADD_TO_SEGMENTED_WORDS', { position: index, simplified: word })
      ));

What is the ‘ADD_TO_SEGMENTED_WORDS’ mutation you ask? (stop asking so many questions)

    ADD_TO_SEGMENTED_WORDS(state, word) {
      state.segmentedWords.set(word.position, word.simplified);
    }

Updating our SearchInput.vue component

And these are the changes we have in our Vuex store today. We still haven’t implemented the actual search, we will get into that next time. Let’s use these new actions in our SearchInput component and display the results in a newly created Search view component. For now, we only make two changes in our SearchInput.vue component (later we will also remove a few other things and put them in our Search.vue view component). What changes are we making in SearchInput? Instead of

  watch: {
    localSearchString() {
      this.setSearch(this.localSearchString);
    },
  },

we have

  watch: {
    localSearchString() {
      this.search(this.localSearchString);
    },
  },

and in our mapActions, rather than mapping setSearch, let’s map search.

  methods: {
    ...mapActions('search', ['search']),
  }

Creating Search.vue view component. Let’s create a Search.vue view component in the views folder.

<template>
  <div class="home">
    <SearchInput />

    <div v-if="segmentedWords.size>0" class="container p-grid p-flex-column">
        <p v-for="word of segmentedWords.values()" :key="word">{{ word }}</p>
    </div>
    <div v-else>
        <p>Enter some text to get started.</p>
    </div>
  </div>
</template>

<script>
import SearchInput from '@/components/SearchInput.vue';
import { mapState } from 'vuex';

export default {
  components: {
    SearchInput,
  },
  computed: {
    ...mapState('search', ['segmentedWords']),
  },
};
</script>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}
</style>

We’re doing a few things in here. First of all, we import the SearchInput component (will add it here rather than the Home view). Yes I am aware that if I say Search.vue is a view component we shouldn’t put it in our Home.vue view component. We will fix that shortly. We also mapState the segmentedWords Map from the search module in our Vuex store. What do we do in the template?

We first add the SearchInput component. Then, I use conditional rendering to display either the segmentedWords found or a messasge saying to enter something in the text input first. For that, we use the v-if and v-else directives. If there are some segmented words, I then use the v-for directive to iterate over the words.

Ok now let’s delete our Home.vue component. We don’t need it. Then in our router/index.js file, let’s make a change:

...
import Search from '../views/Search.vue';

const routes = [
  {
    path: '/',
    name: 'Search',
    component: Search,
  },
];
...

Now when our app loads, we start at the Search view component.

Re-Testing the SearchInput Component

There are some changes we need to make to our existing tests and then add a few more tests of course. I decided to leave the GH Actions integration to next post. So what’s the change we need to make in our tests before we can write new ones? Our SearchInput tests would currently fail because when we set data we used some English words/phrases. Since our search action now extracts the Chinese characters, if we enter anything else they won’t be displayed above the search field. So I changed the first two tests in SearchInput.spec.js to:

  test('Checks if inputting values works', async () => {
    const { store, wrapper } = factory();
    // first check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
    // set the searchString data using setData method
    await wrapper.setData({ localSearchString: "你好" })
    expect(wrapper.find('[data-test="search_string"]').exists()).toBe(true)
    const search_string = wrapper.find(
      '[data-test="search_string"]').element.textContent
    expect(search_string).toBe("你好")
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('你好')

    // let's also "write some text" in the textfield using setValue
    await wrapper.find('[data-test="search_box"]').setValue("我们的朋友")
    const updated_search_string = wrapper.find('[data-test="search_string"]').element.textContent
    expect(updated_search_string).toBe("我们的朋友")
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('我们的朋友')
  });
  test('Checks if the component got mounted', async () => {
    const { store, wrapper } = factory();
    expect(wrapper.find('[data-test="searched_for"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="search_box"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="search_string"]').exists()).toBe(false)
    // let's also check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
  });

Ok but let’s test that anything that is not a Chinese character gets filtered out. Let’s write a new test (yay!):

  test('Checks if anything that is not a Chinese character gets filtered out', async () => {
    const { store, wrapper } = factory();
    // first check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
    // set the searchString data using setData method
    await wrapper.setData({ localSearchString: "你们是our friends" })
    expect(wrapper.find('[data-test="search_string"]').exists()).toBe(true)
    const search_string = wrapper.find(
      '[data-test="search_string"]').element.textContent
    expect(search_string).toBe("你们是")
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('你们是')

    // let's also "write some text" in the textfield using setValue
    await wrapper.find('[data-test="search_box"]').setValue("我们的朋友are you")
    const updated_search_string = wrapper.find('[data-test="search_string"]').element.textContent
    expect(updated_search_string).toBe("我们的朋友")
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('我们的朋友')
  });

So even if we set the localSearchString to include non-Chinese characters, they will get filtered out when “travelling” through our Vuex search module. Consequently, they shouldn’t be present in the final searchString state. So we have tested our SearchInput component. What about our Search component? Let’s have a look.

Testing our Search view. Nevermind. Creating a SearchResults component

When we pass in a string to our SearchInput, it gets segmented and we see the results on our Search.vue view component. (later we will extract that to a SearchResults component.) Actually, let’s not wait. Let’s create a SearchResults.vue component. And we’ll remove the logic from our Search view component. In SearchResults.vue, we have (extracted from the Search component) the following:

<template>
  <div data-test="search_results_div" class="search_results">
    <div data-test="search_results_found" v-if="segmentedWords.size>0" class="container p-grid p-flex-column">
        <p v-for="word of segmentedWords.values()" :key="word">{{ word }}</p>
    </div>
    <div data-test="search_results_blank" v-else>
        <p>Enter some text to get started.</p>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex';

export default {
  computed: {
    ...mapState('search', ['segmentedWords']),
  },
};
</script>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}
</style>

Don’t do in the next post what you can do in the current post. Note that since our state lives in a global store, this change was very easy. Had we been passing state between components with props, this would have taken longer. I’ve also added some stuff for testing in there. Ok let’s quickly change our Search view component to:

<template>
  <div class="home">
    <SearchInput />

    <SearchResults />
  </div>
</template>

<script>
import SearchInput from '@/components/SearchInput.vue';
import SearchResults from '@/components/SearchResults.vue';

export default {
  components: {
    SearchInput,
    SearchResults,
  },
};
</script>

<style scoped>
</style>

Now it just serves as a viewing component. We took the logic out of it and put it in the right component. The other benefit of doing that is that it’s a lot easier to test the SearchResults component as an individual component.

Testing the SearchResults component

Ok let’s create a SearchResults.spec.js file in the tests/unit directory. Are we gonna do other tests besides unit tests? Yes, at some point. Then let’s add this code:

import { mount } from '@vue/test-utils';
import SearchResults from '@/components/SearchResults.vue';
import PrimeVue from 'primevue/config';
import search from '@/store/modules/search';
import { createStore } from 'vuex';
import { nextTick } from 'vue';
import { zip } from 'lodash';

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


describe('SearchResults', () => {
    test('Checks if the component got mounted', async () => {
        const { store, wrapper } = factory();
        expect(wrapper.find('[data-test="search_results_div"]').exists()).toBe(true)
        expect(wrapper.find('[data-test="search_results_found"]').exists()).toBe(false)
        expect(wrapper.find('[data-test="search_results_blank"]').exists()).toBe(true)
        // let's also check that the segmentedWords map in the state is blank
        expect(store.state.search.segmentedWords.size).toEqual(0);
    });
    test('Checks if segmentedWords are shown', async () => {
        const { store, wrapper } = factory();
        expect(wrapper.find('[data-test="search_results_div"]').exists()).toBe(true)
        expect(wrapper.find('[data-test="search_results_found"]').exists()).toBe(false)
        expect(wrapper.find('[data-test="search_results_blank"]').exists()).toBe(true)
        // let's also check that the searchString in the search module state is null 
        const segmentedWordsArray = ["我们", "是", "你们", "的", "朋友"];
        segmentedWordsArray.forEach((word, index) => {
            store.state.search.segmentedWords.set(index, word);
        })
        await nextTick();
        expect(wrapper.find('[data-test="search_results_div"]').exists()).toBe(true)
        expect(wrapper.find('[data-test="search_results_found"]').exists()).toBe(true)
        expect(wrapper.find('[data-test="search_results_blank"]').exists()).toBe(false)
        // let's see if we can see the 5 <p></p> items
        const pElements = wrapper.findAll('p')
        // now let's iterate over each pElement and each word in segmentedWordsArray
        // let's zip them together...yes Python I know
        const pElsAndWords = zip(pElements, segmentedWordsArray);
        pElsAndWords.forEach((pElementAndWord, _) => {
            const pElement = pElementAndWord[0];
            const word = pElementAndWord[1];
            // now check the text in each one and compare with the word in the array
            expect(pElement.text()).toEqual(word);
        })
    });
});

Nothing new in the factory function, I simply changed the component that is getting mounted. Ok let’s look at the first test.

    test('Checks if the component got mounted', async () => {
        const { store, wrapper } = factory();
        expect(wrapper.find('[data-test="search_results_div"]').exists()).toBe(true)
        expect(wrapper.find('[data-test="search_results_found"]').exists()).toBe(false)
        expect(wrapper.find('[data-test="search_results_blank"]').exists()).toBe(true)
        // let's also check that the segmentedWords map in the state is blank
        expect(store.state.search.segmentedWords.size).toEqual(0);
    });

When the component is first mounted, the segmentedWords state is blank (i.e. size of 0.). We will have the wrapping div so that will exist. The div that shows the segmented words, however, will be empty so we expect its existance to be false. Finally, we should see that message “Enter some text to get started” so we check for its wrapping v-else div. All good, it passes. Finall, we are checking that the size of the segmentedWords Map is 0.

Time to move on to the second test:

    test('Checks if segmentedWords are shown', async () => {
        const { store, wrapper } = factory();
        expect(wrapper.find('[data-test="search_results_div"]').exists()).toBe(true)
        expect(wrapper.find('[data-test="search_results_found"]').exists()).toBe(false)
        expect(wrapper.find('[data-test="search_results_blank"]').exists()).toBe(true)
        // let's also check that the searchString in the search module state is null 
        const segmentedWordsArray = ["我们", "是", "你们", "的", "朋友"];
        segmentedWordsArray.forEach((word, index) => {
            store.state.search.segmentedWords.set(index, word);
        })
        await nextTick();
        expect(wrapper.find('[data-test="search_results_div"]').exists()).toBe(true)
        expect(wrapper.find('[data-test="search_results_found"]').exists()).toBe(true)
        expect(wrapper.find('[data-test="search_results_blank"]').exists()).toBe(false)
        // let's see if we can see the 5 <p></p> items
        const pElements = wrapper.findAll('p')
        // now let's iterate over each pElement and each word in segmentedWordsArray
        // let's zip them together...yes Python I know
        const pElsAndWords = zip(pElements, segmentedWordsArray);
        pElsAndWords.forEach((pElementAndWord, _) => {
            const pElement = pElementAndWord[0];
            const word = pElementAndWord[1];
            // now check the text in each one and compare with the word in the array
            expect(pElement.text()).toEqual(word);
        })
    });

The first three expect statements are the same as above: we start with a new component and store. Then we set an array of segmented words like would occur in the search action in the search module of our Vuex store. It’s worth noting that in these components I’m not testing our store (that will occur later). I am simply testing our components and what they output for a specific input. Then, I look for the five elements I expect to find. Let’s pause here. Why? What are you aWaiting for? We need to wait for the DOM to update so we use the nextTick function from Vue. Then I make the same assertions as above except I expect the “found” div to exist and the “blank” div to not. Then let’s also check if we can see the 5 elements. I do that by first ziping (or whatever the equivalent would be in JavaScript) the two arrays: i.e. I will have an array of 2 element arrays. In the 2 element array, the first one is the DOMWRapper element and the second one is the word. Oh and to zip I use the zip function from lodash (so also install it and import it…) Then I iterate over that zipped array, asserting each time that the text of the element is what I would expect. Everything runs, we are happy, we stop here.

The commit for this post is here.