Graphql JavaScript Nest.js Next.js React TypeScript

NestJS、Next.js、GraphQLでTODOアプリ[フロントエンド]

前回はbackendの実装が終わったので今回からはfrontendの実装となります。

まだbackendの実装ができてない方は以下の記事から。

NestJS、Next.js、GraphQLでTODOアプリ[バックエンド]

続きを見る

今回のソースコードは以下となります。

https://github.com/109naoki/nest-graph-frontend

 

環境構築

npx create-next-app --typescript
✔ What is your project named? … frontend
✔ Would you like to use ESLint? …  Yes
✔ Would you like to use Tailwind CSS? …  Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias? … No

 

次にfrontendディレクトリで以下のコマンドで必要なライブラリのインストールを行なってください。

```

npm i modern-css-reset
npm install @mui/material @emotion/react @emotion/styled
npm i @mui/icons-material
npm i @apollo/client graphql
npm i jwt-decode

```

app/layout.tsxとpage.tsxを以下のようにします。

 layout.tsx
import type { Metadata } from "next";
import "modern-css-reset";
export const metadata: Metadata = {
title: "Create GraphQL TODO",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>{children}</body>
</html>
);
}
 page.tsx
 
export default function Home() {
return <h1>Hello</h1>;
}

 

これでnpm run devでサーバーを立ち上げてHelloという文字列が見えればOK。

コンポーネントの作成

appディレクトリの中に

error.tsx、main/page.tsx、signin/page.tsx,signup/page.tsxを作成してください。

 error.tsx
"use client";
const Error = () => {
return <div>エラーです</div>;
};
export default Error;
 main/page.tsx
 
const Main = () => {
return <div>メイン</div>;
};
export default Main;
 signin/page.tsx
const SignIn = () => {
return <div>サインイン</div>;
};
export default SignIn;
 
 signup/page.tsx
const SignUp = () => {
return <div>サインアップ</div>;
};
export default SignUp;
 

これで以下のURLにアクセスをして文字が表示されれば問題ないです。

localhost:3000/main

localhost:3000/signin

localhost:3000/signup

 

次に認証のカスタムフックを実装します。frountend直下にhooksディレクトリを作成し

useAuth.tsを作成してください。

 useAuth.ts
import { useEffect, useState } from "react";
import jwtDecode from "jwt-decode";
import { Payload } from "@/types/payload";
export const useAuth = () => {
const [authInfo, setAuthInfo] = useState<{
checked: boolean;
isAuthenticated: boolean;
}>({ checked: false, isAuthenticated: false });
useEffect(() => {
const token = localStorage.getItem("token");
try {
if (token) {
const decodedToken = jwtDecode<Payload>(token);
if (decodedToken.exp * 1000 < Date.now()) {
localStorage.removeItem("token");
setAuthInfo({ checked: true, isAuthenticated: false });
} else {
setAuthInfo({ checked: true, isAuthenticated: true });
}
} else {
setAuthInfo({ checked: true, isAuthenticated: false });
}
} catch (error) {
setAuthInfo({ checked: true, isAuthenticated: false });
}
}, []);
return authInfo;
};

frountend直下にtypesディレクトリを作成し

payload.tsを作成してください。

 payload.ts
export type Payload = {
email: string;
sub: number;
iat: number;
exp: number;
};

この useAuth フックは、localStorage に保存されたJWTトークンをデコードして、認証情報を取得するものですね。具体的には、JWTの有効期限が過ぎているかどうかをチェックし、それに基づいて認証状態を更新しています。

フックの内容は次のようになっています:

authInfo という状態を使用して、認証が確認されたかどうか(checked)およびユーザーが認証されているかどうか(isAuthenticated)を追跡します。
フックが初めて実行されたとき(useEffect に空の依存配列があるため)、ローカルストレージから token を取得します。
トークンが存在すれば、それをデコードして有効期限が過ぎているかどうかを確認します。有効期限が過ぎている場合、またはデコード中にエラーが発生した場合、トークンはローカルストレージから削除されます。
最後に、認証情報の状態を更新します。
更新された authInfo がフックのコンシューマに返されます。

 

