0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

DjangoとReactでPDCAアプリを作る その3

Last updated at Posted at 2021-03-20

##まず私について
初めまして!

kenshoと申します。
2019年からプログラミングの勉強を本格的に始めて、web制作フリーランスを経験、その後はIT企業のインターン生として働きながら独学でwebエンジニアの勉強をしてきました。
2021年4月から新卒で上場企業のwebエンジニアとして働く予定です。

よかったらTwitterもやっておりますので気軽にフォローお願いします♪

Twitterアカウント↓
健将@WEBエンジニア×明大生

##前回までのあらすじ

前回は、このPDCAアプリのサーバーサイドの認証部分を作成した
↓↓
DjangoとReactでPDCAアプリを作る その1

DjangoとReactでPDCAアプリを作る その2

DjangoとReactでPDCAアプリを作る その4

ここまでで
このアプリのバックエンドが完成している状態。

##今回やること
git⬇️
https://github.com/kenshow-blog/workapplication

今回からフロントエンドの開発に突入していく。
と、その前に、、、、

私はフロントエンドにReactとTypeScriptを採用しています。

理由は、どちらも需要が高いからです笑
需要が高い理由はググればたくさん出てくるので今回は割愛します。

これらを勉強し始めた当初、まだ就活生だったこともあり、多くの企業で採用されている言語を学ぶことが賢明だと判断いたしました。

しかし、これらに学習コストを割いてとても良かったと思っています。
現に私は、モダン技術を積極的に取り入れるweb系の上場企業に無事採用していただくことができました。

また、Udemy等でもこれらを学ぶのに最適な教材が豊富にありますので、周りが言うほど、学習難易度も高くないのではと思います。
(あくまで個人的な感想)

なのでこれからweb系企業への就職活動、転職活動を考えている方で、ポートフォリオのフロントの技術選定を迷われている方には、ReactとTypeScriptをおすすめします。

###今回の開発部分

1.ログイン画面

スクリーンショット 2021-03-17 11.14.11.png

2.登録画面
スクリーンショット 2021-03-17 11.14.37.png

3.HOME画面
今までのA(アクション)をまとめて表示することで、日々の記録の中で見えた、自分が改善するべき点をわかるようにしている。

スクリーンショット 2021-03-19 12.50.15.png

###アプリ構造
フロントエンド

.
├── README.md
├── package-lock.json
├── package.json
├── public
├── src
│   ├── App.css
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── app
│   │   └── store.ts
│   ├── features
│   │   ├── auth #認証部分
│   │   │   ├── Auth.module.css
│   │   │   ├── Auth.tsx
│   │   │   └── authSlice.ts
│   │   ├── core #共通画面(ヘッダーなど)
│   │   │   ├── Core.module.css
│   │   │   └── Core.tsx
│   │   ├── home #ホーム画面
│   │   │   ├── Home.module.css
│   │   │   └── Home.tsx
│   │   ├── pdca #PDCA部分
│   │   │   ├── DeleteDialog.tsx
│   │   │   ├── Pdca.module.css
│   │   │   ├── Pdca.tsx
│   │   │   ├── PdcaDetail.tsx
│   │   │   └── pdcaSlice.ts
│   │   └── types.ts
│   ├── index.css
│   ├── index.tsx
│   ├── logo.svg
│   ├── react-app-env.d.ts
│   ├── serviceWorker.ts
│   └── setupTests.ts
└── tsconfig.json

##ReactとTypeScriptでフロントの認証画面、HOME画面を作成する

###必要なライブラリをインストール

npx create-react-app . --template redux-typescript

npm install @material-ui/core
npm install @material-ui/icons
npm install @material-ui/lab
npm install react-icons
npm install axios

npm install @types/react-modal
npm install react-modal
npm install react-router-dom @types/react-router-dom

これらは基本的にいつもインストールしているもの、ここから何か必要になり次第追加でインストールしていった感じでした。

###型を予め定義する

src/features/types.ts
export interface LOGIN_USER {
    id: number;
    username: string;
}

export interface FILE extends Blob {
    readonly LastModified: number;
    readonly name: string;
}

export interface PROFILE {
    id: number;
    user_profile: number;
    img: string | null;
}

