CNLearn Vue 8 - Stroke Diagram Component

Is it time to add a new component? You bet. What will it be? A stroke diagram for the characters. In order to do that, however, we should only display one charater at a time (for now, maybe later we will show multiple stroke diagrams on the same page). That means that in order to show the stroke diagram, and we start with a word like “不好意思”, we want each character to be clickable so that we can go to its page -> think of it as displaying the component characters.

      <div v-if="characters.size > 1">
        <h2>Component Characters</h2>
        <div class="container-row p-d-flex">
          <span v-for="character in characters" :key="character" class="component-character">
            <router-link :to="{name: 'wordDetail', params: {simplified: character}">
              {{character}}
            </router-link>
          </span>
        </div>
      </div>

Ok we click on it…uh oh, nothing happens. why? Please note that the url in the browser bar does change. so what’s happening?

well, the parameter changes but our component is not refreshed. how can we get it to refresh? easiest way is to tell router-view to look at full path changes (can also use a watchEffect but won’t get into that here)

<router-view :key="$route.fullPath" />

And that’s all we need to do. What about tests? of course we have tests. And really, we’ll just be modifying existing ones to look for extra information in there.

in the

test("Checks if the view component got mounted and got the information from the state", async () => {});

let’s add this at the end

// let's check the information if we have a Component Characters header
// we should as we have two characters here
const componentCharactersHeader = h2s[1];
expect(componentCharactersHeader.text()).toEqual("Component Characters");
// let's find each span for the component characters
const componentCharacterSpans = wrapper.findAll("span");
const compCharactersAndCharacters = zip(componentCharacterSpans, [
  ...initialSearchStore.searchString,
]);
compCharactersAndCharacters.forEach(([characterSpan, character]) => {
  expect(characterSpan.text()).toEqual(character);
  // let's also check their links, format sohuld be /words/URIencodedChar
  // e.g. href="/words/%E6%88%91"
  expect(characterSpan.html()).toEqual(
    expect.stringContaining(`/words/${encodeURI(character)}`)
  );
});

and let’s add similar ones to the other tests as well. And finally, since it shouldn’t show up for one character word, let’s add another test. For just one word:

test("Checks if the view component got mounted and got information from state for 1 character", async () => {
  const initialSearchStore = {
    searchString: "我",
    segmentedWords: new Map([[0, "我"]]),
    cachedWords: new Map([
      [
        "我",
        [
          {
            id: 40806,
            simplified: "我",
            traditional: "我",
            pinyin_num: "wo3",
            pinyin_accent: "wǒ",
            pinyin_clean: "wo",
            pinyin_no_spaces: "wo",
            also_written: "",
            classifiers: "",
            definitions: "I; me; my",
            frequency: 1293594,
          },
        ],
      ],
    ]),
    currentWords: new Set(["我"]),
  };
  const { store, wrapper, router } = await factory(initialSearchStore, "我");
  await router.isReady();

  // test the header
  const header = wrapper.find("h2");
  expect(header.exists()).toBe(true);
  expect(header.text()).toBe("我(wǒ)");
  // it's worth also testing that there's only one h2, as there is no component
  // characters par// let's check the information if we have a Component Characters header
  // we should as we have two characters here
  const h2s = wrapper.findAll("h2");
  expect(h2s.length).toEqual(1);
  // check the definitions part
  expect(wrapper.find("h3").text()).toBe("Definitions");
  // definitions wrapper elements
  const definitions = store.state.search.cachedWords
    .get("我")[0]
    .definitions.split(";");
  const definitionElements = wrapper.findAll("li");
  const defElementsAndDefinitions = zip(definitionElements, definitions);
  defElementsAndDefinitions.forEach(([defElement, definition]) => {
    expect(defElement.text()).toEqual(definition.trim());
  });
});

What’s different in this one? Well, I have removed the parts that check for component characters (as there is only 1) and I’m also ensuring that there’s only one h2 header.

Why all this preparation? Well, in the case of 1 character component words, we want to display a Stroke Diagram. What will we use for it? There’s a great project called Hanzi Writer that I love. In fact I am also using (hopefully there will be a post on that soon) that library for the GUI Desktop App of CNLearn.

First let’s install it:

npm install hanzi-writer

Then let’s create a new compoennt called Stroke Diagram. Reading the documentation, we see that:

Creating a new HanziWriter instance requires passing in a target div (either ID or DOM reference), the character you’d like to render, and configuration options.

So we will need to reference some kind of DOM element. While I will move onto a composition API version of this app soon with the <script setup> notation, for now let’s stick to the Options API In order to access a template ref, we will add a ref to the HTML element using the ref attribute. You can find more information here and let’s look at our script part of our StrokeDiagram.vue SFC (will be in src/components/Strokediagram.vue).

<script>
import Button from "primevue/button";

const HanziWriter = require("hanzi-writer").default;

export default {
  props: {
    character: {
      type: String,
      required: true
    }
  },
  components: {
    Button
  },
  mounted() {
    this.stroke_diagram = HanziWriter.create(this.$refs.stroke_diagram, this.character, {
      width: 150,
      height: 150,
      padding: 5,
      strokeAnimationSpeed: 0.75,
      delayBetweenStrokes: 50,
      delayBetweenLoops: 2000
    });
  },
  methods: {
    animateButton() {
      this.stroke_diagram.animateCharacter();
    }
  }
};
</script>

