React 企业级实践指南(四)
十一、使用 RTK 在 FullCalendar 上创建、删除和更新事件
在上一章中,我们设置了 Redux 工具包,并学习了如何将异步动作分派到存储中。我们还开始构建我们的日历组件。
我们将继续我们停止的地方——使用 Redux 工具包 在日历组件上创建、删除和更新事件。为此,我们将使用 FullCalendar 库添加用户表单来创建、删除和更新事件。
为了让您先睹为快应用的最终外观,图 11-1 和 11-2 ,以及清单 11-1 ,在本章末尾提供了我们应用的用户界面。
图 11-2
第十一章末尾添加事件截图
图 11-1
第十一章末尾完整日历截图
安装时刻和完整日历
让我们开始安装一些第三方库,我们将需要这些库来构建完整的日历组件。
打开您的终端并安装下面的包,如清单 11-1 所示。
npm i moment @date-io/moment@1 @fullcalendar/core
npm i @fullcalendar/daygrid @fullcalendar/interaction
npm i @fullcalendar/list @fullcalendar/react
npm i @fullcalendar/timegrid @fullcalendar/timeline
Listing 11-1Importing Additional Libraries
让我们快速回顾一下我们已经安装的每个库:
moment.js :一个 JavaScript 日期库,用于解析、验证、操作和格式化日期。关于这个著名的库的项目状态的说明:即使它现在处于维护代码中——这意味着创建者不打算向库添加更多的功能——补丁和错误修复将继续。它拥有超过 1800 万次下载,截至 2021 年初仍处于上升趋势。
还有很多 JavaScript 库可以用来操作日期。但是学习 moment.js 同样可以帮助您理解其他 React 应用,因为许多 React 库可能都在使用这个流行的日期库。
如果你去npmjs.org找这些库,你会看到它们的如下定义:
@ date-io/moment:date-io-mono repo 的一部分,包含了 moment 的统一接口。我们将需要版本 1。
@fullcalendar/core :提供核心功能,包括日历类。
@fullcalendar/daygrid :在月视图或日网格视图上显示事件。
@ full calendar/interaction:提供事件拖放、调整大小、日期点击和可选动作的功能。
@fullcalendar/list :以项目列表的形式查看您的活动。
@fullcalendar/react :是个连接器。它告诉核心 FullCalendar 包开始使用 react 虚拟 DOM Node 而不是它通常使用的 Preact Node 进行渲染,将 FullCalendar 转换为“真正的”React 组件。
@fullcalendar/timegrid :在时间段网格上显示你的事件。
@fullcalendar/timeline :在水平时间轴上显示事件(无资源)。
成功导入所有库和模块后,让我们更新根组件。
更新根组件
首先,让我们在index.tsx,中添加这些模块,如清单 11-2 所示。
import MomentUtils from '@date-io/moment';
import {MuiPickersUtilsProvider} from '@material-ui/pickers';
Listing 11-2Importing Modules in the Root Component index.tsx
在同一个根文件中,我们将使用MuiPickersUtils包装从SnackbarProvide r 开始的所有内容,如清单 11-3 所示。
export function App() {
return (
<BrowserRouter>
/*required props called utils and we're passing the MomentUtils */
<MuiPickersUtilsProvider utils={MomentUtils}>
<SnackbarProvider dense maxSnack={3}>
<Helmet
titleTemplate="%s - React Boilerplate"
defaultTitle="React Boilerplate"
>
<meta name="description" content="A React Boilerplate application" />
</Helmet>
<MainLayout>
<Routes />
</MainLayout>
<GlobalStyle />
</SnackbarProvider>
</MuiPickersUtilsProvider>
</BrowserRouter>
);
}
Listing 11-3Adding MuiPickersUtilsProvider to the Root Component
更新日历切片
接下来让我们看看组件calendarSlice .我们将添加新的非异步或同步动作以及异步动作。
创建事件操作
我们将首先创建事件动作,如清单 11-4 所示。
createEvent(state, action: PayloadAction<EventType>) {
state.events.push(action.payload);
},
selectEvent(state, action: PayloadAction<string>) {
state.isModalOpen = true;
state.selectedEventId = action.payload;
},
updateEvent(state, action: PayloadAction<EventType>) {
const index = state.events.findIndex(e => e.id === action.payload.id);
state.events[index] = action.payload;
},
deleteEvent(state, action: PayloadAction<string>) {
state.events = state.events.filter(e => e.id !== action.payload);
},
/*{start: number; end: number} - this is the shape of the model that we can define here right away, although we can also write it separately in the models' folder. */
selectRange(state, action: PayloadAction<{ start: number; end: number }>) {
/*deconstructing the payload */
const { start, end } = action.payload;
state.isModalOpen = true;
state.selectedRange = {
start,
end,
};
},
openModal(state) {
state.isModalOpen = true;
},
closeModal(state) {
state.isModalOpen = false;
state.selectedEventId = null;
state.selectedRange = null;
},
Listing 11-4Creating the Event Actions in calendarSlice.ts
让我们回顾一下清单 11-4 中发生了什么。
createEvent:createEvent的参数是EventType的一个对象,为了产生一个新的事件,我们将把它放入一个现有的数组中。这将在EvenType的数组中生成一个新对象。
获取一个字符串,我们在这里修改两个状态。
updateEvent:获取一个事件类型,然后我们需要得到我们正在经过的这个EventType的位置(findIndex)。这是更新现有对象。
我们正在传递一个字符串,然后我们正在做一个过滤器。过滤器返回一个没有我们选择的 id(字符串)的新数组。
selectRange:接受一个有开始和结束的对象——都是 number 类型。
openModal:不需要任何参数;它只是将状态更新为 true。
closeModel:不需要任何参数;它只是将状态更新回 false,selectedEventId,和selectedRange更新回 null。
接下来,我们将在同一个文件 calendarSlice.ts 中导出一些非异步动作,如清单 11-5 所示
添加非异步操作
/* Export these actions so components can use them. Non-asynchronous actions. HTTP client is not needed. */
export const selectEvent = (id?: string): AppThunk => dispatch => {
dispatch(slice.actions.selectEvent(id));
};
export const selectRange = (start: Date, end: Date): AppThunk => dispatch => {
dispatch(
slice.actions.selectRange({
start: start.getTime(),
end: end.getTime(),
}),
);
};
export const openModal = (): AppThunk => dispatch => {
dispatch(slice.actions.openModal());
};
export const closeModal = (): AppThunk => dispatch => {
dispatch(slice.actions.closeModal());
};
Listing 11-5Adding non-async actions in calendarSlice.ts
同样,让我们看看清单 11-5 中发生了什么。
selectEvent:这是一个高阶函数,它接受一个 id 并返回一个 dispatch 以供执行。
id 来自于selectEvent.。函数selectEvent的名称与调度selectEvent相同,以避免在导入组件时产生混淆。
selectRange:该功能也与动作selectRange.同名
让我们继续在 calendarSlice.ts 中添加我们的异步事件动作,如清单 11-6 所示。
export const createEvent = (event: EventType): AppThunk => async dispatch => {
/* data – we deconstructed the response object */
const { data } = await axios.post<EventType>(EndPoints.events, event);
dispatch(slice.actions.createEvent(data));
};
export const updateEvent = (update: EventType): AppThunk => async dispatch => {
/*updating the state in the database */
const { data } = await axios.put<EventType>(
`${EndPoints.events}/${update.id}`,
update,
);
/*updating the state in the UI */
dispatch(slice.actions.updateEvent(data));
};
export const deleteEvent = (id: string): AppThunk => async dispatch => {
/*deleting from the database */
await axios.delete(`${EndPoints.events}/${id}`);
/*deleting it from the UI */
dispatch(slice.actions.deleteEvent(id));
};
Listing 11-6Adding Asynchronous Event Actions in calendarSlice.ts
在清单 11-6 中,我们添加了另外三个事件——create event、updateEvent 和 deleteEvent:
createEvent:我们正在导出和使用createEvent函数,它接受一个EventType对象。我们异步运行dispatch,因为我们等待axios.post,,它从 Endpoints.events 中获取EventType,。必需的 body 参数是一个event。
我们解构了响应对象,因为我们只需要一个属性,即data。
我们在createEvent动作中传递data并分派它。
updateEvent:它也更新EventType,异步运行dispatch,等待axios.put,,并返回一个EventType。
在这里,我们只需要一个 id。在运行异步调度并将其从数据库中删除后,我们还将它从 UI 中过滤出来。
FOR YOUR ACTIVITY
如果您注意到,在 createEven t、 updateEvent 和 deleteEvent 中没有 try-catch 块。
例如,在deleteEvent中,如果没有 try-catch 并且axios.delete失败,调度将继续运行并删除 UI 中的对象,即使数据库中的对象没有被删除。所以现在,UI 中的状态和数据库中的状态会不匹配。
对于您的活动,在三个异步事件中实现一个 try-catch。看看我们对getEvents做了什么。不要忘记实现 setLoading 和 setError 操作。
完成活动后,我们将更新CalendarView的index.tsx。
更新日历视图
我们将从 Material-UI Core 导入页面组件模板以及Container和makeStyles模块,如清单 11-7 所示。
import { Container, makeStyles} from '@material-ui/core';
import Page from 'app/components/page';
Listing 11-7Importing Named Components in CalendarView
然后让我们用新导入的页面和容器替换 return。我们还将在高度和填充上添加一些样式,如清单 11-8 所示。
const CalendarView = () => {
const classes = useStyles();
const dispatch = useDispatch();
/* destructuring it because we only need the events, loading, error */
const { events, loading, error } = useSelector(
(state: RootState) => state.calendar,
);
useEffect(() => {
dispatch(getEvents());
}, []);
return (
<Page className={classes.root} title="Calendar">
<Container maxWidth={false}>
<h1>Calendar Works!</h1>
{loading && <h2>Loading... </h2>}
{error && <h2>Something happened </h2>}
<ul>
{events?.map(e => (
<li key={e.id}>{e.title} </li>
))}
</ul>
</Container>
</Page>
);
};
export default CalendarView;
const useStyles = makeStyles(theme => ({
root: {
minHeight: '100%',
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
},
}));
Listing 11-8Updating the Styling of the CalendarView Component
之后,让我们为CalendarView添加一个 Header 组件。创建一个新文件Header.tsx:
calendar ➤ CalendarView ➤ Header.tsx
创建标题组件
在清单 11-9 中,我们导入了Header.tsx.的命名组件
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import clsx from 'clsx';
import { PlusCircle as PlusCircleIcon } from 'react-feather';
import NavigateNextIcon from '@material-ui/icons/NavigateNext';
import {
Button,
Breadcrumbs,
Grid,
Link,
SvgIcon,
Typography,
makeStyles,
Box,
} from '@material-ui/core';
Listing 11-9Importing the Named Components in Header.tsx
我们将立即使用我们的 Header 函数组件来跟进,如清单 11-10 所示。
/*nullable className string and nullable onAddClick function
*/
type Props = {
className?: string;
onAddClick?: () => void;
};
/* using the Props here and ...rest operator */
const Header = ({ className, onAddClick, ...rest }: Props) => {
const classes = useStyles();
return (
<Grid
className={clsx(classes.root, className)}
container
justify="space-between"
spacing={3}
{...rest}
>
<Grid item>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
>
<Link
variant="body1"
color="inherit"
to="/app"
component={RouterLink}
>
Dashboard
</Link>
<Box>
<Typography variant="body1" color="inherit">
Calendar
</Typography>
</Box>
</Breadcrumbs>
<Typography variant="h4" color="textPrimary">
Here's what you planned
</Typography>
</Grid>
<Grid item>
<Button
color="primary"
variant="contained"
onClick={onAddClick}
className={classes.action}
startIcon={
<SvgIcon fontSize="small">
<PlusCircleIcon />
</SvgIcon>
}
>
New Event
</Button>
</Grid>
</Grid>
);
};
Listing 11-10Creating the Header Component
最后是 Material-UI Core 的makeStyles中的useStyles,如清单 11-11 所示。
const useStyles = makeStyles(theme => ({
root: {},
action: {
marginBottom: theme.spacing(1),
'& + &': {
marginLeft: theme.spacing(1),
},
},
}));
export default Header;
Listing 11-11Adding the Styling Margin for the Header Component
接下来,我们将在 CalendarView 的index.tsx中使用新创建的 Header 组件,如清单 11-12 所示。
import Header from './Header';
...
return (
<Page className={classes.root} title="Calendar">
<Container maxWidth={false}>
<Header />
<h1>Calendar Works!</h1>
Listing 11-12Using the Header Component in the index.tsx of the CalendarView
刷新浏览器,您应该会看到相同的内容。
图 11-3
使用 index.tsx 中的 Header 组件后的 UI 截图
所以婴儿再次迈步。我们可以看到它正在工作。我们现在可以继续添加编辑事件表单。在CalendarView文件夹中,创建另一个组件并将其命名为AddEditEventForm.tsx。
使用 Formik 创建添加编辑事件表单
在AddEditEventForm,中,我们将使用moment.js, Formik ,和yup validation。在这种情况下,我们不需要为 Yup 验证创建单独的文件。
打开文件AddEditEventForm并添加以下命名组件,如清单 11-13 所示。
import React from 'react';
import moment from 'moment';
import * as Yup from 'yup';
import { Formik } from 'formik';
import { useSnackbar } from 'notistack';
import { DateTimePicker } from '@material-ui/pickers';
import { Trash as TrashIcon } from 'react-feather';
import { useDispatch } from 'react-redux';
import {
Box,
Button,
Divider,
FormControlLabel,
FormHelperText,
IconButton,
makeStyles,
SvgIcon,
Switch,
TextField,
Typography,
} from '@material-ui/core';
/*the async actions we created earlier in the calendarSlice */
import {
createEvent,
deleteEvent,
updateEvent,
} from 'features/calendar/calendarSlice';
import { EventType } from 'models/calendar-type';
Listing 11-13Adding the Named Components in AddEditEventForm
Material-UI 中新增加的是DateTimePicker。如果你去看看 Material-UI 网站,你会看到很多可以重用的日期时间选择器组件,你不必从头开始创建自己的组件。
接下来,让我们为我们的组件 AddEditEventForm 编写类型定义,如清单 11-14 所示。
/* the ? indicates it is a nullable type */
type Props = {
event?: EventType;
onAddComplete?: () => void;
onCancel?: () => void;
onDeleteComplete?: () => void;
onEditComplete?: () => void;
range?: { start: number; end: number };
};
Listing 11-14Creating the Type or Shape of the AddEditEventForm
在清单 11-14 中,我们有Props,并且我们在AddEditEventForm组件中使用它,如清单 11-15 所示。
const AddEditEventForm = ({
event,
onAddComplete,
onCancel,
onDeleteComplete,
onEditComplete,
range,
}: Props) => {
const classes = useStyles();
const dispatch = useDispatch();
const { enqueueSnackbar } = useSnackbar();
/*event is coming from the parent of the AddEditEventForm */
const isCreating = !event;
const handleDelete = async (): Promise<void> => {
try {
await dispatch(deleteEvent(event?.id));
onDeleteComplete();
} catch (err) {
console.error(err);
}
};
Listing 11-15Creating the AddEditEventForm Component
正如您在清单 11-15 中注意到的,AddEditEventForm使用 Props,以及 dispatch Snackbar,而handleDelete是一个异步 fun c 操作,它调度deleteEvent动作并传递 event.id .
当然,我们还没有完成。接下来,让我们使用 Formik 创建我们的表单。因为我们使用的是 TypeScript,所以必须初始化以下三个 Formik 属性:initialValues、validationSchema 和 onSubmit。
我们将首先从清单 11-16 所示的initialValues和使用 Yup 的 ValidationSchema 开始。
return (
<Formik
initialValues={getInitialValues(event, range)}
validationSchema={Yup.object().shape({
allDay: Yup.bool(),
description: Yup.string().max(5000),
end: Yup.date().when(
'start',
(start: Date, schema: any) =>
start &&
schema.min(start, 'End date must be later than start date'),
),
start: Yup.date(),
title: Yup.string().max(255).required('Title is required'),
})}
Listing 11-16Creating the Two Formik Props: initialValues and validationSchema
让我们看看我们在清单 11-16 中做了什么:
initialValues:清单 11-17 中增加了getInitialValues。
通常,验证模式保存在另一个文件中,特别是如果它很长的话,但是在这个例子中,我们已经在这里编写了它,因为它只是一个小的验证对象。
简而言之,通常将 initialValues 和 validationSchema 放在一个单独的文件中,只在需要它们的组件中使用它们。
好,接下来,让我们添加另一个必需的 Formik 属性: onSubmit.
onSubmit={async (
/* where the input values (i.e. from TextField) are being combined. */
values,
/* Formik helper deconstructed.*/
{ resetForm, setErrors, setStatus, setSubmitting },
) => {
try {
const data = {
allDay: values.allDay,
description: values.description,
end: values.end,
start: values.start,
title: values.title,
id: '',
};
if (event) {
data.id = event.id;
await dispatch(updateEvent(data));
} else {
await dispatch(createEvent(data));
}
resetForm();
setStatus({ success: true });
setSubmitting(false);
enqueueSnackbar('Calendar updated', {
variant: 'success',
});
if (isCreating) {
onAddComplete();
} else {
onEditComplete();
}
} catch (err) {
console.error(err);
setStatus({ success: false });
setErrors({ submit: err.message });
setSubmitting(false);
}
}}
>
/*deconstructing here the Formik props */
{({
errors,
handleBlur,
handleChange,
handleSubmit,
isSubmitting,
setFieldTouched,
setFieldValue,
touched,
values,
}) => (
/*this will trigger the onSubmit of Formik */
<form onSubmit={handleSubmit}>
<Box p={3}>
<Typography
align="center"
gutterBottom
variant="h3"
color="textPrimary"
>
{isCreating ? 'Add Event' : 'Edit Event'}
</Typography>
</Box>
/*TextField -- make sure to map everything to title */
<Box p={3}>
<TextField
error={Boolean(touched.title && errors.title)}
fullWidth
helperText={touched.title && errors.title}
label="Title"
name="title"
onBlur={handleBlur}
onChange={handleChange}
value={values.title}
variant="outlined"
/>
<Box mt={2}>
/*TextFields -- make sure to map everything to description */
<TextField
error={Boolean(touched.description && errors.description)}
fullWidth
helperText={touched.description && errors.description}
label="Description"
name="description"
onBlur={handleBlur}
onChange={handleChange}
value={values.description}
variant="outlined"
/>
</Box>
/*Form Control Label */
<Box mt={2}>
<FormControlLabel
control={
<Switch
checked={values.allDay}
name="allDay"
onChange={handleChange}
/>
}
label="All day"
/>
</Box>
/*DateTimePicker for Start date.
onChange - we're using the setFieldValue because the onChange emits a date, not an event.
*/
<Box mt={2}>
<DateTimePicker
fullWidth
inputVariant="outlined"
label="Start date"
name="start"
onClick={() => setFieldTouched('end')} // install the @date-io/moment@1.x
onChange={date => setFieldValue('start', date)} // and use it in MuiPickersUtilsProvider
value={values.start}
/>
</Box>
/*DateTimePicker for End date*/
<Box mt={2}>
<DateTimePicker
fullWidth
inputVariant="outlined"
label="End date"
name="end"
onClick={() => setFieldTouched('end')}
onChange={date => setFieldValue('end', date)}
value={values.end}
/>
</Box>
/*FormHelperText - to show an error message */
{Boolean(touched.end && errors.end) && (
<Box mt={2}>
<FormHelperText error>{errors.end}</FormHelperText>
</Box>
)}
</Box>
<Divider />
<Box p={2} display="flex" alignItems="center">
{!isCreating && (
<IconButton onClick={() => handleDelete()}>
<SvgIcon>
<TrashIcon />
</SvgIcon>
</IconButton>
)}
<Box flexGrow={1} />
<Button onClick={onCancel}>Cancel</Button>
<Button
variant="contained"
type="submit"
disabled={isSubmitting} ➤ /* this is to prevent double clicking */
color="primary"
className={classes.confirmButton}
>
Confirm
</Button>
</Box>
</form>
)}
</Formik>
);
};
export default AddEditEventForm;
Listing 11-17Creating the onSubmit on the AddEditEventForm
这里是列表 11-18 ,Formik 属性初始值的getInitialValues。
const getInitialValues = (
event?: EventType,
range?: { start: number; end: number },
) => {
if (event) {
const defaultEvent = {
allDay: false,
color: '',
description: '',
end: moment().add(30, 'minutes').toDate(),
start: moment().toDate(),
title: '',
submit: null,
};
return { ...defaultEvent, event };
}
if (range) {
const defaultEvent = {
allDay: false,
color: '',
description: '',
end: new Date(range.end),
start: new Date(range.start),
title: '',
submit: null,
};
return { ...defaultEvent, event };
}
return {
allDay: false,
color: '',
description: '',
end: moment().add(30, 'minutes').toDate(),
start: moment().toDate(),
title: '',
submit: null,
};
};
Listing 11-18Creating the getInitialValues of Formik
在清单 11-18 中,我们有getInitialValues——一个接受事件和取值范围的函数。该函数显示一个默认事件或一系列事件。
在创建了 Formik 属性,之后,我们回到CalendarView的index.tsx来做一些更新。
更新日历视图
让我们在calendarSlice,中导入closeModal和openModal,如清单 11-19 所示。
import {
getEvents,
openModal,
closeModal,
} from 'features/calendar/calendarSlice';
Listing 11-19Importing Modules in calendarSlice
在 CalendarView 的同一个索引文件中,我们使用了下面的:useSelector.中的isModalOpen和selectedRange我们还将创建一个handleAddClick和一个handleModalClose,,如清单 11-20 所示。
const { events, loading, error, isModalOpen, selectedRange } = useSelector(
(state: RootState) => state.calendar,
);
useEffect(() => {
dispatch(getEvents());
}, []);
const handleAddClick = (): void => {
dispatch(openModal());
};
const handleModalClose = (): void => {
dispatch(closeModal());
};
Listing 11-20Adding States and Handles in calendarSlice
更新标题
所以现在我们可以用handleClick函数更新Header,如清单 11-21 所示。
<Page className={classes.root} title="Calendar">
<Container maxWidth={false}>
<Header onAddClick={handleAddClick} />
<h1>Calendar Works!</h1>
Listing 11-21Using the handleAddClick in the Header
更新日历视图
让我们在 CalendarView 的 index.tsx 中添加样式组件。我们将从 Material-UI 核心导入这些样式组件,如清单 11-22 所示。
import {
Container,
makeStyles,
Dialog, //a modal popup
Paper, //in Material Design, the physical properties of paper are translated to the screen.
useMediaQuery, // a CSS media query hook for React. Detects when its media queries change
} from '@material-ui/core';
Listing 11-22Adding Styling Components to the index.tsx of CalendarView
在同一个索引文件中,我们需要一个小函数来选择一个事件。
我们还将从models/calendar-type和AddEditEventForm中导入EventType和ViewType组件,如清单 11-23 所示。
import Header from './Header';
import { EventType, ViewType } from 'models/calendar-type';
import AddEditEventForm from './AddEditEventForm';
...
export default CalendarView;
const selectedEventSelector = (state: RootState): EventType | null => {
const { events, selectedEventId } = state.calendar;
if (selectedEventId) {
return events?.find(_event => _event.id === selectedEventId);
} else {
return null;
}
};
const useStyles = makeStyles(theme => ({
...
Listing 11-23Creating an Event Selector in the index.tsx of CalendarView
在清单 11-23 中,我们有selectedEventSelector——一个带state、RootState和calendar的函数,我们从calendar传递的是变量events和selectedEventId.
现在我们将调用useSelector并传递selectedEventSelector,如清单 11-24 所示
const selectedEvent = useSelector(selectedEventSelector);
Listing 11-24Using the useSelector in the index.tsx of CalendarView
还是在同一个索引文件中,我们将在Container.中做一些重构
我们将用Dialog, isModalOpen,和AddEditEventForm替换当前的 h1 标签,如清单 11-25 所示。
<Container maxWidth={false}>
<Header onAddClick={handleAddClick} />
<Dialog
maxWidth="sm"
fullWidth
onClose={handleModalClose}
open={isModalOpen}
>
{isModalOpen && (
<AddEditEventForm
event={selectedEvent}
range={selectedRange}
onAddComplete={handleModalClose}
onCancel={handleModalClose}
onDeleteComplete={handleModalClose}
onEditComplete={handleModalClose}
/>
)}
</Dialog>
</Container>
Listing 11-25Adding Dialog and AddEditEventForm in index.tsx of CalendarView
在清单 11-25 中,我们有Dialog——一个材质-UI 模态组件——我们在这里定义大小并使用事件onClose和open对话框。还有isModalOpen,如果为真,显示 AddEditEventForm。在 AddEditEventForm 属性中,我们传递 selectedEvent、selectedRange 和 handleModalClose 。
检查 CalendarView 的用户界面
让我们看看它在 UI 中是如何工作的。刷新浏览器,打开 Chrome DevTools。单击新建事件。您应该会看到名为“添加事件”的弹出模式对话框。
图 11-4
浏览器中模式对话框添加事件的屏幕截图
尝试创建一个事件,然后单击确认按钮。你会在 Chrome DevTools 中看到数据事件被成功返回,如图 11-5 所示。
图 11-5
Chrome DevTools 中的事件请求截图
检查 Chrome 开发工具
另请注意,模式对话框会自动关闭。在 Chrome DevTools 中,检查标题,您会看到创建了状态代码 201。这意味着我们能够创建一个对象并将其保存在数据库中。
检查 Redux 开发工具
接下来,打开 Redux DevTools。确保选择顶部下拉箭头上的日历–React 样本。
Redux DevTools 记录分派的动作和存储的状态。我们可以通过应用的时间旅行调试功能在每个时间点检查应用的状态,而无需重新加载或重启应用。
图 11-6
Redux DevTools 中事件请求的屏幕截图
我们对观察差异很感兴趣,你可以看到状态从setLoading到createEvents再到closeModal等等。
您可以看到可以从存储中访问的事件或事件数组,还可以查找任何错误消息等。所有的动作都被记录下来,我们可以通过 Redux DevTools 的时间旅行调试功能来回放。
创建工具栏
我们将在CalendarView文件夹下创建一个工具栏组件Toolbar.tsx,。
首先,我们导入命名的组件,如清单 11-26 所示。
import React, { ElementType, ReactNode } from 'react';
import clsx from 'clsx';
import moment from 'moment';
import {
Button,
ButtonGroup,
Grid,
Hidden,
IconButton,
Tooltip,
Typography,
makeStyles,
} from '@material-ui/core';
import ViewConfigIcon from '@material-ui/icons/ViewComfyOutlined';
import ViewWeekIcon from '@material-ui/icons/ViewWeekOutlined';
import ViewDayIcon from '@material-ui/icons/ViewDayOutlined';
import ViewAgendaIcon from '@material-ui/icons/ViewAgendaOutlined';
import { ViewType } from 'models/calendar-type';
Listing 11-26Adding Named Components in Toolbar.tsx
在清单 11-26 中,我们从 Material-UI 核心导入了 moment 和标准样式模块。新的图标与材质界面图标不同。
我们还从模型/日历类型中导入了视图类型。
在同一个 Toolbar.tsx 文件中,我们将为模型Toolbar和ViewOption创建类型或模式,如清单 11-27 所示。
type ViewOption = {
label: string;
value: ViewType;
icon: ElementType;
};
type Props = {
children?: ReactNode;
className?: string;
date: Date;
/* the ? means it's a nullable void function
onDateNext?: () => void;
onDatePrev?: () => void;
onDateToday?: () => void;
onAddClick?: () => void;
/* takes a view object and returns nothing or void */
onViewChange?: (view: ViewType) => void;
view: ViewType;
};
Listing 11-27Creating the Type or Schema of Toolbar
在清单 11-27 中,我们有类型Props,,它有date和view作为必需的类型属性,而其余的都是可空类型。ViewOption型需要三个属性:label, value,和icon.我们一会儿会用到ViewPoint。我们现在正在这里准备。
因此,我们现在将使用我们在工具栏组件中定义的Props,如清单 11-28 所示。
const Toolbar = ({
className,
date,
onDateNext,
onDatePrev,
onDateToday,
onAddClick,
onViewChange,
view,
...rest // the rest parameter
}: Props) => {
const classes = useStyles();
Listing 11-28Using the Props in the Toolbar Component
在清单 11-28 中,您会注意到静止参数。这允许我们在函数中接受多个参数,并将它们作为一个数组。
Rest 参数可用于函数、箭头函数或类中。但在函数定义中,rest 参数必须出现在参数表的最后;否则,TypeScript 编译器将会报错并显示错误。
接下来,我们将在同一个工具栏组件文件中创建 return 语句。
我们把所有东西都放在一个Grid中,并添加了ButtonGroup.
我们使用moment,格式化date,我们还映射了viewOptions中的四个对象,如清单 11-29 所示,并返回Tooltip key title和IconButton。
return (
<Grid
className={clsx(classes.root, className)}
alignItems="center"
container
justify="space-between"
spacing={3}
{...rest}
>
<Grid item>
<ButtonGroup size="small">
<Button onClick={onDatePrev}>Prev</Button>
<Button onClick={onDateToday}>Today</Button>
<Button onClick={onDateNext}>Next</Button>
</ButtonGroup>
</Grid>
<Hidden smDown>
<Grid item>
<Typography variant="h3" color="textPrimary">
{moment(date).format('MMMM YYYY')}
</Typography>
</Grid>
<Grid item>
{viewOptions.map(viewOption => {
const Icon = viewOption.icon;
return (
<Tooltip key={viewOption.value} title={viewOption.label}>
<IconButton
color={viewOption.value === view ? 'primary' : 'default'}
onClick={() => {
if (onViewChange) {
onViewChange(viewOption.value);
}
}}
>
<Icon />
</IconButton>
</Tooltip>
);
})}
</Grid>
</Hidden>
</Grid>
);
};
export default Toolbar;
Listing 11-29Adding the Return Statement of the Toolbar Component
接下来,我们添加ViewOption和makeStyles组件,如清单 11-30 所示。
const viewOptions: ViewOption[] = [
{
label: 'Month',
value: 'dayGridMonth',
icon: ViewConfigIcon,
},
{
label: 'Week',
value: 'timeGridWeek',
icon: ViewWeekIcon,
},
{
label: 'Day',
value: 'timeGridDay',
icon: ViewDayIcon,
},
{
label: 'Agenda',
value: 'listWeek',
icon: ViewAgendaIcon,
},
];
const useStyles = makeStyles(() => ({
root: {},
}));
Listing 11-30Creating ViewOption and makeStyles components in Toolbar.tsx
又该更新 CalendarView 的 index.tsx 了。
设置日历视图的样式
我们将开始在 CalendarView 的 index.tsx 中添加新的样式组件,如清单 11-31 所示。
calendar: {
marginTop: theme.spacing(3),
padding: theme.spacing(2),
'& .fc-unthemed .fc-head': {},
'& .fc-unthemed .fc-body': {
backgroundColor: theme.palette.background.default,
},
'& .fc-unthemed .fc-row': {
borderColor: theme.palette.divider,
},
'& .fc-unthemed .fc-axis': {
...theme.typography.body2,
},
'& .fc-unthemed .fc-divider': {
borderColor: theme.palette.divider,
},
'& .fc-unthemed th': {
borderColor: theme.palette.divider,
},
'& .fc-unthemed td': {
borderColor: theme.palette.divider,
},
'& .fc-unthemed td.fc-today': {},
'& .fc-unthemed .fc-highlight': {},
'& .fc-unthemed .fc-event': {
backgroundColor: theme.palette.secondary.main,
color: theme.palette.secondary.contrastText,
borderWidth: 2,
opacity: 0.9,
'& .fc-time': {
...theme.typography.h6,
color: 'inherit',
},
'& .fc-title': {
...theme.typography.body1,
color: 'inherit',
},
},
'& .fc-unthemed .fc-day-top': {
...theme.typography.body2,
},
'& .fc-unthemed .fc-day-header': {
...theme.typography.subtitle2,
fontWeight: theme.typography.fontWeightMedium,
color: theme.palette.text.secondary,
padding: theme.spacing(1),
},
'& .fc-unthemed .fc-list-view': {
borderColor: theme.palette.divider,
},
'& .fc-unthemed .fc-list-empty': {
...theme.typography.subtitle1,
},
'& .fc-unthemed .fc-list-heading td': {
borderColor: theme.palette.divider,
},
'& .fc-unthemed .fc-list-heading-main': {
...theme.typography.h6,
},
'& .fc-unthemed .fc-list-heading-alt': {
...theme.typography.h6,
},
'& .fc-unthemed .fc-list-item:hover td': {},
'& .fc-unthemed .fc-list-item-title': {
...theme.typography.body1,
},
'& .fc-unthemed .fc-list-item-time': {
...theme.typography.body2,
},
},
Listing 11-31Adding Styling Components in the index.tsx of CalendarView
清单 11-31 中的附加样式组件只是日历的几种边框颜色,以及一些边距和填充。
现在,在 CalendarView 的同一个索引文件中,我们将从 FullCalendar 库中导入 moment 库和模块,如清单 11-32 所示。
import moment from 'moment';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import listPlugin from '@fullcalendar/list';
import timelinePlugin from '@fullcalendar/timeline';
Listing 11-32Importing Named Components in index.tsx of CalendarView
然后让我们添加并使用来自calendarSlice和 React 钩子的一些模块,如清单 11-33 所示。
import React, { useEffect, useState, useRef } from 'react';
import {
getEvents,
openModal,
closeModal,
selectRange,
selectEvent,
updateEvent
} from 'features/calendar/calendarSlice';
Listing 11-33Importing Additional Modules from calendarSlice and React Hooks
同样,在同一个索引文件中,我们将创建一些本地状态,如清单 11-34 所示。
const selectedEvent = useSelector(selectedEventSelector);
const mobileDevice = useMediaQuery('(max-width:600px)');
const [date, setDate] = useState<Date>(moment().toDate());
const [view, setView] = useState<ViewType>(
mobileDevice ? 'listWeek' : 'dayGridMonth',
);
const calendarRef = useRef<FullCalendar | null>(null);
useEffect(() => {
dispatch(getEvents());
},
Listing 11-34Creating local states in index.tsx of CalendarView
让我们看看清单 11-34 中发生了什么。我们有useRef来访问 DOM 元素,并在后续或下一次渲染中保存值或状态。将鼠标悬停在useRef,上,您会看到这是一个React.MutableRefObject<FullCalendar>.,这意味着我们可以访问这个完整日历的 API 或接口。
我们用它来检测小的浏览器屏幕,比如移动设备的屏幕。
之后,我们将在handleModalClose,下面创建额外的句柄函数,如清单 11-35 所示。
/* calendarRef is a reference to the element FullCalendar*/
const handleDateNext = (): void => {
const calendarEl = calendarRef.current;
/*the getApi here is part of FullCalendar. If you 'dot space' the 'calendarEl,' you'll see the interfaces or APIs available. */
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.next();
setDate(calendarApi.getDate());
}
};
const handleDatePrev = (): void => {
const calendarEl = calendarRef.current;
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.prev();
setDate(calendarApi.getDate());
}
};
const handleDateToday = (): void => {
const calendarEl = calendarRef.current;
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.today();
setDate(calendarApi.getDate());
}
};
const handleViewChange = (newView: ViewType): void => {
const calendarEl = calendarRef.current;
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.changeView(newView);
setView(newView);
}
};
/*the arg: any - could be a string or a number */
const handleEventSelect = (arg: any): void => {
dispatch(selectEvent(arg.event.id));
};
/*We have here a try-catch block because handleEventDrop is an async function */
const handleEventDrop = async ({ event }: any): Promise<void> => {
try {
await dispatch(
updateEvent({
allDay: event.allDay,
start: event.start,
end: event.end,
id: event.id,
} as any),
);
} catch (err) {
console.error(err);
}
};
const handleEventResize = async ({ event }: any): Promise<void> => {
try {
await dispatch(
updateEvent({
allDay: event.allDay,
start: event.start,
end: event.end,
id: event.id,
} as any),
);
} catch (err) {
console.error(err);
}
};
const handleRangeSelect = (arg: any): void => {
const calendarEl = calendarRef.current;
if (calendarEl) {
const calendarApi = calendarEl.getApi();
calendarApi.unselect();
}
dispatch(selectRange(arg.start, arg.end));
};
Listing 11-35Creating Additional Handle Events in the index.tsx of CalendarView
我们还没说完呢。我们需要添加来自 Material-UI 的纸张模块和 UI 样式的完整日历。
在 return 语句中找到Dialog标签;我们已经写了只有当isModalOpen为真时Dialog才可见。所以在 Header 组件之后和对话框之前,我们将放置 FullCalendar,如清单 11-36 所示。
return (
<Page className={classes.root} title="Calendar">
<Container maxWidth={false}>
<Header onAddClick={handleAddClick} />
<Toolbar
date={date}
onDateNext={handleDateNext}
onDatePrev={handleDatePrev}
onDateToday={handleDateToday}
onViewChange={handleViewChange}
view={view}
/>
<Paper className={classes.calendar}>
<FullCalendar
allDayMaintainDuration
droppable
editable
selectable
weekends
dayMaxEventRows
eventResizableFromStart
headerToolbar={false}
select={handleRangeSelect}
eventClick={handleEventSelect}
eventDrop={handleEventDrop}
eventResize={handleEventResize}
initialDate={date}
initialView={view}
events={events}
height={800}
ref={calendarRef}
rerenderDelay={10}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
listPlugin,
timelinePlugin,
]}
/>
</Paper>
<Dialog
maxWidth="sm"
fullWidth
onClose={handleModalClose}
open={isModalOpen}
>
Listing 11-36Rendering the FullCalendar in the UI of the index.tsx of CalendarView
如果你注意到清单 11-36 中的一些属性(即allDayMaintainDuration, droppable 、 editable、**、**等)。)没有等号=号;这意味着它们默认设置为真。
这是书写allDayMaintainDuration={true},的速记,这也意味着它们都是布尔型的。
但是对于headerToolbar,我们必须显式地声明 false 值。我们将它设置为 false,因为我们有工具栏组件,我们将很快添加。
在 UI 中检查完整日历
让我们在浏览器中测试一切。刷新它,您应该能够看到完整的日历和我们之前创建的测试事件。
图 11-7
完整日历的屏幕截图
点按显示的事件并尝试编辑它。您应该可以成功地进行更改,如图 11-8 所示。
图 11-8
编辑完整日历的事件表单
检查 Chrome 开发工具和 Redux 开发工具
看一眼 Redux DevTools,你会看到它也在更新,并在 Chrome DevTools 中看到 200 OK 状态码。
还要测试编辑事件表单左下方的删除图标,您应该能够删除所选择的事件。
一旦你删除了它,在 Chrome DevTools 中再次检查网络,查看请求方法:DELETE 和状态代码:200 OK。
图 11-9
删除事件
创建一个在连续两个月中有大约两周时间范围的事件怎么样?在图 11-10 中,我们可以看到我们已经成功地为多月日历添加了一个事件。
图 11-10
在多月日历中创建事件
我们能够创建从 2 月到 3 月的多月活动。但是,您会注意到我们无法导航到下个月。
这是因为我们还需要增加一个东西,就是工具栏。所以现在让我们在CalendarView.的index.tsx中导入它
我们将导入工具栏组件,并在标题组件下使用它,如清单 11-37 所示。
import Toolbar from './Toolbar';
...
<Header onAddClick={handleAddClick} />
<Toolbar
date={date}
onDateNext={handleDateNext}
onDatePrev={handleDatePrev}
onDateToday={handleDateToday}
onViewChange={handleViewChange}
view={view}
/>
Listing 11-37Adding the Toolbar Component in the index.tsx of CalendarView
检查 UI,您应该会看到如图 11-11 所示的变化。现在,您应该能够导航到之前或之后的月份。
图 11-11
添加工具栏后更新的 UI 的屏幕截图
摘要
在这一章中,我们继续构建我们的应用。我们安装了 FullCalendar 库,并学习了如何使用 Redux 工具包 在日历组件上创建、删除和更新事件。希望您现在对 Redux 工具包 的实现流程有了更好的理解。
在下一章,我们将构建登录和注册表单。我们将需要假 Node json-server 和 json-server-auth 的帮助,以及来自优秀 Material-UI 的更多样式组件。
十二、React 中的保护路由和认证
在上一章中,我们已经展示了如何使用 Redux 工具包 创建、删除和更新应用的事件。我们已经知道用我们的存储库进行 CRUD 是多么的高效和方便,它保存了我们应用的所有全局状态。
在这一章中,我们将为我们的应用建立一个登录和注册表单。我们将从伪 Node json-server 开始,我们已经在前一章中安装了它。json-server 允许我们发送 HTTP 方法或 HTTP 请求。
设置假服务器
设置假服务器只需要我们几分钟的时间,对构建我们的 UI 帮助很大;我们不需要等待我们的后端开发团队给我们 API。我们可以创建一个假的 API,并用它来测试 UI。
这就是我们在 json 服务器上所做的。我们还将使用json-server-auth,一个插件或模块,在 json-server 内部创建一个认证服务。
除了json-server-auth,之外,我们还使用了concurrently.
允许我们在一次运行中同时运行两个 npm 命令。
所以我们需要修改我们的脚本。
转到package.json ,编辑后端脚本并添加一个start:fullstack脚本,如清单 12-1 所示。
"backend": "json-server --watch db.json --port 5000 --delay=1000 -m ./node_modules/json-server-auth",
"start:fullstack": "concurrently \"npm run backend\" \"npm run start\""
Listing 12-1Modifying the Scripts in package.json
同时运行多个命令。
现在我们已经设置好了,让我们试一试。取消所有正在运行的应用,然后在终端中键入以下命令:
npm run start:fullstack
db.json
一旦完成,让我们更新事件下面的 db. json .,我们将添加一个用户对象数组,如清单 12-2 所示。
"users": [
{
"id": "7fguyfte5",
"email": "demo@acme.io",
"password": "$2a$10$Pmk32D/fgkig8pU.r1rGrOpYYJSrnqqpLO6dRdo88iYxxIsl1sstC",
"name": "Mok Kuh",
"mobile": "+34782364823",
"policy": true
}
],
Listing 12-2Adding the users Object in the db.json
我们稍后将使用它登录。用户的端点是用户对象的数组。它包含登录详细信息,包括哈希密码。
API:登录和注册
接下来,在 axios.ts 文件中,让我们更新端点,如清单 12-3 所示。
export const EndPoints = {
sales: 'sales',
products: 'products',
events: 'events',
login: 'login',
register: 'register',
};
Listing 12-3Updating the Endpoints in axios.ts
“登录”和“注册”都是 json-server-auth 的一部分。如果您转到 npmjs.org 并搜索 json-server-auth,您会看到我们可以在认证流程中使用以下任何一条路线。
在这种情况下,我们使用登录和注册,如图 12-1 所示。
图 12-1
json-server-auth 中的认证流程
authService(认证服务)
我们现在可以更新服务了。在 services 文件夹中,创建一个名为authService.ts的新文件。
authService是一个包含我们使用 axios 的日志和注册服务的文件。
import axios, { EndPoints } from 'api/axios';
export type UserModel = {
email: string;
password: string;
};
/*The return object will be an object with an access token of type string. We're expecting an access token from the json-server-auth */
export async function loginAxios(userModel: UserModel) {
return await axios.post<{ accessToken: string }>(EndPoints.login, userModel);
}
export type RegisterModel = {
email: string;
password: string;
name: string;
mobile: string;
policy: boolean;
};
export async function registerAxios(registerModel: RegisterModel) {
return await axios.post<{ accessToken: string }>(
EndPoints.register,
registerModel,
);
}
Listing 12-4Creating the authService.ts
在清单 12-4 中,我们有登录信息——请求一个用户模型——我们在用户模型类型中定义它,它需要一个电子邮件和字符串类型的密码。我们还有registerAxios——请求 registerModel——我们在 register model 中描述它,它需要电子邮件、密码、姓名、手机和策略。
现在让我们继续创建登录页面。
在 views ➤页面文件夹中,创建一个新文件夹并将其命名为 auth,在 auth 中,添加另一个文件夹并将其命名为 components。
在 auth 文件夹中,创建一个新文件,并将其命名为 LoginPage.tsx :
app ➤ views ➤ pages ➤ auth ➤ LoginPage.tsx
在 components 文件夹中,创建一个新文件,并将其命名为 LoginForm.tsx :
app ➤ views ➤ pages ➤ auth ➤ components ➤ LoginForm.tsx
设置登录表单
让我们先建立逻辑关系。导入命名的组件,如清单 12-5 所示。
import React, { useState } from 'react';
import * as Yup from 'yup';
import { Formik } from 'formik';
import { Alert } from '@material-ui/lab';
import { useHistory } from 'react-router-dom';
import {
Box,
Button,
FormHelperText,
TextField,
CardHeader,
Divider,
Card,
} from '@material-ui/core';
import { loginAxios } from 'services/authService';
Listing 12-5Importing Named Components of LoginForm.tsx
在清单 12-5 中,我们通常会怀疑命名导入。让我们看看这里还有什么新的东西:
Alert:我们这里是第一次从物料界面导入预警。Alert 用于显示简短而重要的消息,以便在不中断用户任务的情况下引起用户的注意。
这是我们从 React-Router-DOM 导入的一个钩子;这允许我们访问历史实例,并让我们向后导航。
我们还从the authService.中导入了loginAxios
然后让我们为LoginForm创建一个函数,并创建另一个函数来保存用户的身份验证细节。当然,我们将这样命名它,如清单 12-6 所示。
作为一种最佳实践,我们应该尽可能描述性地命名我们的函数和方法,以便于我们自己和其他开发人员阅读我们的代码。
const LoginForm = () => {
const key = 'token';
const history = useHistory();
const [error, setError] = useState('');
const saveUserAuthDetails = (data: { accessToken: string }) => {
localStorage.setItem(key, data.accessToken);
};
Listing 12-6Creating the Function for LoginForm.tsx
LoginForm:在这里,我们将“令牌”定义为密钥,并将useHistory和useState用于错误。
saveUserAuthDetails:将用户资料保存在本地存储器的功能。local storage是浏览器的本地部分,所以我们可以访问它。它是一等公民支持的,所以我们不需要再进口它了。
接下来,让我们添加我们的LoginForm,的返回语句,它包含 Formik 及其所需的属性,如清单 12-7 所示。
return (
<Formik
initialValues={{
email: 'demo@acme.io',
password: 'Pass123!',
}}
validationSchema={Yup.object().shape({
email: Yup.string()
.email('Must be a valid email')
.max(255)
.required('Email is required'),
password: Yup.string().max(255).required('Password is required'),
})}
onSubmit={async (values, formikHelpers) => {
try {
const { data } = await loginAxios(values);
saveUserAuthDetails(data);
formikHelpers.resetForm();
formikHelpers.setStatus({ success: true });
formikHelpers.setSubmitting(false);
history.push('dashboard');
} catch (e) {
setError('Failed. Please try again.');
console.log(e.message);
formikHelpers.setStatus({ success: false });
formikHelpers.setSubmitting(false);
}
}}
>
{/* deconstructed Formik props */}
{({
errors,
handleBlur,
handleChange,
handleSubmit,
isSubmitting,
touched,
values,
}) => (
<Card>
<form noValidate onSubmit={handleSubmit}>
<CardHeader title="Login" />
<Divider />
<Box m={2}>
<TextField
error={Boolean(touched.email && errors.email)}
fullWidth
autoFocus
helperText={touched.email && errors.email}
label="Email Address"
margin="normal"
name="email"
onBlur={handleBlur}
onChange={handleChange}
type="email"
value={values.email}
variant="outlined"
/>
<TextField
error={Boolean(touched.password && errors.password)}
fullWidth
helperText={touched.password && errors.password}
label="Password"
margin="normal"
name="password"
onBlur={handleBlur}
onChange={handleChange}
type="password"
value={values.password}
variant="outlined"
/>
<Box mt={2}>
<Button
color="primary"
disabled={isSubmitting}
fullWidth
size="large"
type="submit"
variant="contained"
>
Log In
</Button>
</Box>
{error && (
<Box mt={3}>
<FormHelperText error>{error}</FormHelperText>
</Box>
)}
<Box mt={2}
<Alert severity="info">
<div>
Use <b>demo@acme.io</b> and password <b>Pass123!</b>
</div>
</Alert>
</Box>
</Box>
</form>
</Card>
)}
</Formik>
);
};
export default LoginForm;
Listing 12-7Creating Formik in the LoginForm
让我们回顾一下我们在清单 12-7 中所做的一些事情:
initialValues:福米克必备属性。我们用电子邮件和密码的值初始化它。
一个有效的验证模式。我们将电子邮件定义为一个字符串,其中有效电子邮件地址的最大字符数为 255,密码的最大字符数为 255。
onSubmit:一个接受values和formikHelpers.的异步函数,因为它是一个异步函数,我们把它包装在一个 try-catch 块中。
在尝试中,我们使用loginAxios来看看我们是否可以登录。我们需要的结果就是这个data,是一个大对象结果的析构。我们不需要获得这个巨大物体的所有属性。
然后,我们将data保存到saveUserAuthDetails,这意味着将它保存在我们的本地存储中。
然后我们有一组正在使用的formikHelpers,比如resetForm, setStatus和setSubmitting。
对于 catch,我们放置了setError以防登录失败。
我们使用 Material-UI 中的 Card 组件来设计登录 UI 的样式,并使用两个文本字段,分别用于电子邮件和密码。
创建注册表单
之后,我们需要在授权➤组件文件夹下创建另一个组件。姑且称之为RegisterForm .tsx.
同样,让我们先做命名的组件,如清单 12-8 所示。
import React, { useState } from 'react';
import * as Yup from 'yup';
import { Formik } from 'formik';
import { Alert } from '@material-ui/lab';
import {
Box,
Button,
Card,
CardContent,
CardHeader,
Checkbox,
CircularProgress,
Divider,
FormHelperText,
Grid,
Link,
TextField,
Typography,
} from '@material-ui/core';
import { useHistory } from 'react-router-dom';
import { registerAxios } from 'services/authService';
Listing 12-8Importing Named Components in RegisterForm.tsx
除了 Material-UI 中的几个模块之外,注册表单需要与登录表单相同。我们还添加了来自authService的registerAxios。
接下来,让我们创建函数来注册用户并在本地存储中保存他们的身份验证细节,如清单 12-9 所示。
const RegisterForm = () => {
const key = 'token';
const history = useHistory();
const [error, setError] = useState('');
const [isAlertVisible, setAlertVisible] = useState(false);
const saveUserAuthDetails = (data: { accessToken: string }) => {
localStorage.setItem(key, data.accessToken);
};
Listing 12-9Adding the RegisterForm Function
以及用 Formik 包装的 return 语句,如清单 12-10 所示。
return
<Formik
initialValues={{
email: 'johnnydoe@yahoo.com',
name: 'John',
mobile: '+34782364823',
password: 'Pass123!',
policy: false,
}}
validationSchema={Yup.object().shape({
email: Yup.string().email().required('Required'),
name: Yup.string().required('Required'),
mobile: Yup.string().min(10).required('Required'),
password: Yup.string()
.min(7, 'Must be at least 7 characters')
.max(255)
.required('Required'),policy: Yup.boolean().oneOf([true], 'This field must be checked'),
})}
onSubmit={async (values, formikHelpers) => {
try {
const { data } = await registerAxios(values);
saveUserAuthDetails(data);
formikHelpers.resetForm();
formikHelpers.setStatus({ success: true });
formikHelpers.setSubmitting(false);
history.push('dashboard');
} catch (e) {
setError(e);
setAlertVisible(true);
formikHelpers.setStatus({ success: false });
formikHelpers.setSubmitting(false);
}
}}
>
{({
errors,
handleBlur,
handleChange,
handleSubmit,
isSubmitting,
touched,
values,
}) => (
<Card>
<CardHeader title="Register Form" />
<Divider />
<CardContent>
{isAlertVisible && (
<Box mb={3}>
<Alert onClose={() => setAlertVisible(false)} severity="info">{error}!
</Alert>
</Box>
)}
{isSubmitting ? (
<Box display="flex" justifyContent="center" my={5}>
<CircularProgress />
{/*for the loading spinner*/}
</Box>
) : (
<Box>
<Grid container spacing={2}>
<Grid item md={6} xs={12}>
<TextField
error={Boolean(touched.name && errors.name)}
fullWidth
helperText={touched.name && errors.name}
label="Name"
name="name"
onBlur={handleBlur}
onChange={handleChange}
value={values.name}
variant="outlined"
/>
</Grid>
<Grid item md={6} xs={12}>
<TextField
error={Boolean(touched.mobile && errors.mobile)}
fullWidth
helperText={touched.mobile && errors.mobile}
label="Mobile"
name="mobile"
onBlur={handleBlur}
onChange={handleChange}
value={values.mobile}
variant="outlined"
/>
</Grid>
</Grid>
<Box mt={2}>
<TextField
error={Boolean(touched.email && errors.email)}
fullWidth
helperText={touched.email && errors.email}
label="Email Address"
name="email"
onBlur={handleBlur}
onChange={handleChange}
type="email"
value={values.email}
variant="outlined"
/>
</Box>
<Box mt={2}>
<TextField
error={Boolean(touched.password && errors.password)}
fullWidth
helperText={touched.password && errors.password}
label="Password"
name="password"
onBlur={handleBlur}
onChange={handleChange}
type="password"
value={values.password}
variant="outlined"
/>
</Box>
<Box alignItems="center" display="flex" mt={2} ml={-1}>
<Checkbox
checked={values.policy}
name="policy"
onChange={handleChange}
/>
<Typography variant="body2" color="textSecondary">
I have read the{' '}
<Link component="a" href="#" color="secondary">
Terms and Conditions
</Link>
</Typography>
</Box>
{Boolean(touched.policy && errors.policy) && (
<FormHelperText error>{errors.policy}</FormHelperText>
)}
<form onSubmit={handleSubmit}>
<Button
color="primary"
disabled={isSubmitting}
fullWidth
size="large"
type="submit"
variant="contained"
>
Sign up
</Button>
</form>
</Box>
)}
</CardContent>
</Card>
)}
</Formik>
);
};
export default RegisterForm;
Listing 12-10Creating Formik in the RegisterForm.tsx
在 initialValues 中,您可以将其保留为空字符串或传递一个示例值。请注意,我们不在这里保存或存储密码。我们这样做只是为了演示的目的。
此外,initialValues 和 validationSchema 通常保存在一个单独的文件中,以获得更清晰的代码,尤其是一个长文件。
这就是注册表中的内容。我们稍后会测试它。让我们现在建立登录页面。
添加登录页面
现在让我们创建 LoginPage,我们将从导入我们需要的命名组件开始,如清单 12-11 所示。
import React, { useState } from 'react';
import { makeStyles } from '@material-ui/styles';
import { Box, Button, Container, Divider } from '@material-ui/core';
import LoginForm from './components/LoginForm';
import RegisterForm from './components/RegisterForm';
import Page from 'app/components/page';
Listing 12-11Importing the Named Components in LoginPage.tsx
我们导入了刚刚创建的LoginForm和RegisterForm。我们也有页面模板。
所以,接下来,让我们创建LoginPage组件函数,如清单 12-12 所示。
const LoginPage = () => {
const classes = useStyles();
const [isLogin, setIsLogin] = useState(true);
Listing 12-12Creating the LoginPage Function
本地状态,它是一个布尔值,默认情况下设置为 true,因为我们正在登录。如果这是假的,我们显示注册表。我们如何做到这一点?这样做:{isLogin ? <LoginForm /> : <RegisterForm />}。
所以现在,让我们接下来制作那个返回语句,如清单 12-13 所示。
return (
<Page className={classes.root} title="Authentication">
<Container>
<Box
my={5}
display={'flex'}
flexDirection={'column'}
justifyContent={'center'}
alignItems={'center'}
>
{/*if isLogin is true - show LoginForm, otherwise show RegisterForm */}
{isLogin ? <LoginForm /> : <RegisterForm />}
<Divider />
<Box mt={5}>
Go to{' '}
{isLogin ? (
<Button
size={'small'}
color={'primary'}
variant={'text'}
onClick={() => setIsLogin(false)}
>
Register Form
</Button>
) : (
<Button
size={'small'}
color={'primary'}
variant={'text'}
onClick={() => setIsLogin(true)}
>
Login Form
</Button>
)}
</Box>
</Box>
</Container>
</Page>
);
};
const useStyles = makeStyles(() => ({root: {},}));
export default LoginPage;
Listing 12-13Adding the Return Statement of the LoginPage.tsx
好了,暂时就这样了。routes.tsx.更新时间到了
更新路线
在AboutPage路线下方插入LoginPage路线,如清单 12-14 所示。
<Route
exact
path={'/login'}
component={lazy(() => import('./views/pages/auth/LoginPage'))}
/>
Listing 12-14Adding the LoginPage Routes
让我们在浏览器中测试一下。点击刷新按钮或者转到你的localhost:3000/login,应该会看到登录页面,如图 12-2 所示。
图 12-2
登录页面的屏幕截图
点击注册表单,创建您的账户,如图 12-3 所示。
图 12-3
注册表单的屏幕截图
要检查成功的登录或注册,进入 Chrome DevTools 的网络,你应该在标题下看到状态代码 OK,在响应中,你会看到访问令牌。
复制访问令牌,让我们看看里面有什么。为此,我们将访问这个优秀的网站 jwt.io ,并在那里粘贴我们的访问令牌。
JSON Web 令牌(JWT)
JWT 有报头、有效载荷和签名。在标题中,您会看到alg或算法和typ或类型。在有效负载中,数据是访问令牌或 JWT 的解码值。解码后的值是我们在下一章的 React 应用中需要的。
图 12-4
检查来自服务器的访问令牌响应
点击 jwt.io/introduction 了解更多关于 JSON Web Token 结构的信息。
简单地说,JSON Web 令牌或 jwt 是用于访问资源的基于令牌的认证。基于令牌的身份验证是无状态的,不同于基于会话的身份验证,基于会话的身份验证是有状态的,需要一个 cookie,并将会话 ID 放在用户的浏览器中。
这是一个很大的话题,所以我建议你多读一些。
让我们回到应用的 Chrome DevTools,点击应用➤本地存储和本地主机。
图 12-5
存储在本地存储中的令牌的屏幕截图
令牌表示我们已经成功地在本地存储中存储了 JWT 或 JSON Web 令牌。
创建受保护的路由组件
接下来,我们需要保护我们的路由,以便未经身份验证的用户无法访问或看到仪表板。为此,让我们创建一个受保护的路由。
在应用目录中,转到 components,在其中创建一个新组件,并将其命名为protected-route.tsx:
app ➤ components ➤ protected-route.tsx
打开protected-route.tsx文件,复制下面的代码,如清单 12-15 所示。
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
const ProtectedRoute = props => {
const token = localStorage.getItem('token');
return token ? (
<Route {...props} />
) : (
<Redirect to={{ pathname: '/login' }} />
);
};
export default ProtectedRoute;
Listing 12-15Creating the protected-route.tsx
在清单 12-15 中,我们暂时保持它的简单,但是随着认证变得更加复杂,我们将在以后更新它。
我们从 React-Router-DOM 导入了重定向和路由。我们还有ProtectedRoute——一个接受属性并从localStorage中检索用户令牌的函数。
在 return 语句中,我们检查是否存在一个现有的**令牌?**如果这是真的,用户被定向到仪表板内的特定路径;否则,用户将被重定向到登录页面。
完成之后,我们现在可以使用ProtectedRoute组件来包装仪表板路线。
更新 Routes.tsx
转到 routes.tsx,我们将使用 ProtectedRoute 组件,如清单 12-16 所示。
import ProtectedRoute from './components/protected-route';
...
<ProtectedRoute
path={'/dashboard'}
render={({ match: { path } }) => (
<Dashboard>
<Switch>
<Route
exact
path={path + '/'}
component={lazy(() => import('./views/dashboard/dashboard-default-content'),)/>
Listing 12-16Adding the ProtectedRoute in the routes.tsx
检查它是否工作。打开一个新窗口并转到localhost:3000/dashboard。由于令牌已经在我们的本地存储中,我们可以立即访问仪表板,而无需重定向到登录页面。
更新仪表板侧栏导航
在此之后,我们将需要更新注销。
去dashboard-layout➤dashboard-sidebar-navigation??。
我们将为注销创建一个新的句柄事件函数。把它放在handleClick函数的正下方,如清单 12-17 所示。
const handleLogout = () => {
localStorage.clear();
};
Listing 12-17Updating the dashboard-sidebar-navigation.tsx
handleLogout:通过删除所有存储值来清除localStorage的功能
在同一个文件中,转到注销,让我们在按钮上添加一个onClick事件,这样我们就可以触发它,如清单 12-18 所示。
<ListItem button onClick={handleLogout}>
<ListItemIcon>
<LogOutIcon />
</ListItemIcon>
<ListItemText primary={'logout'} />
</ListItem>
Listing 12-18Adding an onClick Event for the handleLogout
我们来测试一下。
测试时间到了
转到仪表板,也打开你的 Chrome DevTools,点击应用。
单击 logout 按钮,浏览器应该会刷新,您会被定向到主页面;如果你看看 Chrome DevTools,这个令牌应该会被删除。
图 12-6
注销后删除令牌后本地存储的屏幕截图
这就是我们如何创建一个简单的认证。
随着我们的应用变得越来越复杂,我们将在接下来的章节中对此进行改进。
我想强调的是,我们了解身份认证的基础知识及其工作原理是至关重要的。但是现在,老实说,我强烈建议将身份验证作为一种服务或身份提供者。
我推荐第三方身份即服务的一些原因:
-
从应用中分散身份。用户的身份信息不会存储在您的数据库中。
-
允许开发人员专注于开发应用的业务价值,而不是花费数周时间构建身份验证和授权服务。
-
大部分第三方身份即服务公司比如 Auth0,我也是个人使用和推荐的,都是非常安全可靠的。Auth0 也有很好的文档、大量可以构建的开源项目和强大的社区支持。
我尝试过的其他优秀的身份即服务提供商有 AWS Cognito、Azure AD 和 Okta。他们中的许多人提供了一个免费层程序,这是最适合小项目,所以你可以了解它是如何工作的。
(完全披露:我目前是一名授权大使。不,我不是公司的员工,也没有任何金钱报酬。每当我在会议上发言并提到它们时,我偶尔会得到一些奖品和其他极好的额外津贴。但我之所以成为认证大使,正是因为我以前就一直在使用它并推荐它们。)
- 最后,这些第三方身份提供者是由安全工程师或安全专家开发和维护的。他们更了解网络安全领域的最新动态,包括最佳实践、趋势和问题。
摘要
本章利用 Material-UI 组件的样式帮助构建了登录和注册表单。我们使用伪 Node json-server 中的 json-server-auth 库来模拟认证和保护我们的路由的实现流程。当我们在脚本中添加并发内容时,我们还使自己运行和构建应用变得更加容易。
在下一章中,我们将在 React 应用中构建更多的组件和功能。我们首先创建一个个人资料表单,然后将其同步到我们应用中的不同深层组件——所有这些都有 Redux 的强大帮助。
最后,我们将展示并非所有组件都需要绑定到 Redux。如果我们不需要增加复杂性,那么使用它就没有意义。有些人有这种错误的观念,认为一旦我们将 Redux 添加到我们的应用中,我们所有的组件都必须包含它。有需要就用;不然就不用了。