export interface POST_PROFILE {
    id: number;
    //ユーザー登録される時、画像がnullとして保存されるため↓
    img: File | null;
    
}

export interface CRED {
    username: string;
    password: string;
}
export interface JWT {
    refresh: string;
    access: string;
}

export interface USER {
    id: number;
    username: string;
}
export interface AUTH_STATE {
    isLoginView: boolean;
    openModal: boolean;
    loginUser: LOGIN_USER;
    profile: PROFILE[];
}

//PDCA
export interface ACTION {
    id: number;
    action: string;
    pdca: number;
    action_user: number;
    category: number;
    category_item:string;
    created_at: string;
    updated_at: string;
}

export interface CATEGORY {
    id: number;
    item: string;
}

export interface PDC {
    id: number;
    userPdc: number;
    title: string;
    plan: string;
    do: string;
    check: string;
    created_at: string;
    updated_at: string;
}

export interface PDCA_STATE {
    actions: ACTION[],
    category: CATEGORY[],
    pdc: PDC[],
    selectedPdc: PDC,
    editedPdc: PDC,
    editedAction: ACTION,
    editView: boolean,
    createView: boolean,
}

###ユーザー認証画面のsliceを作成する

####sliceとは
Slice とは、ストア全体を構成する一部分のストアを意味する。切り取った(= slice)一部分という意味。

イメージ図↓
スクリーンショット 2021-03-19 13.11.40.png

####createSliceとは
createSlice は、State / Reducer / Action Creator をまとめて作成する関数。

参考記事↓
HookとRedux ToolkitでReact Reduxに入門する

実際にsliceの中身をみていく

###authSlice.ts

src/features/auth/authSlice.ts
import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import axios from "axios";
import {
    AUTH_STATE,
    CRED,
    LOGIN_USER,
    POST_PROFILE,
    PROFILE,
    JWT,
    USER,
} from "../types";

export const fetchAsyncLogin = createAsyncThunk(
    'auth/login',
    async (auth: CRED) => {
        const res = await axios.post<JWT>(
            `${process.env.REACT_APP_API_URL}/authen/jwt/create`,
            auth,
            {
                headers: {
                    "Content-Type": "application/json",
                },
            }
        );
        return res.data
    }
);

export const fetchAsyncRegister = createAsyncThunk(
    'auth/register',
    async (auth: CRED) => {
        const res = await axios.post<USER>(
            `${process.env.REACT_APP_API_URL}/auth_api/create/`,
            auth,
            {
                headers: {
                    "Content-Type": "application/json",
                },
            }
        );
        return res.data
    }
);

export const fetchAsyncGetMyProf = createAsyncThunk(
    'auth/loginuser',
    async () => {
        const res = await axios.get<LOGIN_USER>(
            `${process.env.REACT_APP_API_URL}/auth_api/loginuser/`,
            {
                headers: {
                    Authorization: `JWT ${localStorage.localJWT}`
                },
            }
        );
        return res.data;
    }
);

//ユーザー登録が完了した直後に行われるプロフィール作成
//ユーザー画像は最初登録されないので、imgにはnullを入れておく
export const fetchAsyncCreateProf = createAsyncThunk(
    'auth/createProfile',
    async () => {
        const res = await axios.post<PROFILE>(
            `${process.env.REACT_APP_API_URL}/auth_api/profile/`,
            { img: null }, {
                headers: {
                    "Content-Type": "application/json",
                    Authorization: `JWT ${localStorage.localJWT}`
                },
            }
        );
        return res.data
    }
);

export const fetchAsyncGetMyProfile = createAsyncThunk(
    'auth/getProfile',
    async () => {
        const res = await axios.get<PROFILE[]>(
            `${process.env.REACT_APP_API_URL}/auth_api/profile/`,
             {
                headers: {
                    "Content-Type": "application/json",
                    Authorization: `JWT ${localStorage.localJWT}`
                },
            }
        );
        return res.data
    }
);

