Laravel + React.JS를 이용한 비동기 방식의 관리자 페이지 만들기(laravel을 API 데이터 서버로 사용)

김문범·2020년 5월 4일
0

laravel에 React.JS를 사용하여 만들었던 프로젝트였으나...
중단이 되어버려서 정리하여 개인공부를 겸하여 누군가에게는 도움이 될까하고 이 글을 작성해봅니다.

부족한 점이 많은 방식입니다.
설명 또한 매우 부족할 것입니다.
이러한 부분에 대해서 이 글을 보시는 모든 분께 죄송합니다.

프로젝트의 주제는 "마일리지의 관리"를 비동기방식의 원페이지 관리자 화면을 만드는 것이었습니다.
정확한 용어를 사용한 것인지는 저도 잘 모르겠습니다.

laravel은 5.5 최신버전을 사용하고
React.JS는 16.2.0 버전을 사용하고 있습니다.

laravel 및 React.JS의 설치과정과 React.JS로의 preset 변경에 대해서는 laravel 메뉴얼에 설명되어있으므로 생략하도록 하겠습니다.

1. Package.json 추가

제가 React.JS를 편하게 사용하기위해 추가한 라이브러리 목록입니다. laravel을 설치하면 기본적으로 설치되는 것과 React.JS로 preset을 변경하면 적용되는 것을 포함하여 제가 추가한 라이브러리 목록을 그래도 복사해서 보여드립니다.

제가 설명할 방법에는 아래의 라이브러리가 없으면 오류가 발생 할 수 있음을 알려드립니다.

{
    "private": true,
    "scripts": {
        "dev": "npm run development",
        "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
        "watch": "npm run development -- --watch",
        "watch-poll": "npm run watch -- --watch-poll",
        "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
        "prod": "npm run production",
        "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
    },
    "devDependencies": {
        "@babel/preset-react": "^7.7.4",
        "axios": "^0.18.0",
        "babel-preset-react": "^6.24.1",
        "bootstrap": "^4.0.0",
        "cross-env": "^5.2.1",
        "jquery": "^3.2",
        "laravel-mix": "^5.0",
        "lodash": "^4.17.4",
        "popper.js": "^1.12.9",
        "react": "^16.2.0",
        "react-dom": "^16.2.0",
        "resolve-url-loader": "^3.1.0",
        "sass": "^1.24.2",
        "sass-loader": "^8.0.0"
    },
    "dependencies": {
        "@fortawesome/fontawesome-free": "^5.9.0",
        "babel-plugin-transform-class-properties": "^6.24.1",
        "isomorphic-fetch": "^2.2.1",
        "jwt-decode": "^2.2.0",
        "moment": "^2.24.0",
        "react-redux": "^7.1.3",
        "react-router-dom": "^4.2.2",
        "redux": "^4.0.5",
        "redux-devtools-extension": "^2.13.8",
        "redux-saga": "^1.1.3",
        "redux-saga-requests": "^0.26.1",
        "redux-saga-requests-axios": "^0.7.7",
        "redux-saga-requests-fetch": "^0.9.7",
        "redux-saga-thunk": "^0.7.3",
        "redux-thunk": "^2.3.0",
        "reselect": "^4.0.0"
    }
}

2. Laravel 설정

laravel 설치 후 내장되어 제공하는 Authorization-권한승인의 로그인 및 회원가입 방식을 이용하고 API 인증(Passport-OAuth2)을 사용하였습니다.

먼저 내장된 로그인 및 회원가입 방식을 사용한다면 welcome.blade.php에 아래와 같으 코드를 작성합니다.

아 ! 그전에 회원가입을 미리 해두셔야합니다...
저는 로그인 방식만 구현을 해두었고 회원가입은 기본 Laravel에서 가입하여 처리하였습니다.
완벽하지 않아서 죄송합니다.

<!doctype html>
<html lang="{{ app()->getLocale() }}">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="csrf-token" content="{{ csrf_token() }}">


        <title>Laravel</title>

        <!-- Fonts -->
        <link href="https://fonts.googleapis.com/css?family=Raleway:100,600" rel="stylesheet" type="text/css">
        <link href="{{ asset(mix('css/app.css')) }}" rel="stylesheet" type="text/css">
    </head>
    <body>
        <div id="app"></div>

        <script src="{{ asset(mix('js/app.js')) }}"></script>
    </body>
