learn-nuxt-ts

Polls: Components and Vuex

Let’s explorer further nuxt-property-decorator. Instead of the eternal Counter example, let’s try something, not that different: Polls. Polls have questions with multiple choices. The interface lets you vote for choice and you can optionally add some comment to your vote.

Polls Models

Before getting into VueJS/Nuxt affairs, Polls models must be defined. This is pure TypeScript writing so I won’t dive into details.

I define my models in lib/polls/models.ts. As models are not depending on the store nor components, I decided to create a lib/ folder to serve this purpose.

/**
 * A vote for a given choice
 */
export class Vote {
  public constructor(
    public id: number,
    public choiceId: number,
    public comment?: string
  ) {}
}

/**
 * A choice to vote for within a Poll
 */
export class Choice {
  public count: number;

  public constructor(
    public id: number,
    public pollId: number,
    public text: string
  ) {
    this.count = 0;
  }
}

/**
 * A topic with which user is offered multiple choices to vote for
 */
export class Poll {
  public choices: Choice[];

  public constructor(
    public id: number,
    public topic: string,
    choices?: Choice[]
  ) {
    this.choices = choices !== undefined ? choices : [];
  }
}

/**
 * An intention of voting a given choice with an optional comment
 */
export interface ChoiceVote {
  choiceId: number;
  comment?: string;
}

To provide polls, I created a lib/polls/api.ts file with a dummy variables:

import { Poll } from './models';

/**
 * Dummy polls
 */
export const DUMMY_POLLS: Poll[] = [
  {
    id: 1,
    topic: 'Which framework are you using?',
    choices: [
      { id: 1, count: 0, pollId: 1, text: 'NuxtJS' },
      { id: 2, count: 0, pollId: 1, text: 'Plain VueJS' },
      { id: 3, count: 0, pollId: 1, text: 'Angular' },
      { id: 4, count: 0, pollId: 1, text: 'React' }
    ]
  },
  {
    id: 2,
    topic: 'What is your OS?',
    choices: [
      { id: 5, count: 0, pollId: 2, text: 'Windows' },
      { id: 6, count: 0, pollId: 2, text: 'Linux' },
      { id: 7, count: 0, pollId: 2, text: 'MacOS' }
    ]
  }
];

Polls Page

Let’s add an empty page pages/polls.vue. For lazy people like me, feel free to add a link : TypeScript components in pages/index.vue:

<template>
  <section class="container">
    <!-- put that wherever you want -->
    <nuxt-link to="/polls">Polls</nuxt-link>
  </section>
</template>

Polls Components

The Polls page must display a list of polls. So let’s create a components/polls/PollList.vue:

<template>
  <div>
    <div>
      <h2>Polls</h2>
      <poll-detail
        v-for="poll in polls"
        :key="'poll-' + poll.id"
        :poll="poll"
      />
    </div>

    <div>
      <h2>Votes</h2>
      <p>votes count: </p>
      <div v-for="vote in votes" :key="'vote-' + vote.id">
        <p> []: </p>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'nuxt-property-decorator';

import { Poll, Vote } from '@/lib/polls/models';
import PollDetail from './PollDetail.vue';

@Component({
  components: {
    PollDetail
  }
})
export default class PollList extends Vue {
  @Prop({ type: Array })
  polls!: Poll[];

  @Prop({ type: Array })
  votes!: Vote[];
}
</script>

Notes:

For code flexibility reason, I defined poll details in a dedicated components/polls/PollDetail.vue:

<template>
  <div>
    <h3></h3>

    <div
      v-for="choice in poll.choices"
      :key="choice.id"
      @click="selectChoice(choice)"
    >
      <p>
        <span v-if="choice.id === selectedChoiceId">[SELECTED]</span>
        <span>Select  (count: )</span>
      </p>
    </div>

    <div v-if="selectedChoiceId > 0">
      <textarea v-model="comment"></textarea>
      <button @click="voteChoice()">Vote!</button>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'nuxt-property-decorator';

import { Poll, Choice, ChoiceVote } from '@/lib/polls/models';
import { pollsModule } from '@/store/polls/const';