export const fetchAsyncUpdateProf = createAsyncThunk(
    'auth/updateProfile',
    async (profile: POST_PROFILE) => {
        const uploadData = new FormData();
        profile.img && uploadData.append("img", profile.img, profile.img.name);
        const res = await axios.put<PROFILE>(
            `${process.env.REACT_APP_API_URL}/auth_api/profile/${profile.id}/`,
            uploadData, {
                headers: {
                    "Content-Type": "application/json",
                    Authorization: `JWT ${localStorage.localJWT}`
                },
            }
        );
        return res.data
    }
);

const initialState: AUTH_STATE = {
    //isLoginViewなどを用いてログイン画面の表示の有無を実装する
    isLoginView: true,
    openModal: true,
    loginUser: {
        id: 0,
        username: "",
    },
    profile: [{id:0, user_profile: 0, img: null}],
}
export const authSlice = createSlice({
    name: "auth",
    initialState,
    reducers: {
        toggleMode(state) {
            state.isLoginView = !state.isLoginView;
        },
        setOpenModal(state) {
            state.openModal = true;
        },
        resetOpenModal(state) {
            state.openModal = false;
        },
    },
    extraReducers: (builder) => {
        builder.addCase(
            fetchAsyncLogin.fulfilled,
            (state, action: PayloadAction<JWT>) => {
                localStorage.setItem("localJWT", action.payload.access);
                action.payload.access && (window.location.href = "/");
            }
        );
        builder.addCase(
            fetchAsyncGetMyProf.fulfilled,
            (state, action: PayloadAction<LOGIN_USER>) => {

                return {
                    ...state,
                    loginUser: action.payload,
                }
            }

        );
        builder.addCase(
            fetchAsyncGetMyProfile.fulfilled,
            (state, action) => {
                return {
                    ...state,
                    profile: action.payload,
                }
            }

        );
       
        builder.addCase(
            fetchAsyncUpdateProf.fulfilled,
            (state, action) => {
                return {
                    ...state,
                    profile: [action.payload]
                }
            }
        );
    }
})

export const {
    toggleMode,
    setOpenModal,
    resetOpenModal } = authSlice.actions

export const selectIsLoginView = (state: RootState) => state.auth.isLoginView;
export const selectOpenModal = (state: RootState) => state.auth.openModal;
export const selectLoginUser = (state: RootState) => state.auth.loginUser;
export const selectProfile = (state: RootState) => state.auth.profile;

export default authSlice.reducer;
.env
REACT_APP_DEV_API_URL="http://127.0.0.1:8000/"

authSliceの一番下に「select〜」があると思うがこれを設定しておくことで、それらのstate状況をリアルタイムで参照することができる

sliceの作成が終わったら、そのslice(storeの一部分)をstoreに登録する

###sliceをstoreに登録する

src/app/store.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
import authReducer from '../features/auth/authSlice';

