Как создать базовое CRUD-приложение с компонентами NextJS, TS, React, Redux-Tookit и MUI5
Мы узнаем, как создать приложение NextJS, которое использует redux-observable
для управления побочными эффектами.
В этом проекте мы узнаем, как создать базовое приложение CRUD, используя NextJS Typescript и Redux-Toolkit для управления побочными эффектами.
Технический стек:
- NextJS - фреймворк React
- Typescript
- Redux-Toolkit - набор инструментов для управления состоянием redux
- MUI5 - компоненты UI
- Formik - управление формой
- Yup - проверка формы
- Prisma - база данных
Кодирование: настройка базы данных
В этом примере мы используем Prisma, облегченную серверную базу данных, в качестве нашего хранилища данных. Чтобы включить Prisma в нашем проекте, нам нужно добавить следующие зависимости:
- "@prisma/client": "^3.8.0"
- "prisma": "^3.8.0" - devDependencies
Создайте конфигурацию источника данных Prisma
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = ["native"]
}
model User {
id Int @id @default(autoincrement())
firstName String
lastName String
email String?
birthDate DateTime?
}
И начальная схема и миграционный SQL внутри папки src/prisma.
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT,
"birthDate" DATETIME
);
Чтобы создать локальную базу данных, выполните:
npx prisma migrate dev --name=init
Следующий шаг заключается в том, хотите ли вы настроить хранилище redux или просмотр. Это зависит от вашего стиля программирования.
Настройка хранилища приложений
Хранилище приложений
Хранилище приложений является производным от примера NextJS. В нем сохраняются объединенные редукторы и промежуточные программы из createApi Redux-toolkit.
import {configureStore} from '@reduxjs/toolkit';
import {UsersService} from "./UserService";
import {UserSlice} from "./slices/UserSlice";
export function makeStore() {
return configureStore({
reducer: {
[UsersService.reducerPath]: UsersService.reducer,
user: UserSlice.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
UsersService.middleware
),
})
}
const store = makeStore()
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {users: UsersState}
export type AppDispatch = typeof store.dispatch
export default store;
Пользовательский Service/API
Этот файл содержит все конечные точки API для пользователя, который использует запрос и мутацию для редактирования пользовательских данных. Он использует заголовок, определенный в базовом сервисном файле.
import {BaseService} from "./BaseService";
import {UserType} from "./types/UserType";
import {createSlice} from "@reduxjs/toolkit";
interface Pagination {
offset: number,
limit: number
}
export const UsersService = BaseService.injectEndpoints({
endpoints: (build) => ({
getUsers: build.query<UserType[], Pagination>({
query: (param: Pagination) => `/users?offset=${param.offset}&limit=${param.limit}`,
providesTags: [{type: "User", id: "LIST"}]
}),
getUser: build.query<UserType, number>({
query: (id) => ({
url: `/users/${id}`,
})
}),
createUser: build.mutation<UserType, UserType>({
query: (body: UserType) => ({
url: `/users`,
method: 'POST',
body
}),
invalidatesTags: [{type: "User", id: "LIST"}]
}),
updateUser: build.mutation<UserType, Pick<UserType, 'id'> & Partial<UserType>>({
query: ({id, ...body}) => ({
url: `/users/${id}`,
method: 'PATCH',
body
}),
invalidatesTags: [{type: "User", id: "LIST"}]
}),
deleteUser: build.mutation<void, number>({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE',
}),
invalidatesTags: [{type: "User", id: "LIST"}],
}),
}),
overrideExisting: true,
})
export const {
useGetUsersQuery, useGetUserQuery,
useCreateUserMutation, useDeleteUserMutation, useUpdateUserMutation
} = UsersService;
Сторона UI
Мы внедрили традиционный список и страницы сведений с возможностью прокрутки в пользовательском интерфейсе, используя компоненты UI5.
Страница списка пользователей использует компоненты MUI 5 для отображения таблицы. При загрузке страницы, реализованной с использованием эффекта использования, он вызывает API для получения списка пользователей. Действие удаления также реализовано на этой странице.
import React, {useState} from 'react';
import {
Alert,
Box,
Button,
ButtonGroup,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Snackbar,
SwipeableDrawer,
Table,
TableBody,
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow
} from "@mui/material";
import moment from "moment";
import {Delete, Edit, PersonAdd} from "@mui/icons-material";
import {useAppDispatch} from 'services/hooks';
import {useRouter} from "next/router";
import {NextPage} from "next";
import {useDeleteUserMutation, useGetUsersQuery} from "../../services/UserService";
import Footer from "../../components/Footer/Footer";
import UserDetail from "./components/UserDetail";
import {UserType} from "../../services/types/UserType";
import {clearUser, setUser} from "../../services/slices/UserSlice";
const EMPTY_DIALOG = {
open: false,
text: '',
title: '',
onConfirm: () => {
},
onCancel: () => {
}
}
const EMPTY_ALERT = {
open: false,
text: '',
};
const Users: NextPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(10);
const [dialog, setDialog] = useState(EMPTY_DIALOG);
const [alert, setAlert] = useState(EMPTY_ALERT);
const {
data,
error,
isLoading: isUsersLoading,
isSuccess: isUsersQueried,
isFetching: isUsersFetching,
isError: isUsersError
} = useGetUsersQuery({offset: (offset * limit), limit});
const [deleteUser, {
data: deletedUser,
isLoading: isUserDeleting,
isSuccess: isUserDeleted
}] = useDeleteUserMutation();
const drawerBleeding = 56;
const [openDrawer, setOpenDrawer] = React.useState(false);
const handleChangeRowsPerPage = ({target: {value}}) => {
setLimit(value);
};
const handleChangePage = (_, nextPage) => {
setOffset(nextPage);
};
const handleDeleteUser = (userId: number) => async () => {
try {
await deleteUser(userId).unwrap();
setAlert({
open: true,
text: `Successfully deleted user: ${userId}`,
});
resetDeleteDialog();
} catch (error) {
console.log(`Error: Failed deleting user with id ${userId}`);
}
};
const resetDeleteDialog = () => {
setDialog(EMPTY_DIALOG);
}
const openDeleteDialog = (userId: number) => () => {
setDialog({
open: true,
title: 'Delete user',
text: `Delete user: ${userId}?`,
onConfirm: handleDeleteUser(userId),
onCancel: () => resetDeleteDialog()
});
}
const resetAlert = () => {
setAlert(EMPTY_ALERT);
}
const editUser = (user: UserType) => () => {
setOpenDrawer(true);
dispatch(setUser(user));
};
const toggleEditDrawer = (newOpen: boolean) => () => {
if (!newOpen) {
dispatch(clearUser());
}
setOpenDrawer(newOpen);
};
const renderTable = (users: UserType[], count: number) => {
const hasUsers = count > 0;
return (
<React.Fragment>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell colSpan={6} align="right">
<Button variant="outlined" color="primary" onClick={toggleEditDrawer(true)}>
<PersonAdd/>
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Id</TableCell>
<TableCell>First name</TableCell>
<TableCell>Last name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Birth date</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{hasUsers ? (
users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.id}</TableCell>
<TableCell>{user.firstName}</TableCell>
<TableCell>{user.lastName}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{moment.utc(user.birthDate).format('MM-DD-YYYY')}
</TableCell>
<TableCell sx={{textAlign: "right"}}>
<ButtonGroup>
<Button onClick={editUser(user)}>
<Edit/>
</Button>
<Button onClick={openDeleteDialog(user.id)}>
{<Delete/>}
</Button>
</ButtonGroup>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6}>No users found.</TableCell>
</TableRow>
)}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
count={count}
page={offset}
rowsPerPage={limit}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
<SwipeableDrawer
anchor="bottom"
open={openDrawer}
onClose={toggleEditDrawer(false)}
onOpen={toggleEditDrawer(true)}
swipeAreaWidth={drawerBleeding}
disableSwipeToOpen={false}
ModalProps={{
keepMounted: true,
}}
>
<UserDetail toggleEditDrawer={toggleEditDrawer}></UserDetail>
</SwipeableDrawer>
</React.Fragment>
);
}
const renderBody = () => {
if (isUsersQueried) {
const {users, count} = data;
return (isUsersFetching || isUsersLoading) ?
<Box sx={{display: 'flex'}}>
<CircularProgress/>
</Box> :
renderTable(users, count)
}
}
const renderError = () => {
return isUsersError && <Alert severity="error">{JSON.stringify(error)}</Alert>;
}
return (
<Container maxWidth={"md"} fixed>
{renderError()}
{renderBody()}
<Footer></Footer>
<Dialog
open={dialog.open}
onClose={dialog.onCancel}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{dialog.title}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{dialog.text}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={dialog.onCancel}>Disagree</Button>
<Button onClick={dialog.onConfirm} autoFocus>
Agree
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={alert.open}
autoHideDuration={6000}
onClose={resetAlert}
message={alert.text}
/>
</Container>
);
}
export default Users;
А вот страница сведений о пользователе. Мы используем formik для создания формы и yup для определения правил проверки. Это срабатывает, когда мы отправляем форму.
Обратите внимание, что внутри useEffect мы устанавливаем значения формы в режиме редактирования, когда пользователь использует formic. Этот useEffect будет срабатывать только в том случае, если значение пользователя изменится, чтобы избежать слишком большого количества ошибок повторного отображения.