GraphQLクライアントの作成

frontendディレクトリ直下にapolloClient.tsを作成してください。

 apolloClient.ts
import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
const httpLink = createHttpLink({
uri: "http://localhost:3000/graphql",
});
const authLink = setContext((_, prevContext) => {
const token = localStorage.getItem("token");
return {
headers: {
...prevContext.headers,
authorization: token ? `Bearer ${token}` : "",
},
};
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
export default client;

このコードは、Apollo Client(GraphQLクライアントライブラリ)の設定を行っています。具体的には、Apollo Clientのインスタンスを作成し、GraphQLサーバへのリクエスト時に認証情報をヘッダーに含める設定を行っています。

各部分の詳しい解説は以下の通りです:

インポート:

ApolloClient: Apollo Clientのコアクラスで、Apolloのインスタンスを作成するために使用します。
createHttpLink: Apollo ClientがGraphQLサーバーにリクエストを行うためのHTTPリンクを作成します。
InMemoryCache: Apollo Clientのキャッシュを扱うためのクラスです。このキャッシュは、以前に取得したデータを保持して、再度同じリクエストを行わないようにします。
setContext: リクエストごとにコンテキスト(ここではHTTPヘッダー)を設定するための関数。
HTTPリンクの作成:
httpLink はGraphQLサーバのエンドポイントを指定して、HTTPリンクを作成しています。

認証リンクの作成:
authLink は、リクエストのヘッダーに認証情報を追加するためのリンクを作成しています。具体的には、ローカルストレージからトークンを取得し、存在する場合はヘッダーに Bearer トークンとして設定しています。

Apollo Clientのインスタンス作成:
client は、上記で作成した認証リンクとHTTPリンクを組み合わせ(concatメソッドを使用)、キャッシュの設定とともにApollo Clientのインスタンスを作成しています。

エクスポート:
最後に作成した client インスタンスをエクスポートしています。これにより、他の部分のコードでこのインスタンスを利用してGraphQLのクエリやミューテーションを行うことができます。

要するに、このコードはApollo Clientを設定し、認証情報を持つGraphQLリクエストを行うための準備を行っています。

 

次にlayout.tsxでApolloProviderを使えるようにします。

 layout.tsx
"use client";
import "modern-css-reset";
import { ApolloProvider } from "@apollo/client";
import client from "@/apolloClient";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ApolloProvider client={client}>
<html lang="ja">
<body>{children}</body>
</html>
</ApolloProvider>
);
}

サインイン機能の実装

signin/page.tsxを以下のようにしてください。

 signin/page.tsx
"use client";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import CssBaseline from "@mui/material/CssBaseline";
import TextField from "@mui/material/TextField";
import Link from "@mui/material/Link";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { useState } from "react";
import { useMutation } from "@apollo/client";
import { SignInResponse } from "@/types/signInResponse";
import { SIGN_IN } from "@/mutations/authMutations";
import { useRouter } from "next/navigation";
const theme = createTheme();
export default function SignIn() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [failSignIn, setFailSignIn] = useState(false);
const [signIn] = useMutation<SignInResponse>(SIGN_IN);
const router = useRouter();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const signInInput = { email, password };
try {
const result = await signIn({
variables: { signInInput },
});
if (result.data) {
localStorage.setItem("token", result.data.signIn.accessToken);
}
localStorage.getItem("token") && router.push("/");
} catch (err: any) {
if (err.message === "Unauthorized") {
setFailSignIn(true);
return;
}
console.log(err.message);
alert("予期せぬエラーが発生しました。");
}
};
return (
<>
<ThemeProvider theme={theme}>
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign in
</Typography>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1 }}
>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)}
/>
{failSignIn && (
<Typography color="red">
メールアドレスまたはパスワードを確認してください。
</Typography>
)}
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign In
</Button>
<Grid container>
<Grid item>
<Link href="/signup" variant="body2">
{"Don't have an account? Sign Up"}
</Link>
</Grid>
</Grid>
</Box>
</Box>
</Container>
</ThemeProvider>
</>
);
}