export const store = configureStore({
  reducer: {
    auth: authReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;

//ここはtypeScritp用にAppDispatchを編集しておく
export type AppDispatch = typeof store.dispatch;

###ログイン機能、登録機能のコンポーネントを作成する

src/features/auth/Auth.tsx
import React, { useState } from 'react';
import styles from "./Auth.module.css";
import { makeStyles, Theme } from "@material-ui/core/styles";
import { TextField, Button } from "@material-ui/core";

import { useSelector, useDispatch } from "react-redux";
import { AppDispatch } from "../../app/store";
import {
    toggleMode,
    fetchAsyncLogin,
    fetchAsyncRegister,
    fetchAsyncCreateProf,
    selectIsLoginView,
} from "./authSlice"; 

const useStyles = makeStyles((theme: Theme) => ({
            button: {
                margin: theme.spacing(3),
            }
}));

export const Auth : React.FC = () => {
    const classes = useStyles();
    const dispatch: AppDispatch = useDispatch();
    const isLoginView =  useSelector(selectIsLoginView);
    const [credential, setCredential] = useState({ username: "", password: "" });

    const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const value = e.target.value;
        const name = e.target.name;
        setCredential({ ...credential, [name]: value});
    }

    const login = async () => {
        if (isLoginView) {
            await dispatch(fetchAsyncLogin(credential));
        } else {
            const result = await dispatch(fetchAsyncRegister(credential));
            if (fetchAsyncRegister.fulfilled.match(result)) {
                await dispatch(fetchAsyncLogin(credential));
                await dispatch(fetchAsyncCreateProf());
            }
        }
    }
    return (
        <div className={styles.auth_root}>
       {/*isloginView(TrueかFalse)によってログイン画面と登録画面を切り替えられるようにしている*/}
            <h1>{isLoginView ? "Login" : "Register"}</h1>
            <br />
            <TextField
                InputLabelProps={{
                    shrink: true,
                }}
                label="Username"
                type="text"
                name="username"
                value={credential.username}
                onChange={handleInputChange}
                />
                <br />
                <TextField
                    InputLabelProps={{
                        shrink: true,
                    }}
                    label="Password"
                    type="password"
                    name="password"
                    value={credential.password}
                    onChange={handleInputChange}
                    />
                    <Button
                    variant="contained"
                    color="primary"
                    size="small"
                    onClick={login}
                    className={classes.button}
                    >
                        {isLoginView ? "Login": "Register"}
                    </Button>
                    <span onClick={() => dispatch(toggleMode())}>
                        {isLoginView ? "Create new account ?" : "Back to Login"}
                    </span>
            
        </div>
    )
}

css等も編集したがここでは割愛します。
代わりにgitにて、公開いたしましたので、参考にしたい場合はそちらを確認していただければと思います。

###Header等のどのページでも共通する部分のコンポーネントを作成する

src/features/core/Core.tsx



----省略-----

const Core: React.FC = () => {
    const classes = useStyles();
    const dispatch: AppDispatch = useDispatch();
    const profile = useSelector(selectProfile);
    const modal = useSelector(selectOpenModal);
    const loginUser = useSelector(selectLoginUser);
    
    const Logout = () => {
        localStorage.removeItem("localJWT");
        window.location.href = "/";
    }
    const handleEditPicture = () => {
        const fileInput = document.getElementById('imageInput');
        fileInput?.click();
    }
   //このコンポーネントが開く際にuseEffectの中身が実行される
    useEffect(() => {
        const fetchBootLoader = async () => {
            if(localStorage.localJWT) {
                dispatch(resetOpenModal());
                
                const result = await dispatch(fetchAsyncGetMyProf());

                if (fetchAsyncGetMyProf.rejected.match(result)) {
                    dispatch(setOpenModal());
                    localStorage.removeItem("localJWT");
                    return null;
                }
                await dispatch(fetchAsyncGetMyProfile());
                await dispatch(fetchAsyncGetActions());
                await dispatch(fetchAsyncGetCategory());
            }
        };
        fetchBootLoader();
    }, [dispatch]);
    return (
        <MuiThemeProvider theme={theme}>
            {/*ログインしているか否かによって表示を切り替えてる */}
            {modal ? <Auth />:

            <>
            <div className={styles.app__root}>
                <Grid container>
                    <Grid item xs={4}>
                    <a href="/" style={{textDecoration: "none", color: "inherit"}}>
                        <WorkIcon fontSize="large" className={classes.icon} />
                    </a>
                    </Grid>
                
                <Grid item xs={4}>
                    <a href="/" style={{textDecoration: "none", color: "inherit"}}><h1>WORK APP</h1></a>
                </Grid>
                <Grid item xs={4}>
                    <div className={styles.app__logout}>
                        <button className={styles.app__iconLogout} onClick={Logout}>
                            <ExitToAppIcon fontSize="large" />
                        </button>
                    <input
                    type="file"
                    id="imageInput"
                    hidden={true}
                    onChange={(e) => {
                        dispatch(
                            fetchAsyncUpdateProf({
                                id: profile[0].id,
                                img: e.target.files !== null ? e.target.files[0] : null,
                            })
                        )
                    }}
                    />
                    <div>
                    <button className={styles.app__btn} onClick={handleEditPicture}>
                        <Avatar
                        className={classes.avatar}
                        alt="avatar"
                        src={
                            profile[0]?.img !== null ? profile[0]?.img : undefined
                        }
                        />
                    </button>
                    <br/>
                    <span>{loginUser.username}</span>
                    </div>
                    </div>
                </Grid>

                {/* pathによってbody部分に何のコンポーネントを表示させるかを切り替えてる  */}
                <BrowserRouter>
                    <Switch>
                        <Route exact path="/" component={Home} />
                        <Route exact path="/pdca" component={Pdca} />
                        <Route path="/pdca/detail/" component={PdcaDetail} />
                     </Switch>
                    </BrowserRouter>
                </Grid>
            </div>
            </>}
            
        </MuiThemeProvider>
    );
}

export default Core

Route部分については、path一部分が被ってしまう場合(上で言うと「path="/pdca"」と「path="/pdca/detail/"」では"/pdca"部分が被っている)は、被ってしまうRouteのpathの前に「exact」をつけてあげる。
そうしないと「/pdca/detail/」に遷移しても、「/pdca」で指定したコンポーネントが表示されてしまう。

###Homeコンポーネントを作成する

src/features/Home/Home.tsx




----省略-----

const Home: React.FC = () => {
    const classes = useStyles();
    const actions = useSelector(selectActions);
    const columns = actions[0] && Object.keys(actions[0]);
    const [state, setState] = useState<ACTIONS>({
        rows: actions
    })
    useEffect(() => {
        setState((state) => ({
                ...state,
                rows: actions,
            }));  
    }, [actions]);

    return (
        <>
        <div style={{ width: "100%", marginBottom: "100px"}}>
        </div>
        <Grid item xs={8} className={classes.gridTable}>
            {actions[0]?.id &&
            
                <Table size="small" className={classes.table}>
                    <TableHead>
                        <TableRow>
                            {columns
                            .map(
                                (column, colIndex) =>
                                (column === "action" ||
                                column === "category_item") && (
                                    <TableCell align="center" key={colIndex}>
                                       <strong>
                                        {column === "category_item" ? "Category" : "Action"}
                                        </strong>
                                        <br />
                                    </TableCell>

                                )
                            )}
                        </TableRow>
                    </TableHead>
                    <TableBody>
                        {state.rows
                        .slice(0)
                        .reverse()
                        .filter(function(act) {
                            //カテゴリーが未選択のものは、アクション内容が空欄と判断し、skipするようにしている
                            if (act.category_item === "未選択") {
                            return false; // skip
                            }
                            return true;
                        })
                        .map((row, rowIndex) => (
                           <TableRow key={rowIndex} >
                               {Object.keys(row).map(
                                   (key, colIndex) =>
                                   (
                                       key === "action" ||
                                       key === "category_item"
                                   ) && (
                                       <TableCell
                                       align="center"
                                       key={`${rowIndex}+${colIndex}`}
                                       >
                                           <span>{row[key]}</span>
                                           <br />
                                           <br />
                                           <br />
                                       </TableCell>
                                   )
                               )} 
                           </TableRow>
                        ))}
                    </TableBody>
                </Table>
                }     
        </Grid>
        <Grid item xs={4}
        container
        direction="column"
        alignItems="center"
        justify="center"
        style={{ minHeight: "30vh"  }}
         >
        <Link to="/pdca" style={{ textDecoration: 'none' }}>
            <Button color="primary" variant="contained" className={classes.button}>
                PDCA
            </Button>
        </Link>
        </Grid>
        </>
    )
}

export default Home

これで、ログイン、登録、Header、Homeのコンポーネントと、sliceを作成した🎉🎉

##ここまでの感想

今回は、自分のプロフィールに画像を登録できるようにした。

前回のアプリ([DjangoとReact redux TypeScriptを使ってオリジナルアプリを作ってみました(TwitterAPI)その3]
(https://qiita.com/kenshow-blog/items/7ecbd85e00a7e1f0bc75))とは、違う形式で、ユーザー情報周りを実装してみたので、より理解を深めることができた。

こういった、いつもとは違うやり方で実装してみようといった取り組みは、理解を深めることにつながったり新たな発見ができたりするのでとても良いなと思いました。

ここまで読んでくださりありがとうございました!🙇‍♂️🙇‍♂️

次回は、このアプリの核となるPDCAのフロント周りをReactとTypeScriptで実装していきます!

また、Twitterでも日々の積み上げや、プログラミング学習についてのツイートをしておりますので、よかったらフォローと応援の程よろしくお願いします!🙇‍♂️

Twitterアカウント↓
健将@WEBエンジニア×明大生

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?