@Component({})
export default class PollList extends Vue {
  /**
   * Optional comment
   */
  private comment: string = '';
  /**
   * Avoid undefined to make it reactive
   */
  private selectedChoiceId: number = -1;

  @Prop({ type: Object })
  public poll: Poll;

  public selectChoice(choice: Choice): void {
    this.selectedChoiceId = choice.id;
  }

  public voteChoice(): void {
    console.log('Voting: ', {
      choiceId: this.selectedChoiceId,
      comment: this.comment.length > 0 ? this.comment : undefined
    });

    // reset vote selection
    this.selectedChoiceId = -1;
    this.comment = '';
  }
}
</script>

Still following class style syntax, data are simply defined by class attributes. The usage of undefined is not recommended as data must be initialized to be reactive. Check the Vue doc for more information.

Selected choice is first defined by selectChoice(). Once a choice has been selected, the textarea and button appear so that the choice can be voted for.

We can now add <poll-list/> to our pages/polls.vue and populate it with dummy data:

<template>
  <poll-list v-if="polls.length" :polls="polls" :votes="votes" />
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator';

import PollList from '@/components/polls/PollList.vue';
import { Poll, Vote } from '@/lib/polls/models';
import { DUMMY_POLLS } from '@/lib/polls/api';
: TypeScript components
@Component({
  components: {
    PollList
  }
})
export default class Polls extends Vue {
  public polls!: Poll[];

  public votes!: Vote[];

  created() {
    // provide some dummy data
    this.polls = DUMMY_POLLS;
    this.votes = [
      { id: 1, choiceId: 1 },
      { id: 2, choiceId: 2, comment: 'some comment' }
    ];
  }
}
</script>

Because non-null assertion operator is used, this.polls and this.votes cannot be left undefined. In a pure TypeScript program, they would need to be initialized in the constructor(). The usage of created() over mounted() is simple: When mounted() is called, HTML template is compiled but at this stage, both this.polls and this.votes are still undefined. Consequently, they need to be defined earlier. To know more about VueJS component lifecycle, please check Vue doc.

At this stage, http://localhost:3000/polls should render an ugly list:

Polls simple display

If a choice is clicked (no pointer cursor to show it), then [SELECTED] is prepended and a textbox appears:

Choice select

Clicking on Vote! button does not do anything apart from logging into the console and resetting the selected choice.

Note that as choice selection is defined at PollDetail level. Consequently, you can select a choice from a poll and another choice from another poll at the same first.

Polls Store

Static data are a bit boring: a store would make it a tad more lively. Nuxt requires a specific folder structure for Vuex modules. For those who need it, the documentation is over there.

Root state

For typing sake, let’s start with an empty store/types.ts, our root state definition:

export interface RootState {}

ESLint would complain about having an empty interface so I switched it from error to warning by adding in the .eslintrc.js:

  rules: {
    "@typescript-eslint/no-empty-interface": 1
  }

Polls store types

Let’s first define all the types required for our polls store with a store/polls/types.ts. To summarize our polls store:

import { Poll, Vote, ChoiceVote } from '@/lib/polls/models';
import { ActionTree, ActionContext, MutationTree, GetterTree } from 'vuex';
import { RootState } from '../types';

export interface PollsState {
  polls: Poll[];
  votes: Vote[];
}

/**
 * Create a type for convenience
 */
export type PollActionContext = ActionContext<PollsState, RootState>;

/**
 * Polls actions
 */
export interface PollsActions extends ActionTree<PollsState, RootState> {
  load: (ctx: PollActionContext) => void;
  vote: (ctx: PollActionContext, choiceVote: ChoiceVote) => void;
}

/**
 * Polls mutations
 */
export interface PollsMutations extends MutationTree<PollsState> {
  setPolls: (state: PollsState, polls: Poll[]) => void;
  vote: (state: PollsState, vote: Vote) => void;
}

/**
 * Polls getters is type instead of interface because it is
 * empty
 */
export type PollsGetters = GetterTree<PollsState, RootState>;