</html>

3. React.JS 파일 구조 및 설정

제가 사용한 파일 구조는 위의 사진과 같습니다.

app.js에서는 Here.js를 require한다고 작성해주시면 되겠습니다.

/**
 * First we will load all of this project's JavaScript dependencies which
 * includes React and other helpers. It's a great starting point while
 * building robust, powerful web applications using React + Laravel.
 */

require('./bootstrap');

/**
 * Next, we will create a fresh React component instance and attach it to
 * the page. Then, you may begin adding components to this application
 * or customize the JavaScript scaffolding to fit your unique needs.
 */

require('./Here'); // <<== 이 부분입니다.

preset을 변경하기 전 코드

/**
 * First we will load all of this project's JavaScript dependencies which
 * includes Vue and other libraries. It is a great starting point when
 * building robust, powerful web applications using Vue and Laravel.
 */

require('./bootstrap');

window.Vue = require('vue');

/**
 * Next, we will create a fresh Vue application instance and attach it to
 * the page. Then, you may begin adding components to this application
 * or customize the JavaScript scaffolding to fit your unique needs.
 */

Vue.component('example-component', require('./components/ExampleComponent.vue'));

const app = new Vue({
    el: '#app'
});

Here.js 코드

import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux'
import store from './store/store'

import './style/style.css'
import AppLayout from "./components/AppLayout";

export default class Here extends Component {
    render() {
        return (
            <Provider store={store}>
                <AppLayout/>
            </Provider>
        );
    }
}

if (document.getElementById('app')) {
    ReactDOM.render(<Here/>, document.getElementById('app'));
}

4. CORS 오류를 대비하는 설정

API 통신 간 CORS 오류가 발생하여 제가 찾아보고 설정한 방법입니다.
저보다 더 좋은 방법이 있을 것 같지만 일단 작성해 보겠습니다.
laravel 파일구조에서 app/Http/Middleware 폴더에 Cors.php라고 파일을 생성합니다. 파일 내의 코드는 아래와 같이 작성합니다.

<?php
namespace App\Http\Middleware;
use Closure;
class Cors
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        return $next($request)
            ->header("Access-Control-Allow-Origin", "*")
            ->header("Access-Control-Allow-Methods", "GET, POST, PUT, FETCH, DELETE, OPTIONS")
            ->header("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, X-Token-Auth, Authorization");
    }
}

그리고 app/Http 폴더에서 kernel.php 파일에서

/**
     * The application's global HTTP middleware stack.
     *
     * These middleware are run during every request to your application.
     *
     * @var array
     */
    protected $middleware = [
        \App\Http\Middleware\Cors::class, // <<== 이 부분을 추가합니다.
        \Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
        \App\Http\Middleware\TrustProxies::class,
    ];

protected $middleware에 \App\Http\Middleware\Cors::class 이 부분을 추가합니다.

5. sagas.js 설정

import 'isomorphic-fetch';
import {
    createRequestInstance,
    getRequestInstance,
    requestsPromiseMiddleware,
    sendRequest,
    watchRequests,
} from 'redux-saga-requests';
// import { createDriver } from 'redux-saga-requests-axios';
import { createDriver } from 'redux-saga-requests-fetch';
import { take, fork, select, call, put } from 'redux-saga/effects';
import config from "../config/config";

function* onRequestSaga(request, action) {
    // refresh token 미들웨어 대신 사용함.
    let auth = yield select(state => state.logInOut);
    if (auth.logged && auth.access_token !== null
        && auth.tokenRefreshing === false) {
        request.headers = {
            ...request.headers,
            Authorization: `Bearer ${auth.access_token}`,
        };
    }

    request.headers = {
        ...request.headers,
        'Content-Type': 'application/json',
    };

    // do sth with you request, like add token to header, or dispatch some action etc.
    return request;
}

function* onResponseSaga(response, action) {
    // do sth with the response, dispatch some action etc
    return response;
}