Let’s go line by line. We import Button and HanziWriter. We need a button in order to animate the stroke diagram component. When we use the component in our WordPage, we will be sending it the character as props (of type string. Did I mention I also want to move towards TypeScript? Did I mentioned I want to do too many things?). We have Button under components and we are also using the ]mounted() lifecycle hooks. For a complete reference of life cycle hooks, have a look here. Why are we using this one? We need to access the template itself.

We use the HanziWriter create method, and in order to pass a DOM id to insert, we use this.$refs.stroke_diagram and as character we pass in this.character from props. Then we pass in some HanziWriter configuration options and we also define an animateButton method that will call the animateCharacter() method of the HanziWriter instance. But first, let’s look at the template.

<template>
  <div class="container">
    <div id="stroke_diagram" ref="stroke_diagram"></div>
    <br />
    <button
      icon="pi pi-play"
      label="Animate"
      class="p-button-outlined p-ripple"
      @click="animateButton"
    />
  </div>
</template>

The most important part is the div with the “stroke_diagram” ref. Remember that we access it using this.$refs.stroke_diagram. We also have a button that when we click, calls the animateButton in our script.

And this is all! Now we have a working StrokeDiagram component that we can use. Let’s see its usage in the WordPage view component. In src/views/WordPage.vue:

export default {
  components: {
    Skeleton,
    StrokeDiagram,
  },
  ...

We imported the StrokeDiagram component. Let’s add it to the template:

...
<div v-if="characters.size > 1">
  <h2>Component Characters</h2>
  <div class="container-row p-d-flex">
    <span
      v-for="character in characters"
      :key="character"
      class="component-character"
    >
      <router-link :to="{name: 'wordDetail', params: {simplified: character}}">
        {{character}}
      </router-link>
    </span>
  </div>
</div>
<div v-else>
  <StrokeDiagram :character="[...characters][0]" />
</div>
...

I added a v-else condition to the conditional that displayed the component characters. If there are multiple characters, it displays those. Otherwise, it uses the StrokeDiagram component to show it passing in the single character as props. It’s a bit annoying I am using "[...characters][0]" but annoyingly (for someone mostly doing Python), there was no indexing in set so I’m creating an array from it and getting the first element….yes i agree…

What’s left? Well, we have to test! We need tests for the StrokeDiagram component and to change the WordPage.spec.js tests. Let’s do the latter first.

Changing our factory function

Since I don’t want to test the StrokeDiagram component in the context of the WordPage, I will stub it. That being said, we still want to check if it’s “there”, so let’s replace it by an element that says <p>StrokeDiagramStub</p>'.

...
const wrapper = mount(WordPage, {
  global: {
    stubs: {
      StrokeDiagram: {
         template: '<p>StrokeDiagramStub</p>'
      },
    },
    mocks: {
      $route: {
        params: { simplified },
      },
    },
    plugins: [PrimeVue, store, router],
  },
});
...

Now when the test starts, the StrokeDiagram will not be mounted and instead be replaced with a <p>StrokeDiagramStub</p>' . Which tests do we need to change? Well, given that this should only be there when there’s one character, let’s change the following tests:

  1. Checks if the view component got mounted and got information from state for 1 character
  2. oh

Ha, I honestly thought I had more tests for a single character WordPage :) Ok I guess we just need to change one test. Let’s add this at the end:

const strokeDiagram = wrapper.find("p");
expect(strokeDiagram.exists()).toBe(true);
expect(strokeDiagram.text()).toBe("StrokeDiagramStub");

And then we rerun our test suite with:

npm run test:unit

Everything should pass.

Testing the StrokeDiagram component

Before we test it, let’s decide on what we will acctually test. Remember that in order for it to render, we needed some div with a ref we know:

<div id="stroke_diagram" ref="stroke_diagram"></div>

where during the mounted lifecycle hook, the HanziWriter instance will render the stroke diagram in there. The first thing we will test is that something gets rendered. How do we do that? If we inspect the stroke diagram in the browser, we will see that a SVG gets gets added in there. Perhaps we can test for that? Well, let’s try. Let’s create a new file called StrokeDiagram.spec.js and add this in there:

describe("StrokeDiagram", () => {
  test("Check if the svg element gets created", async () => {
    const wrapper = mount(StrokeDiagram, {
      props: {
        character: "好",
      },
      global: {
        plugins: [PrimeVue],
      },
    });
    const svg = wrapper.find("svg");
    expect(svg.exists()).toBe(true);
  });
});

Nice, a much simpler test. And now one for the button that we can press to animate.

test("Check if the click event occurs", async () => {
  const mockAnimateAction = jest.spyOn(StrokeDiagram.methods, "animateButton");
  const wrapper = mount(StrokeDiagram, {
    props: {
      character: "好",
    },
    global: {
      plugins: [PrimeVue],
    },
  });
  const button = wrapper.find("button");
  expect(button.exists()).toBe(true);
  expect(button.text()).toBe("Animate");

  // let's click on the button
  await button.trigger("click");
  expect(mockAnimateAction).toHaveBeenCalled();
});

What are we doing there? Well, we are finding the button, checking the button label text and also clicking it. Note that in order to determine whether it’s been clicked, I mocked the animateButton method of the StrokeDiagram component. Then, using jest, I can check whether it has been called.

And that’s it for today :) In the next post, let’s make some changes to the SearchResults component, to the home page so that only the latest search is shown and then implement a so called History page (for checking just how often you looked for the same character). If we have time, I’d also like to explore using IndexedDB to save some of the data so you don’t have to hit my server each time :) let’s see how that will go.

The commit for this post is here.