Polls state

Let’s start define our polls initial state. Nothing fancy, it just reflects the needs of pages/polls.vue. In store/polls/state.ts:

import { PollsState } from './types';

export const initState = (): PollsState => ({
  polls: [],
  votes: []
});

export default initState;

Notes:

Polls getters

Getters are not relevant for polls. You can skip this part. I added an empty store/polls/getters.ts:

import { PollsGetters } from './types';

export const getters: PollsGetters = {};

export default getters;

Polls actions

Actions are implemented in store/polls/actions.ts:

import { PollsActions } from './types';
import { loadPolls } from '@/lib/polls/api';
import { Vote } from '@/lib/polls/models';

export const actions: PollsActions = {
  load: async ({ commit }) => {
    const polls = await loadPolls();
    commit('setPolls', polls);
  },

  vote: ({ commit, state }, { choiceId, comment }) => {
    const voteId = state.votes.length
      ? state.votes[state.votes.length - 1].id + 1
      : 1;
    const vote = new Vote(voteId, choiceId, comment);
    commit('vote', vote);
  }
};

export default actions;

Notes:

Polls mutations

To update the state, the mutations called by actions must be defined in store/polls/mutations.ts:

import { PollsMutations } from './types';

export const mutations: PollsMutations = {
  setPolls: (state, polls) => {
    state.polls = polls;
  },

  vote: (state, vote) => {
    // add vote
    state.votes.push(vote);

    // update choice
    state.polls
      .map(poll => poll.choices)
      .reduce((prev, curr) => prev.concat(curr), [])
      .filter(choice => choice.id === vote.choiceId)
      .forEach(choice => (choice.count += 1));
  }
};

export default mutations;

Polls namespace

For convenience purpose, let’s define polls namespace in a store/polls/const.ts:

import { namespace } from 'vuex-class';

export const pollsModule = namespace('polls/');

Connect store to page/components

Now our state is ready, let’s connect it to our page and components.

pages/polls.vue script is updated as follows:

import { Component, Vue } from 'nuxt-property-decorator';

import PollList from '@/components/polls/PollList.vue';
import { Poll, Vote } from '@/lib/polls/models';
+import { pollsModule } from '@/store/polls/const';

@Component({
  components: {
    PollList
  }
})

export default class Polls extends Vue {
+  @pollsModule.State('polls')
  public polls!: Poll[];
+  @pollsModule.State('votes')
  public votes!: Vote[];

  @pollsModule.Action('load')
  private loadPolls!: () => void;

  mounted() {
    this.loadPolls();
  }

-  created() {
-    // provide some dummy data
-    this.polls = DUMMY_POLLS;
-    this.votes = [
-      { id: 1, choiceId: 1 },
-      { id: 2, choiceId: 2, comment: 'some comment' }
-    ];
-  }
}

Because votes is initialized as an empty list, you should have the same content at http://localhost:3000/polls except that no vote is displayed.

Time to vote! Let’s update components/polls/PollDetail.vue script:

  /**
   * Optional comment
   */
  private comment: string = '';
  /**
   * Avoid undefined to make it reactive
   */
  private selectedChoiceId: number = -1;

  @Prop({ type: Object })
-  public poll: Poll;
+  public poll!: Poll;

+  @pollsModule.Action('vote')
+  private vote!: (choiceVote: ChoiceVote) => void;

  public selectChoice(choice: Choice): void {
    this.selectedChoiceId = choice.id;
  }

  public voteChoice(): void {
-    console.log('Voting: ', {
-      choiceId: this.selectedChoiceId,
-      comment: this.comment.length > 0 ? this.comment : undefined
-    });
+    this.vote({
+      choiceId: this.selectedChoiceId,
+      comment: this.comment.length > 0 ? this.comment : undefined
+    });

    // reset vote selection
    this.selectedChoiceId = -1;
    this.comment = '';
  }

Our store is now operational. If you vote with a comment:

Vote!

The vote appears, with its comments and when selecting a choice from the same poll, the comment text is cleared:

Voted!