function* onErrorSaga(error, action) {
    // do sth here, like dispatch some action

    // you must return { error } in case you dont want to catch error
    // or { error: anotherError }
    // or { response: someRequestResponse } if you want to recover from error

    /*
    if (tokenExpired(error)) {
      // get driver instance, in our case Axios to make a request without Redux
      const requestInstance = yield getRequestInstance();

      try {
        // trying to get a new token
        const { data } = yield call(
          requestInstance.post,
          '/refreshToken',
        );

        saveNewToken(data.token); // for example to localStorage

        // we fire the same request again:
        // - with silent: true not to dispatch duplicated actions
        // - with runOnError: false not to call this interceptor again for this request
        return yield call(sendRequest, action,
          { silent: true, runOnError: false });

        // above is a handy shortcut of doing
        // const { response, error } = yield call(
        //   sendRequest,
        //   action,
        //   { silent: true, runOnError: false },
        // );
        //
        // if (response) {
        //   return { response };
        // } else {
        //   return { error };
        // }
      } catch (e) {
        // we didnt manage to get a new token
        return { error: e };
      }
    }
    */

    // not related token error, we pass it like nothing happened
    return { error };
}

function* onAbortSaga(action) {
    // do sth, for example an action dispatch
}

// export default function* rootSaga(axiosInstance) {
export default function* rootSaga() {
    yield createRequestInstance({
        driver: createDriver(
            window.fetch,
            {
                baseURL: 'http://127.0.0.1:8000/api'
            }
        ),
        onRequest: onRequestSaga,
        onSuccess: onResponseSaga,
        onError: onErrorSaga,
        onAbort: onAbortSaga,
    });
    yield watchRequests();
}

아래의 코드를 사용하여 API 통신 시 token 및 Content-Type을 자동으로 같이 보내도록 처리하였습니다.

function* onRequestSaga(request, action) {
    // refresh token 미들웨어 대신 사용함.
    let auth = yield select(state => state.logInOut);
    if (auth.logged && auth.access_token !== null
        && auth.tokenRefreshing === false) {
        request.headers = {
            ...request.headers,
            Authorization: `Bearer ${auth.access_token}`,
        };
    }
    request.headers = {
        ...request.headers,
        'Content-Type': 'application/json',
    };
    // do sth with you request, like add token to header, or dispatch some action etc.
    return request;
}

6. store.js 설정

import {createStore, applyMiddleware, combineReducers} from 'redux';
import axios from 'axios';
import createSagaMiddleware from 'redux-saga';
import {requestsPromiseMiddleware} from 'redux-saga-requests';
import { middleware as thunkMiddleware } from 'redux-saga-thunk';
import thunk from 'redux-thunk';
import {composeWithDevTools} from 'redux-devtools-extension/developmentOnly';
import rootSaga from "../saga/sagas";
import config from '../config/config'
import refreshTokens from "../components/refreshTokens";

import loginReducer from './login/reducer';
import categoryReducer from './category/reducer';
import sortationReducer from './sortation/reducer'
import studentReducer from './student/reducer'
import pointReducer from './point/reducer'
const sagaMiddleware = createSagaMiddleware();

export default createStore(
    combineReducers({
        logInOut: loginReducer,
        category: categoryReducer,
        sortation: sortationReducer,
        student: studentReducer,
        point: pointReducer,
    }), composeWithDevTools(
        applyMiddleware(refreshTokens, thunk, requestsPromiseMiddleware(), thunkMiddleware, sagaMiddleware),
    ),
);

// const axiosInstance = axios.create({
//     baseURL: config.API_URL,
// });

sagaMiddleware.run(rootSaga);

rootSage는 sagas.js에 있습니다.

6. refreshTokens.js 설정

API 통신 간 로그인이 되어있을때만 데이터가 주고받을 수 있도록 token값이 만료되었을때 다시 자동으로 갱신가능하도록 만드는 기능입니다.

import jwtDecode from 'jwt-decode';
import moment from 'moment';

import { refreshToken } from '../store/login/actions';
import { REFRESH_TOKENS } from '../store/login/constants';

