├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.test.tsx
├── App.tsx
├── app
│ ├── hooks.ts
│ └── store.ts
├── components
│ ├── AutoCompleteFields.test.tsx
│ ├── AutoCompleteFields.tsx
│ ├── Header.test.tsx
│ ├── Header.tsx
│ ├── InputFile.test.tsx
│ ├── InputFile.tsx
│ ├── Layout.test.tsx
│ ├── Layout.tsx
│ ├── Login.test.tsx
│ ├── Login.tsx
│ ├── ProtectedRoute.tsx
│ ├── Rating.test.tsx
│ ├── Rating.tsx
│ ├── RatingsList.test.tsx
│ ├── RatingsList.tsx
│ ├── ResponsiveDrawer.test.tsx
│ ├── ResponsiveDrawer.tsx
│ └── __snapshots__
│ │ ├── AutoCompleteFields.test.tsx.snap
│ │ ├── Header.test.tsx.snap
│ │ ├── Layout.test.tsx.snap
│ │ ├── Login.test.tsx.snap
│ │ ├── Rating.test.tsx.snap
│ │ ├── RatingsList.test.tsx.snap
│ │ └── ResponsiveDrawer.test.tsx.snap
├── config
│ └── theme.ts
├── features
│ ├── api
│ │ └── apiSlice.ts
│ ├── auth
│ │ ├── authSlice.test.ts
│ │ └── authSlice.ts
│ ├── cast
│ │ ├── CreateCastMembers.test.tsx
│ │ ├── CreateCastMembers.tsx
│ │ ├── EditCastMember.test.tsx
│ │ ├── EditCastMember.tsx
│ │ ├── ListCastmembers.test.tsx
│ │ ├── ListCastmembers.tsx
│ │ ├── __snapshots__
│ │ │ ├── CreateCastMembers.test.tsx.snap
│ │ │ ├── EditCastMember.test.tsx.snap
│ │ │ └── ListCastmembers.test.tsx.snap
│ │ ├── castMembersSlice.ts
│ │ └── components
│ │ │ ├── CastMembersTable.test.tsx
│ │ │ ├── CastMembersTable.tsx
│ │ │ ├── CastMembersform.test.tsx
│ │ │ ├── CastMembersform.tsx
│ │ │ └── __snapshots__
│ │ │ ├── CastMembersTable.test.tsx.snap
│ │ │ └── CastMembersform.test.tsx.snap
│ ├── categories
│ │ ├── CreateCategory.test.tsx
│ │ ├── CreateCategory.tsx
│ │ ├── EditCategory.test.tsx
│ │ ├── EditCategory.tsx
│ │ ├── ListCaegory.test.tsx
│ │ ├── ListCaegory.tsx
│ │ ├── __snapshots__
│ │ │ ├── CreateCategory.test.tsx.snap
│ │ │ ├── EditCategory.test.tsx.snap
│ │ │ └── ListCaegory.test.tsx.snap
│ │ ├── categorySlice.ts
│ │ └── components
│ │ │ ├── CategoryFrom.test.tsx
│ │ │ ├── CategoryFrom.tsx
│ │ │ ├── CategoryTable.test.tsx
│ │ │ ├── CategoryTable.tsx
│ │ │ └── __snapshots__
│ │ │ ├── CategoryFrom.test.tsx.snap
│ │ │ └── CategoryTable.test.tsx.snap
│ ├── genre
│ │ ├── GenreCreate.test.tsx
│ │ ├── GenreCreate.tsx
│ │ ├── GenreEdit.test.tsx
│ │ ├── GenreEdit.tsx
│ │ ├── GenreList.test.tsx
│ │ ├── GenreList.tsx
│ │ ├── __snapshots__
│ │ │ ├── GenreCreate.test.tsx.snap
│ │ │ ├── GenreEdit.test.tsx.snap
│ │ │ └── GenreList.test.tsx.snap
│ │ ├── components
│ │ │ ├── GenreFrom.test.tsx
│ │ │ ├── GenreFrom.tsx
│ │ │ ├── GenreTable.test.tsx
│ │ │ ├── GenreTable.tsx
│ │ │ └── __snapshots__
│ │ │ │ ├── GenreFrom.test.tsx.snap
│ │ │ │ └── GenreTable.test.tsx.snap
│ │ ├── genreSlice.ts
│ │ ├── util.test.ts
│ │ └── util.ts
│ ├── mocks
│ │ ├── genre.ts
│ │ └── index.ts
│ ├── uploads
│ │ ├── UploadList.tsx
│ │ ├── UploadSlice.ts
│ │ ├── components
│ │ │ ├── UploadItem.test.tsx
│ │ │ ├── UploadItem.tsx
│ │ │ ├── UploadStatus.tsx
│ │ │ └── __snapshots__
│ │ │ │ └── UploadItem.test.tsx.snap
│ │ ├── uploadAPI.ts
│ │ ├── uploadThunk.test.ts
│ │ └── uploadThunk.ts
│ └── videos
│ │ ├── VideoSlice.ts
│ │ ├── VideosCreate.test.tsx
│ │ ├── VideosCreate.tsx
│ │ ├── VideosEdit.test.tsx
│ │ ├── VideosEdit.tsx
│ │ ├── VideosList.test.tsx
│ │ ├── VideosList.tsx
│ │ ├── __snapshots__
│ │ ├── VideosCreate.test.tsx.snap
│ │ ├── VideosEdit.test.tsx.snap
│ │ └── VideosList.test.tsx.snap
│ │ ├── components
│ │ ├── VideosForm.test.tsx
│ │ ├── VideosForm.tsx
│ │ ├── VideosTable.tsx
│ │ └── __snapshots__
│ │ │ └── VideosForm.test.tsx.snap
│ │ ├── util .test.ts
│ │ └── util.ts
├── hooks
│ ├── useAppTheme.test.ts
│ ├── useAppTheme.ts
│ ├── useLocalStorage.test.ts
│ ├── useLocalStorage.ts
│ └── useUniqueCategories.ts
├── index.css
├── index.tsx
├── keycloakConfig.ts
├── middleware
│ └── uploadQueue.ts
├── providers
│ ├── KeycloakProvider.test.tsx
│ └── KeycloakProvider.tsx
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupTests.ts
├── types
│ ├── CastMembers.ts
│ ├── Category.ts
│ ├── Genres.ts
│ └── Videos.ts
└── utils
│ └── test-utils.tsx
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) TS template.
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "codeflix",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@emotion/react": "^11.10.0",
7 | "@emotion/styled": "^11.10.0",
8 | "@hookform/resolvers": "^2.9.10",
9 | "@mui/icons-material": "^5.8.4",
10 | "@mui/material": "^5.9.3",
11 | "@mui/x-data-grid": "^5.15.2",
12 | "@mui/x-date-pickers": "^5.0.8",
13 | "@react-keycloak/web": "^3.4.0",
14 | "@reduxjs/toolkit": "^1.8.3",
15 | "@testing-library/jest-dom": "^5.16.4",
16 | "@testing-library/react": "^13.4.0",
17 | "@testing-library/user-event": "^14.4.3",
18 | "@types/jest": "^27.5.2",
19 | "@types/node": "^17.0.45",
20 | "@types/react": "^18.0.15",
21 | "@types/react-dom": "^18.0.6",
22 | "axios": "^1.2.3",
23 | "date-fns": "^2.29.3",
24 | "jest-mock-axios": "^4.7.1",
25 | "keycloak-js": "^21.0.2",
26 | "nanoid": "^4.0.0",
27 | "notistack": "^2.0.5",
28 | "react": "^18.2.0",
29 | "react-dom": "^18.2.0",
30 | "react-hook-form": "^7.41.1",
31 | "react-redux": "^8.0.2",
32 | "react-router-dom": "6",
33 | "react-scripts": "5.0.1",
34 | "typescript": "^4.7.4",
35 | "web-vitals": "^2.1.4",
36 | "zod": "^3.20.2"
37 | },
38 | "scripts": {
39 | "start": "react-scripts start",
40 | "build": "react-scripts build",
41 | "test": "react-scripts test",
42 | "eject": "react-scripts eject"
43 | },
44 | "jest": {
45 | "moduleNameMapper": {
46 | "axios": "axios/dist/node/axios.cjs",
47 | "^nanoid(/(.*)|$)": "nanoid$1"
48 | }
49 | },
50 | "eslintConfig": {
51 | "extends": [
52 | "react-app",
53 | "react-app/jest"
54 | ]
55 | },
56 | "browserslist": {
57 | "production": [
58 | ">0.2%",
59 | "not dead",
60 | "not op_mini all"
61 | ],
62 | "development": [
63 | "last 1 chrome version",
64 | "last 1 firefox version",
65 | "last 1 safari version"
66 | ]
67 | },
68 | "devDependencies": {
69 | "msw": "^0.47.3"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devfullcycle/FC3-admin-videos-react/cbb9f77c5355ca553c3bad6f48d819f13de7b34d/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
16 |
17 |
21 |
22 |
26 |
27 |
36 | React Redux App
37 |
38 |
39 |
40 |
41 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devfullcycle/FC3-admin-videos-react/cbb9f77c5355ca553c3bad6f48d819f13de7b34d/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devfullcycle/FC3-admin-videos-react/cbb9f77c5355ca553c3bad6f48d819f13de7b34d/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { Provider } from "react-redux";
2 | import { BrowserRouter } from "react-router-dom";
3 | import App from "./App";
4 | import { setupStore } from "./app/store";
5 | import { KeycloakProvider } from "./providers/KeycloakProvider";
6 | import { render, screen } from "./utils/test-utils";
7 |
8 | // mock nanoid
9 | jest.mock("nanoid", () => ({
10 | nanoid: () => "test-id",
11 | }));
12 |
13 | describe("App", () => {
14 | it("renders the root component without crashing", () => {
15 | const store = setupStore();
16 |
17 | render(
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 |
27 | // Add a data-testid to the top-level element in App.tsx
28 | expect(screen.getByTestId("app")).toBeInTheDocument();
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Typography } from "@mui/material";
2 | import { Box } from "@mui/system";
3 | import { Route, Routes } from "react-router-dom";
4 | import { Layout } from "./components/Layout";
5 |
6 | import { CreateCastMember } from "./features/cast/CreateCastMembers";
7 | import { EditCastMember } from "./features/cast/EditCastMember";
8 | import { ListCastmembers } from "./features/cast/ListCastmembers";
9 |
10 | import { CategoryCreate } from "./features/categories/CreateCategory";
11 | import { CategoryEdit } from "./features/categories/EditCategory";
12 | import { CategoryList } from "./features/categories/ListCaegory";
13 |
14 | import { GenreCreate } from "./features/genre/GenreCreate";
15 | import { GenreEdit } from "./features/genre/GenreEdit";
16 | import { GenreList } from "./features/genre/GenreList";
17 | import { UploadList } from "./features/uploads/UploadList";
18 |
19 | import { VideosCreate } from "./features/videos/VideosCreate";
20 | import { VideosEdit } from "./features/videos/VideosEdit";
21 | import { VideosList } from "./features/videos/VideosList";
22 | import { ProtectedRoute } from "./components/ProtectedRoute";
23 | import Login from "./components/Login";
24 |
25 | function App() {
26 | return (
27 |
28 |
29 |
30 |
31 | } />
32 |
33 | {/* Login */}
34 | } />
35 |
36 | {/* Category */}
37 |
41 |
42 |
43 | }
44 | />
45 |
49 |
50 |
51 | }
52 | />
53 |
57 |
58 |
59 | }
60 | />
61 |
62 | {/* Cast members */}
63 |
67 |
68 |
69 | }
70 | />
71 |
75 |
76 |
77 | }
78 | />
79 |
83 |
84 |
85 | }
86 | />
87 |
88 | {/* Genre */}
89 |
93 |
94 |
95 | }
96 | />
97 |
101 |
102 |
103 | }
104 | />
105 |
109 |
110 |
111 | }
112 | />
113 |
114 | {/* Videos */}
115 |
119 |
120 |
121 | }
122 | />
123 |
127 |
128 |
129 | }
130 | />
131 |
135 |
136 |
137 | }
138 | />
139 |
140 |
144 | 404
145 | Page not found
146 |
147 | }
148 | />
149 |
150 |
151 |
152 | );
153 | }
154 |
155 | export default App;
156 |
--------------------------------------------------------------------------------
/src/app/hooks.ts:
--------------------------------------------------------------------------------
1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
2 | import type { RootState, AppDispatch } from './store';
3 |
4 | // Use throughout your app instead of plain `useDispatch` and `useSelector`
5 | export const useAppDispatch = () => useDispatch();
6 | export const useAppSelector: TypedUseSelectorHook = useSelector;
7 |
--------------------------------------------------------------------------------
/src/app/store.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Action,
3 | combineReducers,
4 | configureStore,
5 | PreloadedState,
6 | ThunkAction,
7 | } from "@reduxjs/toolkit";
8 | import { apiSlice } from "../features/api/apiSlice";
9 | import { castMembersApiSlice } from "../features/cast/castMembersSlice";
10 | import { categoriesApiSlice } from "../features/categories/categorySlice";
11 | import { genreSlice } from "../features/genre/genreSlice";
12 | import { videosSlice } from "../features/videos/VideoSlice";
13 | import { uploadReducer } from "../features/uploads/UploadSlice";
14 | import { authSlice } from "../features/auth/authSlice";
15 | import { uploadQueue } from "../middleware/uploadQueue";
16 |
17 | const rootReducer = combineReducers({
18 | [apiSlice.reducerPath]: apiSlice.reducer,
19 | [categoriesApiSlice.reducerPath]: apiSlice.reducer,
20 | [castMembersApiSlice.reducerPath]: apiSlice.reducer,
21 | [videosSlice.reducerPath]: apiSlice.reducer,
22 | [genreSlice.reducerPath]: apiSlice.reducer,
23 | auth: authSlice.reducer,
24 | uploadSlice: uploadReducer,
25 | });
26 |
27 | export const setupStore = (preloadedState?: PreloadedState) => {
28 | return configureStore({
29 | reducer: rootReducer,
30 | preloadedState,
31 | middleware: (getDefaultMiddleware) =>
32 | getDefaultMiddleware({
33 | serializableCheck: {
34 | ignoredActions: ["uploads/addUpload, uploads/updateUpload"],
35 | ignoredPaths: ["uploadSlice.file"],
36 | },
37 | })
38 | .prepend(uploadQueue.middleware)
39 | .concat(apiSlice.middleware),
40 | });
41 | };
42 |
43 | export type AppStore = ReturnType;
44 | export type AppDispatch = AppStore["dispatch"];
45 | export type RootState = ReturnType;
46 | export type AppThunk = ThunkAction<
47 | ReturnType,
48 | RootState,
49 | unknown,
50 | Action
51 | >;
52 |
--------------------------------------------------------------------------------
/src/components/AutoCompleteFields.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { Category } from "../types/Category";
3 | import { AutoCompleteFields } from "./AutoCompleteFields";
4 |
5 | describe("AutoCompleteFields", () => {
6 | const categories: Category[] = [
7 | {
8 | id: "1",
9 | name: "Comedy",
10 | deleted_at: "",
11 | is_active: true,
12 | created_at: "2022-01-01",
13 | updated_at: "2022-01-01",
14 | description: null,
15 | },
16 | {
17 | id: "2",
18 | name: "Adventure",
19 | deleted_at: "",
20 | is_active: true,
21 | created_at: "2022-01-01",
22 | updated_at: "2022-01-01",
23 | description: null,
24 | },
25 | ];
26 |
27 | it("should render the component with loading", () => {
28 | const { asFragment } = render(
29 | {}}
37 | />
38 | );
39 | expect(asFragment()).toMatchSnapshot();
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/components/AutoCompleteFields.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Autocomplete,
3 | AutocompleteRenderInputParams,
4 | TextField,
5 | } from "@mui/material";
6 | import { CastMember } from "../types/CastMembers";
7 | import { Category } from "../types/Category";
8 | import { Genre } from "../types/Genres";
9 |
10 | type Props = {
11 | name: string;
12 | label: string;
13 | isLoading: boolean;
14 | isDisabled: boolean;
15 | values?: (Genre | Category | CastMember)[];
16 | options?: (Genre | Category | CastMember)[];
17 | handleChange: (e: React.ChangeEvent) => void;
18 | };
19 |
20 | export const AutoCompleteFields = ({
21 | name,
22 | label,
23 | values,
24 | options,
25 | isLoading,
26 | isDisabled,
27 | handleChange,
28 | }: Props) => {
29 | const renderOptions = (
30 | props: React.HTMLAttributes,
31 | option: Category | Genre | CastMember
32 | ) => (
33 |
34 | {option.name}
35 |
36 | );
37 |
38 | const isEqualId = (
39 | option: Genre | Category | CastMember,
40 | value: Genre | Category | CastMember
41 | ) => {
42 | return option.id === value.id;
43 | };
44 |
45 | const handleOnChange = (
46 | _e: React.ChangeEvent<{}>,
47 | newValue: (Genre | Category | CastMember)[]
48 | ) => {
49 | handleChange({ target: { name, value: newValue } } as any);
50 | };
51 |
52 | const renderInput = (params: AutocompleteRenderInputParams) => (
53 |
54 | );
55 |
56 | return (
57 | option.name}
69 | />
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/src/components/Header.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from "@testing-library/react";
2 | import { Header } from "./Header";
3 | import { keycloak } from "../keycloakConfig";
4 |
5 | // mock keycloak
6 | jest.mock("../keycloakConfig");
7 |
8 | describe("Header", () => {
9 | it("should render correctly", () => {
10 | const { asFragment } = render( {}} theme="dark" />);
11 | expect(asFragment()).toMatchSnapshot();
12 | });
13 |
14 | it("should render correctly with light theme", () => {
15 | const { asFragment } = render( {}} theme="light" />);
16 | expect(asFragment()).toMatchSnapshot();
17 | });
18 |
19 | it("should render correctly with dark theme", () => {
20 | const { asFragment } = render( {}} theme="dark" />);
21 | expect(asFragment()).toMatchSnapshot();
22 | });
23 |
24 | // keycloak for logout
25 | it("should handle logout", () => {
26 | render( {}} theme="dark" />);
27 |
28 | const logoutButton = screen.getByRole("button", { name: /Logout/i });
29 | expect(logoutButton).toBeInTheDocument();
30 |
31 | // click logout button
32 | logoutButton.click();
33 |
34 | // check if logout is called
35 | expect(keycloak.logout).toHaveBeenCalled();
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import Brightness4Icon from "@mui/icons-material/Brightness4";
2 | import Brightness7Icon from "@mui/icons-material/Brightness7";
3 | import MenuIcon from "@mui/icons-material/Menu";
4 | import { Box, Button, IconButton, Toolbar } from "@mui/material";
5 | import { keycloak } from "../keycloakConfig";
6 |
7 | type HeaderProps = {
8 | toggle: () => void;
9 | theme: string;
10 | handleDrawerToggle?: () => void;
11 | };
12 |
13 | export function Header({ toggle, theme, handleDrawerToggle }: HeaderProps) {
14 | return (
15 |
16 |
17 |
25 |
26 |
27 |
28 |
29 | {theme === "dark" ? : }
30 |
31 |
32 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/InputFile.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from "@testing-library/react";
2 | import { InputFile } from "./InputFile";
3 |
4 | describe("InputFile", () => {
5 | it("renders the InputFile component with a placeholder", () => {
6 | const onAdd = jest.fn();
7 | const onRemove = jest.fn();
8 | const placeholder = "Select a file";
9 |
10 | render(
11 |
12 | );
13 |
14 | expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/InputFile.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, TextField } from "@mui/material";
2 | import { useRef, useState } from "react";
3 | import DeleteIcon from "@mui/icons-material/Delete";
4 | import FileIcon from "@mui/icons-material/FileCopy";
5 |
6 | interface Props {
7 | onAdd: (files: File) => void;
8 | onRemove: (file: File) => void;
9 | placeholder?: string;
10 | "data-testid"?: string;
11 | }
12 |
13 | export const InputFile: React.FC = ({
14 | onAdd,
15 | onRemove,
16 | placeholder,
17 | "data-testid": dataTestId = "input-file",
18 | }: Props) => {
19 | const [selectedFiles, setSelectedFiles] = useState();
20 | const fileInputRef = useRef(null);
21 |
22 | const handleChange = (event: React.ChangeEvent) => {
23 | const file = event.target.files ? event.target.files[0] : undefined;
24 | if (!file) return;
25 | setSelectedFiles(file);
26 | onAdd(file);
27 | };
28 |
29 | const handleFileInput = () => {
30 | fileInputRef.current?.click();
31 | };
32 |
33 | const handleClear = () => {
34 | setSelectedFiles(undefined);
35 | if (selectedFiles) {
36 | onRemove(selectedFiles);
37 | }
38 | };
39 |
40 | return (
41 | <>
42 |
50 |
51 |
52 | ) : (
53 |
54 |
55 |
56 | ),
57 | }}
58 | />
59 |
68 | >
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/src/components/Layout.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderWithProviders } from "../utils/test-utils";
2 | import { Layout } from "./Layout";
3 |
4 | describe("Layout", () => {
5 | it("should render correctly", () => {
6 | const { asFragment } = renderWithProviders(
7 |
8 | Test
9 |
10 | );
11 | expect(asFragment()).toMatchSnapshot();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/src/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { AppBar, Box, CssBaseline, ThemeProvider } from "@mui/material";
2 | import { Container } from "@mui/system";
3 | import { SnackbarProvider } from "notistack";
4 | import React, { useState } from "react";
5 | import { useAppTheme } from "../hooks/useAppTheme";
6 | import { Header } from "./Header";
7 | import ResponsiveDrawer from "./ResponsiveDrawer";
8 |
9 | const drawerWidth = 240;
10 |
11 | export function Layout({ children }: { children: React.ReactNode }) {
12 | const [mobileOpen, setMobileOpen] = useState(false);
13 | const [currentTheme, toggleCurrentTheme] = useAppTheme();
14 |
15 | const handleDrawerToggle = () => {
16 | setMobileOpen(!mobileOpen);
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
30 |
35 |
36 |
37 |
38 |
39 |
44 |
45 | {children}
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/Login.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderWithProviders } from "../utils/test-utils";
2 | import Login from "./Login";
3 |
4 | describe("Login", () => {
5 | it("should render correctly", () => {
6 | const { asFragment } = renderWithProviders();
7 | expect(asFragment()).toMatchSnapshot();
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/Login.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function Login() {
4 | return (
5 |
6 |
Please login to access the application
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/ProtectedRoute.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 | import { Navigate } from "react-router-dom";
4 | import { selectIsAuthenticated } from "../features/auth/authSlice";
5 |
6 | export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
7 | // const isAuthenticated = useSelector(selectIsAuthenticated);
8 | // if (!isAuthenticated) {
9 | // return ;
10 | // }
11 |
12 | return <>{children}>;
13 | };
14 |
--------------------------------------------------------------------------------
/src/components/Rating.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { Rating } from "./Rating";
3 |
4 | describe("Rating", () => {
5 | it("should render correctly", () => {
6 | const { asFragment } = render();
7 | expect(asFragment()).toMatchSnapshot();
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/Rating.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from "@mui/material";
2 |
3 | const backgroundColors = {
4 | L: "#39B549",
5 | "10": "#20A3D4",
6 | "12": "#E79738",
7 | "14": "#E35E00",
8 | "16": "#d00003",
9 | "18": "#000000",
10 | };
11 |
12 | interface RatingProps {
13 | rating: "L" | "10" | "12" | "14" | "16" | "18";
14 | }
15 |
16 | export const Rating: React.FC = (props) => {
17 | return (
18 | :first-of-type": {
21 | mr: 0,
22 | },
23 | width: 40,
24 | height: 40,
25 | backgroundColor: backgroundColors[props.rating],
26 | borderRadius: "4px",
27 | display: "flex",
28 | justifyContent: "center",
29 | alignItems: "center",
30 | }}
31 | >
32 | {props.rating}
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/src/components/RatingsList.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { RatingsList } from "./RatingsList";
3 |
4 | describe("RatingsList", () => {
5 | it("should render correctly", () => {
6 | const { asFragment } = render();
7 | expect(asFragment()).toMatchSnapshot();
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/RatingsList.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | FormControlLabel,
4 | FormControlLabelProps,
5 | Radio,
6 | } from "@mui/material";
7 | import { Rating } from "./Rating";
8 |
9 | const ratings: FormControlLabelProps[] = [
10 | {
11 | value: "L",
12 | control: ,
13 | label: ,
14 | labelPlacement: "top",
15 | },
16 | {
17 | value: "10",
18 | control: ,
19 | label: ,
20 | labelPlacement: "top",
21 | },
22 | {
23 | value: "12",
24 | control: ,
25 | label: ,
26 | labelPlacement: "top",
27 | },
28 | {
29 | value: "14",
30 | control: ,
31 | label: ,
32 | labelPlacement: "top",
33 | },
34 | {
35 | value: "16",
36 | control: ,
37 | label: ,
38 | labelPlacement: "top",
39 | },
40 | {
41 | value: "18",
42 | control: ,
43 | label: ,
44 | labelPlacement: "top",
45 | },
46 | ];
47 |
48 | export function RatingsList({ isDisabled }: { isDisabled?: boolean }) {
49 | return (
50 |
51 | {ratings.map((rating, index) => (
52 |
61 | ))}
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/ResponsiveDrawer.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderWithProviders, screen } from "../utils/test-utils";
2 | import ResponsiveDrawer from "./ResponsiveDrawer";
3 |
4 | describe("ResponsiveDrawer", () => {
5 | it("should render correctly", () => {
6 | const { asFragment } = renderWithProviders(
7 | {}} open={false} />
8 | );
9 | expect(asFragment()).toMatchSnapshot();
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/src/components/ResponsiveDrawer.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Divider,
3 | List,
4 | ListItem,
5 | Toolbar,
6 | Typography,
7 | Box,
8 | Drawer,
9 | ListItemButton,
10 | ListItemText,
11 | } from "@mui/material";
12 | import { Link } from "react-router-dom";
13 |
14 | const drawerWidth = 240;
15 |
16 | type Props = {
17 | open: boolean;
18 | onClose: () => void;
19 | };
20 |
21 | export default function ResponsiveDrawer({ open, onClose }: Props) {
22 | const routes = [
23 | { path: "/", name: "Categories" },
24 | { path: "/cast-members", name: "Cast Members" },
25 | { path: "/genres", name: "Genres" },
26 | { path: "/videos", name: "Videos" },
27 | ];
28 |
29 | const drawer = (
30 |
31 |
32 |
33 | Codeflix
34 |
35 |
36 |
37 |
38 | {routes.map((route) => (
39 |
45 |
46 |
47 | {route.name}
48 |
49 |
50 |
51 | ))}
52 |
53 |
54 | );
55 |
56 | return (
57 |
61 |
71 | {drawer}
72 |
73 |
74 |
86 | {drawer}
87 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/Login.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Login should render correctly 1`] = `
4 |
5 |
6 |
7 | Please login to access the application
8 |
9 |
10 |
11 | `;
12 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/Rating.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Rating should render correctly 1`] = `
4 |
5 |
14 |
15 | `;
16 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/ResponsiveDrawer.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`ResponsiveDrawer should render correctly 1`] = `
4 |
5 |
143 |
144 | `;
145 |
--------------------------------------------------------------------------------
/src/config/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from "@mui/material";
2 |
3 | export const darkTheme = createTheme({
4 | palette: {
5 | background: { default: "#222222" },
6 | mode: "dark",
7 | primary: { main: "#f5f5f1" },
8 | secondary: { main: "#E50914" },
9 | text: { primary: "#f5f5f1" },
10 | },
11 | });
12 |
13 | export const lightTheme = createTheme({
14 | palette: {
15 | background: {},
16 | mode: "light",
17 | primary: { main: "#E50914" },
18 | secondary: { main: "#222222" },
19 | text: { primary: "#222222" },
20 | },
21 | });
22 |
--------------------------------------------------------------------------------
/src/features/api/apiSlice.ts:
--------------------------------------------------------------------------------
1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
2 | import { keycloak } from "../../keycloakConfig";
3 |
4 | export const baseUrl = "http://localhost:8000/api";
5 |
6 | export const apiSlice = createApi({
7 | reducerPath: "api",
8 | tagTypes: ["Categories", "CastMembers", "Genres", "Videos"],
9 | endpoints: (builder) => ({}),
10 | baseQuery: fetchBaseQuery({
11 | baseUrl,
12 | prepareHeaders: (headers) => {
13 | if (keycloak.token) {
14 | headers.set("Authorization", `Bearer ${keycloak.token}`);
15 | }
16 | return headers;
17 | },
18 | }),
19 | });
20 |
--------------------------------------------------------------------------------
/src/features/auth/authSlice.test.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import {
3 | authSlice,
4 | selectIsAuthenticated,
5 | selectIsLoading,
6 | setAuthenticated,
7 | setLoading,
8 | setToken,
9 | setUserDetails,
10 | } from "./authSlice";
11 | import { RootState } from "../../app/store";
12 |
13 | describe("authSlice", () => {
14 | let store = configureStore({ reducer: { auth: authSlice.reducer } });
15 |
16 | const mockState = {
17 | auth: {
18 | token: "",
19 | isLoading: false,
20 | userDetails: null,
21 | isAuthenticated: false,
22 | },
23 | } as RootState;
24 |
25 | beforeEach(() => {
26 | store = configureStore({ reducer: { auth: authSlice.reducer } });
27 | });
28 |
29 | it("sets isAuthenticated", () => {
30 | store.dispatch(setAuthenticated(true));
31 | expect(store.getState().auth.isAuthenticated).toBe(true);
32 | });
33 |
34 | it("sets isLoading", () => {
35 | store.dispatch(setLoading(true));
36 | expect(store.getState().auth.isLoading).toBe(true);
37 | });
38 |
39 | it("sets token", () => {
40 | store.dispatch(setToken("token"));
41 | expect(store.getState().auth.token).toBe("token");
42 | });
43 |
44 | it("sets userDetails", () => {
45 | store.dispatch(setUserDetails({ name: "John Doe" }));
46 | expect(store.getState().auth.userDetails).toEqual({ name: "John Doe" });
47 | });
48 |
49 | // selectIsAuthenticated
50 | it("selects isAuthenticated", () => {
51 | const isAuthenticated = selectIsAuthenticated(mockState);
52 | expect(isAuthenticated).toBe(mockState.auth.isAuthenticated);
53 | });
54 |
55 | it("selects isLoading", () => {
56 | const isLoading = selectIsLoading(mockState);
57 | expect(isLoading).toBe(mockState.auth.isLoading);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/features/auth/authSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { RootState } from "../../app/store";
3 |
4 | const initialState = {
5 | token: "",
6 | isLoading: false,
7 | userDetails: null,
8 | isAuthenticated: false,
9 | };
10 |
11 | export const authSlice = createSlice({
12 | name: "auth",
13 | initialState,
14 | reducers: {
15 | setAuthenticated: (state, action) => {
16 | state.isAuthenticated = action.payload;
17 | },
18 | setLoading: (state, action) => {
19 | state.isLoading = action.payload;
20 | },
21 | setToken: (state, action) => {
22 | state.token = action.payload;
23 | },
24 | setUserDetails: (state, action) => {
25 | state.userDetails = action.payload;
26 | },
27 | },
28 | });
29 |
30 | export const { setAuthenticated, setLoading, setToken, setUserDetails } =
31 | authSlice.actions;
32 |
33 | export const selectIsAuthenticated = (state: RootState) =>
34 | state.auth.isAuthenticated;
35 |
36 | export const selectIsLoading = (state: RootState) => state.auth.isLoading;
37 |
--------------------------------------------------------------------------------
/src/features/cast/CreateCastMembers.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 |
4 | import {
5 | fireEvent,
6 | renderWithProviders,
7 | screen,
8 | waitFor,
9 | } from "../../utils/test-utils";
10 | import { CreateCastMember } from "./CreateCastMembers";
11 | import { baseUrl } from "../api/apiSlice";
12 |
13 | export const handlers = [
14 | rest.post(`${baseUrl}/cast_members`, (_, res, ctx) => {
15 | return res(ctx.delay(150), ctx.status(201));
16 | }),
17 | ];
18 |
19 | const server = setupServer(...handlers);
20 |
21 | describe("CreateCastMember", () => {
22 | afterAll(() => server.close());
23 | beforeAll(() => server.listen());
24 | afterEach(() => server.resetHandlers());
25 |
26 | it("should render correctly", () => {
27 | const { asFragment } = renderWithProviders();
28 | expect(asFragment()).toMatchSnapshot();
29 | });
30 |
31 | it("should handle submit", async () => {
32 | renderWithProviders();
33 | const name = screen.getByTestId("name");
34 | const submit = screen.getByText("Save");
35 |
36 | fireEvent.change(name, { target: { value: "Test" } });
37 | fireEvent.click(submit);
38 |
39 | await waitFor(() => {
40 | const text = screen.getByText("Cast member created");
41 | expect(text).toBeInTheDocument();
42 | });
43 | });
44 |
45 | it("should handle submit error", async () => {
46 | server.use(
47 | rest.post(`${baseUrl}/cast_members`, (_, res, ctx) => {
48 | return res(ctx.status(500));
49 | })
50 | );
51 |
52 | renderWithProviders();
53 | const name = screen.getByTestId("name");
54 | const submit = screen.getByText("Save");
55 |
56 | fireEvent.change(name, { target: { value: "Test" } });
57 | fireEvent.click(submit);
58 |
59 | await waitFor(() => {
60 | const text = screen.getByText("Cast member not created");
61 | expect(text).toBeInTheDocument();
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/src/features/cast/CreateCastMembers.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { CastMember } from "../../types/CastMembers";
3 | import { initialState, useCreateCastMemberMutation } from "./castMembersSlice";
4 | import { useSnackbar } from "notistack";
5 | import { Box } from "@mui/system";
6 | import { Paper, Typography } from "@mui/material";
7 | import { CastMemberForm } from "./components/CastMembersform";
8 |
9 | export const CreateCastMember = () => {
10 | const [castMemberState, setCastMemberState] =
11 | useState(initialState);
12 | const [createCastMember, status] = useCreateCastMemberMutation();
13 | const { enqueueSnackbar } = useSnackbar();
14 |
15 | function handleChange(e: React.ChangeEvent) {
16 | const { name, value } = e.target;
17 | setCastMemberState({ ...castMemberState, [name]: value });
18 | }
19 |
20 | async function handleSubmit(e: React.FormEvent) {
21 | e.preventDefault();
22 | await createCastMember(castMemberState);
23 | }
24 |
25 | useEffect(() => {
26 | if (status.isSuccess) {
27 | enqueueSnackbar(`Cast member created`, { variant: "success" });
28 | }
29 | if (status.isError) {
30 | enqueueSnackbar(`Cast member not created`, { variant: "error" });
31 | }
32 | }, [status, enqueueSnackbar]);
33 |
34 | return (
35 |
36 |
37 |
38 |
39 | Create Cast Member
40 |
41 |
42 |
49 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/src/features/cast/EditCastMember.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 | import {
4 | fireEvent,
5 | renderWithProviders,
6 | screen,
7 | waitFor,
8 | } from "../../utils/test-utils";
9 | import { EditCastMember } from "./EditCastMember";
10 | import { baseUrl } from "../api/apiSlice";
11 |
12 | const data = {
13 | id: 1,
14 | name: "test",
15 | type: 1,
16 | };
17 |
18 | export const handlers = [
19 | rest.get(`${baseUrl}/cast_members/`, (_, res, ctx) => {
20 | return res(ctx.delay(150), ctx.status(200), ctx.json({ data: data }));
21 | }),
22 | rest.put(`${baseUrl}/cast_members/1`, (_, res, ctx) => {
23 | return res(ctx.delay(150), ctx.status(201));
24 | }),
25 | ];
26 |
27 | const server = setupServer(...handlers);
28 |
29 | describe("EditCastMember", () => {
30 | afterAll(() => server.close());
31 | beforeAll(() => server.listen());
32 | afterEach(() => server.resetHandlers());
33 |
34 | it("should render correctly", () => {
35 | const { asFragment } = renderWithProviders();
36 | expect(asFragment()).toMatchSnapshot();
37 | });
38 |
39 | it("should handle submit", async () => {
40 | renderWithProviders();
41 | const name = screen.getByTestId("name");
42 |
43 | await waitFor(() => {
44 | expect(name).toHaveValue("test");
45 | });
46 |
47 | await waitFor(() => {
48 | const submit = screen.getByText("Save");
49 | expect(submit).toBeInTheDocument();
50 | });
51 |
52 | const submit = screen.getByText("Save");
53 | fireEvent.change(name, { target: { value: "Test" } });
54 | fireEvent.click(submit);
55 |
56 | await waitFor(() => {
57 | const text = screen.getByText("Cast member updated");
58 | expect(text).toBeInTheDocument();
59 | });
60 | });
61 |
62 | it("should handle submit error", async () => {
63 | server.use(
64 | rest.put(`${baseUrl}/cast_members/1`, (_, res, ctx) => {
65 | return res(ctx.status(500));
66 | })
67 | );
68 |
69 | renderWithProviders();
70 | const name = screen.getByTestId("name");
71 | await waitFor(() => {
72 | expect(name).toHaveValue("test");
73 | });
74 |
75 | await waitFor(() => {
76 | const submit = screen.getByText("Save");
77 | expect(submit).toBeInTheDocument();
78 | });
79 |
80 | const submit = screen.getByText("Save");
81 | fireEvent.change(name, { target: { value: "test1" } });
82 | fireEvent.click(submit);
83 |
84 | await waitFor(() => {
85 | const text = screen.getByText("Cast member not updated");
86 | expect(text).toBeInTheDocument();
87 | });
88 | });
89 | });
90 |
--------------------------------------------------------------------------------
/src/features/cast/EditCastMember.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { CastMember } from "../../types/CastMembers";
3 | import {
4 | initialState,
5 | useGetCastMemberQuery,
6 | useUpdateCastMemberMutation,
7 | } from "./castMembersSlice";
8 | import { useSnackbar } from "notistack";
9 | import { Box } from "@mui/system";
10 | import { Paper, Typography } from "@mui/material";
11 | import { CastMemberForm } from "./components/CastMembersform";
12 | import { useParams } from "react-router-dom";
13 |
14 | export const EditCastMember = () => {
15 | const id = useParams().id ?? "";
16 | const { data: castMember, isFetching } = useGetCastMemberQuery({ id });
17 | const [castMemberState, setCastMemberState] =
18 | useState(initialState);
19 |
20 | const [updateCastMember, status] = useUpdateCastMemberMutation();
21 |
22 | const { enqueueSnackbar } = useSnackbar();
23 |
24 | function handleChange(e: React.ChangeEvent) {
25 | const { name, value } = e.target;
26 | setCastMemberState({ ...castMemberState, [name]: value });
27 | }
28 |
29 | async function handleSubmit(e: React.FormEvent) {
30 | e.preventDefault();
31 | await updateCastMember(castMemberState);
32 | }
33 |
34 | useEffect(() => {
35 | if (castMember) {
36 | setCastMemberState(castMember.data);
37 | }
38 | }, [castMember]);
39 |
40 | useEffect(() => {
41 | if (status.isSuccess) {
42 | enqueueSnackbar(`Cast member updated`, { variant: "success" });
43 | }
44 | if (status.isError) {
45 | enqueueSnackbar(`Cast member not updated`, { variant: "error" });
46 | }
47 | }, [status, enqueueSnackbar]);
48 |
49 | return (
50 |
51 |
52 |
53 |
54 | Edit Cast member
55 |
56 |
57 |
64 |
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/src/features/cast/ListCastmembers.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 | import {
4 | screen,
5 | waitFor,
6 | fireEvent,
7 | renderWithProviders,
8 | } from "../../utils/test-utils";
9 | import { baseUrl } from "../api/apiSlice";
10 | import { ListCastmembers } from "./ListCastmembers";
11 | import { castMemberResponse, castMemberResponsePage2 } from "../mocks";
12 |
13 | const handlers = [
14 | rest.get(`${baseUrl}/cast_members`, (req, res, ctx) => {
15 | // check if is page 2
16 | if (req.url.searchParams.get("page") === "2") {
17 | return res(ctx.json(castMemberResponsePage2), ctx.delay(150));
18 | }
19 |
20 | return res(ctx.delay(150), ctx.status(200), ctx.json(castMemberResponse));
21 | }),
22 |
23 | rest.delete(
24 | `${baseUrl}/cast_members/f55fca48-d422-48bf-b212-956215eddcaf`,
25 | (_, res, ctx) => {
26 | return res(ctx.delay(150), ctx.status(204));
27 | }
28 | ),
29 | ];
30 |
31 | const server = setupServer(...handlers);
32 |
33 | describe("ListCastmembers", () => {
34 | beforeAll(() => server.listen());
35 | afterEach(() => server.resetHandlers());
36 | afterAll(() => server.close());
37 |
38 | it("should render correctly", () => {
39 | const { asFragment } = renderWithProviders();
40 | expect(asFragment()).toMatchSnapshot();
41 | });
42 |
43 | it("should render loading state", () => {
44 | renderWithProviders();
45 | const loading = screen.getByRole("progressbar");
46 | expect(loading).toBeInTheDocument();
47 | });
48 |
49 | it("should render success state", async () => {
50 | renderWithProviders();
51 | await waitFor(() => {
52 | const name = screen.getByText("Teste");
53 | expect(name).toBeInTheDocument();
54 | });
55 | });
56 |
57 | it("should render error state", async () => {
58 | server.use(
59 | rest.get(`${baseUrl}/cast_members`, (_, res, ctx) => {
60 | return res(ctx.status(500));
61 | })
62 | );
63 |
64 | renderWithProviders();
65 |
66 | await waitFor(() => {
67 | const error = screen.getByText("Error!");
68 | expect(error).toBeInTheDocument();
69 | });
70 | });
71 |
72 | it("should handle On PageChange", async () => {
73 | renderWithProviders();
74 |
75 | await waitFor(() => {
76 | const name = screen.getByText("Teste");
77 | expect(name).toBeInTheDocument();
78 | });
79 |
80 | const nextButton = screen.getByTestId("KeyboardArrowRightIcon");
81 | fireEvent.click(nextButton);
82 |
83 | await waitFor(() => {
84 | const name = screen.getByText("Teste 2");
85 | expect(name).toBeInTheDocument();
86 | });
87 | });
88 |
89 | it("should handle filter change", async () => {
90 | renderWithProviders();
91 |
92 | await waitFor(() => {
93 | const name = screen.getByText("Teste");
94 | expect(name).toBeInTheDocument();
95 | });
96 |
97 | const input = screen.getByPlaceholderText("Search…");
98 | fireEvent.change(input, { target: { value: "Teste" } });
99 |
100 | await waitFor(() => {
101 | const loading = screen.getByRole("progressbar");
102 | expect(loading).toBeInTheDocument();
103 | });
104 | });
105 |
106 | it("should handle Delete Category success", async () => {
107 | renderWithProviders();
108 |
109 | await waitFor(() => {
110 | const name = screen.getByText("Teste");
111 | expect(name).toBeInTheDocument();
112 | });
113 |
114 | const deleteButton = screen.getAllByTestId("delete-button")[0];
115 | fireEvent.click(deleteButton);
116 |
117 | await waitFor(() => {
118 | const name = screen.getByText("Cast member deleted");
119 | expect(name).toBeInTheDocument();
120 | });
121 | });
122 |
123 | it("should handle Delete Category error", async () => {
124 | server.use(
125 | rest.delete(
126 | `${baseUrl}/cast_members/f55fca48-d422-48bf-b212-956215eddcaf`,
127 | (_, res, ctx) => {
128 | return res(ctx.status(500));
129 | }
130 | )
131 | );
132 |
133 | renderWithProviders();
134 |
135 | await waitFor(() => {
136 | const name = screen.getByText("Teste");
137 | expect(name).toBeInTheDocument();
138 | });
139 |
140 | const deleteButton = screen.getAllByTestId("delete-button")[0];
141 | fireEvent.click(deleteButton);
142 |
143 | await waitFor(() => {
144 | const name = screen.getByText("Cast member not deleted");
145 | expect(name).toBeInTheDocument();
146 | });
147 | });
148 | });
149 |
--------------------------------------------------------------------------------
/src/features/cast/ListCastmembers.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Typography } from "@mui/material";
2 | import { Box } from "@mui/system";
3 | import { GridFilterModel } from "@mui/x-data-grid";
4 | import { useEffect, useState } from "react";
5 | import { Link } from "react-router-dom";
6 | import { useSnackbar } from "notistack";
7 | import {
8 | useDeleteCastMemberMutation,
9 | useGetcastMembersQuery,
10 | } from "./castMembersSlice";
11 | import { CastMembersTable } from "./components/CastMembersTable";
12 |
13 | export const ListCastmembers = () => {
14 | const { enqueueSnackbar } = useSnackbar();
15 | const [options, setOptions] = useState({
16 | page: 1,
17 | search: "",
18 | perPage: 10,
19 | rowsPerPage: [10, 20, 30],
20 | });
21 | const { data, isFetching, error } = useGetcastMembersQuery(options);
22 | const [deleteCastMember, deleteCastMemberStatus] =
23 | useDeleteCastMemberMutation();
24 |
25 | async function handleDeleteCastMember(id: string) {
26 | await deleteCastMember({ id });
27 | }
28 |
29 | function handleOnPageChange(page: number) {
30 | setOptions({ ...options, page: page + 1 });
31 | }
32 |
33 | function handleOnPageSizeChange(perPage: number) {
34 | setOptions({ ...options, perPage });
35 | }
36 |
37 | function handleFilterChange(filterModel: GridFilterModel) {
38 | if (filterModel.quickFilterValues?.length) {
39 | const search = filterModel.quickFilterValues.join("");
40 | return setOptions({ ...options, search });
41 | }
42 |
43 | return setOptions({ ...options, search: "" });
44 | }
45 |
46 | useEffect(() => {
47 | if (deleteCastMemberStatus.isSuccess) {
48 | enqueueSnackbar(`Cast member deleted`, { variant: "success" });
49 | }
50 | if (deleteCastMemberStatus.isError) {
51 | enqueueSnackbar(`Cast member not deleted`, { variant: "error" });
52 | }
53 | }, [deleteCastMemberStatus, enqueueSnackbar]);
54 |
55 | if (error) {
56 | return Error!;
57 | }
58 |
59 | return (
60 |
61 |
62 |
71 |
72 |
82 |
83 | );
84 | };
85 |
--------------------------------------------------------------------------------
/src/features/cast/castMembersSlice.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Results,
3 | CastMemberParams,
4 | CastMember,
5 | Result,
6 | } from "../../types/CastMembers";
7 |
8 | import { apiSlice } from "../api/apiSlice";
9 |
10 | const endpointUrl = "/cast_members";
11 |
12 |
13 | export const initialState: CastMember = {
14 | id: "",
15 | name: "",
16 | type: 1,
17 | created_at: "",
18 | updated_at: "",
19 | deleted_at: null,
20 | };
21 |
22 | function parseQueryParams(params: CastMemberParams) {
23 | const query = new URLSearchParams();
24 |
25 | if (params.page) {
26 | query.append("page", params.page.toString());
27 | }
28 |
29 | if (params.perPage) {
30 | query.append("perPage", params.perPage.toString());
31 | }
32 |
33 | if (params.search) {
34 | query.append("search", params.search);
35 | }
36 |
37 | if (params.type) {
38 | query.append("type", params.type.toString());
39 | }
40 |
41 | return query.toString();
42 | }
43 |
44 | function getCastMembers(params: CastMemberParams) {
45 | const { page = 1, perPage = 10, search, type } = params;
46 | return `${endpointUrl}?${parseQueryParams({
47 | page,
48 | perPage,
49 | search,
50 | type,
51 | })}`;
52 | }
53 |
54 | function deleteCastMember({ id }: { id: string }) {
55 | return {
56 | method: "DELETE",
57 | url: `${endpointUrl}/${id}`,
58 | };
59 | }
60 |
61 | function getCastMember({ id }: { id: string }) {
62 | return {
63 | method: "GET",
64 | url: `${endpointUrl}/${id}`,
65 | };
66 | }
67 |
68 | function updateCastMember(castMember: CastMember) {
69 | return {
70 | method: "PUT",
71 | url: `${endpointUrl}/${castMember.id}`,
72 | body: castMember,
73 | };
74 |
75 | }
76 |
77 | function createCastMember(castMember: CastMember) {
78 | return {
79 | method: "POST",
80 | url: endpointUrl,
81 | body: castMember,
82 | };
83 | }
84 |
85 | export const castMembersApiSlice = apiSlice.injectEndpoints({
86 | endpoints: ({ query, mutation }) => ({
87 | getcastMembers: query({
88 | query: getCastMembers,
89 | providesTags: ["CastMembers"],
90 | }),
91 | getCastMember: query({
92 | query: getCastMember,
93 | providesTags: ["CastMembers"],
94 | }),
95 | updateCastMember: mutation({
96 | query: updateCastMember,
97 | invalidatesTags: ["CastMembers"],
98 | }),
99 | createCastMember: mutation({
100 | query: createCastMember,
101 | invalidatesTags: ["CastMembers"],
102 | }),
103 | deleteCastMember: mutation({
104 | query: deleteCastMember,
105 | invalidatesTags: ["CastMembers"],
106 | }),
107 | }),
108 | });
109 |
110 | export const {
111 | useGetCastMemberQuery,
112 | useGetcastMembersQuery,
113 | useDeleteCastMemberMutation,
114 | useUpdateCastMemberMutation,
115 | useCreateCastMemberMutation,
116 | } = castMembersApiSlice;
117 |
--------------------------------------------------------------------------------
/src/features/cast/components/CastMembersTable.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { BrowserRouter } from "react-router-dom";
3 | import { CastMembersTable } from "./CastMembersTable";
4 |
5 | const Props = {
6 | data: {
7 | data: [
8 | {
9 | id: "123",
10 | type: 1,
11 | name: "test",
12 | deleted_at: null,
13 | created_at: "2021-03-01T00:00:00.000000Z",
14 | updated_at: "2021-03-01T00:00:00.000000Z",
15 | },
16 | ],
17 | meta: {
18 | currentPage: 1,
19 | from: 1,
20 | lastPage: 1,
21 | path: "http://localhost:8000/api/cast_members",
22 | perPage: 1,
23 | to: 1,
24 | total: 1,
25 | },
26 | links: {
27 | first: "http://localhost:8000/api/cast_members?page=1",
28 | last: "http://localhost:8000/api/cast_members?page=1",
29 | prev: "",
30 | next: "",
31 | },
32 | },
33 | perPage: 15,
34 | isFetching: false,
35 | rowsPerPage: [15, 25, 50],
36 | handleOnPageChange: () => {},
37 | handleFilterChange: () => {},
38 | handleOnPageSizeChange: () => {},
39 | handleDelete: () => {},
40 | };
41 |
42 | describe("CastMembersTable", () => {
43 | it("should render castMember talbe correcly", () => {
44 | const { asFragment } = render(, {
45 | wrapper: BrowserRouter,
46 | });
47 |
48 | expect(asFragment()).toMatchSnapshot();
49 | });
50 |
51 | it("should render CastMembersTable with loading", () => {
52 | const { asFragment } = render(, {
53 | wrapper: BrowserRouter,
54 | });
55 |
56 | expect(asFragment()).toMatchSnapshot();
57 | });
58 |
59 | it("should render CastMembersTable with empty data", () => {
60 | const { asFragment } = render(
61 | ,
62 | { wrapper: BrowserRouter }
63 | );
64 |
65 | expect(asFragment()).toMatchSnapshot();
66 | });
67 |
68 | it("should render corret type", () => {
69 | const { asFragment } = render(
70 | ,
78 | {
79 | wrapper: BrowserRouter,
80 | }
81 | );
82 |
83 | expect(asFragment()).toMatchSnapshot();
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/src/features/cast/components/CastMembersTable.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, Typography } from "@mui/material";
2 | import {
3 | DataGrid,
4 | GridColDef,
5 | GridFilterModel,
6 | GridRenderCellParams,
7 | GridToolbar,
8 | } from "@mui/x-data-grid";
9 | import { Results } from "../../../types/CastMembers";
10 | import DeleteIcon from "@mui/icons-material/Delete";
11 | import { Link } from "react-router-dom";
12 | import { Box } from "@mui/system";
13 |
14 | type Props = {
15 | data: Results | undefined;
16 | perPage: number;
17 | isFetching: boolean;
18 | rowsPerPage?: number[];
19 |
20 | handleOnPageChange: (page: number) => void;
21 | handleFilterChange: (filterModel: GridFilterModel) => void;
22 | handleOnPageSizeChange: (perPage: number) => void;
23 | handleDelete: (id: string) => void;
24 | };
25 |
26 | export function CastMembersTable({
27 | data,
28 | perPage,
29 | isFetching,
30 | rowsPerPage,
31 | handleOnPageChange,
32 | handleFilterChange,
33 | handleOnPageSizeChange,
34 | handleDelete,
35 | }: Props) {
36 | const componentProps = {
37 | toolbar: {
38 | showQuickFilter: true,
39 | quickFilterProps: { debounceMs: 500 },
40 | },
41 | };
42 |
43 | const columns: GridColDef[] = [
44 | {
45 | flex: 1,
46 | field: "name",
47 | headerName: "Name",
48 | renderCell: renderNameCell,
49 | },
50 | {
51 | flex: 1,
52 | field: "type",
53 | headerName: "Type",
54 | renderCell: renderTypeCell,
55 | },
56 | {
57 | flex: 1,
58 | field: "id",
59 | headerName: "Actions",
60 | renderCell: renderActionsCell,
61 | },
62 | ];
63 |
64 | function mapDataToGridRows(data: Results) {
65 | const { data: castMembers } = data;
66 | return castMembers.map((castMember) => ({
67 | id: castMember.id,
68 | name: castMember.name,
69 | type: castMember.type,
70 | }));
71 | }
72 |
73 | function renderActionsCell(params: GridRenderCellParams) {
74 | return (
75 | handleDelete(params.value)}
78 | aria-label="delete"
79 | data-testid="delete-button"
80 | >
81 |
82 |
83 | );
84 | }
85 |
86 | function renderNameCell(rowData: GridRenderCellParams) {
87 | return (
88 |
92 | {rowData.value}
93 |
94 | );
95 | }
96 |
97 | function renderTypeCell(rowData: GridRenderCellParams) {
98 | return (
99 |
100 | {rowData.value === 1 ? "Diretor" : "Actor"}
101 |
102 | );
103 | }
104 |
105 | const rows = data ? mapDataToGridRows(data) : [];
106 | const rowCount = data?.meta.total || 0;
107 |
108 | return (
109 |
110 |
130 |
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/src/features/cast/components/CastMembersform.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { BrowserRouter } from "react-router-dom";
3 | import { CastMemberForm } from "./CastMembersform";
4 |
5 | const Props = {
6 | castMember: {
7 | id: "1",
8 | name: "Teste",
9 | type: 1,
10 | deleted_at: null,
11 | created_at: "2021-10-01T00:00:00.000000Z",
12 | updated_at: "2021-10-01T00:00:00.000000Z",
13 | },
14 | isdisabled: false,
15 | isLoading: false,
16 | handleSubmit: jest.fn(),
17 | handleChange: jest.fn(),
18 | };
19 |
20 | describe("CastMemberForm", () => {
21 | it("should render castMember form correctly", () => {
22 | const { asFragment } = render(, {
23 | wrapper: BrowserRouter,
24 | });
25 |
26 | expect(asFragment()).toMatchSnapshot();
27 | });
28 |
29 | it("should render castMember form with loading state", () => {
30 | const { asFragment } = render(, {
31 | wrapper: BrowserRouter,
32 | });
33 |
34 | expect(asFragment()).toMatchSnapshot();
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/features/cast/components/CastMembersform.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | FormControl,
5 | FormControlLabel,
6 | FormGroup,
7 | FormLabel,
8 | Grid,
9 | Radio,
10 | RadioGroup,
11 | TextField,
12 | } from "@mui/material";
13 |
14 | import { Link } from "react-router-dom";
15 | import { CastMember } from "../../../types/CastMembers";
16 |
17 | type Props = {
18 | castMember: CastMember;
19 | isdisabled?: boolean;
20 | isLoading?: boolean;
21 | handleSubmit: (e: React.FormEvent) => void;
22 | handleChange: (e: React.ChangeEvent) => void;
23 | };
24 |
25 | export function CastMemberForm({
26 | castMember,
27 | isdisabled = false,
28 | isLoading = false,
29 | handleSubmit,
30 | handleChange,
31 | }: Props) {
32 | return (
33 |
34 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/features/categories/CreateCategory.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 |
4 | import {
5 | fireEvent,
6 | renderWithProviders,
7 | screen,
8 | waitFor,
9 | } from "../../utils/test-utils";
10 | import { CategoryCreate } from "./CreateCategory";
11 | import { baseUrl } from "../api/apiSlice";
12 |
13 | export const handlers = [
14 | rest.post(`${baseUrl}/categories`, (req, res, ctx) => {
15 | return res(ctx.delay(150), ctx.status(201));
16 | }),
17 | ];
18 |
19 | const server = setupServer(...handlers);
20 |
21 | describe("CreateCategory", () => {
22 | afterAll(() => server.close());
23 | beforeAll(() => server.listen());
24 | afterEach(() => server.resetHandlers());
25 | it("should render correctly", () => {
26 | const { asFragment } = renderWithProviders();
27 | expect(asFragment()).toMatchSnapshot();
28 | });
29 |
30 | it("should handle submit", async () => {
31 | renderWithProviders();
32 | const name = screen.getByTestId("name");
33 | const description = screen.getByTestId("description");
34 | const isActive = screen.getByTestId("is_active");
35 | const submit = screen.getByText("Save");
36 |
37 | fireEvent.change(name, { target: { value: "test" } });
38 | fireEvent.change(description, { target: { value: "test desc" } });
39 | fireEvent.click(isActive);
40 |
41 | fireEvent.click(submit);
42 |
43 | await waitFor(() => {
44 | const text = screen.getByText("Category created successfully");
45 | expect(text).toBeInTheDocument();
46 | });
47 | });
48 |
49 | it("should handle submit error", async () => {
50 | server.use(
51 | rest.post(`${baseUrl}/categories`, (_, res, ctx) => {
52 | return res(ctx.status(500));
53 | })
54 | );
55 |
56 | renderWithProviders();
57 | const name = screen.getByTestId("name");
58 | const description = screen.getByTestId("description");
59 | const isActive = screen.getByTestId("is_active");
60 | const submit = screen.getByText("Save");
61 |
62 | fireEvent.change(name, { target: { value: "test" } });
63 | fireEvent.change(description, { target: { value: "test desc" } });
64 | fireEvent.click(isActive);
65 |
66 | fireEvent.click(submit);
67 |
68 | await waitFor(() => {
69 | const text = screen.getByText("Category not created");
70 | expect(text).toBeInTheDocument();
71 | });
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/src/features/categories/CreateCategory.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Paper, Typography } from "@mui/material";
2 | import { useSnackbar } from "notistack";
3 | import { useEffect, useState } from "react";
4 | import { Category } from "../../types/Category";
5 | import { useCreateCategoryMutation } from "./categorySlice";
6 | import { CategoryFrom } from "./components/CategoryFrom";
7 |
8 | export const CategoryCreate = () => {
9 | const { enqueueSnackbar } = useSnackbar();
10 | const [createCategory, status] = useCreateCategoryMutation();
11 | const [isdisabled, setIsdisabled] = useState(false);
12 | const [categoryState, setCategoryState] = useState({
13 | id: "",
14 | name: "",
15 | is_active: false,
16 | created_at: "",
17 | updated_at: "",
18 | deleted_at: "",
19 | description: "",
20 | });
21 |
22 | async function handleSubmit(e: React.FormEvent) {
23 | e.preventDefault();
24 | await createCategory(categoryState);
25 | }
26 |
27 | const handleChange = (e: React.ChangeEvent) => {
28 | const { name, value } = e.target;
29 | setCategoryState({ ...categoryState, [name]: value });
30 | };
31 |
32 | const handleToggle = (e: React.ChangeEvent) => {
33 | const { name, checked } = e.target;
34 | setCategoryState({ ...categoryState, [name]: checked });
35 | };
36 |
37 | useEffect(() => {
38 | if (status.isSuccess) {
39 | enqueueSnackbar("Category created successfully", { variant: "success" });
40 | setIsdisabled(true);
41 | }
42 | if (status.error) {
43 | enqueueSnackbar("Category not created", { variant: "error" });
44 | }
45 | }, [enqueueSnackbar, status.error, status.isSuccess]);
46 |
47 | return (
48 |
49 |
50 |
51 |
52 | Create Category
53 |
54 |
55 |
63 |
64 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/src/features/categories/EditCategory.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 |
4 | import {
5 | fireEvent,
6 | renderWithProviders,
7 | screen,
8 | waitFor,
9 | } from "../../utils/test-utils";
10 | import { CategoryEdit } from "./EditCategory";
11 | import { baseUrl } from "../api/apiSlice";
12 |
13 | const data = {
14 | id: "1",
15 | name: "Category 1",
16 | is_active: true,
17 | deleted_at: null,
18 | created_at: "2022-09-27T17:10:33+0000",
19 | updated_at: "2022-09-27T17:10:33+0000",
20 | };
21 |
22 | export const handlers = [
23 | rest.get(`${baseUrl}/categories/undefined`, (_, res, ctx) => {
24 | return res(ctx.delay(150), ctx.json({ data }));
25 | }),
26 | rest.put(`${baseUrl}/categories/1`, (_, res, ctx) => {
27 | return res(ctx.delay(150), ctx.status(200));
28 | }),
29 | ];
30 |
31 | const server = setupServer(...handlers);
32 |
33 | describe("EditCategory", () => {
34 | afterAll(() => server.close());
35 | beforeAll(() => server.listen());
36 | afterEach(() => server.resetHandlers());
37 |
38 | it("should render correctly", () => {
39 | const { asFragment } = renderWithProviders();
40 | expect(asFragment()).toMatchSnapshot();
41 | });
42 |
43 | it("should handle submit", async () => {
44 | renderWithProviders();
45 |
46 | const name = screen.getByTestId("name");
47 | const description = screen.getByTestId("description");
48 | const isActive = screen.getByTestId("is_active");
49 | const submit = screen.getByText("Save");
50 |
51 | await waitFor(() => {
52 | expect(name).toHaveValue("Category 1");
53 | });
54 |
55 | fireEvent.change(name, { target: { value: "Category 2" } });
56 | fireEvent.change(description, { target: { value: "Description 2" } });
57 | fireEvent.click(isActive);
58 |
59 | fireEvent.click(submit);
60 |
61 | await waitFor(() => {
62 | const text = screen.getByText("Category updated successfully");
63 | expect(text).toBeInTheDocument();
64 | });
65 | });
66 |
67 | it("should handle submit error", async () => {
68 | server.use(
69 | rest.put(`${baseUrl}/categories/1`, (_, res, ctx) => {
70 | return res(ctx.status(400));
71 | })
72 | );
73 |
74 | renderWithProviders();
75 | const name = screen.getByTestId("name");
76 | const description = screen.getByTestId("description");
77 | const isActive = screen.getByTestId("is_active");
78 | const submit = screen.getByText("Save");
79 |
80 | await waitFor(() => {
81 | expect(name).toHaveValue("Category 1");
82 | });
83 |
84 | fireEvent.change(name, { target: { value: "Category 2" } });
85 | fireEvent.change(description, { target: { value: "Description 2" } });
86 | fireEvent.click(isActive);
87 |
88 | fireEvent.click(submit);
89 |
90 | await waitFor(() => {
91 | const text = screen.getByText("Category not updated");
92 | expect(text).toBeInTheDocument();
93 | });
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/src/features/categories/EditCategory.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Paper, Typography } from "@mui/material";
2 | import { useSnackbar } from "notistack";
3 | import { useEffect, useState } from "react";
4 | import { useParams } from "react-router-dom";
5 | import {
6 | Category,
7 | useGetCategoryQuery,
8 | useUpdateCategoryMutation,
9 | } from "./categorySlice";
10 | import { CategoryFrom } from "./components/CategoryFrom";
11 |
12 | export const CategoryEdit = () => {
13 | const id = useParams().id as string;
14 | const { data: category, isFetching } = useGetCategoryQuery({ id });
15 | const [isdisabled, setIsdisabled] = useState(false);
16 | const [updateCategory, status] = useUpdateCategoryMutation();
17 | const [categoryState, setCategoryState] = useState({
18 | id: "",
19 | name: "",
20 | is_active: false,
21 | created_at: "",
22 | updated_at: "",
23 | deleted_at: "",
24 | description: "",
25 | });
26 |
27 | const { enqueueSnackbar } = useSnackbar();
28 |
29 | async function handleSubmit(e: React.FormEvent) {
30 | e.preventDefault();
31 | await updateCategory(categoryState);
32 | }
33 |
34 | const handleChange = (e: React.ChangeEvent) => {
35 | const { name, value } = e.target;
36 | setCategoryState({ ...categoryState, [name]: value });
37 | };
38 |
39 | const handleToggle = (e: React.ChangeEvent) => {
40 | const { name, checked } = e.target;
41 | setCategoryState({ ...categoryState, [name]: checked });
42 | };
43 |
44 | useEffect(() => {
45 | if (category) {
46 | setCategoryState(category.data);
47 | }
48 | }, [category]);
49 |
50 | useEffect(() => {
51 | if (status.isSuccess) {
52 | enqueueSnackbar("Category updated successfully", { variant: "success" });
53 | setIsdisabled(false);
54 | }
55 | if (status.error) {
56 | enqueueSnackbar("Category not updated", { variant: "error" });
57 | }
58 | }, [enqueueSnackbar, status.error, status.isSuccess]);
59 |
60 | return (
61 |
62 |
63 |
64 |
65 | Edit Category
66 |
67 |
68 |
76 |
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/features/categories/ListCaegory.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 | import {
4 | fireEvent,
5 | renderWithProviders,
6 | screen,
7 | waitFor,
8 | } from "../../utils/test-utils";
9 | import { baseUrl } from "../api/apiSlice";
10 | import { categoryResponse, categoryResponsePage2 } from "../mocks";
11 | import { CategoryList } from "./ListCaegory";
12 |
13 | export const handlers = [
14 | rest.get(`${baseUrl}/categories`, (req, res, ctx) => {
15 | if (req.url.searchParams.get("page") === "2") {
16 | return res(ctx.json(categoryResponsePage2), ctx.delay(150));
17 | }
18 | return res(ctx.json(categoryResponse), ctx.delay(150));
19 | }),
20 |
21 | rest.delete(
22 | `${baseUrl}/categories/cbdd550c-ad46-4e50-be8d-a8266aff4162`,
23 | (_, res, ctx) => {
24 | return res(ctx.delay(150), ctx.status(204));
25 | }
26 | ),
27 | ];
28 |
29 | const server = setupServer(...handlers);
30 |
31 | describe("CategoryList", () => {
32 | afterAll(() => server.close());
33 | beforeAll(() => server.listen());
34 | afterEach(() => server.resetHandlers());
35 |
36 | it("should render correctly", () => {
37 | const { asFragment } = renderWithProviders();
38 | expect(asFragment()).toMatchSnapshot();
39 | });
40 |
41 | it("should render loading state", () => {
42 | renderWithProviders();
43 | const loading = screen.getByRole("progressbar");
44 | expect(loading).toBeInTheDocument();
45 | });
46 |
47 | it("should render success state", async () => {
48 | renderWithProviders();
49 | // esperar que o elemento seja renderizado
50 | await waitFor(() => {
51 | const name = screen.getByText("PaleTurquoise");
52 | expect(name).toBeInTheDocument();
53 | });
54 | });
55 |
56 | it("should render error state", async () => {
57 | server.use(
58 | rest.get(`${baseUrl}/categories`, (_, res, ctx) => {
59 | return res(ctx.status(500));
60 | })
61 | );
62 |
63 | renderWithProviders();
64 |
65 | await waitFor(() => {
66 | const error = screen.getByText("Error fetching categories");
67 | expect(error).toBeInTheDocument();
68 | });
69 | });
70 |
71 | it("should handle On PageChange", async () => {
72 | renderWithProviders();
73 |
74 | await waitFor(() => {
75 | const name = screen.getByText("PaleTurquoise");
76 | expect(name).toBeInTheDocument();
77 | });
78 |
79 | const nextButton = screen.getByTestId("KeyboardArrowRightIcon");
80 | fireEvent.click(nextButton);
81 |
82 | await waitFor(() => {
83 | const name = screen.getByText("SeaGreen");
84 | expect(name).toBeInTheDocument();
85 | });
86 | });
87 |
88 | it("should handle filter change", async () => {
89 | renderWithProviders();
90 | // esperar que o elemento seja renderizado
91 | await waitFor(() => {
92 | const name = screen.getByText("PaleTurquoise");
93 | expect(name).toBeInTheDocument();
94 | });
95 | // pegar o input com o placeholder "Search..."
96 | const input = screen.getByPlaceholderText("Search…");
97 |
98 | // Fire event on change
99 | fireEvent.change(input, { target: { value: "PapayaWhip" } });
100 |
101 | await waitFor(() => {
102 | const loading = screen.getByRole("progressbar");
103 | expect(loading).toBeInTheDocument();
104 | });
105 | });
106 |
107 | it("should handle Delete Category success", async () => {
108 | renderWithProviders();
109 |
110 | await waitFor(() => {
111 | const name = screen.getByText("PaleTurquoise");
112 | expect(name).toBeInTheDocument();
113 | });
114 |
115 | const deleteButton = screen.getAllByTestId("delete-button")[0];
116 | fireEvent.click(deleteButton);
117 |
118 | await waitFor(() => {
119 | const name = screen.getByText("Category deleted");
120 | expect(name).toBeInTheDocument();
121 | });
122 | });
123 |
124 | it("should handle Delete Category error", async () => {
125 | server.use(
126 | rest.delete(
127 | `${baseUrl}/categories/cbdd550c-ad46-4e50-be8d-a8266aff4162`,
128 | (_, res, ctx) => {
129 | return res(ctx.delay(150), ctx.status(500));
130 | }
131 | )
132 | );
133 |
134 | renderWithProviders();
135 |
136 | await waitFor(() => {
137 | const name = screen.getByText("PaleTurquoise");
138 | expect(name).toBeInTheDocument();
139 | });
140 |
141 | const deleteButton = screen.getAllByTestId("delete-button")[0];
142 | fireEvent.click(deleteButton);
143 |
144 | await waitFor(() => {
145 | const name = screen.getByText("Category not deleted");
146 | expect(name).toBeInTheDocument();
147 | });
148 | });
149 | });
150 |
--------------------------------------------------------------------------------
/src/features/categories/ListCaegory.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Typography } from "@mui/material";
2 | import { Link } from "react-router-dom";
3 | import {
4 | useDeleteCategoryMutation,
5 | useGetCategoriesQuery,
6 | } from "./categorySlice";
7 |
8 | import { GridFilterModel } from "@mui/x-data-grid";
9 | import { useSnackbar } from "notistack";
10 | import { useEffect, useState } from "react";
11 | import { CategoriesTable } from "./components/CategoryTable";
12 |
13 | export const CategoryList = () => {
14 | const { enqueueSnackbar } = useSnackbar();
15 | const [options, setOptions] = useState({
16 | page: 1,
17 | search: "",
18 | perPage: 10,
19 | rowsPerPage: [10, 20, 30],
20 | });
21 | const { data, isFetching, error } = useGetCategoriesQuery(options);
22 | const [deleteCategory, { error: deleteError, isSuccess: deleteSuccess }] =
23 | useDeleteCategoryMutation();
24 |
25 | function handleOnPageChange(page: number) {
26 | setOptions({ ...options, page: page + 1 });
27 | }
28 |
29 | function handleOnPageSizeChange(perPage: number) {
30 | setOptions({ ...options, perPage });
31 | }
32 |
33 | function handleFilterChange(filterModel: GridFilterModel) {
34 | if (!filterModel.quickFilterValues?.length) {
35 | return setOptions({ ...options, search: "" });
36 | }
37 |
38 | const search = filterModel.quickFilterValues.join("");
39 | setOptions({ ...options, search });
40 | }
41 |
42 | async function handleDeleteCategory(id: string) {
43 | await deleteCategory({ id });
44 | }
45 |
46 | useEffect(() => {
47 | if (deleteSuccess) {
48 | enqueueSnackbar(`Category deleted`, { variant: "success" });
49 | }
50 | if (deleteError) {
51 | enqueueSnackbar(`Category not deleted`, { variant: "error" });
52 | }
53 | }, [deleteSuccess, deleteError, enqueueSnackbar]);
54 |
55 | if (error) {
56 | return Error fetching categories;
57 | }
58 |
59 | return (
60 |
61 |
62 |
71 |
72 |
82 |
83 | );
84 | };
85 |
--------------------------------------------------------------------------------
/src/features/categories/categorySlice.ts:
--------------------------------------------------------------------------------
1 | import { Result, Results, CategoryParams } from "../../types/Category";
2 | import { apiSlice } from "../api/apiSlice";
3 |
4 | export interface Category {
5 | id: string;
6 | name: string;
7 | is_active: boolean;
8 | created_at: string;
9 | updated_at: string;
10 | deleted_at: null | string;
11 | description: null | string;
12 | }
13 | const endpointUrl = "/categories";
14 |
15 | function parseQueryParams(params: CategoryParams) {
16 | const query = new URLSearchParams();
17 |
18 | if (params.page) {
19 | query.append("page", params.page.toString());
20 | }
21 |
22 | if (params.perPage) {
23 | query.append("per_page", params.perPage.toString());
24 | }
25 |
26 | if (params.search) {
27 | query.append("search", params.search);
28 | }
29 |
30 | if (params.isActive) {
31 | query.append("is_active", params.isActive.toString());
32 | }
33 |
34 | return query.toString();
35 | }
36 |
37 | function getCategories({ page = 1, perPage = 10, search = "" }) {
38 | const params = { page, perPage, search, isActive: true };
39 |
40 | return `${endpointUrl}?${parseQueryParams(params)}`;
41 | }
42 |
43 | function deleteCategoryMutation(category: Category) {
44 | return {
45 | url: `${endpointUrl}/${category.id}`,
46 | method: "DELETE",
47 | };
48 | }
49 |
50 | function createCategoryMutation(category: Category) {
51 | return { url: endpointUrl, method: "POST", body: category };
52 | }
53 |
54 | function updateCategoryMutation(category: Category) {
55 | return {
56 | url: `${endpointUrl}/${category.id}`,
57 | method: "PUT",
58 | body: category,
59 | };
60 | }
61 |
62 | function getCategory({ id }: { id: string }) {
63 | return `${endpointUrl}/${id}`;
64 | }
65 |
66 | export const categoriesApiSlice = apiSlice.injectEndpoints({
67 | endpoints: ({ query, mutation }) => ({
68 | getCategories: query({
69 | query: getCategories,
70 | providesTags: ["Categories"],
71 | }),
72 | getCategory: query({
73 | query: getCategory,
74 | providesTags: ["Categories"],
75 | }),
76 | createCategory: mutation({
77 | query: createCategoryMutation,
78 | invalidatesTags: ["Categories"],
79 | }),
80 | deleteCategory: mutation({
81 | query: deleteCategoryMutation,
82 | invalidatesTags: ["Categories"],
83 | }),
84 | updateCategory: mutation({
85 | query: updateCategoryMutation,
86 | invalidatesTags: ["Categories"],
87 | }),
88 | }),
89 | });
90 |
91 | export const {
92 | useGetCategoriesQuery,
93 | useDeleteCategoryMutation,
94 | useCreateCategoryMutation,
95 | useUpdateCategoryMutation,
96 | useGetCategoryQuery,
97 | } = categoriesApiSlice;
98 |
--------------------------------------------------------------------------------
/src/features/categories/components/CategoryFrom.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { BrowserRouter } from "react-router-dom";
3 | import { CategoryFrom } from "./CategoryFrom";
4 |
5 | const Props = {
6 | category: {
7 | id: "123",
8 | name: "test",
9 | description: "test",
10 | is_active: true,
11 | created_at: "2021-03-01T00:00:00.000000Z",
12 | updated_at: "2021-03-01T00:00:00.000000Z",
13 | deleted_at: null,
14 | },
15 | isdisabled: false,
16 | isLoading: false,
17 | handleSubmit: () => {},
18 | handleChange: () => {},
19 | handleToggle: () => {},
20 | };
21 |
22 | describe("CategoryFrom", () => {
23 | it("should render correctly", () => {
24 | const { asFragment } = render(, {
25 | wrapper: BrowserRouter,
26 | });
27 |
28 | expect(asFragment()).toMatchSnapshot();
29 | });
30 |
31 | it("should render CategoryFrom with loading", () => {
32 | const { asFragment } = render(
33 | ,
34 | {
35 | wrapper: BrowserRouter,
36 | }
37 | );
38 |
39 | expect(asFragment()).toMatchSnapshot();
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/features/categories/components/CategoryFrom.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | FormControl,
5 | FormControlLabel,
6 | FormGroup,
7 | Grid,
8 | Switch,
9 | TextField,
10 | } from "@mui/material";
11 |
12 | import { Link } from "react-router-dom";
13 | import { Category } from "../categorySlice";
14 |
15 | type Props = {
16 | category: Category;
17 | isdisabled?: boolean;
18 | isLoading?: boolean;
19 | handleSubmit: (e: React.FormEvent) => void;
20 | handleChange: (e: React.ChangeEvent) => void;
21 | handleToggle: (e: React.ChangeEvent) => void;
22 | };
23 |
24 | export function CategoryFrom({
25 | category,
26 | isdisabled = false,
27 | isLoading = false,
28 | handleSubmit,
29 | handleChange,
30 | handleToggle,
31 | }: Props) {
32 | return (
33 |
34 |
100 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/src/features/categories/components/CategoryTable.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { BrowserRouter } from "react-router-dom";
3 | import { CategoriesTable } from "./CategoryTable";
4 |
5 | const Props = {
6 | data: undefined,
7 | perPage: 10,
8 | isFetching: false,
9 | rowsPerPage: [10, 25, 50],
10 | handleOnPageChange: () => {},
11 | handleFilterChange: () => {},
12 | handleOnPageSizeChange: () => {},
13 | handleDelete: () => {},
14 | };
15 |
16 | const mockData = {
17 | data: [
18 | {
19 | id: "123",
20 | name: "test",
21 | description: "test",
22 | is_active: true,
23 | created_at: "2021-03-01T00:00:00.000000Z",
24 | updated_at: "2021-03-01T00:00:00.000000Z",
25 | deleted_at: "",
26 | },
27 | ],
28 | meta: {
29 | to: 1,
30 | from: 1,
31 | path: "http://localhost:8000/api/categories",
32 | total: 1,
33 | per_page: 1,
34 | last_page: 1,
35 | current_page: 1,
36 | },
37 | links: {
38 | first: "http://localhost:8000/api/cast_members?page=1",
39 | last: "http://localhost:8000/api/cast_members?page=1",
40 | prev: "",
41 | next: "",
42 | },
43 | };
44 |
45 | describe("CategoryTable", () => {
46 | it("should render correctly", () => {
47 | const { asFragment } = render();
48 | expect(asFragment()).toMatchSnapshot();
49 | });
50 |
51 | it("should render CategoryTable with loading", () => {
52 | const { asFragment } = render(
53 |
54 | );
55 | expect(asFragment()).toMatchSnapshot();
56 | });
57 |
58 | it("should render CategoryTable with data", () => {
59 | const { asFragment } = render(
60 | ,
61 | { wrapper: BrowserRouter }
62 | );
63 | expect(asFragment()).toMatchSnapshot();
64 | });
65 |
66 | it("should render CategoryTable with Inactive value", () => {
67 | const { asFragment } = render(
68 | ,
75 | { wrapper: BrowserRouter }
76 | );
77 | expect(asFragment()).toMatchSnapshot();
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/features/categories/components/CategoryTable.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, Typography } from "@mui/material";
2 | import {
3 | DataGrid,
4 | GridColDef,
5 | GridFilterModel,
6 | GridRenderCellParams,
7 | GridToolbar,
8 | } from "@mui/x-data-grid";
9 | import { Results } from "../../../types/Category";
10 | import DeleteIcon from "@mui/icons-material/Delete";
11 | import { Link } from "react-router-dom";
12 | import { Box } from "@mui/system";
13 | type Props = {
14 | data: Results | undefined;
15 | perPage: number;
16 | isFetching: boolean;
17 | rowsPerPage?: number[];
18 |
19 | handleOnPageChange: (page: number) => void;
20 | handleFilterChange: (filterModel: GridFilterModel) => void;
21 | handleOnPageSizeChange: (perPage: number) => void;
22 | handleDelete: (id: string) => void;
23 | };
24 |
25 | export function CategoriesTable({
26 | data,
27 | perPage,
28 | isFetching,
29 | rowsPerPage,
30 | handleOnPageChange,
31 | handleFilterChange,
32 | handleOnPageSizeChange,
33 | handleDelete,
34 | }: Props) {
35 | const componentProps = {
36 | toolbar: {
37 | showQuickFilter: true,
38 | quickFilterProps: { debounceMs: 500 },
39 | },
40 | };
41 |
42 | const columns: GridColDef[] = [
43 | { field: "name", headerName: "Name", flex: 1, renderCell: renderNameCell },
44 | {
45 | field: "isActive",
46 | headerName: "Active",
47 | flex: 1,
48 | type: "boolean",
49 | renderCell: renderIsActiveCell,
50 | },
51 | {
52 | field: "id",
53 | headerName: "Actions",
54 | type: "string",
55 | flex: 1,
56 | renderCell: renderActionsCell,
57 | },
58 | ];
59 |
60 | function mapDataToGridRows(data: Results) {
61 | const { data: categories } = data;
62 | return categories.map((category) => ({
63 | id: category.id,
64 | name: category.name,
65 | isActive: category.is_active,
66 | created_at: new Date(category.created_at).toLocaleDateString("pt-BR"),
67 | }));
68 | }
69 |
70 | function renderActionsCell(params: GridRenderCellParams) {
71 | return (
72 | handleDelete(params.value)}
75 | aria-label="delete"
76 | data-testid="delete-button"
77 | >
78 |
79 |
80 | );
81 | }
82 |
83 | function renderNameCell(rowData: GridRenderCellParams) {
84 | return (
85 |
89 | {rowData.value}
90 |
91 | );
92 | }
93 |
94 | function renderIsActiveCell(rowData: GridRenderCellParams) {
95 | return (
96 |
97 | {rowData.value ? "Active" : "Inactive"}
98 |
99 | );
100 | }
101 |
102 | const rows = data ? mapDataToGridRows(data) : [];
103 | const rowCount = data?.meta.total || 0;
104 |
105 | return (
106 |
107 |
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/src/features/genre/GenreCreate.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 | import {
4 | fireEvent,
5 | renderWithProviders,
6 | screen,
7 | waitFor,
8 | } from "../../utils/test-utils";
9 | import { GenreCreate } from "./GenreCreate";
10 | import { baseUrl } from "../api/apiSlice";
11 | import { categoryResponse } from "../mocks";
12 |
13 | const handlers = [
14 | rest.get(`${baseUrl}/categories`, (req, res, ctx) => {
15 | return res(ctx.json(categoryResponse));
16 | }),
17 |
18 | rest.post(`${baseUrl}/genres`, (_, res, ctx) => {
19 | return res(ctx.status(201));
20 | }),
21 | ];
22 |
23 | const server = setupServer(...handlers);
24 |
25 | describe("GenreCreate", () => {
26 | afterAll(() => server.close());
27 | beforeAll(() => server.listen());
28 | afterEach(() => server.resetHandlers());
29 |
30 | it("should render correctly", () => {
31 | const { asFragment } = renderWithProviders();
32 | expect(asFragment()).toMatchSnapshot();
33 | });
34 |
35 | it("should handle submit", async () => {
36 | renderWithProviders();
37 | const name = screen.getByTestId("name");
38 | const submit = screen.getByText("Save");
39 |
40 | await waitFor(() => {
41 | expect(submit).toBeInTheDocument();
42 | });
43 |
44 | fireEvent.change(name, { target: { value: "test" } });
45 | fireEvent.click(submit);
46 |
47 | await waitFor(() => {
48 | const text = screen.getByText("Genre created");
49 | expect(text).toBeInTheDocument();
50 | });
51 | });
52 |
53 | it("should handle error", async () => {
54 | server.use(
55 | rest.post(`${baseUrl}/genres`, (_, res, ctx) => {
56 | return res(ctx.status(500));
57 | })
58 | );
59 |
60 | renderWithProviders();
61 | const name = screen.getByTestId("name");
62 | const submit = screen.getByText("Save");
63 |
64 | await waitFor(() => {
65 | expect(submit).toBeInTheDocument();
66 | });
67 |
68 | fireEvent.change(name, { target: { value: "test" } });
69 | fireEvent.click(submit);
70 |
71 | await waitFor(() => {
72 | const text = screen.getByText("Genre not created");
73 | expect(text).toBeInTheDocument();
74 | });
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/src/features/genre/GenreCreate.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Paper, Typography } from "@mui/material";
2 | import { useSnackbar } from "notistack";
3 | import { useEffect, useState } from "react";
4 | import { Genre } from "../../types/Genres";
5 | import { GenreForm } from "./components/GenreFrom";
6 | import {
7 | initialState as genreInintalState,
8 | useCreateGenreMutation,
9 | useGetCaTegoriesQuery,
10 | } from "./genreSlice";
11 | import { mapGenreToForm } from "./util";
12 |
13 | export const GenreCreate = () => {
14 | const { enqueueSnackbar } = useSnackbar();
15 | const { data: categories } = useGetCaTegoriesQuery();
16 | const [createGenre, status] = useCreateGenreMutation();
17 | const [genreState, setGenreState] = useState(genreInintalState);
18 |
19 | function handleChange(e: React.ChangeEvent) {
20 | const { name, value } = e.target;
21 | setGenreState({ ...genreState, [name]: value });
22 | }
23 |
24 | async function handleSubmit(e: React.FormEvent) {
25 | e.preventDefault();
26 | await createGenre(mapGenreToForm(genreState));
27 | }
28 |
29 | useEffect(() => {
30 | if (status.isSuccess) {
31 | enqueueSnackbar(`Genre created`, { variant: "success" });
32 | }
33 | if (status.isError) {
34 | enqueueSnackbar(`Genre not created`, { variant: "error" });
35 | }
36 | }, [status, enqueueSnackbar]);
37 |
38 | return (
39 |
40 |
41 |
42 |
43 | Create Genre
44 |
45 |
46 |
47 |
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/src/features/genre/GenreEdit.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 | import {
4 | fireEvent,
5 | renderWithProviders,
6 | screen,
7 | waitFor,
8 | } from "../../utils/test-utils";
9 | import { GenreEdit } from "./GenreEdit";
10 | import { baseUrl } from "../api/apiSlice";
11 | import { categoryResponse } from "../mocks";
12 |
13 | const mockData = {
14 | id: "1",
15 | name: "test",
16 | isActive: true,
17 | deleted_at: null,
18 | created_at: "2021-09-01T00:00:00.000000Z",
19 | updated_at: "2021-09-01T00:00:00.000000Z",
20 | categories: [],
21 | description: "test",
22 | pivot: { genre_id: "1", category_id: "1" },
23 | };
24 |
25 | const handlers = [
26 | rest.get(`${baseUrl}/genres/undefined`, (_, res, ctx) => {
27 | return res(ctx.delay(150), ctx.status(200), ctx.json({ data: mockData }));
28 | }),
29 |
30 | rest.get(`${baseUrl}/categories`, (req, res, ctx) => {
31 | return res(ctx.json(categoryResponse), ctx.delay(150));
32 | }),
33 |
34 | rest.put(`${baseUrl}/genres/1`, (_, res, ctx) => {
35 | return res(ctx.delay(150), ctx.status(200));
36 | }),
37 | ];
38 |
39 | const server = setupServer(...handlers);
40 |
41 | describe("GenreEdit", () => {
42 | afterAll(() => server.close());
43 | beforeAll(() => server.listen());
44 | afterEach(() => server.resetHandlers());
45 |
46 | it("should render correctly", () => {
47 | const { asFragment } = renderWithProviders();
48 | expect(asFragment()).toMatchSnapshot();
49 | });
50 |
51 | it("should handle submit", async () => {
52 | renderWithProviders();
53 | const name = screen.getByTestId("name");
54 |
55 | await waitFor(() => {
56 | expect(name).toHaveValue("test");
57 | });
58 |
59 | await waitFor(() => {
60 | const submit = screen.getByText("Save");
61 | expect(submit).toBeInTheDocument();
62 | });
63 |
64 | const submit = screen.getByText("Save");
65 | fireEvent.change(name, { target: { value: "test2" } });
66 | fireEvent.click(submit);
67 |
68 | await waitFor(() => {
69 | const text = screen.getByText("Genre updated");
70 | expect(text).toBeInTheDocument();
71 | });
72 | });
73 |
74 | it("should handle submit error", async () => {
75 | server.use(
76 | rest.put(`${baseUrl}/genres/1`, (_, res, ctx) => {
77 | return res(ctx.delay(150), ctx.status(500));
78 | })
79 | );
80 |
81 | renderWithProviders();
82 | const name = screen.getByTestId("name");
83 |
84 | await waitFor(() => {
85 | expect(name).toHaveValue("test");
86 | });
87 |
88 | await waitFor(() => {
89 | const submit = screen.getByText("Save");
90 | expect(submit).toBeInTheDocument();
91 | });
92 |
93 | const submit = screen.getByText("Save");
94 | fireEvent.change(name, { target: { value: "test2" } });
95 | fireEvent.click(submit);
96 |
97 | await waitFor(() => {
98 | const text = screen.getByText("Error updating genre");
99 | expect(text).toBeInTheDocument();
100 | });
101 | });
102 | });
103 |
--------------------------------------------------------------------------------
/src/features/genre/GenreEdit.tsx:
--------------------------------------------------------------------------------
1 | import { Paper, Typography } from "@mui/material";
2 | import { Box } from "@mui/system";
3 | import { useSnackbar } from "notistack";
4 | import { useEffect, useState } from "react";
5 | import { useParams } from "react-router-dom";
6 | import { Genre } from "../../types/Genres";
7 | import { GenreForm } from "./components/GenreFrom";
8 | import {
9 | useGetCaTegoriesQuery,
10 | initialState as genreInintalState,
11 | useGetGenreQuery,
12 | useUpdateGenreMutation,
13 | } from "./genreSlice";
14 | import { mapGenreToForm } from "./util";
15 |
16 | export const GenreEdit = () => {
17 | const id = useParams<{ id: string }>().id as string;
18 | const { data: genre, isFetching } = useGetGenreQuery({ id });
19 | const { enqueueSnackbar } = useSnackbar();
20 | const { data: categories } = useGetCaTegoriesQuery();
21 | const [updateGenre, status] = useUpdateGenreMutation();
22 | const [genreState, setGenreState] = useState(genreInintalState);
23 |
24 | function handleChange(event: React.ChangeEvent) {
25 | const { name, value } = event.target;
26 | setGenreState((state) => ({ ...state, [name]: value }));
27 | }
28 |
29 | async function handleSubmit(event: React.FormEvent) {
30 | event.preventDefault();
31 | await updateGenre(mapGenreToForm(genreState));
32 | }
33 |
34 | useEffect(() => {
35 | if (genre) {
36 | setGenreState(genre.data);
37 | }
38 | }, [genre]);
39 |
40 | useEffect(() => {
41 | if (status.isSuccess) {
42 | enqueueSnackbar(`Genre updated`, { variant: "success" });
43 | }
44 |
45 | if (status.isError) {
46 | enqueueSnackbar(`Error updating genre`, { variant: "error" });
47 | }
48 | }, [status, enqueueSnackbar]);
49 |
50 | return (
51 |
52 |
53 |
54 |
55 | Edit Genre
56 |
57 |
58 |
59 |
67 |
68 |
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/src/features/genre/GenreList.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 | import {
4 | fireEvent,
5 | renderWithProviders,
6 | screen,
7 | waitFor,
8 | } from "../../utils/test-utils";
9 | import { GenreList } from "./GenreList";
10 | import { baseUrl } from "../api/apiSlice";
11 | import { categoryResponse } from "../mocks";
12 | import { genreResponse, genreResponsePage2 } from "../mocks/genre";
13 |
14 | const handlers = [
15 | rest.get(`${baseUrl}/genres`, (req, res, ctx) => {
16 | if (req.url.searchParams.get("page") === "2") {
17 | return res(ctx.json(genreResponsePage2), ctx.delay(150));
18 | }
19 | return res(ctx.delay(150), ctx.status(200), ctx.json(genreResponse));
20 | }),
21 |
22 | rest.get(`${baseUrl}/categories`, (_, res, ctx) => {
23 | return res(ctx.json(categoryResponse), ctx.delay(150));
24 | }),
25 |
26 | rest.delete(`${baseUrl}/genres/1`, (_, res, ctx) => {
27 | return res(ctx.delay(150), ctx.status(200));
28 | }),
29 | ];
30 |
31 | const server = setupServer(...handlers);
32 |
33 | describe("GenreList", () => {
34 | afterAll(() => server.close());
35 | beforeAll(() => server.listen());
36 | afterEach(() => server.resetHandlers());
37 |
38 | it("should render correctly", () => {
39 | const { asFragment } = renderWithProviders();
40 | expect(asFragment()).toMatchSnapshot();
41 | });
42 |
43 | it("should render loading state", () => {
44 | renderWithProviders();
45 | const loading = screen.getByRole("progressbar");
46 | expect(loading).toBeInTheDocument();
47 | });
48 |
49 | it("should render error state", async () => {
50 | server.use(
51 | rest.get(`${baseUrl}/genres`, (_, res, ctx) => {
52 | return res(ctx.status(500));
53 | })
54 | );
55 |
56 | renderWithProviders();
57 | await waitFor(() => {
58 | const error = screen.getByText("Error fetching genres");
59 | expect(error).toBeInTheDocument();
60 | });
61 | });
62 |
63 | it("should handle On PageChange", async () => {
64 | renderWithProviders();
65 |
66 | await waitFor(() => {
67 | const name = screen.getByText("Norfolk Island");
68 | expect(name).toBeInTheDocument();
69 | });
70 |
71 | const nextButton = screen.getByTestId("KeyboardArrowRightIcon");
72 | fireEvent.click(nextButton);
73 |
74 | await waitFor(() => {
75 | const name = screen.getByText("Norfolk Island 2");
76 | expect(name).toBeInTheDocument();
77 | });
78 | });
79 |
80 | it("should handle filter change", async () => {
81 | renderWithProviders();
82 |
83 | await waitFor(() => {
84 | const name = screen.getByText("Norfolk Island");
85 | expect(name).toBeInTheDocument();
86 | });
87 |
88 | const input = screen.getByPlaceholderText("Search…");
89 | fireEvent.change(input, { target: { value: "Norfolk Island" } });
90 |
91 | await waitFor(() => {
92 | const loading = screen.getByRole("progressbar");
93 | expect(loading).toBeInTheDocument();
94 | });
95 | });
96 |
97 | it("should handle Delete Genre success", async () => {
98 | renderWithProviders();
99 |
100 | await waitFor(() => {
101 | const name = screen.getByText("Norfolk Island");
102 | expect(name).toBeInTheDocument();
103 | });
104 |
105 | const deleteButton = screen.getAllByTestId("delete-button")[0];
106 | fireEvent.click(deleteButton);
107 |
108 | await waitFor(() => {
109 | const name = screen.getByText("Genre deleted");
110 | expect(name).toBeInTheDocument();
111 | });
112 | });
113 |
114 | it("should handle Delete Genre error", async () => {
115 | server.use(
116 | rest.delete(`${baseUrl}/genres/1`, (_, res, ctx) => {
117 | return res(ctx.status(500));
118 | })
119 | );
120 |
121 | renderWithProviders();
122 |
123 | await waitFor(() => {
124 | const name = screen.getByText("Norfolk Island");
125 | expect(name).toBeInTheDocument();
126 | });
127 |
128 | const deleteButton = screen.getAllByTestId("delete-button")[0];
129 | fireEvent.click(deleteButton);
130 |
131 | await waitFor(() => {
132 | const name = screen.getByText("Genre not deleted");
133 | expect(name).toBeInTheDocument();
134 | });
135 | });
136 | });
137 |
--------------------------------------------------------------------------------
/src/features/genre/GenreList.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Typography } from "@mui/material";
2 | import { GridFilterModel } from "@mui/x-data-grid";
3 | import { useSnackbar } from "notistack";
4 | import { useEffect, useState } from "react";
5 | import { Link } from "react-router-dom";
6 | import { GenresTable } from "./components/GenreTable";
7 | import { useDeleteGenreMutation, useGetGenresQuery } from "./genreSlice";
8 |
9 | export const GenreList = () => {
10 | const { enqueueSnackbar } = useSnackbar();
11 | const [options, setOptions] = useState({
12 | page: 1,
13 | search: "",
14 | perPage: 10,
15 | rowsPerPage: [10, 20, 30],
16 | });
17 |
18 | const { data, isFetching, error } = useGetGenresQuery(options);
19 | const [deleteGenre, { error: deleteError, isSuccess: deleteSuccess }] =
20 | useDeleteGenreMutation();
21 |
22 | function handleOnPageChange(page: number) {
23 | setOptions({ ...options, page: page + 1 });
24 | }
25 |
26 | function handleOnPageSizeChange(perPage: number) {
27 | setOptions({ ...options, perPage });
28 | }
29 |
30 | function handleFilterChange(filterModel: GridFilterModel) {
31 | if (!filterModel.quickFilterValues?.length) {
32 | return setOptions({ ...options, search: "" });
33 | }
34 | const search = filterModel.quickFilterValues.join("");
35 | setOptions({ ...options, search });
36 | }
37 |
38 | async function handleDeleteGenre(id: string) {
39 | await deleteGenre({ id });
40 | }
41 |
42 | useEffect(() => {
43 | if (deleteSuccess) {
44 | enqueueSnackbar(`Genre deleted`, { variant: "success" });
45 | }
46 | if (deleteError) {
47 | enqueueSnackbar(`Genre not deleted`, { variant: "error" });
48 | }
49 | }, [deleteSuccess, deleteError, enqueueSnackbar]);
50 |
51 | if (error) {
52 | return Error fetching genres;
53 | }
54 |
55 | return (
56 |
57 |
58 |
67 |
68 |
69 |
79 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/features/genre/components/GenreFrom.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderWithProviders } from "../../../utils/test-utils";
2 | import { GenreForm } from "./GenreFrom";
3 |
4 | const Props = {
5 | genre: {
6 | id: "1",
7 | name: "Action",
8 | isActive: true,
9 | deleted_at: null,
10 | created_at: "2021-09-01T00:00:00.000000Z",
11 | updated_at: "2021-09-01T00:00:00.000000Z",
12 | categories: [],
13 | Description: "Action",
14 | pivot: {
15 | genre_id: "1",
16 | category_id: "1",
17 | },
18 | },
19 | isDisabled: false,
20 | isLoading: false,
21 | handleSubmit: () => {},
22 | handleChange: () => {},
23 | };
24 |
25 | const mockData = {
26 | data: [
27 | {
28 | id: "1",
29 | name: "test",
30 | isActive: true,
31 | deleted_at: null,
32 | created_at: "2021-09-01T00:00:00.000000Z",
33 | updated_at: "2021-09-01T00:00:00.000000Z",
34 | categories: [
35 | {
36 | id: "1233",
37 | name: "alore",
38 | deleted_at: "",
39 | is_active: true,
40 | created_at: "",
41 | updated_at: "",
42 | description: "",
43 | },
44 | ],
45 |
46 | Description: "test",
47 | pivot: {
48 | genre_id: "1",
49 | category_id: "1",
50 | },
51 | },
52 | ],
53 | links: {
54 | first: "http://localhost:8000/api/genres?page=1",
55 | last: "http://localhost:8000/api/genres?page=1",
56 | prev: "",
57 | next: "",
58 | },
59 | meta: {
60 | current_page: 1,
61 | from: 1,
62 | last_page: 1,
63 | path: "http://localhost:8000/api/genres",
64 | per_page: 15,
65 | to: 1,
66 | total: 1,
67 | },
68 | };
69 |
70 | describe("GenreForm", () => {
71 | it("should render correctly", () => {
72 | const { asFragment } = renderWithProviders();
73 |
74 | expect(asFragment()).toMatchSnapshot();
75 | });
76 |
77 | it("should render GenreForm with loading", () => {
78 | const { asFragment } = renderWithProviders(
79 |
80 | );
81 |
82 | expect(asFragment()).toMatchSnapshot();
83 | });
84 |
85 | it("should render GenreForm with data", () => {
86 | const { asFragment } = renderWithProviders(
87 |
88 | );
89 |
90 | expect(asFragment()).toMatchSnapshot();
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/src/features/genre/components/GenreFrom.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, FormControl, Grid, TextField } from "@mui/material";
2 | import Autocomplete from "@mui/material/Autocomplete";
3 |
4 | import { Link } from "react-router-dom";
5 | import { Genre } from "../../../types/Genres";
6 | import { Category } from "../../../types/Category";
7 |
8 | type Props = {
9 | genre: Genre;
10 | categories?: Category[];
11 | isDisabled?: boolean;
12 | isLoading?: boolean;
13 | handleSubmit: (e: React.FormEvent) => void;
14 | handleChange: (e: React.ChangeEvent) => void;
15 | };
16 |
17 | export function GenreForm({
18 | genre,
19 | categories,
20 | isDisabled = false,
21 | isLoading = false,
22 | handleSubmit,
23 | handleChange,
24 | }: Props) {
25 | return (
26 |
27 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/features/genre/components/GenreTable.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderWithProviders } from "../../../utils/test-utils";
2 | import { GenresTable } from "./GenreTable";
3 |
4 | const Props = {
5 | data: undefined,
6 | perPage: 10,
7 | isFetching: false,
8 | rowsPerPage: [10, 25, 50],
9 | handleOnPageChange: () => {},
10 | handleFilterChange: () => {},
11 | handleOnPageSizeChange: () => {},
12 | handleDelete: () => {},
13 | };
14 |
15 | const mockData = {
16 | data: [
17 | {
18 | id: "1",
19 | name: "test",
20 | isActive: true,
21 | deleted_at: null,
22 | created_at: "2021-09-01T00:00:00.000000Z",
23 | updated_at: "2021-09-01T00:00:00.000000Z",
24 | categories: [],
25 | description: "test",
26 | pivot: {
27 | genre_id: "1",
28 | category_id: "1",
29 | },
30 | },
31 | ],
32 | links: {
33 | first: "http://localhost:8000/api/genres?page=1",
34 | last: "http://localhost:8000/api/genres?page=1",
35 | prev: "",
36 | next: "",
37 | },
38 | meta: {
39 | current_page: 1,
40 | from: 1,
41 | last_page: 1,
42 | path: "http://localhost:8000/api/genres",
43 | per_page: 15,
44 | to: 1,
45 | total: 1,
46 | },
47 | };
48 |
49 | describe("GenresTable", () => {
50 | it("should render correctly", () => {
51 | const { asFragment } = renderWithProviders();
52 | expect(asFragment()).toMatchSnapshot();
53 | });
54 |
55 | it("should render GenresTable with loading", () => {
56 | const { asFragment } = renderWithProviders(
57 |
58 | );
59 | expect(asFragment()).toMatchSnapshot();
60 | });
61 |
62 | it("should render GenresTable with data", () => {
63 | const { asFragment } = renderWithProviders(
64 |
65 | );
66 | expect(asFragment()).toMatchSnapshot();
67 | });
68 |
69 | it("should render GenresTable with data and loading", () => {
70 | const { asFragment } = renderWithProviders(
71 |
72 | );
73 | expect(asFragment()).toMatchSnapshot();
74 | });
75 |
76 | it("should render GenresTable with data and loading and delete", () => {
77 | const { asFragment } = renderWithProviders(
78 | {
83 | console.log("delete");
84 | }}
85 | />
86 | );
87 | // expect to find the delete button
88 | expect(asFragment()).toMatchSnapshot();
89 | });
90 |
91 | it("should render GenresTable with data categories", () => {
92 | const { asFragment } = renderWithProviders(
93 |
115 | );
116 | expect(asFragment()).toMatchSnapshot();
117 | });
118 | });
119 |
--------------------------------------------------------------------------------
/src/features/genre/components/GenreTable.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, Typography } from "@mui/material";
2 | import {
3 | DataGrid,
4 | GridColDef,
5 | GridFilterModel,
6 | GridRenderCellParams,
7 | GridToolbar,
8 | } from "@mui/x-data-grid";
9 | import { Genres } from "../../../types/Genres";
10 | import DeleteIcon from "@mui/icons-material/Delete";
11 | import { Link } from "react-router-dom";
12 | import { Box } from "@mui/system";
13 |
14 | type Props = {
15 | data: Genres | undefined;
16 | perPage: number;
17 | isFetching: boolean;
18 | rowsPerPage?: number[];
19 | handleOnPageChange: (page: number) => void;
20 | handleFilterChange: (filterModel: GridFilterModel) => void;
21 | handleOnPageSizeChange: (perPage: number) => void;
22 | handleDelete: (id: string) => void;
23 | };
24 |
25 | export function GenresTable({
26 | data,
27 | perPage,
28 | isFetching,
29 | rowsPerPage,
30 | handleOnPageChange,
31 | handleFilterChange,
32 | handleOnPageSizeChange,
33 | handleDelete,
34 | }: Props) {
35 | const componentProps = {
36 | toolbar: {
37 | showQuickFilter: true,
38 | quickFilterProps: { debounceMs: 500 },
39 | },
40 | };
41 |
42 | const columns: GridColDef[] = [
43 | { field: "name", headerName: "Name", flex: 1, renderCell: renderNameCell },
44 | {
45 | field: "id",
46 | headerName: "Actions",
47 | type: "string",
48 | flex: 1,
49 | renderCell: renderActionsCell,
50 | },
51 | ];
52 |
53 | function mapDataToGridRows(data: Genres) {
54 | const { data: genres } = data;
55 | return genres.map((genre) => ({
56 | id: genre.id,
57 | name: genre.name,
58 | categories: genre.categories,
59 | }));
60 | }
61 |
62 | function renderActionsCell(params: GridRenderCellParams) {
63 | return (
64 | handleDelete(params.value)}
67 | aria-label="delete"
68 | data-testid="delete-button"
69 | >
70 |
71 |
72 | );
73 | }
74 |
75 | function renderNameCell(rowData: GridRenderCellParams) {
76 | return (
77 |
81 | {rowData.value}
82 |
83 | );
84 | }
85 |
86 | const rows = data ? mapDataToGridRows(data) : [];
87 | const rowCount = data?.meta.total || 0;
88 |
89 | return (
90 |
91 |
111 |
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/src/features/genre/genreSlice.ts:
--------------------------------------------------------------------------------
1 | import { Results } from "../../types/Category";
2 | import {
3 | Genre,
4 | GenreParams,
5 | GenrePayload,
6 | Genres,
7 | Result,
8 | } from "../../types/Genres";
9 | import { apiSlice } from "../api/apiSlice";
10 | const endpointUrl = "/genres";
11 |
12 | export const initialState = {
13 | id: "",
14 | name: "",
15 | created_at: "",
16 | updated_at: "",
17 | deleted_at: null,
18 | isActive: false,
19 | categories: [],
20 | description: "",
21 | pivot: { genre_id: "", category_id: "" },
22 | };
23 |
24 | function parseQueryParams(params: GenreParams) {
25 | const query = new URLSearchParams();
26 |
27 | if (params.page) {
28 | query.append("page", params.page.toString());
29 | }
30 |
31 | if (params.perPage) {
32 | query.append("per_page", params.perPage.toString());
33 | }
34 |
35 | if (params.search) {
36 | query.append("search", params.search);
37 | }
38 |
39 | if (params.isActive) {
40 | query.append("is_active", params.isActive.toString());
41 | }
42 |
43 | return query.toString();
44 | }
45 |
46 | function getGenres({ page = 1, perPage = 10, search = "" }) {
47 | const params = { page, perPage, search };
48 | return `${endpointUrl}?${parseQueryParams(params)}`;
49 | }
50 |
51 | function deleteGenreMutation({ id }: { id: string }) {
52 | return { url: `${endpointUrl}/${id}`, method: "DELETE" };
53 | }
54 |
55 | function createGenreMutation(genre: GenrePayload) {
56 | return { url: endpointUrl, method: "POST", body: genre };
57 | }
58 |
59 | function getGenre({ id }: { id: string }) {
60 | return `${endpointUrl}/${id}`;
61 | }
62 |
63 | function updateGenreMutation(genre: GenrePayload) {
64 | return { url: `${endpointUrl}/${genre.id}`, method: "PUT", body: genre };
65 | }
66 |
67 | function getCategories() {
68 | return `categories?all=true`;
69 | }
70 |
71 | export const genreSlice = apiSlice.injectEndpoints({
72 | endpoints: ({ query, mutation }) => ({
73 | getCaTegories: query({
74 | query: getCategories,
75 | }),
76 | getGenre: query({
77 | query: getGenre,
78 | providesTags: ["Genres"],
79 | }),
80 | updateGenre: mutation({
81 | query: updateGenreMutation,
82 | invalidatesTags: ["Genres"],
83 | }),
84 | createGenre: mutation({
85 | query: createGenreMutation,
86 | invalidatesTags: ["Genres"],
87 | }),
88 | deleteGenre: mutation({
89 | query: deleteGenreMutation,
90 | invalidatesTags: ["Genres"],
91 | }),
92 |
93 | getGenres: query({
94 | query: getGenres,
95 | providesTags: ["Genres"],
96 | }),
97 | }),
98 | });
99 |
100 | export const {
101 | useGetGenresQuery,
102 | useDeleteGenreMutation,
103 | useGetGenreQuery,
104 | useGetCaTegoriesQuery,
105 | useUpdateGenreMutation,
106 | useCreateGenreMutation,
107 | } = genreSlice;
108 |
--------------------------------------------------------------------------------
/src/features/genre/util.test.ts:
--------------------------------------------------------------------------------
1 | import { mapGenreToForm } from "./util";
2 |
3 | describe("mapGenreToForm", () => {
4 | it("should map genre to form", () => {
5 | const formData = mapGenreToForm({
6 | id: "1",
7 | name: "test",
8 | isActive: true,
9 | deleted_at: null,
10 | created_at: "2021-09-01T00:00:00.000000Z",
11 | updated_at: "2021-09-01T00:00:00.000000Z",
12 | categories: [
13 | {
14 | id: "1",
15 | name: "test",
16 | deleted_at: "",
17 | is_active: true,
18 | created_at: "2021-09-01T00:00:00.000000Z",
19 | updated_at: "2021-09-01T00:00:00.000000Z",
20 | description: "test",
21 | },
22 | ],
23 | description: "test",
24 | pivot: {
25 | genre_id: "1",
26 | category_id: "1",
27 | },
28 | });
29 |
30 | expect(formData).toEqual({
31 | id: "1",
32 | name: "test",
33 | categories_id: ["1"],
34 | });
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/features/genre/util.ts:
--------------------------------------------------------------------------------
1 | import { Genre } from "../../types/Genres";
2 |
3 | export const mapGenreToForm = (genre: Genre) => {
4 | return {
5 | id: genre.id,
6 | name: genre.name,
7 | categories_id: genre.categories?.map((category) => category.id),
8 | };
9 | };
10 |
--------------------------------------------------------------------------------
/src/features/mocks/genre.ts:
--------------------------------------------------------------------------------
1 | export const genreResponse = {
2 | data: [
3 | {
4 | id: "1",
5 | name: "Norfolk Island",
6 | is_active: true,
7 | deleted_at: null,
8 | created_at: "2022-10-20T08:28:28+0000",
9 | updated_at: "2022-10-20T08:28:28+0000",
10 | categories: [
11 | {
12 | id: "ba994b75-2b8d-4773-abe2-6b43424a2677",
13 | name: "PaleTurquoise",
14 | description: "Quae quo pariatur ut doloribus consequatur.",
15 | is_active: true,
16 | deleted_at: null,
17 | created_at: "2022-10-20T08:28:21+0000",
18 | updated_at: "2022-10-20T08:28:21+0000",
19 | pivot: {
20 | genre_id: "1",
21 | category_id: "ba994b75-2b8d-4773-abe2-6b43424a2677",
22 | },
23 | },
24 | ],
25 | },
26 | ],
27 | links: {
28 | first: "http://localhost:8000/api/genres?page=1",
29 | last: "http://localhost:8000/api/genres?page=7",
30 | prev: null,
31 | next: "http://localhost:8000/api/genres?page=2",
32 | },
33 | meta: {
34 | current_page: 1,
35 | from: 1,
36 | last_page: 7,
37 | path: "http://localhost:8000/api/genres",
38 | per_page: 15,
39 | to: 15,
40 | total: 99,
41 | },
42 | };
43 |
44 | export const genreResponsePage2 = {
45 | data: [
46 | {
47 | id: "2",
48 | name: "Norfolk Island 2",
49 | is_active: true,
50 | deleted_at: null,
51 | created_at: "2022-10-20T08:28:28+0000",
52 | updated_at: "2022-10-20T08:28:28+0000",
53 | categories: [
54 | {
55 | id: "ba994b75-2b8d-4773-abe2-6b43424a2677",
56 | name: "PaleTurquoise",
57 | description: "Quae quo pariatur ut doloribus consequatur.",
58 | is_active: true,
59 | deleted_at: null,
60 | created_at: "2022-10-20T08:28:21+0000",
61 | updated_at: "2022-10-20T08:28:21+0000",
62 | pivot: {
63 | genre_id: "2",
64 | category_id: "ba994b75-2b8d-4773-abe2-6b43424a2677",
65 | },
66 | },
67 | ],
68 | },
69 | ],
70 | links: {
71 | first: "http://localhost:8000/api/genres?page=1",
72 | last: "http://localhost:8000/api/genres?page=7",
73 | prev: "http://localhost:8000/api/genres?page=1",
74 | next: "http://localhost:8000/api/genres?page=3",
75 | },
76 | meta: {
77 | current_page: 2,
78 | from: 16,
79 | last_page: 7,
80 | path: "http://localhost:8000/api/genres",
81 | per_page: 15,
82 | to: 30,
83 | total: 99,
84 | },
85 | };
86 |
--------------------------------------------------------------------------------
/src/features/uploads/UploadList.tsx:
--------------------------------------------------------------------------------
1 | import CloseIcon from "@mui/icons-material/Close";
2 | import {
3 | Accordion,
4 | AccordionDetails,
5 | AccordionSummary,
6 | IconButton,
7 | List,
8 | Typography,
9 | } from "@mui/material";
10 | import { Box } from "@mui/system";
11 | import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
12 | import { useAppDispatch, useAppSelector } from "../../app/hooks";
13 | import { UploadItem } from "./components/UploadItem";
14 | import {
15 | cleanAllUploads,
16 | cleanFinishedUploads,
17 | selectUploads,
18 | } from "./UploadSlice";
19 | import { useState } from "react";
20 |
21 | type Upload = {
22 | name: string;
23 | progress: number;
24 | };
25 |
26 | type Props = {
27 | uploads?: Upload[];
28 | };
29 |
30 | export const UploadList: React.FC = () => {
31 | const [expanded, setExpanded] = useState(false);
32 |
33 | const uploadList = useAppSelector(selectUploads);
34 | const dispatch = useAppDispatch();
35 |
36 | if (!uploadList || uploadList.length === 0) {
37 | return null;
38 | }
39 |
40 | const handleOnClose = (event: React.MouseEvent) => {
41 | event.stopPropagation();
42 | dispatch(cleanFinishedUploads());
43 | };
44 |
45 | const handleCancelAll = (event: React.MouseEvent) => {
46 | event.stopPropagation();
47 | dispatch(cleanAllUploads());
48 | };
49 |
50 | return (
51 |
59 | {
62 | setExpanded(isExpanded ? "upload" : false);
63 | }}
64 | >
65 |
69 |
75 |
76 | Uploading {uploadList.length}{" "}
77 | {uploadList.length > 1 ? "files" : "file"}
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | {uploadList.map((upload, index) => (
94 |
95 |
96 |
97 | ))}
98 |
99 |
100 |
101 |
102 | );
103 | };
104 |
--------------------------------------------------------------------------------
/src/features/uploads/UploadSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit";
2 | import { RootState } from "../../app/store";
3 | import { updateVideo } from "./uploadThunk";
4 |
5 | export const STATUS = {
6 | IDDLE: "idle",
7 | LOADING: "loading",
8 | SUCCESS: "success",
9 | FAILED: "failed",
10 | } as const;
11 |
12 | export type Status = typeof STATUS[keyof typeof STATUS];
13 |
14 | export interface UploadState {
15 | id: string;
16 | file: File;
17 | field: string;
18 | videoId: string;
19 | progress?: number;
20 | status?: Status;
21 | }
22 |
23 | type UploadProgress = {
24 | id: string;
25 | progress: number;
26 | };
27 |
28 | const initialState: UploadState[] = [];
29 |
30 | const uploadsSlice = createSlice({
31 | name: "uploads",
32 | initialState,
33 | reducers: {
34 | addUpload(state, action: PayloadAction) {
35 | state.push({ ...action.payload, status: "idle", progress: 0 });
36 | },
37 | removeUpload(state, action: PayloadAction) {
38 | const index = state.findIndex((upload) => upload.id === action.payload);
39 | if (index !== -1) {
40 | state.splice(index, 1);
41 | }
42 | },
43 | cleanAllUploads(state) {
44 | return [];
45 | },
46 | cleanFinishedUploads(state) {
47 | const uploads = state.filter(
48 | (upload) => upload.status !== "success" && upload.status !== "failed"
49 | );
50 | state.splice(0, state.length);
51 | state.push(...uploads);
52 | },
53 | setUploadProgress(state, action: PayloadAction) {
54 | const { id, progress } = action.payload;
55 | const upload = state.find((upload) => upload.id === id);
56 |
57 | if (upload) {
58 | upload.progress = progress;
59 | }
60 | },
61 | },
62 | extraReducers: (builder) => {
63 | builder.addCase(updateVideo.pending, (state, action) => {
64 | const upload = state.find((upload) => upload.id === action.meta.arg.id);
65 | if (upload) {
66 | upload.status = "loading";
67 | }
68 | });
69 |
70 | builder.addCase(updateVideo.fulfilled, (state, action) => {
71 | const upload = state.find((upload) => upload.id === action.meta.arg.id);
72 | if (upload) {
73 | upload.status = "success";
74 | }
75 | });
76 |
77 | builder.addCase(updateVideo.rejected, (state, action) => {
78 | const upload = state.find((upload) => upload.id === action.meta.arg.id);
79 | if (upload) {
80 | upload.status = "failed";
81 | }
82 | });
83 | },
84 | });
85 |
86 | export const {
87 | addUpload,
88 | removeUpload,
89 | setUploadProgress,
90 | cleanFinishedUploads,
91 | cleanAllUploads,
92 | } = uploadsSlice.actions;
93 |
94 | // selector
95 | export const selectUploads = (state: RootState) => state.uploadSlice;
96 |
97 | export const uploadReducer = uploadsSlice.reducer;
98 |
--------------------------------------------------------------------------------
/src/features/uploads/components/UploadItem.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { STATUS } from "../UploadSlice";
3 | import { UploadItem } from "./UploadItem";
4 |
5 | const Props = {
6 | upload: {
7 | id: "test",
8 | videoId: "test",
9 | field: "test",
10 | progress: 0,
11 | status: STATUS.IDDLE,
12 | file: new File(["test"], "test.txt", { type: "text/plain" }),
13 | },
14 | };
15 |
16 | describe("UploadItem", () => {
17 | it("should render UploadItem", () => {
18 | const { asFragment } = render();
19 | expect(asFragment()).toMatchSnapshot();
20 | });
21 |
22 | it("should render failed UploadItem", () => {
23 | const upload = { ...Props.upload, status: STATUS.FAILED };
24 | const { asFragment } = render();
25 | expect(asFragment()).toMatchSnapshot();
26 | });
27 |
28 | it("should render uploading UploadItem", () => {
29 | const upload = { ...Props.upload, status: STATUS.SUCCESS };
30 | const { asFragment } = render();
31 | expect(asFragment()).toMatchSnapshot();
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/features/uploads/components/UploadItem.tsx:
--------------------------------------------------------------------------------
1 | import ArticleIcon from "@mui/icons-material/Article";
2 | import { Box, ListItem, Typography } from "@mui/material";
3 | import { UploadState } from "../UploadSlice";
4 | import { UploadStatus } from "./UploadStatus";
5 |
6 | type Props = {
7 | upload: UploadState;
8 | };
9 |
10 | export const UploadItem: React.FC = ({ upload }) => {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 | {upload.field}
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/features/uploads/components/UploadStatus.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
3 | import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
4 | import { CircularProgress } from "@mui/material";
5 | import { STATUS, Status } from "../UploadSlice";
6 |
7 | interface Props {
8 | status?: Status;
9 | progress?: number;
10 | }
11 |
12 | export const UploadStatus: React.FC = ({ status, progress }) => {
13 | switch (status) {
14 | case STATUS.SUCCESS:
15 | return ;
16 | case STATUS.FAILED:
17 | return ;
18 | default:
19 | return (
20 |
26 | );
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/src/features/uploads/components/__snapshots__/UploadItem.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`UploadItem should render UploadItem 1`] = `
4 |
5 |
8 |
11 |
14 |
25 |
28 | test
29 |
30 |
31 |
37 |
51 |
52 |
53 |
54 |
55 | `;
56 |
57 | exports[`UploadItem should render failed UploadItem 1`] = `
58 |
59 |
62 |
65 |
68 |
79 |
82 | test
83 |
84 |
85 |
96 |
97 |
98 |
99 | `;
100 |
101 | exports[`UploadItem should render uploading UploadItem 1`] = `
102 |
103 |
106 |
109 |
112 |
123 |
126 | test
127 |
128 |
129 |
140 |
141 |
142 |
143 | `;
144 |
--------------------------------------------------------------------------------
/src/features/uploads/uploadAPI.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosProgressEvent } from "axios";
2 | import { baseUrl } from "../api/apiSlice";
3 |
4 | export const API_ENDPOINT = `${baseUrl}/videos`;
5 |
6 | export const getEndpoint = (id: string) => `${API_ENDPOINT}/${id}`;
7 |
8 | export const formdata = (field: string, file: File) => {
9 | const data = new FormData();
10 | data.append(field, file);
11 | data.append("_method", "PATCH");
12 | data.append("Content-Type", "multipart/form-data");
13 | return data;
14 | };
15 |
16 | export const uploadProgress = (progressEvent: AxiosProgressEvent) => {
17 | if (progressEvent.total) {
18 | const progress = (progressEvent.loaded * 100) / progressEvent.total;
19 | return Math.round(progress * 100) / 100;
20 | }
21 | return 0;
22 | };
23 |
24 | export const uploadService = (params: {
25 | field: string;
26 | file: File;
27 | videoId: string;
28 | onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
29 | }) => {
30 | const { field, file, videoId, onUploadProgress } = params;
31 | const endpoint = getEndpoint(videoId);
32 | const data = formdata(field, file);
33 |
34 | return axios.post(endpoint, data, { onUploadProgress });
35 | };
36 |
--------------------------------------------------------------------------------
/src/features/uploads/uploadThunk.test.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import { rest } from "msw";
3 | import { setupServer } from "msw/node";
4 | import { addUpload, uploadReducer } from "./UploadSlice";
5 | import { updateVideo } from "./uploadThunk";
6 | // Mocking axios
7 | import axios from "axios";
8 | import { baseUrl } from "../api/apiSlice";
9 | jest.mock("axios");
10 |
11 | const mockedAxios = axios as jest.Mocked;
12 |
13 | const store = configureStore({
14 | reducer: {
15 | uploadSlice: uploadReducer,
16 | },
17 | middleware: (getDefaultMiddleware) =>
18 | getDefaultMiddleware({
19 | serializableCheck: {
20 | ignoredActions: [
21 | "uploads/addUpload",
22 | "uploads/updateUpload",
23 | "uploads/uploadVideo/rejected",
24 | ],
25 | ignoredPaths: [
26 | "uploadSlice.file",
27 | "payload.file",
28 | `uploadSlice.0.file`,
29 | `uploadSlice.1.file`,
30 | ],
31 | },
32 | }),
33 | });
34 |
35 | const handlers = [
36 | rest.post(`${baseUrl}/videos/1`, (req, res, ctx) => {
37 | return res(
38 | ctx.status(200),
39 | ctx.json({ message: "File uploaded successfully" })
40 | );
41 | }),
42 | ];
43 |
44 | const server = setupServer(...handlers);
45 |
46 | beforeAll(() => server.listen());
47 | afterEach(() => server.resetHandlers());
48 | afterAll(() => server.close());
49 |
50 | describe("updateVideo async thunk", () => {
51 | afterEach(() => {
52 | mockedAxios.post.mockClear();
53 | });
54 |
55 | test("upload success", async () => {
56 | const file = new File(["dummy content"], "test.mp4", { type: "video/mp4" });
57 | const uploadState = {
58 | id: "1",
59 | videoId: "1",
60 | file,
61 | field: "file",
62 | };
63 |
64 | store.dispatch(addUpload(uploadState));
65 |
66 | mockedAxios.post.mockResolvedValue({
67 | data: { message: "File uploaded successfully" },
68 | });
69 |
70 | await store.dispatch(updateVideo(uploadState));
71 |
72 | const currentState = store.getState().uploadSlice;
73 | const upload = currentState.find((upload) => upload.id === uploadState.id);
74 | expect(upload?.status).toBe("success");
75 | });
76 |
77 | test("upload failed", async () => {
78 | const file = new File(["dummy content"], "test.mp4", { type: "video/mp4" });
79 | const uploadState = {
80 | id: "1",
81 | videoId: "1",
82 | file,
83 | field: "file",
84 | };
85 |
86 | // Add the upload to the state
87 | store.dispatch(addUpload(uploadState));
88 |
89 | mockedAxios.post.mockRejectedValue(new Error("Failed to upload file"));
90 |
91 | await store.dispatch(updateVideo(uploadState));
92 |
93 | const currentState = store.getState().uploadSlice;
94 | const upload = currentState.find((upload) => upload.id === uploadState.id);
95 | expect(upload?.status).toBe("failed");
96 | });
97 | });
98 |
--------------------------------------------------------------------------------
/src/features/uploads/uploadThunk.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from "@reduxjs/toolkit";
2 | import { AxiosProgressEvent } from "axios";
3 | import { uploadProgress, uploadService } from "./uploadAPI";
4 | import { setUploadProgress, UploadState } from "./UploadSlice";
5 |
6 | export const updateVideo = createAsyncThunk(
7 | "uploads/uploadVideo",
8 | async ({ videoId, id, file, field }: UploadState, thunkAPI) => {
9 | const onUploadProgress = (progressEvent: AxiosProgressEvent) => {
10 | const progress = uploadProgress(progressEvent);
11 | thunkAPI.dispatch(setUploadProgress({ id, progress }));
12 | };
13 |
14 | try {
15 | const params = { videoId, id, file, field, onUploadProgress };
16 | const response = await uploadService(params);
17 | return response;
18 | } catch (error) {
19 | if (error instanceof Error) {
20 | return thunkAPI.rejectWithValue({ message: error.message });
21 | }
22 | // You can provide a generic error message here if the error is not an instance of Error
23 | return thunkAPI.rejectWithValue({ message: "An unknown error occurred" });
24 | }
25 | }
26 | );
27 |
--------------------------------------------------------------------------------
/src/features/videos/VideoSlice.ts:
--------------------------------------------------------------------------------
1 | import { apiSlice } from "../api/apiSlice";
2 | import {
3 | Result,
4 | Results,
5 | Video,
6 | VideoParams,
7 | VideoPayload,
8 | } from "../../types/Videos";
9 | import { Results as CategoriesResults } from "../../types/Category";
10 | import { Genres as GenresResults } from "../../types/Genres";
11 | import { Results as CastMembersResults } from "../../types/CastMembers";
12 |
13 | const endpointUrl = "/videos";
14 |
15 | export const initialState: Video = {
16 | id: "",
17 | title: "",
18 | rating: "",
19 | genres: [],
20 | duration: "0",
21 | opened: false,
22 | deleted_at: "",
23 | created_at: "",
24 | updated_at: "",
25 | categories: [],
26 | description: "",
27 | year_launched: "0",
28 | cast_members: [],
29 | thumb_file_url: "",
30 | video_file_url: "",
31 | banner_file_url: "",
32 | trailer_file_url: "",
33 | };
34 |
35 | function parseQueryParams(params: VideoParams) {
36 | const query = new URLSearchParams();
37 | Object.entries(params).forEach(([key, value]) => {
38 | if (value) {
39 | query.append(key, value.toString());
40 | }
41 | });
42 |
43 | return query.toString();
44 | }
45 |
46 | const getVideos = ({ page = 1, perPage = 10, search = "" }: VideoParams) => {
47 | return `${endpointUrl}?${parseQueryParams({ page, perPage, search })}`;
48 | };
49 |
50 | function deleteVideo({ id }: { id: string }) {
51 | return { url: `${endpointUrl}/${id}`, method: "DELETE" };
52 | }
53 |
54 | function getVideo({ id }: { id: string }) {
55 | return `${endpointUrl}/${id}`;
56 | }
57 |
58 | function updateVideo(video: VideoPayload) {
59 | return {
60 | url: `${endpointUrl}/${video.id}`,
61 | method: "PUT",
62 | body: video,
63 | };
64 | }
65 | function createVideo(video: VideoPayload) {
66 | return {
67 | url: endpointUrl,
68 | method: "POST",
69 | body: video,
70 | };
71 | }
72 |
73 | function getAllCategories() {
74 | return `categories?all=true`;
75 | }
76 |
77 | function getAllGenres() {
78 | return `genres?all=true`;
79 | }
80 |
81 | function getAllCastMembers() {
82 | return `cast_members?all=true`;
83 | }
84 |
85 | export const videosSlice = apiSlice.injectEndpoints({
86 | endpoints: ({ query, mutation }) => ({
87 | createVideo: mutation({
88 | query: createVideo,
89 | invalidatesTags: ["Videos"],
90 | }),
91 | getVideos: query({
92 | query: getVideos,
93 | providesTags: ["Videos"],
94 | }),
95 | updateVideo: mutation({
96 | query: updateVideo,
97 | invalidatesTags: ["Videos"],
98 | }),
99 | getVideo: query({
100 | query: getVideo,
101 | providesTags: ["Videos"],
102 | }),
103 |
104 | getAllCategories: query({
105 | query: getAllCategories,
106 | providesTags: ["Categories"],
107 | }),
108 |
109 | getAllGenres: query({
110 | query: getAllGenres,
111 | providesTags: ["Genres"],
112 | }),
113 |
114 | getAllCastMembers: query({
115 | query: getAllCastMembers,
116 | providesTags: ["CastMembers"],
117 | }),
118 |
119 | deleteVideo: mutation({
120 | query: deleteVideo,
121 | invalidatesTags: ["Videos"],
122 | }),
123 | }),
124 | });
125 |
126 | export const {
127 | useGetVideoQuery,
128 | useGetVideosQuery,
129 | useGetAllGenresQuery,
130 | useDeleteVideoMutation,
131 | useUpdateVideoMutation,
132 | useGetAllCategoriesQuery,
133 | useGetAllCastMembersQuery,
134 | useCreateVideoMutation,
135 | } = videosSlice;
136 |
--------------------------------------------------------------------------------
/src/features/videos/VideosCreate.test.tsx:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { setupServer } from "msw/node";
3 |
4 | import {
5 | fireEvent,
6 | renderWithProviders,
7 | screen,
8 | waitFor,
9 | } from "../../utils/test-utils";
10 | import { VideosCreate } from "./VideosCreate";
11 | import { baseUrl } from "../api/apiSlice";
12 | import { genreResponse } from "../mocks/genre";
13 | import { castMemberResponse } from "../mocks";
14 |
15 | // mock nanoid
16 | jest.mock("nanoid", () => ({
17 | nanoid: () => "test-id",
18 | }));
19 |
20 | export const handlers = [
21 | rest.post(`${baseUrl}/videos`, (req, res, ctx) => {
22 | return res(ctx.delay(150), ctx.status(201));
23 | }),
24 | rest.get(`${baseUrl}/genres`, (req, res, ctx) => {
25 | return res(ctx.delay(150), ctx.status(200), ctx.json(genreResponse));
26 | }),
27 | rest.get(`${baseUrl}/cast_members`, (req, res, ctx) => {
28 | return res(ctx.delay(150), ctx.status(200), ctx.json(castMemberResponse));
29 | }),
30 | ];
31 |
32 | const server = setupServer(...handlers);
33 | beforeAll(() => server.listen());
34 | afterEach(() => server.resetHandlers());
35 | afterAll(() => server.close());
36 |
37 | describe("VideosCreate", () => {
38 | it("should render correctly", () => {
39 | const { asFragment } = renderWithProviders();
40 | expect(asFragment()).toMatchSnapshot();
41 | });
42 |
43 | it("should handle submit", async () => {
44 | renderWithProviders();
45 |
46 | const title = screen.getByTestId("title");
47 | const description = screen.getByTestId("description");
48 | const year_launched = screen.getByTestId("year_launched");
49 | const duration = screen.getByTestId("duration");
50 | const submit = screen.getByText("Save");
51 |
52 | fireEvent.change(title, { target: { value: "Test Video" } });
53 | fireEvent.change(description, { target: { value: "Test description" } });
54 | fireEvent.change(year_launched, { target: { value: "2022" } });
55 | fireEvent.change(duration, { target: { value: "120" } });
56 |
57 | fireEvent.click(submit);
58 |
59 | await waitFor(() => {
60 | const text = screen.getByText("Video created");
61 | expect(text).toBeInTheDocument();
62 | });
63 | });
64 |
65 | it("should handle submit error", async () => {
66 | server.use(
67 | rest.post(`${baseUrl}/videos`, (_, res, ctx) => {
68 | return res(ctx.status(500));
69 | })
70 | );
71 |
72 | renderWithProviders();
73 |
74 | const title = screen.getByTestId("title");
75 | const description = screen.getByTestId("description");
76 | const year_launched = screen.getByTestId("year_launched");
77 | const duration = screen.getByTestId("duration");
78 | const submit = screen.getByText("Save");
79 |
80 | fireEvent.change(title, { target: { value: "Test Video" } });
81 | fireEvent.change(description, { target: { value: "Test description" } });
82 | fireEvent.change(year_launched, { target: { value: "2022" } });
83 | fireEvent.change(duration, { target: { value: "120" } });
84 |
85 | fireEvent.click(submit);
86 |
87 | await waitFor(() => {
88 | const text = screen.getAllByText("Error creating Video");
89 | expect(text).toHaveLength(2);
90 | });
91 | });
92 |
93 | it("should handle adding a file", async () => {
94 | renderWithProviders();
95 |
96 | const file = new File(["file content"], "file.txt", { type: "text/plain" });
97 | const selectFileButton = screen.getByTestId("thumbnail-input");
98 |
99 | fireEvent.change(selectFileButton, { target: { files: [file] } });
100 |
101 | await waitFor(() => {
102 | expect(screen.getByDisplayValue(file.name)).toBeInTheDocument();
103 | });
104 | });
105 |
106 | it("should handle removing a file", async () => {
107 | renderWithProviders();
108 |
109 | const file = new File(["file content"], "file.txt", { type: "text/plain" });
110 | const selectFileButton = screen.getByTestId("thumbnail-input");
111 |
112 | fireEvent.change(selectFileButton, { target: { files: [file] } });
113 |
114 | await waitFor(() => {
115 | expect(screen.getByDisplayValue(file.name)).toBeInTheDocument();
116 | });
117 |
118 | fireEvent.click(screen.getByTestId("remove"));
119 |
120 | await waitFor(() => {
121 | expect(screen.queryByDisplayValue(file.name)).not.toBeInTheDocument();
122 | });
123 | });
124 |
125 | it("should handle adding a file error", async () => {
126 | renderWithProviders();
127 |
128 | const file = new File(["file content"], "file.txt", { type: "text/plain" });
129 | const selectFileButton = screen.getByTestId("thumbnail-input");
130 |
131 | fireEvent.change(selectFileButton, { target: { files: [file] } });
132 |
133 | await waitFor(() => {
134 | expect(screen.getByDisplayValue(file.name)).toBeInTheDocument();
135 | });
136 |
137 | fireEvent.click(screen.getByTestId("remove"));
138 |
139 | await waitFor(() => {
140 | expect(screen.queryByDisplayValue(file.name)).not.toBeInTheDocument();
141 | });
142 | });
143 | });
144 |
--------------------------------------------------------------------------------
/src/features/videos/VideosCreate.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Paper, Typography } from "@mui/material";
2 | import { nanoid } from "nanoid";
3 | import { useSnackbar } from "notistack";
4 | import { useEffect, useState } from "react";
5 | import { useAppDispatch } from "../../app/hooks";
6 | import { useUniqueCategories } from "../../hooks/useUniqueCategories";
7 | import { FileObject, Video } from "../../types/Videos";
8 | import { addUpload } from "../uploads/UploadSlice";
9 | import { VideosForm } from "./components/VideosForm";
10 | import { mapVideoToForm } from "./util";
11 | import {
12 | initialState,
13 | useCreateVideoMutation,
14 | useGetAllCastMembersQuery,
15 | useGetAllGenresQuery,
16 | } from "./VideoSlice";
17 |
18 | export const VideosCreate = () => {
19 | const { enqueueSnackbar } = useSnackbar();
20 | const { data: genres } = useGetAllGenresQuery();
21 | const { data: castMembers } = useGetAllCastMembersQuery();
22 | const [createVideo, status] = useCreateVideoMutation();
23 | const [videoState, setVideoState] = useState