概要
フロントエンド勉強会
ToDoアプリを作成して、Vue.jsでの実践的な開発をつかむ。
また、Vuetifyを用いてUIフレームワークへの頼り方をつかむ。
基本的にはvanillaのTypeScriptでToDoアプリを作成をVue.jsでなぞる。
本記事では前回(フロントエンド勉強会 Vue.jsハンズオン)同様にVue3を用いる。ネットで調べるとVue2の書き方をしている記事もかなりヒットするので注意。
環境構築
Get started with Vuetify 3に従う。
npm create vuetify
色々訊かれるので以下のように回答。
プロジェクト名は好きな名前をつければよい。
%project_name% を置き換える。
何使えばいいかは私がnpm使っているのでnpmを選択。
✔ Project name: … %project_name%
✔ Which preset would you like to install? › Default (Vuetify)
✔ Use TypeScript? … Yes
✔ Would you like to install dependencies with yarn, npm, pnpm, or bun? › npm
前回同様に要らないものはクリアする。
<script setup lang="ts">
</script>
<template>
<main>
</main>
</template>
<style scoped>
</style>
components/ 配下のコンポーネントが要らなくなったので削除。
rm -rf src/components/*
消していいか確認されたら y でOK。
sure you want to delete the only file in /path/to/%project_name%/src/components [yn]? y
今回はアイコンも使用する。
用いるのはMaterial Design Iconsで、Icon Fontsに従いフォントの設定を行う。
npm install @mdi/js
src/plugins/vuetify.ts
も下記のように修正。
// Styles
import '@mdi/font/css/materialdesignicons.css';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
import 'vuetify/styles';
// Composables
import { createVuetify } from 'vuetify';
export default createVuetify({
theme: {
themes: {
light: {
colors: {
primary: '#1867C0',
secondary: '#5CBBF6',
error: '#B00020',
},
},
},
},
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi,
},
},
});
Title
headのtitleはデフォルトでは Vuetify 3
になっている。
これを変更するにはルート配下の index.html
で変更できる。
mockAPI
今回はフロントで完結せず、APIと合わせた本格的なアプリケーションにしていく。
とはいえ、APIまで本格的に行うつもりはないのでモックAPIを作成する。
APIもTypeScriptで書けるので下記コマンドで追加のパッケージをインストールする。
APIサイドの解説は行わない。
npm install express @types/express ts-node uuid @types/uuid cors @types/cors
API側のコードを先んじて作成する。
touch tsconfig.mockapi.json
mkdir mock-api
touch mock-api/index.ts mock-api/interfaces.ts
APIはts-nodeで動かすのだが、Vue側のtsconfigもあるため、API用のtsconfigを別途作成する。
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true
}
}
続いてAPI側のコードを作成していく。
APIはexpressjsで書いていく。
DBは用いないがリクエスト内容は揮発性でも残っていて欲しいので、オブジェクトで管理する。
モックAPIなのでcorsは気にしない。
型付けや条件分岐が中途半端だが、その辺は無視いただきたい。
interfaces.ts
const ITEM_STATE = ['未着手', '進行', '完了'] as const;
export type ItemStateLiteral = typeof ITEM_STATE[number];
export interface Tasks {
title: string;
status: ItemStateLiteral;
}
export interface TaskListResponse {
title: string;
uuid: string;
status: ItemStateLiteral;
}
index.ts
import express from 'express';
import cors from 'cors';
import { v4 as uuidv4 } from 'uuid';
import { Tasks, TaskListResponse, ItemStateLiteral } from './interfaces';
const app = express();
app.use(express.json());
app.use(cors());
const data: {
[key in string]: Tasks;
} = {
default1: {
title: 'タイトル1',
status: '未着手',
},
default2: {
title: 'タイトル2',
status: '進行',
},
default3: {
title: 'タイトル3',
status: '完了',
},
default4: {
title: 'タイトル4',
status: '未着手',
},
};
app.get('/', (_req, res) => {
const responseData = {
notStartedYetCategory: new Array<TaskListResponse>(),
wipCategory: new Array<TaskListResponse>(),
finishCategory: new Array<TaskListResponse>(),
};
Object.entries(data).forEach(([key, task]) => {
switch (task.status) {
case '未着手':
responseData.notStartedYetCategory.push({
uuid: key,
...task,
});
break;
case '進行':
responseData.wipCategory.push({
uuid: key,
...task,
});
break;
case '完了':
responseData.finishCategory.push({
uuid: key,
...task,
});
break;
}
});
res.status(200).send(responseData);
});
app.post('/', (req, res) => {
data[uuidv4()] = req.body as Tasks;
res.status(201).send('Created');
});
app.patch('/:uuid/status', (req, res) => {
const uuid = req.params.uuid;
if (!(uuid in data)) {
res.status(404).send('Not Found');
return;
}
data[uuid].status = req.body['status'] as ItemStateLiteral;
res.status(204).send('No Content');
});
app.delete('/:uuid', (req, res) => {
const uuid = req.params.uuid;
if (!(uuid in data)) {
res.status(404).send('Not Found');
return;
}
delete data[uuid];
res.status(204).send('No Content');
});
app.listen(4200, () => {
console.log('http://localhost:4200');
});
APIは http://localhost:4200
に立ち上げている。
2023/12現在、Vueで新しくプロジェクトを立ち上げるとルート直下に vite.config.ts
が存在する。
このファイルの下記部分を見て、
{
server: {
port: 3000,
}
}
4200
以外であればそれでいい。
もしこの部分が4200ならばポートがバッティングしてしまうので3000に書き換えるなどしていただきたい。
実装
mkdir src/interfaces
touch src/interfaces/task.interface.ts
touch src/components/task-form.vue
touch src/components/category.vue
touch functions.ts
src/interfaces/task.interface.ts
/** タスクの状態 */
export const ITEM_STATE = ['未着手', '進行', '完了'] as const;
/** タスクの状態リテラル型 */
export type ItemStateLiteral = typeof ITEM_STATE[number];
/** タスク型 */
export interface TaskInterface {
title: string;
uuid: string;
status: ItemStateLiteral;
}
src/components/task-form.vue
<script setup lang="ts">
import { computed, ref } from 'vue';
/** 入力値 */
const taskInput = ref('');
/** フォームルール */
const rules = {
maxlength: (value: string) => value.length < 30 || 'cannot input string length over 30',
}
/** emit */
const emit = defineEmits<{
(e: 'addTask', taskInput: string): void;
(e: 'inputValueEmpty'): void;
}>();
/** ボタンの活性制御 */
const buttonDisabled = computed(() => taskInput.value.length > 30);
/** 新しいタスクの追加 */
function addNewTask() {
const inputValue = taskInput.value;
if (!inputValue) {
emit('inputValueEmpty');
return;
}
emit('addTask', inputValue);
taskInput.value = '';
}
</script>
<template>
<div class="d-flex align-center ga-4 ma-5 form-width">
<v-text-field
v-model.trim="taskInput"
:rules="[rules.maxlength]"
label="タスク"
type="input"
variant="underlined"
></v-text-field>
<v-btn @click="addNewTask" :disabled="buttonDisabled" prepend-icon="mdi-plus">
追加
</v-btn>
</div>
</template>
<style scoped>
.form-width {
width: 34%;
}
</style>
TypeScript
Vue
Vuetify
src/components/category.vue
<script setup lang="ts">
import { computed } from 'vue';
import { TaskInterface, ItemStateLiteral } from '@/interfaces/task.interface';
/** props */
const props = defineProps<{
title: ItemStateLiteral;
tasks: TaskInterface[]
}>();
/** emit */
const emit = defineEmits<{
(e: 'onClickTask', uuid: string, status: ItemStateLiteral): void;
}>();
/** 所属タスク */
const tasks = computed(() => [...props.tasks]);
</script>
<template>
<v-card
class="mx-auto category-box"
>
<v-list>
<v-list-subheader>{{ props.title }}</v-list-subheader>
<v-list-item
v-for="task in tasks"
:key="task.uuid"
class="task-item"
@click="emit('onClickTask', task.uuid, props.title)"
>
<v-list-item-title v-text="task.title"></v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</template>
<style scoped>
.category-box {
width: 25%;
}
.task-item:hover {
background-color: #eeeeee;
}
</style>
TypeScript
CSS
Vue
Vuetify
src/functions.ts
現在はuseFetchを用いるが、今回はfetch処理が初出のため標準のを用いる。
import { ItemStateLiteral, TaskInterface } from '@/interfaces/task.interface';
const ENDPOINT = 'http://localhost:4200';
/** タスク一覧レスポンス */
interface TaskListResponse {
notStartedYetCategory: TaskInterface[];
wipCtegory: TaskInterface[];
finishCategory: TaskInterface[];
}
/** タスク一覧取得 */
export async function getTaskList(): Promise<TaskListResponse> {
const data = await fetch(ENDPOINT);
return data.json();
}
/**
* タスク作成
* @param title タスクタイトル
*/
export async function createNewTask(title: string): Promise<void> {
const body = {
title: title,
status: '未着手',
};
await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
/**
* タスク状態更新
* @param uuid タスクUUID
* @param status タスク新状態
*/
export async function updateStatus(uuid: string, status: ItemStateLiteral): Promise<void> {
await fetch(`${ENDPOINT}/${uuid}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
status,
}),
});
}
/**
* タスク削除
* @param uuid タスクUUID
*/
export async function deleteTask(uuid: string): Promise<void> {
await fetch(`${ENDPOINT}/${uuid}`, {
method: 'DELETE',
});
}
- 戻り値がない関数とvoid型
- APIとは
- HTTP
- HTTPリクエストメソッド
- フェッチAPIの使用
- フェッチAPI
- プロミスの使用
- Promise
- async function
- await
- Content-Type
- JSON
src/App.vue
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import TaskForm from '@/components/task-form.vue';
import Category from '@/components/category.vue';
import { ITEM_STATE, ItemStateLiteral, TaskInterface } from '@/interfaces/task.interface';
import { getTaskList, createNewTask, updateStatus, deleteTask } from '@/functions';
/** --- 管理 --- */
/** スナックバー管理 */
const snackbar = reactive({
isShow: false,
text: '',
timeout: 1000,
});
/** ダイアログ管理 */
const dialog = reactive({
isShow: false,
});
/** --- public reactive var --- */
/** 状態選択モデル */
const stateModel = ref<ItemStateLiteral>('未着手');
/** 未着手タスク */
const notStartedYetCategory = ref<TaskInterface[]>([]);
/** 進行中タスク */
const wipCategory = ref<TaskInterface[]>([]);
/** 完了タスク */
const finishCategory = ref<TaskInterface[]>([]);
/** --- private var --- */
/**
* 現在選択されているUUID
* @private
*/
let _currentSelectUuid = '';
/** --- life cycle --- */
onMounted(async () => {
await _getTaskList();
});
/** --- public method --- */
/** スナックバー表示 */
function showSnackbar(): void {
snackbar.isShow = true;
}
/** スナックバー非表示 */
function closeSnackbar(): void {
snackbar.isShow = false;
}
/** タスクの追加 */
async function addNewTask(input: string): Promise<void> {
await createNewTask(input);
await _getTaskList();
snackbar.text = 'タスクを追加しました';
showSnackbar();
}
/** 空文字でタスクを追加しようとしたとき */
function inputValueEmpty(): void {
snackbar.text = '空文字だけの追加はできません';
showSnackbar();
}
/** タスククリックでダイアログを表示 */
function onClickTask(uuid: string, status: ItemStateLiteral): void {
_currentSelectUuid = uuid;
stateModel.value = status;
dialog.isShow = true;
}
/** タスクの進行状態変更 */
async function onClickCategoryChangeButton(): Promise<void> {
await updateStatus(_currentSelectUuid, stateModel.value);
await _getTaskList();
dialog.isShow = false;
_currentSelectUuid = '';
snackbar.text = `${stateModel.value}に変更しました`;
showSnackbar();
}
/** タスク削除 */
async function onClickDeleteTask(): Promise<void> {
await deleteTask(_currentSelectUuid);
await _getTaskList();
dialog.isShow = false;
_currentSelectUuid = '';
snackbar.text = `削除しました`;
showSnackbar();
}
/** --- private method --- */
async function _getTaskList(): Promise<void> {
const taskList = await getTaskList();
notStartedYetCategory.value = taskList.notStartedYetCategory;
wipCategory.value = taskList.wipCategory;
finishCategory.value = taskList.finishCategory;
}
</script>
<template>
<main>
<TaskForm @addTask="addNewTask" @inputValueEmpty="inputValueEmpty"></TaskForm>
<div class="d-flex flex-row justify-space-around categories">
<Category title="未着手" :tasks="notStartedYetCategory" @onClickTask="onClickTask"></Category>
<Category title="進行" :tasks="wipCategory" @onClickTask="onClickTask"></Category>
<Category title="完了" :tasks="finishCategory" @onClickTask="onClickTask"></Category>
</div>
<v-dialog
v-model="dialog.isShow"
:persistent="true"
width="300"
>
<v-card>
<v-card-title>どうする?</v-card-title>
<v-card-item>
<v-select
label="状態"
v-model="stateModel"
:items="ITEM_STATE"
></v-select>
</v-card-item>
<v-card-actions class="d-flex justify-space-around">
<v-btn @click="dialog.isShow = false">キャンセル</v-btn>
<v-btn color="primary" @click="onClickCategoryChangeButton">変更</v-btn>
<v-btn color="error" @click="onClickDeleteTask">削除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.isShow"
:timeout="snackbar.timeout"
>
{{ snackbar.text }}
<template v-slot:actions>
<v-btn
color="blue"
variant="text"
@click="closeSnackbar"
>
Close
</v-btn>
</template>
</v-snackbar>
</main>
</template>
<style scoped>
.categories {
height: 600px;
}
</style>
Vue
Vuetify
実行
フロント
npm run dev
API
npx ts-node -P tsconfig.mockapi.json mock-api/index.ts