const refreshTokens = ({ dispatch, getState }) => next => async action => {

    if (typeof action === 'object' && action.type !== REFRESH_TOKENS) {
        // only worry about expiring token for async actions
        const theState = getState();
        if (theState.logInOut && theState.logInOut.access_token &&
            theState.logInOut.tokenRefreshing === false) {
            let tokenExpiration = jwtDecode(theState.logInOut.access_token).exp;

            if (tokenExpiration &&
                (moment.unix(tokenExpiration) - moment(Date.now())) < 5000) {
                // make sure we are not already refreshing the token

                if (!theState.logInOut.tokenRefreshing) {
                    try {
                        // await dispatch(refreshToken(dispatch, getState));
                        await dispatch(refreshToken());
                    } catch (e) {
                        // console.log(e);
                    }
                } else {
                    //Queue up requests and perhaps action them once the token is refreshed
                }
            }
        }
    }

    return next(action);
};

export default refreshTokens;

7. AppLayout.js

Here.js에 작성되어 있는 components의 가장 위에 존재하는 기본틀과 같은 코드입니다.

import React, {Component} from 'react';
import {connect} from "react-redux";
import Login from "./Login";
import Main from "./Main";
import {loginCheck} from "../store/login/actions";

class AppLayout extends Component {
    constructor(props) {
        super(props);
        const access_token = sessionStorage.getItem('access_token');
        const refresh_token = sessionStorage.getItem('refresh_token');
        if (access_token){
            this.props.loginCheck(access_token, refresh_token);
        }
    };


    render() {
        return (
            <div className="divWidth">
                {this.props.logged ? <Main/> : <Login/>}
            </div>
        );
    };
}

const mapStateToProps = state => ({
    logged: state.logInOut.logged,
});

const mapDispatchToProps = dispatch => ({
    loginCheck: (access_token, refresh_token) => dispatch(loginCheck(access_token, refresh_token)),
});

export default connect(mapStateToProps, mapDispatchToProps)(AppLayout);

sessionStorage에 저장되어있는 token값을 이용하여 "loginCheck" action을 실행하여 logged값을 확인하여 main 또는 login 페이지로 변경되도록 처리합니다.
그리고 token값이 만료가 되어 "loginCheck" action이 실행되면 token값이 자동으로 갱신됩니다.

8. login 관련 설정

로그인에 관련된 파일들입니다.

components 폴더 (login.js 코드)
login 화면에 대한 파일입니다.

import React, {Component} from 'react';
import {connect} from "react-redux";
import {userLogin} from "../store/login/actions";

class Login extends Component {
    constructor(props) {
        super(props);
    };

    render() {
        return (
            <div className={'container-fluid'}>
                <div className={'row justify-content-center align-items-center vh-100'}>
                    <div className={'shadow p-3'}>
                        <h3 className={'text-center'}>Login</h3>
                        <div className="input-group flex-nowrap mb-2">
                            <div className="input-group-prepend">
                                <span className="input-group-text" id="userId">ID</span>
                            </div>
                            <input type="text" className="form-control" placeholder="Id" onChange={function (e) {
                                this.setState({userId: e.target.value});
                            }.bind(this)}/>
                        </div>
                        <div className="input-group flex-nowrap mb-2">
                            <div className="input-group-prepend">
                                <span className="input-group-text" id="userPw">PW</span>
                            </div>
                            <input type="password" className="form-control" placeholder="Password" onChange={function (e) {
                                this.setState({userPw: e.target.value});
                            }.bind(this)}/>
                        </div>
                        <button className="btn btn-primary w-100" onClick={function () {
                            this.props.userLogin(this.state.userId, this.state.userPw);
                        }.bind(this)}>
                            Login
                        </button>
                    </div>
                </div>
            </div>
        );
    }
}


const mapStateToProps = state => ({
    logged: state.logInOut.logged,
});

const mapDispatchToProps = dispatch => ({
    userLogin: (userId, userPw) => dispatch(userLogin(userId, userPw)),
});



export default connect(mapStateToProps, mapDispatchToProps)(Login);

store/login 폴더 (constants.js 코드)

const PREFIX = 'LOG_';

export const LOG_IN = `${PREFIX}IN`;
export const LOG_OUT = `${PREFIX}OUT`;
export const LOG_CHECK = `${PREFIX}CHECK`;

export const REFRESH_TOKENS = `${PREFIX}REFRESH_TOKENS`;

store/login 폴더 (actions.js 코드)
login action에 대한 파일입니다.
API 서버로 요청하는 부분입니다.
refreshToken은 통신 간에 sagas.js를 항상 통과하기때문에 token값이 만료되면 실행되게 작성되어있습니다.
sagas.js에 onRequestSaga를 통해서 실행되어집니다.

import {LOG_IN, LOG_OUT, LOG_CHECK, REFRESH_TOKENS} from "./constants";
import {success} from "redux-saga-requests";

export const userLogin = (userId, userPw) => ({
    type: LOG_IN,
    request: {
        url: '/login',
        method: 'POST',
        body: JSON.stringify({userId: userId, password: userPw}),
    },
});

export const userLogout = () => ({
    type: LOG_OUT,
    request: {
        url: '/logout',
        method: 'GET',
    }
});

export const loginCheck = (access_token, refresh_token) => ({
    type: success(LOG_CHECK),
    data: {
        access_token,
        refresh_token,
    },
});

export const refreshToken = () => async (dispatch, getState) => {
    try {
        let theState = getState();
        const {data: {access_token, refresh_token}} = await dispatch(
            {
                type: REFRESH_TOKENS,
                request: {
                    url: '/refreshToken',
                    method: 'post',
                    body: JSON.stringify({refresh_token: theState.logInOut.refresh_token,}),
                },
                meta: {
                    asPromise: true,
                },
            });
        if (access_token) {
            await sessionStorage.setItem('access_token', access_token);
            await sessionStorage.setItem('refresh_token', refresh_token);
            await dispatch(loginCheck(access_token, refresh_token));
        }
    } catch (e) {
        if (e.error.response.status === 401) {
            await sessionStorage.removeItem('access_token');
            await sessionStorage.removeItem('refresh_token');

            await dispatch({type: error(LOG_OUT)});
        }
    }
};

store/login 폴더 (reducer.js 코드)
API 요청으로 돌아온 데이터를 처리하는 파일입니다.
정상적인 HTTP 상태코드로 들어온다면 success,
비정상적인 HTTP 상태코드로 들어온다면 error
로 처리합니다.

import {success, error} from 'redux-saga-requests';
import {LOG_CHECK, LOG_IN, LOG_OUT, REFRESH_TOKENS} from "./constants";

const initialState = {
    logged: false,
    tokenRefreshing: false,
    access_token: null,
    refresh_token: null
};

export default (state = initialState, action) => {
    switch (action.type) {
        case success(LOG_IN): {
            sessionStorage.setItem('access_token', action.data.access_token);
            sessionStorage.setItem('refresh_token', action.data.refresh_token);
            return {
                ...state,
                logged: true,
                access_token: action.data.access_token,
                refresh_token: action.data.refresh_token
            };
        }

        case error(LOG_OUT):
        case success(LOG_OUT): {
            sessionStorage.clear();
            return {
                ...state,
                logged: false,
                tokenRefreshing: false,
                access_token: null,
                refresh_token: null
            };
        }

        case success(LOG_CHECK): {
            return {
                ...state, ...action.data,
                logged: true,
                tokenRefreshing: false,
            };
        }

        case REFRESH_TOKENS: {
            return { ...state, tokenRefreshing: true };
        }

        default:
            return {
                ...state,
            };
    }
}

9. ROUTE/api.php

인증이 필요한 부분 및 필요하지 않는 부분으로 구분하였습니다.

<?php

use Illuminate\Http\Request;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/

//Route::middleware('auth:api')->get('/user', function (Request $request) {
//    return $request->user();
//});

// 인증이 필요한 api 라우트
Route::group([
    'namespace' => 'Api',
    'middleware' => 'auth:api',
], function () {
    Route::get('logout', 'LoginController@logout');

    Route::post('category/store', 'CategoryController@store');
    Route::post('category/list', 'CategoryController@index');
    Route::post('category/update', 'CategoryController@update');
    Route::post('category/destroy', 'CategoryController@destroy');

    Route::post('sortation/sortList', 'SortationController@sortationList');
    Route::post('sortation/contList', 'SortationController@contentsList');
    Route::post('sortation/sortInsert', 'SortationController@sortationStore');
    Route::post('sortation/contInsert', 'SortationController@contentsStore');
    Route::post('sortation/sortUpdate', 'SortationController@sortationUpdate');
    Route::post('sortation/contUpdate', 'SortationController@contentsUpdate');
    Route::post('sortation/sortDestroy', 'SortationController@sortationDestroy');
    Route::post('sortation/contDestroy', 'SortationController@contentsDestroy');

    Route::post('student/list', 'StudentController@index');
    Route::post('student/store', 'StudentController@store');
    Route::post('student/update', 'StudentController@update');
    Route::post('student/destroy', 'StudentController@destroy');
    Route::post('student/studCate', 'StudentController@studCateCreate');

    Route::post('point/studList', 'PointController@studentsList');
    Route::post('point/pointList', 'PointController@pointsList');
});

// 인증이 필요없는 api 라우트
Route::group([
    'namespace' => 'Api',
], function () {
    Route::post('login', 'LoginController@login');
    Route::post('refreshToken', 'LoginController@refreshToken');
});

10. LoginController.php

컨트롤러 단에서 로그인을 처리하는 방식입니다.

<?php

namespace App\Http\Controllers\Api;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator;

class LoginController extends Controller
{
    // login -----------------------------------------------------------------------------------------------------------
    public function login(Request $request)
    {
        $validator = Validator::make($request->post(), [
            'userId' => 'required|string',
            'password' => 'required|string',
        ]);

        if ($validator->fails()) {
            $errors = $validator->errors();
            return response()->json(['message' => $errors->all()[0]], 422);
        }

        request()->request->add([
            'grant_type' => 'password',
            'client_id' => '3',
            'client_secret' => 'secret 입력',
            'username' => $request->post('userId'),
            'password' => $request->post('password'),
        ]);

        $proxy = Request::create('oauth/token', 'POST');

        $response = Route::dispatch($proxy);

        return $response;
    }

    // logout ----------------------------------------------------------------------------------------------------------
    public function logout()
    {
        Auth::user()->token()->revoke();
        return response()->json(null, 204);
    }

    // refreshToken ----------------------------------------------------------------------------------------------------
    public function refreshToken(Request $request)
    {
        $this->validate($request, [
            'refresh_token' => 'required|string',
        ]);

        request()->request->add([
            'grant_type' => 'refresh_token',
            'client_id' => '3',
            'client_secret' => 'secret 입력',
            'refresh_token' => $request->refresh_token,
        ]);

        $proxy = Request::create('oauth/token', 'POST');

        return Route::dispatch($proxy);
    }
}

passport를 사용하시게 된다면 oauth_clients라는 테이블이 DB에 생길것입니다.
'client_secret' 은 그 테이블에 저장된 id는 "3" 인 데이터의 secret를 입력하여 사용한 것입니다.

11. Main.js

login을 정상적으로 완료한다면 main.js로 들어와 기본 페이지인 category.js를 출력합니다.
즉, main.js는 정상적인 관리자 페이지의 틀을 의미합니다.
"react-router-dom" 을 이용하여 페이지를 전환해주었습니다.
코드는 아래와 같습니다.

import React, {Component} from 'react';
import {userLogout} from "../store/login/actions";
import {connect} from "react-redux";
import {
    BrowserRouter as Router,
    Switch,
    Route,
    Link
} from "react-router-dom";

import Category from "./Category";
import Sortation from "./Sortation";
import Student from "./Student";
import Point from "./Point";
import Excel from "./Excel";

class Main extends Component {
    constructor(props) {
        super(props);
    }

    render() {
        return (
            <Router>
                <div className="container-fluid">
                    <nav className="navbar navbar-expand-lg navbar-light bg-dark text-white mb-3">
                        <Link className="navbar-brand text-white text-white" to="/sw">
                            laravel
                        </Link>
                        <button className="navbar-toggler" type="button" data-toggle="collapse"
                                data-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup"
                                aria-expanded="false" aria-label="Toggle navigation">
                            <span className="navbar-toggler-icon"> </span>
                        </button>
                        <div className="collapse navbar-collapse" id="navbarNavAltMarkup">
                            <div className="navbar-nav mr-auto mt-2 mt-lg-0">
                                <Link className="nav-item nav-link active text-white" to="/sw/Category">카테고리 관리</Link>
                                <Link className="nav-item nav-link active text-white" to="/sw/Sortation">항목 관리</Link>
                                <Link className="nav-item nav-link active text-white" to="/sw/Student">학생 관리</Link>
                                <Link className="nav-item nav-link active text-white" to="/sw/Point">마일리지 입력</Link>
                                <Link className="nav-item nav-link active text-white" to="/sw/Excel">엑셀 출력</Link>
                            </div>
                            <div className="my-2 my-lg-0">
                                <button className="btn btn-outline-primary" onClick={function () {
                                    this.props.userLogout();
                                }.bind(this)}>
                                    Logout
                                </button>
                            </div>
                        </div>
                    </nav>
                    <Switch>
                        <Route path="/sw/Category">
                            <Category/>
                        </Route>
                        <Route path="/sw/Sortation">
                            <Sortation/>
                        </Route>
                        <Route path="/sw/Student">
                            <Student/>
                        </Route>
                        <Route path="/sw/Point">
                            <Point/>
                        </Route>
                        <Route path="/sw/Excel">
                            <Excel/>
                        </Route>
                        <Route path="/sw">
                            <Category/>
                        </Route>
                    </Switch>
                </div>
            </Router>
        );
    }
}


const mapStateToProps = state => ({
    logged: state.logInOut.logged,
});

const mapDispatchToProps = dispatch => ({
    userLogout: () => dispatch(userLogout()),
});

export default connect(mapStateToProps, mapDispatchToProps)(Main);

12. 기본적인 CRUD 데이터 주고받기

components 폴더 (Category.js 파일)
카테고리 화면의 기본 구조입니다.
addClass와 같은 방법을 사용하기 위해 state에 id값을 null값으로 초기화하였습니다.
그리고 매우 간단한 onClick, onChange를 이용하였습니다.

import React, {Component} from 'react';
import {cateInsert, cateList, cateUpdate, cateDelete} from "../store/category/actions";
import {connect} from "react-redux";

class Category extends Component {
    constructor(props) {
        super(props);
        this.state = {
            id: null
        }
    }

    componentDidMount() {
        this.props.cateList();
    }

    render() {
        return (
            <div className="container-fluid">
                <div className="row">
                    <div className="col-2">
                        <table className="table table-bordered table-hover text-center">
                            <thead className="thead-dark">
                            <tr>
                                <th>학년</th>
                                <th>학기</th>
                            </tr>
                            </thead>
                            <tbody>
                            {this.props.lists.map(item => (
                                <tr key={item.id}
                                    className={"tdCursor" + (this.state.id === item.id ? ' tdActive' : '')}
                                    onClick={function () {
                                        this.setState({
                                            id: item.id,
                                            year: item.year,
                                            semester: item.semester,
                                        });
                                        $('#year').val(item.year);
                                        $('#semester').val(item.semester);
                                    }.bind(this)}>
                                    <td>{item.year}</td>
                                    <td>{item.semester}</td>
                                </tr>
                            ))}
                            </tbody>
                        </table>
                    </div>
                    <div className="col-10">
                        <div className="input-group mb-3 w-50 mx-auto">
                            <div className="input-group-prepend">
                                <span className="input-group-text">년도</span>
                            </div>
                            <input type="text" id="year" className="form-control" placeholder="년도"
                                   onChange={function (e) {
                                       this.setState({year: e.target.value});
                                   }.bind(this)}/>
                        </div>
                        <div className="input-group mb-3 w-50 mx-auto">
                            <div className="input-group-prepend">
                                <span className="input-group-text">학기</span>
                            </div>
                            <input type="text" id="semester" className="form-control" placeholder="학기"
                                   onChange={function (e) {
                                       this.setState({semester: e.target.value});
                                   }.bind(this)}/>
                        </div>
                        <div className="text-center">
                            <div className={'btn-group'}>
                                <button className="btn btn-primary" onClick={function () {
                                    this.props.cateInsert(this.state.year, this.state.semester);
                                }.bind(this)}>
                                    추가
                                </button>
                                <button className="btn btn-success" onClick={function () {
                                    this.props.cateUpdate(this.state.id, this.state.year, this.state.semester);
                                }.bind(this)}>
                                    수정
                                </button>
                                <button className="btn btn-danger" onClick={function () {
                                    this.props.cateDelete(this.state.id);
                                }.bind(this)}>
                                    삭제
                                </button>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        );
    }
}

const mapStateToProps = state => ({
    lists: state.category.lists,
    isInsert: state.category.isInsert,
});

const mapDispatchToProps = dispatch => ({
    cateInsert: (year, semester) => dispatch(cateInsert(year, semester)),
    cateList: () => dispatch(cateList()),
    cateUpdate: (id, year, semester) => dispatch(cateUpdate(id, year, semester)),
    cateDelete: (id) => dispatch(cateDelete(id)),
});

export default connect(mapStateToProps, mapDispatchToProps)(Category);

store/category 폴더 (constants.js 파일)

const PREFIX = 'CATE_';

export const CATE_INSERT = `${PREFIX}INSERT`;
export const CATE_LIST = `${PREFIX}LIST`;
export const CATE_UPDATE = `${PREFIX}UPDATE`;
export const CATE_DELETE = `${PREFIX}DELETE`;

store/category 폴더 (actions.js 파일)
CRUD에 대한 기본적인 요청방법입니다.

import {CATE_INSERT, CATE_LIST, CATE_UPDATE, CATE_DELETE} from "./constants";

export const cateInsert = (year, semester) => ({
    type: CATE_INSERT,
    request: {
        url: '/category/store',
        method: 'POST',
        body: JSON.stringify({year: year, semester: semester}),
    },
});

export const cateList = () => ({
    type: CATE_LIST,
    request: {
        url: '/category/list',
        method: 'POST',
        body: JSON.stringify(),
    },
});

export const cateUpdate = (id, year, semester) => ({
    type: CATE_UPDATE,
    request: {
        url: '/category/update',
        method: 'post',
        body: JSON.stringify({id: id, year: year, semester: semester}),
    },
});

export const cateDelete = (id) => ({
    type: CATE_DELETE,
    request: {
        url: '/category/destroy',
        method: 'post',
        body: JSON.stringify({id: id}),
    },
});

store/category 폴더 (reducer.js 파일)
기본적인 CRUD 데이터 처리에 대한 파일입니다.
수정은 JavaScript map(프로토타입)을 이용하고,
삭제는 JavaScript filter(프로토타입)을 이용합니다.

import {success, error} from 'redux-saga-requests';
import {CATE_INSERT, CATE_LIST, CATE_UPDATE, CATE_DELETE} from "./constants";

const initialState = {
    lists: [],
    isInsert: false,
};

export default (state = initialState, action) => {
    switch (action.type) {
        case success(CATE_INSERT): {
            return {
                ...state,
                isInsert: true,
                lists: [
                    action.data,
                    ...state.lists
                ],
            };
        }

        case success(CATE_LIST): {
            return {
                ...state,
                lists: action.data.categoryList,
            };
        }

        case success(CATE_UPDATE): {
            return {
                ...state,
                lists: state.lists.map(item => item.id === action.data.id ? action.data : item)
            };
        }

        case success(CATE_DELETE): {
            alert(action.data.message);
            return {
                ...state,
                lists: state.lists.filter(item => item.id !== action.data.id)
            };
        }

        case error(CATE_DELETE): {
            alert(action.error.data.message);
            return {
                ...state,
            };
        }

        default:
            return {
                ...state,
            };
    }
}

여기까지 너무 쓸데없고 지루한 내용을 봐주셔서 감사합니다.
고쳐야할 부분에 대해서 말씀해주시면 감사하겠습니다.
이상입니다.

profile
다양하지만 공부할 것이 많은 개발자입니다.

2개의 댓글

comment-user-thumbnail
2020년 5월 28일

좋은 자료 감사합니다

1개의 답글