次にmutasionsディレクトリを作成しauthMutations.ts

typesディレクトリにsignInResponse.tsを作成します。

 authMutations.ts
import { gql } from "@apollo/client";
export const SIGN_IN = gql`
mutation signIn($signInInput: SignInInput!) {
signIn(signInInput: $signInInput) {
accessToken
}
}
`;
 signInResponse.ts
export type SignInResponse = {
signIn: { accessToken: string };
};

これでサインインができれば完成です。

サインアップ

 signup/page.tsx
"use client";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import CssBaseline from "@mui/material/CssBaseline";
import TextField from "@mui/material/TextField";
import Link from "@mui/material/Link";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { useState } from "react";
import { useMutation } from "@apollo/client";
import { User } from "@/types/user";
import { SIGN_IN, SIGN_UP } from "@/mutations/authMutations";
import { useRouter } from "next/navigation";
import { SignInResponse } from "@/types/signInResponse";
const theme = createTheme();
export default function SignUp() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const router = useRouter();
const [signUp] = useMutation<{ createUser: User }>(SIGN_UP);
const [signIn] = useMutation<SignInResponse>(SIGN_IN);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const signUpInput = { name, email, password };
try {
const result = await signUp({
variables: { createUserInput: signUpInput },
});
if (result.data?.createUser) {
const signInInput = { email, password };
const result = await signIn({
variables: { signInInput },
});
if (result.data) {
localStorage.setItem("token", result.data.signIn.accessToken);
}
localStorage.getItem("token") && router.push("/");
}
} catch (error) {
alert("ユーザの作成に失敗しました。");
return;
}
};
return (
<>
<ThemeProvider theme={theme}>
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign up
</Typography>
<Box
component="form"
noValidate
onSubmit={handleSubmit}
sx={{ mt: 3 }}
>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
autoComplete="name"
name="name"
required
fullWidth
id="name"
label="name"
autoFocus
onChange={(e) => setName(e.target.value)}
/>
</Grid>
<Grid item xs={12}>
<TextField
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
/>
</Grid>
<Grid item xs={12}>
<TextField
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
onChange={(e) => setPassword(e.target.value)}
/>
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign Up
</Button>
<Grid container justifyContent="flex-end">
<Grid item>
<Link href="/signin" variant="body2">
Already have an account? Sign in
</Link>
</Grid>
</Grid>
</Box>
</Box>
</Container>
</ThemeProvider>
</>
);
}

次にtypesディレクトリにuser.tsを作成してください。

 user.ts
export type User = {
id: number;
name: string;
email: string;
};

authMutation.tsにサインアップ用のgqlを記述します。

 authMutation.ts
export const SIGN_UP = gql`
mutation createUser($createUserInput: CreateUserInput!) {
createUser(createUserInput: $createUserInput) {
id
name
email
}
}
`;

これで/signupページから新規登録ができれば完了となります。

ログアウト

次にログアウトです。

appディレクトリ配下にcomponentsフォルダを作り

Header.tsxを作成以下のように記述してください。

 Header.tsx
"use client";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import { useRouter } from "next/navigation";
export default function Header() {
const router = useRouter();
const handleLogout = () => {
localStorage.removeItem("token");
router.push("/signin");
};
return (
<>
<Box sx={{ flexGrow: 1 }}>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
GraphQL Tasks
</Typography>
<Button color="inherit" onClick={handleLogout}>
Logout
</Button>
</Toolbar>
</AppBar>
</Box>
</>
);
}

メインページの実装

最後にメインページの実装を進めていきます。

app/componentsフォルダの中にTaskTable.tsxを作成して中身を以下のようにしてください。

 TaskTable.tsx
