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.
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' }
]
}
];
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>
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:
@Prop
decorator usage. For people not comfortable with
Class-Style Vue Components,
feel free to check the Writing class based components with Vue.js and TypeScript
article.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
andthis.votes
cannot be left undefined. In a pure TypeScript program, they would need to be initialized in theconstructor()
. The usage ofcreated()
overmounted()
is simple: Whenmounted()
is called, HTML template is compiled but at this stage, boththis.polls
andthis.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:
If a choice is clicked (no pointer cursor to show it), then [SELECTED] is
prepended and a textbox
appears:
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.
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.
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
}
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>;
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:
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;
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:
async / await
FTW!loadPolls
:
export const loadPolls = async (): Promise<Poll[]> => {
return new Promise<Poll[]>(resolve =>
setTimeout(() => resolve(DUMMY_POLLS), 500)
);
};
It simulates a back-end called with a 500ms latency.
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;
For convenience purpose, let’s define polls namespace in a store/polls/const.ts:
import { namespace } from 'vuex-class';
export const pollsModule = namespace('polls/');
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' }
- ];
- }
}
nuxt-property-decorator
includes vuex-class
, let’s use it. An
alternative I have not tested is vuex-typex
(Check out this Writing Vuex Stores in TypeScript article)polls
and votes
are now mapped to state. Additionally,
the load
action is also mappedmounted
instead of created
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:
The vote appears, with its comments and when selecting a choice from the same poll, the comment text is cleared: