MyBistro–FrontEnd Configuration
前端是以React 開發的App
啟始一個新的react專案
可以使用npm 內建的指令:npx,
(是一種CLI,要在命令列執行)
新增工作目錄:ex:D:\working\react-my-app
$>npx create-react-app my-app
就可以創建React app的初使開發環境
快速安裝所有需要的套件:
update package.json
"dependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@mui/icons-material": "^5.0.3",
"@mui/material": "^5.0.3",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.2.7",
"@testing-library/user-event": "^12.8.3",
"axios": "^0.24.0",
"formik": "^2.2.9",
"history": "^5.0.1",
"node-sass": "^6.0.1",
"react": "^17.0.2",
"react-device-detect": "^2.0.1",
"react-dom": "^17.0.2",
"react-perfect-scrollbar": "^1.5.8",
"react-redux": "^7.2.5",
"react-router-dom": "^6.0.0-beta.6",
"react-scripts": "4.0.3",
"web-vitals": "^1.1.2",
"yup": "^0.32.11"
},
然後執行:
$>npm install
整合Material UI(MUI)
為何採用MUI,因為最多人用,Components也滿夠用,也算漂亮。
安裝需要的套件:
$>npm install @mui/material @emotion/react @emotion/styled //For material UI
$>npm install @mui/icons-material //For material UI's icons
注意MUI V5.0以後,style的語法和V4以前改滿多的,makeStyles已經不用了
const useStyles = makeStyles((theme) => ({
...
}
}));
所以想要參考MUI template的範例,都要改成如下的新語法。
const Nav = styled('nav')(({ theme }) => ({
[theme.breakpoints.up('md')]: {
width: drawerWidth,
flexShrink: 0
},
}));
Theme
- 新增Theme
ex:/src/themes/botanical.js
import { createTheme } from '@mui/material/styles';
//botanical theme
const botanical = createTheme({
palette: {
//mode: 'dark',
primary: {
main: '#6e8c75',
light:'#ebfff6',
dark: '#415245',
},
secondary: {
main: '#787d7a',
},
warning: {
main: '#d6922d',
},
info: {
main: '#37607f',
},
success: {
main: '#4b5c6b',
light: '#9bb3c7',
dark: '#39444d',
},
background: {
paper: '#ced9d9',
default: '#f2ffff',
},
},
});
export default botanical;
- 新增ThemeProvider
ex: /src/themes/CustomThemeProvider.js
import React, { createContext, useState } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { useSelector } from 'react-redux';
//predefined themes
import LightTheme from './LightTheme';
import botanical from './botanical';
export const CustomThemeContext = createContext({
currentTheme: 'light',
setTheme: null
});
//mui default theme
const mui = createTheme();
const dark = createTheme({
palette: {
mode: 'dark',
},
});
const CustomThemeProvider = props => {
const { children } = props;
const customization = useSelector((state) => state.customization);
const light = LightTheme(customization);
const themes = {
light,
mui,
botanical,
dark,
}
const getTheme = (theme) => {
return themes[theme];
}
// Get current theme from localStorage
let currentTheme = localStorage.getItem('appTheme') ?? 'light';
currentTheme = (currentTheme === "null") ? 'light' : currentTheme;
// State to hold selected theme
const [themeName, _setThemeName] = useState(currentTheme);
// Retrieve theme object by theme name
const theme = getTheme(themeName);
console.log("themeName=" + themeName + ",theme=" + theme);
// Wrap _setThemeName to store new theme names in localStorage
const setThemeName = name => {
localStorage.setItem('appTheme', name);
_setThemeName(name);
};
const contextValue = {
currentTheme: themeName,
setTheme: setThemeName
};
return (
<CustomThemeContext.Provider value={contextValue}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</CustomThemeContext.Provider>
);
};
export default CustomThemeProvider;
- 修改根目錄下的:App.js,
add <StyledEngineProvider>,<CustomThemeProvider>
import { StyledEngineProvider, CssBaseline } from '@mui/material';
import CustomThemeProvider from 'themes/CustomThemeProvider';
...
function App() {
...
return (
<StyledEngineProvider injectFirst>
<CustomThemeProvider>
<CssBaseline />
<Routes isLoggedIn={isLoggedIn} />
</CustomThemeProvider>
</StyledEngineProvider>
);
);
整合React router
React App屬於SinglePageApp(SPA),所有頁面其實都是利用JavaScript動態繪製(Render)出來的,
但要模擬像是一般網頁的操作,就要利用router在網址上產生URL,讓人有一種頁面切換的感覺。
頁面的Component,及所套用的Layout也可以定義在這裡。
安裝需要的套件:
$>npm install react-router-dom
整合方法:
修改根目錄下的:index.js,add <BrowserRouter>
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
修改根目錄下的:App.js,add <Routes>
// routing
import Routes from './routes';
...
function App() {
...
return (
<StyledEngineProvider injectFirst>
<CustomThemeProvider>
<CssBaseline />
<Routes isLoggedIn={isLoggedIn} />
</CustomThemeProvider>
</StyledEngineProvider>
);
);
新增 /routes/index.js
使用React Route提供的useRoutes Hook
將App中會使用到的頁面URL加到此檔。
import { useRoutes } from 'react-router-dom';
...
// ===========================|| MAIN ROUTING ||=========================== //
const AuthRoute = {
path: '/',
element: <MiniLayout />,
children: [
{
path: '/',
element: <Login />
},
{
path: '/login',
element: <Login />
},
...
]
};
const MainRoutes = (isLoggedIn) => {
return {
path: '/bistro',
element: (isLoggedIn) ? <MainLayout /> : <Login />,
children: [
{
path: '/bistro',
element: <Home />
},
{
path: '/bistro/page1',
element: <Page1 />
},
...
]
};
}
// ===========================|| ROUTING RENDER ||=========================== //
export default function ThemeRoutes({ isLoggedIn }) {
return useRoutes([AuthRoute, MainRoutes(isLoggedIn),]);
}
整合React Redux
安裝需要的套件:
$>npm install react-redux
整合方法:
- 新增/store/actions.js
// action - customization reducer
export const SET_MENU = '@customization/SET_MENU';
export const MENU_OPEN = '@customization/MENU_OPEN';
export const SET_FONT_FAMILY = '@customization/SET_FONT_FAMILY';
export const SET_BORDER_RADIUS = '@customization/SET_BORDER_RADIUS';
// action - auth reducer
export const REGISTER_SUCCESS = "REGISTER_SUCCESS";
export const REGISTER_FAIL = "REGISTER_FAIL";
export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
export const LOGIN_FAIL = "LOGIN_FAIL";
export const LOGOUT = "LOGOUT";
// action - error Reducer
export const SET_ERROR = "SET_ERROR";
export const HIDE_ERROR = "HIDE_ERROR";
- 新增 Reducer
ex:/store/authReducer.js
...
const user = JSON.parse(localStorage.getItem("user"));
const initialState = user
? { isLoggedIn: true, user: { username: user.username, email: user.email, theme: user.theme } }
: { isLoggedIn: false, user: {} };
const authReducer = (state = initialState, action) => {
const { type, user } = action;
switch (type) {
case REGISTER_SUCCESS:
return {
...state,
isLoggedIn: false,
};
case REGISTER_FAIL:
return {
...state,
isLoggedIn: false,
};
...
default:
return state;
}
}
export default authReducer;
- 新增 /store/index.js
import { createStore } from 'redux';
import reducer from './reducer';
// ===========================|| REDUX - MAIN STORE ||=========================== //
const store = createStore(
reducer
);
export default store;
- 修改根目錄下的:index.js,add <Provider>
import { Provider } from 'react-redux';
// project imports
import store from './store';
...
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
- 修改根目錄下的:App.js
使用Redux useSelector Hook,來存取store中的state。
import { useSelector } from 'react-redux';
...
function App() {
const isLoggedIn = useSelector((state) => state.auth.isLoggedIn);
...
}
- store中state的值是在別的Component呼叫dispatch()寫入的。
ex: state.auth.isLoggedIn 是登入/src/services/auth.service.js寫入的。
...
export const login = (email, password) => {
return AuthApi.login(email, password).then(
() => {
return getLoginUser();
},
(error) => {
...
store.dispatch({
type: LOGIN_FAIL,
});
...
return Promise.reject(message);
}
);
};
export const getLoginUser = () => {
request("get", "/bistro/user/").then((res) => {
if (res.data) {
let user = res.data;
//console.log("getLoginUser-> user=" + user.username);
store.dispatch({
type: LOGIN_SUCCESS,
user: user,
});
return Promise.resolve();
}
}).catch(error => {
...
store.dispatch({
type: LOGIN_FAIL,
});
...
return Promise.reject(message);
});
}
- 也可以用Redux:useDispatch Hook 呼叫dispatch()。
import { useSelector, useDispatch } from 'react-redux';
...
const dispatch = useDispatch();
const handleLeftDrawerToggle = () => {
dispatch({ type: SET_MENU, opened: !leftDrawerOpened });
};
呼叫後端API
安裝需要的套件:
$>npm install axios
整合方法:
根目錄下新增 .env file
#For DEV USE.............................................
REACT_APP_API_ROOT_URL=http://127.0.0.1:8000/api
#For PROD USE ...........................................
#REACT_APP_API_ROOT_URL=/api
- 新增/src/services/request.js
import axios from "axios";
const API_ROOT_URL = process.env.REACT_APP_API_ROOT_URL;
// baseURL是Backend API ROOT URL,之後只要填相對路徑
const instance = axios.create({
baseURL: API_ROOT_URL,
headers: {
'Content-Type': 'application/json',
'accept': 'application/json'
},
timeout: 5000
});
export default function request(method, url, data = null, config) {
method = method.toLowerCase();
...
switch (method) {
case "post":
return instance
.post(url, data, config)
.catch(error => {
return Promise.reject(new ApiError(error));
});
case "get":
return instance
.get(url, { params: data }).catch(error => {
return Promise.reject(new ApiError(error));
});
case "delete":
return instance
.delete(url, { params: data })
.catch(error => {
return Promise.reject(new ApiError(error));
});
case "put":
return instance
.put(url, data)
.catch(error => {
return Promise.reject(new ApiError(error));
});
case "patch":
return instance
.patch(url, data)
.catch(error => {
return Promise.reject(new ApiError(error));
});
default:
console.log(`未知的 method: ${method}`);
return Promise.reject(new ApiError(`未知的 method: ${method}`));
}
- 透過request呼叫後端API
ex:/src/services/bistro.service.js
import request from "./request";
...
export const getBistroMenus = (params) => {
return request("get", "/bistro/menus/", params)
.catch((error) => {
...
return Promise.reject(message);
});
;
};
整合JWT Anthentication
- 登入時call 後端API:(obtain token)取得token(包含access token和reflesh token),並將token存到localStorage的user物件。
登出時call 後端API:(logout)將reflesh token加入BlackList使token失效。
新增/src/services/AuthApi.js
import axios from "axios";
import request from "./request";
class AuthApi {
constructor() {
let API_ROOT_URL = process.env.REACT_APP_API_ROOT_URL;
this.axioInstance = axios.create({
baseURL: API_ROOT_URL,
headers: {
'Content-Type': 'application/json',
'accept': 'application/json'
},
timeout: 5000
});
}
login(email, password) {
return this.axioInstance.post("/token/obtain/", {
email,
password
})
.then(response => {
if (response.data.access) {
localStorage.setItem("user", JSON.stringify(response.data));
}
return response.data;
});
}
logout() {
let user = JSON.parse(localStorage.getItem('user'));
if (user) {
let refresh_token = user.refresh;
//logout need authentication ,so must use request to do this .....
return request("post", "/logout/", { refresh_token: refresh_token })
}
}
register(username, email, password) {
return this.axioInstance.post("/signup/", {
username,
email,
password
});
}
}
export default new AuthApi();
- 修改/src/services/request.js,
access token 加入headers[‘Authorization’]部分,才能通過驗證。
並利用instance.interceptors.response.use自動refresh token。
...
instance.interceptors.response.use(
response => response,
error => {
const originalRequest = error.config;
// Prevent infinite loops
if (error.response && error.response.status === 401 && originalRequest.url === API_ROOT_URL + '/token/refresh/') {
return Promise.reject(new ApiError(error));
}
if (error.response && error.response.status === 401 && error.response.statusText === "Unauthorized") {
let user = JSON.parse(localStorage.getItem('user'));
let refresh_token = (user && user.refresh) ? user.refresh : '';
//Check whether refresh_token is expired
const tokenParts = JSON.parse(atob(refresh_token.split('.')[1]));
// exp date in token is expressed in seconds, while now() returns milliseconds:
const now = Math.ceil(Date.now() / 1000);
if (tokenParts.exp > now) {
return instance
.post('/token/refresh/', { refresh: refresh_token })
.then((response) => {
localStorage.setItem("user", JSON.stringify(response.data));
instance.defaults.headers['Authorization'] = "JWT " + response.data.access;
originalRequest.headers['Authorization'] = "JWT " + response.data.access;
return instance(originalRequest);
})
.catch(err => {
console.log("refreshtoken時發生錯誤:");
console.log(err)
return Promise.reject(new ApiError("refreshtoken 失敗!"));
});
} else {
console.log("Refresh token is expired", tokenParts.exp, now);
return Promise.reject(new ApiError("refreshtoken 失敗!,token已逾剘"));
}
}
return Promise.reject(new ApiError(error));
}
);
export default function request(method, url, data = null, config) {
method = method.toLowerCase();
let user = JSON.parse(localStorage.getItem('user'));
let access_token = (user && user.access) ? user.access : '';
instance.defaults.headers['Authorization'] = "JWT " + access_token;
...
}