"use client";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
function createData(name: string, dueDate: string, status: string) {
return { name, dueDate, status };
}
const rows = [
createData("task1", "2023-01-01", "NOT_STARTED"),
createData("task2", "2023-01-02", "IN_PROGRESS"),
createData("task3", "2023-01-03", "COMPLETED"),
];
export default function TaskTable() {
return (
<TableContainer component={Paper} sx={{ width: "80%", m: "auto" }}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Task Name</TableCell>
<TableCell align="right">Due Date</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={row.name}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row">
{row.name}
</TableCell>
<TableCell align="right">{row.dueDate}</TableCell>
<TableCell align="right">{row.status}</TableCell>
<TableCell align="right"></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

そしてpage.tsxで今作成したコンポーネントを読み込ませます。

 page.tsx
import Header from "./components/Header";
import TaskTable from "./components/TaskTable";
export default function Home() {
return (
<>
<Header />
<TaskTable />
</>
);
}

 

次にTask一覧取得用に型を定義します。

typesディレクトリにtaskStatus.tsとtask.tsを作成してください。

 taskStatus.ts
export type TaskStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED";
 task.ts
import { TaskStatus } from "./taskStatus";
export type Task = {
id: number;
name: string;
dueDate: string;
status: TaskStatus;
description: string;
};

 

次にTaskのqueryを作成します。

frontend直下にqueriesフォルダを作成してtaskQueries.tsを作成してください。

 taskQueries.ts
import { gql } from "@apollo/client";
export const GET_TASKS = gql`
query getTasks($userId: Int!) {
getTasks(userId: $userId) {
id
name
dueDate
status
description
}
}
`;

query getTasks($userId: Int!):

これは名前付きのクエリgetTasksを定義しています。このクエリには$userIdという名前の変数が必要で、その型はInt!です。末尾の!はこの変数が非nullであることを示しています。つまり、このクエリを実行する際にはuserIdという変数を必ず提供する必要があります。
getTasks(userId: $userId):

クエリの本体部分です。この部分はgetTasksという名前のフィールドをクエリしており、このフィールドにuserId引数を渡しています。渡す値は上述の変数$userIdです。
{ id name dueDate status description }:

getTasksフィールドの結果として取得したいサブフィールド(または属性)を示しています。このクエリでは、id, name, dueDate, status, descriptionという5つのサブフィールドを取得します。

 

次にappディレクトリ直下にloading.tsxを作成します。

 loading.tsx
"use client";
import { Box, CircularProgress } from "@mui/material";
const Loading = () => {
return (
<Box>
<CircularProgress />
</Box>
);
};
export default Loading;

Next.jsのappディレクトリの機能でapp配下にloading.tsxという名前で作成することで

ローディングのアニメーションがグローバルに表示されるようになります。

ちなみにapp配下にerror.tsxという名前で作成することで

エラー時の画面のグローバルに表示されるようになります。

 error.tsx
"use client";
import { Typography } from "@mui/material";
const Error = () => {
return <Typography color="red">エラーが発生しました。</Typography>;
};
export default Error;

次にapp/page.tsxを修正します。

 page.tsx
"use client";
import jwtDecode from "jwt-decode";
import Header from "./components/Header";
import TaskTable from "./components/TaskTable";
import { Payload } from "@/types/payload";
import { Task } from "@/types/task";
import { useQuery } from "@apollo/client";
import { GET_TASKS } from "@/queries/taskQueries";
import { Stack } from "@mui/material";
import { useEffect, useState } from "react";
export default function Home() {
const [userId, setUserId] = useState<number | null>(null);
useEffect(() => {
const token = localStorage.getItem("token");
if (token) {
const decodedToken = jwtDecode<Payload>(token);
setUserId(decodedToken.sub);
}
}, []);
const { data } = useQuery<{ getTasks: Task[] }>(GET_TASKS, {
variables: { userId },
skip: !userId, // userIdがnullの場合、クエリをスキップ
});
return (
<>
<Header />
<Stack>
<TaskTable tasks={data?.getTasks} userId={userId} />
</Stack>
</>
);
}

次にTaskTable.tsxを編集します。

 TaskTable.tsx
"use client";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
import { Task } from "@/types/task";
function createData(name: string, dueDate: string, status: string) {
return { name, dueDate, status };
}
export default function TaskTable({
tasks,
userId,
}: {
tasks: Task[] | undefined;
userId: number | null;
}) {
return (
<TableContainer component={Paper} sx={{ width: "80%", m: "auto" }}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Task Name</TableCell>
<TableCell align="right">Due Date</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{tasks?.map((task) => (
<TableRow
key={task.id}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row">
{task.name}
</TableCell>
<TableCell align="right">{task.dueDate}</TableCell>
<TableCell align="right">{task.status}</TableCell>
<TableCell align="right"></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

 

タスク作成機能

まずはタスク作成のためのモーダルを作成します。

まずはmutationsフォルダにtaskMutation.tsを作成してください。

 taskMutation.ts
import { gql } from "@apollo/client";
export const CREATE_TASK = gql`
mutation createTask($createTaskInput: CreateTaskInput!) {
createTask(createTaskInput: $createTaskInput) {
id
name
status
dueDate
}
}
`;

次にapp/page.tsxを以下のようにしてください。

 page.tsx
"use client";
import jwtDecode from "jwt-decode";
import Header from "./components/Header";
import TaskTable from "./components/TaskTable";
import { Payload } from "@/types/payload";
import { Task } from "@/types/task";
import { useQuery } from "@apollo/client";
import { GET_TASKS } from "@/queries/taskQueries";
import { Stack } from "@mui/material";
import { useEffect, useState } from "react";
import AddTask from "./components/AddTask";
export default function Home() {
const [userId, setUserId] = useState<number | null>(null);
useEffect(() => {
const token = localStorage.getItem("token");
if (token) {
const decodedToken = jwtDecode<Payload>(token);
setUserId(decodedToken.sub);
}
}, []);
const { data } = useQuery<{ getTasks: Task[] }>(GET_TASKS, {
variables: { userId },
skip: !userId, // userIdがnullの場合、クエリをスキップ
});
return (
<>
<Header />
<Stack>
<AddTask userId={userId!} />
<TaskTable tasks={data?.getTasks} userId={userId} />
</Stack>
</>
);
}

そしてcomponentsフォルダにAddTask.tsxを作成てください。

 AddTask.tsx
"use client";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import { useState } from "react";
import { useMutation } from "@apollo/client";
import { Task } from "@/types/task";
import { CREATE_TASK } from "@/mutations/taskMutation";
import { GET_TASKS } from "@/queries/taskQueries";
import { useRouter } from "next/navigation";
export default function AddTask({ userId }: { userId: number | null }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [dueDate, setDueDate] = useState("");
const [description, setDescription] = useState("");
const [isInvalidName, setIsInvalidName] = useState(false);
const [isInvalidDueDate, setIsInvalidDueDate] = useState(false);
const [createTask] = useMutation<{ createTask: Task }>(CREATE_TASK);
const router = useRouter();
const resetState = () => {
setName("");
setDueDate("");
setDescription("");
setIsInvalidName(false);
setIsInvalidDueDate(false);
};
const handleAddTask = async () => {
let canAdd = true;
if (name.length === 0) {
canAdd = false;
setIsInvalidName(true);
} else {
setIsInvalidName(false);
}
if (!Date.parse(dueDate)) {
canAdd = false;
setIsInvalidDueDate(true);
} else {
setIsInvalidDueDate(false);
}
if (canAdd) {
const createTaskInput = { name, dueDate, description, userId };
try {
await createTask({
variables: { createTaskInput },
refetchQueries: [{ query: GET_TASKS, variables: { userId } }],
});
resetState();
setOpen(false);
} catch (err: any) {
if (err.message === "Unauthorized") {
localStorage.removeItem("token");
alert("トークンの有効期限が切れました。サインイン画面に遷移します。");
router.push("/signin");
return;
}
alert("タスクの登録に失敗しました。");
}
}
};
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
resetState();
setOpen(false);
};
return (
<div>
<Button
variant="contained"
sx={{ width: "270px" }}
onClick={handleClickOpen}
>
AddTask
</Button>
<Dialog fullWidth={true} open={open} onClose={handleClose} maxWidth="sm">
<DialogTitle>Add Task</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="normal"
id="name"
label="Task Name"
fullWidth
required
value={name}
onChange={(e) => setName(e.target.value)}
error={isInvalidName}
helperText={isInvalidName && "タスク名を入力してください"}
/>
<TextField
autoFocus
margin="normal"
id="due-date"
label="Due Date"
placeholder="yyyy-mm-dd"
fullWidth
required
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
error={isInvalidDueDate}
helperText={isInvalidDueDate && "日付形式で入力してください"}
/>
<TextField
autoFocus
margin="normal"
id="description"
label="Description"
fullWidth
multiline
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleAddTask}>Add</Button>
</DialogActions>
</Dialog>
</div>
);
}

refetchQueries:このプロパティには、Mutationが成功した後に再度データを取得するためのクエリ(または複数のクエリ)のリストが含まれています。

 

タスク編集

次はタスクを編集できるようにします。

mutationをまずは定義します。

 taskMutation.ts
export const UPDATE_TASK = gql`
mutation updateTask($updateTaskInput: UpdateTaskInput!) {
updateTask(updateTaskInput: $updateTaskInput) {
id
name
dueDate
status
description
createdAt
updatedAt
}
}
`;

 

次にcomponents配下にEditTask.tsxを作成します。

 html
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import { Task } from "@/types/task";
import DialogTitle from "@mui/material/DialogTitle";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
FormControl,
IconButton,
InputLabel,
MenuItem,
Select,
Tooltip,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import { TaskStatus } from "@/types/taskStatus";
import { useMutation } from "@apollo/client";
import { UPDATE_TASK } from "@/mutations/taskMutation";
import { GET_TASKS } from "@/queries/taskQueries";
export default function EditTask({
task,
userId,
}: {
task: Task;
userId: number;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState(task.name);
const [status, setStatus] = useState(task.status);
const [dueDate, setDueDate] = useState(task.dueDate);
const [description, setDescription] = useState(task.description);
const [isInvalidName, setIsInvalidName] = useState(false);
const [isInvalidDueDate, setIsInvalidDueDate] = useState(false);
const router = useRouter();
const [updateTask] = useMutation<{ updateTask: Task }>(UPDATE_TASK);
const resetState = () => {
setName(task.name);
setDueDate(task.dueDate);
setStatus(task.status);
setDescription(task.description);
setIsInvalidDueDate(false);
setIsInvalidName(false);
};
const handleEditTask = async () => {
let canEdit = true;
if (name.length === 0) {
canEdit = false;
setIsInvalidName(true);
} else {
setIsInvalidName(false);
}
if (!Date.parse(dueDate)) {
canEdit = false;
setIsInvalidDueDate(true);
} else {
setIsInvalidDueDate(false);
}
if (canEdit) {
const updateTaskInput = {
id: task.id,
name,
dueDate,
description,
status,
};
try {
await updateTask({
variables: { updateTaskInput },
refetchQueries: [{ query: GET_TASKS, variables: { userId } }],
});
resetState();
setOpen(false);
} catch (err: any) {
if (err.message === "Unauthorized") {
localStorage.removeItem("token");
alert("トークンの有効期限が切れました。サインイン画面に遷移します。");
router.push("/signin");
return;
}
alert("タスクの登録に失敗しました。");
}
}
};
const handleClickOpen = () => {
resetState();
setOpen(true);
};
const handleClose = () => {
resetState();
setOpen(false);
};
return (
<div>
<Tooltip title="編集">
<IconButton onClick={handleClickOpen}>
<EditIcon color="action" />
</IconButton>
</Tooltip>
<Dialog fullWidth={true} maxWidth="sm" open={open} onClose={handleClose}>
<DialogTitle>Edit Task</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="normal"
id="name"
label="Task Name"
fullWidth
required
value={name}
onChange={(e) => setName(e.target.value)}
error={isInvalidName}
helperText={isInvalidName && "タスク名を入力してください"}
/>
<TextField
autoFocus
margin="normal"
id="due-date"
label="Due Date"
placeholder="yyyy-mm-dd"
fullWidth
required
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
error={isInvalidDueDate}
helperText={isInvalidDueDate && "日付形式で入力してください"}
/>
<FormControl fullWidth={true} margin="normal">
<InputLabel id="task-status-label">Status</InputLabel>
<Select
labelId="task-status-label"
id="task-status"
label="Status"
value={status}
onChange={(e) => setStatus(e.target.value as TaskStatus)}
>
<MenuItem value={"NOT_STARTED"}>Not Started</MenuItem>
<MenuItem value={"IN_PROGRESS"}>In Progress</MenuItem>
<MenuItem value={"COMPLETED"}>Completed</MenuItem>
</Select>
</FormControl>
<TextField
autoFocus
margin="normal"
id="description"
label="Description"
fullWidth
multiline
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleEditTask}>Update</Button>
</DialogActions>
</Dialog>
</div>
);
}

 

最後にTaskTable.tsxでEdit.task.tsxを読み込ませます。

 TaskTable.tsx
"use client";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
import { Task } from "@/types/task";
import EditTask from "./EditTask";
function createData(name: string, dueDate: string, status: string) {
return { name, dueDate, status };
}
export default function TaskTable({
tasks,
userId,
}: {
tasks: Task[] | undefined;
userId: number | null;
}) {
return (
<TableContainer component={Paper} sx={{ width: "80%", m: "auto" }}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Task Name</TableCell>
<TableCell align="right">Due Date</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{tasks?.map((task) => (
<TableRow
key={task.id}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row">
{task.name}
</TableCell>
<TableCell align="right">{task.dueDate}</TableCell>
<TableCell align="right">{task.status}</TableCell>
<TableCell align="right">
<EditTask task={task} userId={userId!} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

 

タスクの削除

最後にタスクの削除です。

taskMutationにDELETE_TASKを定義します。

 taskMutation.ts
export const DELETE_TASK = gql`
mutation deleteTask($id: Int!) {
deleteTask(id: $id) {
id
}
}
`;

 

次にcomponents配下にDeleteTask.tsxを作成します。

 DeleteTask.tsx
import { IconButton, Tooltip } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { useMutation } from "@apollo/client";
import { DELETE_TASK } from "@/mutations/taskMutation";
import { GET_TASKS } from "@/queries/taskQueries";
import { useRouter } from "next/navigation";
const DeleteTask = ({ id, userId }: { id: number; userId: number }) => {
const [deleteTask] = useMutation<{ deleteTask: number }>(DELETE_TASK);
const router = useRouter();
const handleDeleteTask = async () => {
try {
await deleteTask({
variables: { id },
refetchQueries: [{ query: GET_TASKS, variables: { userId } }],
});
alert("タスクが削除されました");
} catch (err: any) {
if (err.message === "Unauthorized") {
localStorage.removeItem("token");
alert("トークンの有効期限が切れました。サインイン画面に遷移します。");
router.push("/signin");
return;
}
alert("タスクの削除に失敗しました");
}
};
return (
<>
<Tooltip title="削除">
<IconButton onClick={handleDeleteTask}>
<DeleteIcon color="action" />
</IconButton>
</Tooltip>
</>
);
};
export default DeleteTask;

 

最後にTaskTableを編集します。

 TaskTable.tsx
"use client";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
import { Task } from "@/types/task";
import EditTask from "./EditTask";
import DeleteTask from "./DeleteTask";
import { Stack } from "@mui/material";
function createData(name: string, dueDate: string, status: string) {
return { name, dueDate, status };
}
export default function TaskTable({
tasks,
userId,
}: {
tasks: Task[] | undefined;
userId: number | null;
}) {
return (
<TableContainer component={Paper} sx={{ width: "80%", m: "auto" }}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Task Name</TableCell>
<TableCell align="right">Due Date</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{tasks?.map((task) => (
<TableRow
key={task.id}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row">
{task.name}
</TableCell>
<TableCell align="right">{task.dueDate}</TableCell>
<TableCell align="right">{task.status}</TableCell>
<TableCell align="right">
<Stack spacing={2} direction="row" justifyContent="flex-end">
<EditTask task={task} userId={userId!} />
<DeleteTask id={task.id} userId={userId!} />
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

-Graphql, JavaScript, Nest.js, Next.js, React, TypeScript