[[📗15 Form Inputとv-model]] < [[📒Vue.jsクエスト]] > #todo
## Abstract
前半戦の集大成です。手強いMission揃いですが、目指せ #😱NIGHTMARE 撃破!!
> [!info]
> よろしければ以下のBGMを流しながらチャレンジしてみてください。
>
> <iframe src="https://ext.nicovideo.jp/thumb/sm12037916" height="210px" width="400px" frameborder="0" scrolling="no"></iframe>
## Missions
### Mission 1
#🙂NORMAL
`tadashi-aikawa`がownerの公開リポジトリを、作成された順に最大30件取得する[[GitHub REST API]]の[[URL]]を記載してください。
> [!hint]- Hint 1
> https://docs.github.com/ja/rest/repos/repos
> [!hint]- Hint 2
> https://docs.github.com/ja/rest/repos/repos#list-repositories-for-a-user
%%
回答例
https://api.github.com/users/tadashi-aikawa/repos?sort=created
%%
### Mission 2
#🙂NORMAL
『データ取得』ボタンをクリックしたら[[#Mission 1]]のリクエストを行い、結果を[[console.log]]で出力するコードを書いてください。
> [!hint]- Hint 1
> [[📗04 アットマークは耳マーク]] を復習しましょう。
> [!hint]- Hint 2
> [[Fetch API]]を使います。
> [!hint]- Hint 3
> [[Fetch API]]はレスポンスの`json()`メソッドを利用すると[[object型 (TypeScript)|object]]として結果を取得できます。`json()`メソッドのレスポンスも[[Promise (JavaScript)|Promise]]なので要注意。
%%
回答例
```html
<script setup lang="ts">
const handleClick = async () => {
const response = await fetch(
"https://api.github.com/users/tadashi-aikawa/repos?sort=created"
).then((x) => x.json());
console.log(response);
};
</script>
<template>
<button @click="handleClick">データ取得</button>
</template>
```
%%
### Mission 3
#🙂NORMAL
[[#Mission 2]]のコードを変更し、[[console.log]]ではなく、リポジトリ名のリストとして表示するようにしてください。ただし、**通信中と例外処理の実装は不要**です。
> [!hint]- Hint 1
> [[📗06 状態で広がるセカイ reactive編]]と[[📗13 リストの取り扱い]]を復習しましょう。
%%
回答例
```html
<script setup lang="ts">
import { reactive } from "vue";
interface Repository {
name: string;
}
interface State {
repositories: Repository[];
}
const state = reactive<State>({
repositories: [],
});
const handleClick = async () => {
state.repositories = await fetch(
"https://api.github.com/users/tadashi-aikawa/repos?sort=created"
).then((x) => x.json());
};
</script>
<template>
<button @click="handleClick">データ取得</button>
<hr />
<ul style="text-align: left">
<li :key="repo.name" v-for="repo in state.repositories">{{ repo.name }}</li>
</ul>
</template>
```
%%
### Mission 4
#🙂NORMAL
入力された文字列と部分一致するリポジトリ名のみをリアルタイムで表示できる入力欄を追加してください。
> [!hint]- Hint 1
> [[📗07 計算でテンプレートをシンプルに]] と [[📗15 Form Inputとv-model]] を復習しましょう。
%%
回答例
```html
<script setup lang="ts">
import { computed, reactive } from "vue";
interface Repository {
name: string;
}
interface State {
input: string;
repositories: Repository[];
}
const state = reactive<State>({
input: "",
repositories: [],
});
const handleClick = async () => {
state.repositories = await fetch(
"https://api.github.com/users/tadashi-aikawa/repos?sort=created"
).then((x) => x.json());
};
const filteredRepositories = computed(() =>
state.repositories.filter((x) => x.name.includes(state.input))
);
</script>
<template>
<button @click="handleClick">データ取得</button>
<div>
<input type="text" v-model="state.input" />
</div>
<hr />
<ul style="text-align: left">
<li :key="repo.name" v-for="repo in filteredRepositories">{{ repo.name }}</li>
</ul>
</template>
```
%%
### Mission 5
#😵HARD
[[#Mission 4]]のコードに以下の要件を追加してください。
- 通信中はそれが分かるUIにする
- APIの結果がエラーの場合、前回の結果ではなくエラーメッセージを表示する
> [!note]
> 通信中や通信エラーのデバッグをする際は[[Chrome devtools]]を使いましょう。
>
> ![[Pasted image 20220807144132.png]]
> [!hint]- Hint 1
> [[📗12 2つの条件属性と違い]] を復習しましょう。
> [!hint]- Hint 2
> 通信で取得するリソースを扱う場合、成功したときの値だけでなく、あわせて以下2つの状態も扱います。
>
> - 通信中かどうか
> - エラー (文字列でもOK)
> [!hint]- Hint 3
> 成功値とエラー値は初期状態では空になり、その後はアプリケーションの仕様によって変動します。つまり、[[オプショナルプロパティ]]となります。
> ```ts
> interface State {
> loading: boolean;
> repositories?: Repository[];
> error?: string;
> }
> ```
%%
回答例
```html
<script setup lang="ts">
import { computed, reactive } from "vue";
interface Repository {
name: string;
}
interface State {
input: string;
loading: boolean;
repositories?: Repository[];
error?: string;
}
const state = reactive<State>({
input: "",
loading: false,
});
const handleClick = async () => {
state.loading = true;
try {
state.error = undefined;
state.repositories = await fetch(
"https://api.github.com/users/tadashi-aikawa/repos?sort=created"
).then((x) => x.json());
} catch (e: any) {
state.error = e.message;
}
state.loading = false;
};
const filteredRepositories = computed(() =>
state.repositories?.filter((x) => x.name.includes(state.input))
);
</script>
<template>
<button @click="handleClick">データ取得</button>
<div>
<input type="text" v-model="state.input" />
</div>
<hr />
<template v-if="state.loading">
<span>Loading...</span>
</template>
<template v-else-if="state.error">
<span>{{ state.error }}</span>
</template>
<template v-else>
<ul style="text-align: left">
<li :key="repo.name" v-for="repo in filteredRepositories">{{ repo.name }}</li>
</ul>
</template>
</template>
```
%%
### Mission 6
#😵HARD
[[#Mission 5]]のコードを改変して、結果を以下のようなカード形式で表示してください。
- カード画像はイメージであり忠実に再現する必要はありません
![[Pasted image 20220807144531.png]]
> [!hint]- Hint 1
> [[Vue]]の知識は必要ありません。問われるのは[[CSS]]力のみです。
%%
回答例
```html
<script setup lang="ts">
import { computed, reactive } from "vue";
interface Repository {
name: string;
description: string;
language?: string;
stargazers_count: number;
forks_count: number;
}
interface State {
input: string;
loading: boolean;
repositories?: Repository[];
error?: string;
}
const state = reactive<State>({
input: "",
loading: false,
});
const handleClick = async () => {
state.loading = true;
try {
state.error = undefined;
state.repositories = await fetch(
"https://api.github.com/users/tadashi-aikawa/repos?sort=created"
).then((x) => x.json());
} catch (e: any) {
state.error = e.message;
}
state.loading = false;
};
const filteredRepositories = computed(() =>
state.repositories?.filter((x) => x.name.includes(state.input))
);
</script>
<template>
<button @click="handleClick">データ取得</button>
<div>
<input type="text" v-model="state.input" />
</div>
<hr />
<template v-if="state.loading">
<span>Loading...</span>
</template>
<template v-else-if="state.error">
<span>{{ state.error }}</span>
</template>
<template v-else>
<ul style="text-align: left">
<li :key="repo.name" v-for="repo in filteredRepositories" class="card">
<div class="card-title">{{ repo.name }}</div>
<div class="card-description">{{ repo.description }}</div>
<div style="display: flex; gap: 25px">
<span class="card-language" v-if="repo.language">{{
repo.language
}}</span>
<span class="card-star">{{ repo.stargazers_count }}</span>
<span class="card-fork">{{ repo.forks_count }}</span>
</div>
</li>
</ul>
</template>
</template>
<style scoped>
.card {
width: 800px;
list-style: none;
border: solid 1px gainsboro;
border-radius: 8px;
margin: 15px;
}
.card-title {
font-size: 125%;
font-weight: bolder;
color: darkslateblue;
padding: 5px 0;
}
.card-title:before {
content: "💽";
}
.card-description {
color: grey;
padding: 5px 0;
}
.card-language:before {
content: "🔵";
margin-right: 3px;
}
.card-star:before {
content: "⭐";
margin-right: 3px;
}
.card-fork:before {
content: "🍴";
margin-right: 3px;
}
</style>
```
%%
### Mission 7
#😱NIGHTMARE
仕様を以下のように変更したコードを書いてください。ただし、`watch`を使わないでください。
- 『データ取得』ボタンはなし
- 入力欄の文字が変更され、0.5秒以上新しい入力がなければ、キーワードを含むリポジトリを最大30件表示する
> [!note]
> 必要な情報が問題文に不足していると感じた場合は確認しましょう。
> [!hint]- Hint 1
> 一定時間以上入力がないことを制御するにはdebounceと呼ばれるメソッドを使うことが多いです。[[Vue3]]では何を使うべきか... 🤔
> [!hint]- Hint 2
> [[GitHub REST API]]は検索に特化した別のAPIを使います。
%%
回答例
- ポイント
- クエリは[[URL]]はエンコードされているか
- [[Fetch API]]のレスポンスを`await`を使い`items`を返却できているか
- debounceを使っているか
- computedのフィルタ処理を抜いているか
- 入力中のUIが考慮されているか
- keyがリポジトリ名ではなくIDになっているか
- 入力が空のときは検索を行わないようになっているか
- keyupではなくinputをトリガーとしているか
`main.ts`
```ts
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
// npm i vue-debounce
import { vue3Debounce } from "vue-debounce";
createApp(App)
.directive("debounce", vue3Debounce({ lock: true }))
.mount("#app");
```
`App.vue`
```html
<script setup lang="ts">
import { reactive } from "vue";
interface Repository {
id: number;
name: string;
description: string;
language?: string;
stargazers_count: number;
forks_count: number;
}
interface State {
input: string;
loading: boolean;
repositories?: Repository[];
error?: string;
}
const state = reactive<State>({
input: "",
loading: false,
});
const handleInput = async () => {
if (!state.input) {
return;
}
state.loading = true;
try {
state.error = undefined;
const queryString = new URLSearchParams({ q: state.input });
state.repositories = await fetch(
`https://api.github.com/search/repositories?${queryString}`
).then(async (x) => {
const r: any = await x.json();
return r.items;
});
} catch (e: any) {
state.error = e.message;
}
state.loading = false;
};
</script>
<template>
<div>
<input
type="text"
v-model="state.input"
v-debounce:500="handleInput"
debounce-events="input"
/>
</div>
<hr />
<div v-if="state.loading">Loading...</div>
<template v-if="state.error">
<span>{{ state.error }}</span>
</template>
<template v-else>
<ul style="text-align: left">
<li :key="repo.id" v-for="repo in state.repositories" class="card">
<div class="card-title">{{ repo.name }}</div>
<div class="card-description">{{ repo.description }}</div>
<div style="display: flex; gap: 25px">
<span class="card-language" v-if="repo.language">{{
repo.language
}}</span>
<span class="card-star">{{ repo.stargazers_count }}</span>
<span class="card-fork">{{ repo.forks_count }}</span>
</div>
</li>
</ul>
</template>
</template>
<style scoped>
.card {
width: 800px;
list-style: none;
border: solid 1px gainsboro;
border-radius: 8px;
margin: 15px;
}
.card-title {
font-size: 125%;
font-weight: bolder;
color: darkslateblue;
padding: 5px 0;
}
.card-title:before {
content: "💽";
}
.card-description {
color: grey;
padding: 5px 0;
}
.card-language:before {
content: "🔵";
margin-right: 3px;
}
.card-star:before {
content: "⭐";
margin-right: 3px;
}
.card-fork:before {
content: "🍴";
margin-right: 3px;
}
</style>
```
%%