├── src ├── common │ ├── models │ │ ├── index.ts │ │ └── lookup.ts │ ├── components │ │ ├── table │ │ │ ├── index.ts │ │ │ ├── table.styles.ts │ │ │ └── table.component.tsx │ │ ├── snackbar │ │ │ ├── index.ts │ │ │ ├── snackbar.styles.ts │ │ │ └── snackbar.component.tsx │ │ ├── spinner │ │ │ ├── index.ts │ │ │ ├── spinner.styles.ts │ │ │ └── spinner.component.tsx │ │ ├── index.ts │ │ └── confirmation-dialog │ │ │ ├── index.ts │ │ │ ├── confirmation-dialog.vm.ts │ │ │ ├── confirmation-dialog.hook.ts │ │ │ └── confirmation-dialog.component.tsx │ └── hooks │ │ ├── index.ts │ │ ├── pagination.hook.ts │ │ └── validation-errors.hook.ts ├── core │ ├── auth │ │ ├── index.ts │ │ └── auth.context.tsx │ ├── api-configuration │ │ ├── index.ts │ │ └── api-configuration.interceptors.tsx │ ├── router │ │ ├── index.ts │ │ ├── routes.ts │ │ └── router.component.tsx │ ├── theme │ │ ├── index.ts │ │ ├── theme.vm.ts │ │ ├── theme.ts │ │ └── theme-provider.component.tsx │ ├── mocks │ │ ├── index.ts │ │ ├── book-list.model.ts │ │ ├── author.model.ts │ │ ├── book-list.api-mock.ts │ │ └── book-list.api-mock-backup.ts │ └── notification │ │ ├── index.ts │ │ ├── notification.context.ts │ │ ├── notification.model.ts │ │ ├── notification-dialog.component.tsx │ │ └── notification.provider.tsx ├── pods │ ├── book │ │ ├── index.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── edit-review.styles.ts │ │ │ └── edit-review.component.tsx │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── book.api-model.ts │ │ │ └── book.api.ts │ │ ├── book.vm.ts │ │ ├── book.mappers.ts │ │ ├── book.container.tsx │ │ ├── book.styles.ts │ │ └── book.component.tsx │ ├── login │ │ ├── index.ts │ │ ├── login.validations.ts │ │ ├── login.vm.ts │ │ ├── login.styles.ts │ │ └── login.component.tsx │ ├── book-list │ │ ├── api │ │ │ ├── index.ts │ │ │ └── book-list.api.ts │ │ ├── index.ts │ │ ├── book-list.vm.ts │ │ ├── book-list.mapppers.ts │ │ ├── book-list.container.tsx │ │ ├── book-list.styles.ts │ │ └── book-list.component.tsx │ ├── dashboard │ │ ├── index.ts │ │ ├── dashboard.styles.ts │ │ └── dashboard.component.tsx │ ├── edit-book │ │ ├── index.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── edit-book.api-model.ts │ │ │ └── edit-book.api.ts │ │ ├── edit-book.validations.ts │ │ ├── edit-book.vm.ts │ │ ├── edit-book.mappers.ts │ │ ├── edit-book.styles.ts │ │ ├── edit-book.container.tsx │ │ └── edit-book.component.tsx │ ├── edit-author │ │ ├── index.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── edit-autor.api-model.ts │ │ │ └── edit-autor.api.ts │ │ ├── edit-author.validations.ts │ │ ├── edit-author.mappers.ts │ │ ├── edit-author.vm.ts │ │ ├── edit-author.styles.ts │ │ ├── edit-author.container.tsx │ │ └── edit-author.component.tsx │ ├── edit-book-list │ │ ├── index.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── edit-book-list.api-model.ts │ │ │ └── edit-book-list.api.ts │ │ ├── edit-book-list.vm.ts │ │ ├── edit-book-mappers.ts │ │ ├── edit-book-list.container.tsx │ │ ├── edit-book.list.styles.ts │ │ └── edit-book-list.component.tsx │ └── edit-author-list │ │ ├── components │ │ ├── index.ts │ │ ├── table.styles.ts │ │ ├── table-header.component.tsx │ │ ├── table-body.component.tsx │ │ └── table.component.tsx │ │ ├── index.ts │ │ ├── edit-author-list.constants.ts │ │ ├── api │ │ ├── index.ts │ │ ├── edit-author-list.api.ts │ │ └── edit-author-list.model.ts │ │ ├── edit-author-list.vm.ts │ │ ├── edit-author-list.mappers.ts │ │ ├── edit-author-list.component.tsx │ │ ├── edit-author-list.styles.ts │ │ └── edit-author-list.container.tsx ├── common-app │ └── app-bar │ │ ├── index.ts │ │ ├── app-bar.styles.ts │ │ └── app-bar.component.tsx ├── layouts │ ├── index.ts │ ├── app.layout.styles.ts │ ├── centered.layout.tsx │ ├── centered.layout.styles.ts │ └── app.layout.tsx ├── index.tsx ├── scenes │ ├── login.scene.tsx │ ├── book-list.scene.tsx │ ├── dashboar.scene.tsx │ ├── edit-book-list.scene.tsx │ ├── index.ts │ ├── edit-author-list.scene.tsx │ ├── book.scene.tsx │ ├── edit-book.scene.tsx │ └── edit-author.scene.tsx ├── app.tsx └── app.global-styles.ts ├── .gitignore ├── public ├── 1984.jpg ├── el-hobbit.jpg ├── a-dos-metros-de-ti.jpg ├── matar-a-un-ruiseñor.jpg ├── cien-anyos-de-soledad.jpg └── los-juegos-del-hambre.jpg ├── vite-env.d.ts ├── .editorconfig ├── .prettierrc ├── index.html ├── tsconfig.json ├── vite.config.ts ├── package.json └── README.md /src/common/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lookup'; 2 | -------------------------------------------------------------------------------- /src/core/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.context'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | package-lock.json -------------------------------------------------------------------------------- /src/pods/book/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book.container'; 2 | -------------------------------------------------------------------------------- /src/pods/login/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login.component'; 2 | -------------------------------------------------------------------------------- /src/pods/book-list/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book-list.api'; 2 | -------------------------------------------------------------------------------- /src/common-app/app-bar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-bar.component'; 2 | -------------------------------------------------------------------------------- /src/pods/book-list/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book-list.container'; 2 | -------------------------------------------------------------------------------- /src/pods/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dashboard.component'; 2 | -------------------------------------------------------------------------------- /src/pods/edit-book/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-book.container'; 2 | -------------------------------------------------------------------------------- /src/common/components/table/index.ts: -------------------------------------------------------------------------------- 1 | export * from './table.component'; 2 | -------------------------------------------------------------------------------- /src/pods/book/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-review.component'; 2 | -------------------------------------------------------------------------------- /src/pods/edit-author/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-author.container'; 2 | -------------------------------------------------------------------------------- /src/common/components/snackbar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './snackbar.component'; 2 | -------------------------------------------------------------------------------- /src/common/components/spinner/index.ts: -------------------------------------------------------------------------------- 1 | export * from './spinner.component'; 2 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-book-list.container'; 2 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './table.component'; 2 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-author-list.container'; 2 | -------------------------------------------------------------------------------- /src/core/api-configuration/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api-configuration.interceptors'; 2 | -------------------------------------------------------------------------------- /src/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './centered.layout'; 2 | export * from './app.layout'; 3 | -------------------------------------------------------------------------------- /src/core/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './router.component'; 2 | export * from './routes'; 3 | -------------------------------------------------------------------------------- /src/pods/book/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book.api'; 2 | export * from './book.api-model'; 3 | -------------------------------------------------------------------------------- /public/1984.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/bootcampback-net-example/main/public/1984.jpg -------------------------------------------------------------------------------- /src/core/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from './theme-provider.component'; 2 | export * from './theme'; 3 | -------------------------------------------------------------------------------- /public/el-hobbit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/bootcampback-net-example/main/public/el-hobbit.jpg -------------------------------------------------------------------------------- /src/common/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pagination.hook'; 2 | export * from './validation-errors.hook'; 3 | -------------------------------------------------------------------------------- /src/pods/edit-book/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-book.api'; 2 | export * from './edit-book.api-model'; 3 | -------------------------------------------------------------------------------- /src/pods/edit-author/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-autor.api'; 2 | export * from './edit-autor.api-model'; 3 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/edit-author-list.constants.ts: -------------------------------------------------------------------------------- 1 | export const INITIAL_PAGE = 1; 2 | export const PAGE_SIZE = 10; 3 | -------------------------------------------------------------------------------- /public/a-dos-metros-de-ti.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/bootcampback-net-example/main/public/a-dos-metros-de-ti.jpg -------------------------------------------------------------------------------- /src/pods/edit-author-list/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-author-list.api'; 2 | export * from './edit-author-list.model'; 3 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './edit-book-list.api'; 2 | export * from './edit-book-list.api-model'; 3 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Reference: https://github.com/vitejs/vite/discussions/6799 4 | -------------------------------------------------------------------------------- /public/matar-a-un-ruiseñor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/bootcampback-net-example/main/public/matar-a-un-ruiseñor.jpg -------------------------------------------------------------------------------- /public/cien-anyos-de-soledad.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/bootcampback-net-example/main/public/cien-anyos-de-soledad.jpg -------------------------------------------------------------------------------- /public/los-juegos-del-hambre.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lemoncode/bootcampback-net-example/main/public/los-juegos-del-hambre.jpg -------------------------------------------------------------------------------- /src/core/theme/theme.vm.ts: -------------------------------------------------------------------------------- 1 | import { Theme as DefaultTheme } from '@mui/material/styles'; 2 | 3 | export type Theme = DefaultTheme; 4 | -------------------------------------------------------------------------------- /src/core/mocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book-list.api-mock'; 2 | export * from './book-list.model'; 3 | export * from './author.model'; 4 | -------------------------------------------------------------------------------- /src/core/notification/index.ts: -------------------------------------------------------------------------------- 1 | export { useNotificationContext } from './notification.context'; 2 | export * from './notification.provider'; 3 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/edit-book-list.vm.ts: -------------------------------------------------------------------------------- 1 | export interface BookVm { 2 | id: string; 3 | title: string; 4 | authors: string[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/pods/edit-author/api/edit-autor.api-model.ts: -------------------------------------------------------------------------------- 1 | export interface Author { 2 | id?: number; 3 | firstName: string; 4 | lastName: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/pods/book-list/book-list.vm.ts: -------------------------------------------------------------------------------- 1 | export interface BookVm { 2 | id: string; 3 | title: string; 4 | imageUrl: string; 5 | imageAltText: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/common/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './table'; 2 | export * from './spinner'; 3 | export * from './snackbar'; 4 | export * from './confirmation-dialog'; 5 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/edit-author-list.vm.ts: -------------------------------------------------------------------------------- 1 | export interface Author { 2 | id: number; 3 | firstName: string; 4 | lastName: string; 5 | bookCount: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/layouts/app.layout.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | 3 | export const root = css` 4 | display: flex; 5 | flex-direction: column; 6 | flex-grow: 1; 7 | `; 8 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import App from './app'; 3 | 4 | const root = createRoot(document.getElementById('root')); 5 | 6 | root.render(); 7 | -------------------------------------------------------------------------------- /src/common/components/confirmation-dialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './confirmation-dialog.component'; 2 | export * from './confirmation-dialog.hook'; 3 | export * from './confirmation-dialog.vm'; 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /src/common/models/lookup.ts: -------------------------------------------------------------------------------- 1 | export interface Lookup { 2 | id: Id; 3 | name: string; 4 | } 5 | 6 | export const createEmptyLookup = (): Lookup => ({ 7 | id: '', 8 | name: '', 9 | }); 10 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/api/edit-book-list.api-model.ts: -------------------------------------------------------------------------------- 1 | export interface Book { 2 | id: string; 3 | title: string; 4 | authors: { 5 | id: string; 6 | firstName: string; 7 | lastName: string; 8 | }[]; 9 | } 10 | -------------------------------------------------------------------------------- /src/common/components/snackbar/snackbar.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { snackbarClasses } from '@mui/material'; 3 | 4 | export const root = css` 5 | &.${snackbarClasses.root} { 6 | height: auto; 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "bracketSpacing": true, 8 | "trailingComma": "es5", 9 | "arrowParens": "avoid", 10 | "endOfLine": "lf" 11 | } 12 | -------------------------------------------------------------------------------- /src/scenes/login.scene.tsx: -------------------------------------------------------------------------------- 1 | import { AppLayout } from '@/layouts'; 2 | import { Login } from '@/pods/login'; 3 | 4 | export const LoginScene: React.FC = () => { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/common/components/table/table.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | 3 | export const root = css` 4 | display: flex; 5 | flex-direction: column; 6 | `; 7 | 8 | export const pagination = css` 9 | display: flex; 10 | justify-content: center; 11 | padding: 1rem; 12 | `; 13 | -------------------------------------------------------------------------------- /src/pods/book-list/api/book-list.api.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '@/core/mocks'; 2 | import axios from 'axios'; 3 | 4 | export const getBookList = async (): Promise => { 5 | const baseUrl = '/api/books/novelties'; 6 | const { data } = await axios.get(`${baseUrl}?limit=6`); 7 | return data; 8 | }; 9 | -------------------------------------------------------------------------------- /src/core/notification/notification.context.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NotificationContextModel } from './notification.model'; 3 | 4 | export const NotificationContext = React.createContext(null); 5 | 6 | export const useNotificationContext = () => React.useContext(NotificationContext); 7 | -------------------------------------------------------------------------------- /src/scenes/book-list.scene.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppLayout } from '@/layouts'; 3 | import { BookListContainer } from '@/pods/book-list'; 4 | 5 | export const BookListScene: React.FC = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/scenes/dashboar.scene.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppLayout } from '@/layouts'; 3 | import { DashboardComponent } from '@/pods/dashboard'; 4 | 5 | export const DashboardScene: React.FC = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/scenes/edit-book-list.scene.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppLayout } from '@/layouts'; 3 | import { EditBookList } from '@/pods/edit-book-list'; 4 | 5 | export const EditBookListScene: React.FC = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/scenes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login.scene'; 2 | export * from './book-list.scene'; 3 | export * from './book.scene'; 4 | export * from './dashboar.scene'; 5 | export * from './edit-book-list.scene'; 6 | export * from './edit-book.scene'; 7 | export * from './edit-author-list.scene'; 8 | export * from './edit-author.scene'; 9 | -------------------------------------------------------------------------------- /src/common/components/confirmation-dialog/confirmation-dialog.vm.ts: -------------------------------------------------------------------------------- 1 | export interface ConfirmationDialogLabelProps { 2 | closeButton: string; 3 | acceptButton?: string; 4 | } 5 | 6 | export const createEmptyConfirmationDialogLabelProps = (): ConfirmationDialogLabelProps => ({ 7 | closeButton: '', 8 | acceptButton: '', 9 | }); 10 | -------------------------------------------------------------------------------- /src/layouts/centered.layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as classes from './centered.layout.styles'; 3 | 4 | interface Props { 5 | children: React.ReactNode; 6 | } 7 | 8 | export const CenteredLayout: React.FC = (props) => { 9 | const { children } = props; 10 | return
{children}
; 11 | }; 12 | -------------------------------------------------------------------------------- /src/scenes/edit-author-list.scene.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppLayout } from '@/layouts'; 3 | import { EditAuthorListContainer } from '@/pods/edit-author-list'; 4 | 5 | export const EditAuthorListScene: React.FC = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/core/mocks/book-list.model.ts: -------------------------------------------------------------------------------- 1 | interface Review { 2 | id: string; 3 | reviewer: string; 4 | title: string; 5 | text: string; 6 | } 7 | 8 | export interface Book { 9 | id?: number; 10 | title: string; 11 | imageUrl: string; 12 | imageAltText: string; 13 | description: string; 14 | authors: number[]; 15 | reviews?: Review[]; 16 | } 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Library App 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/core/theme/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | import { Theme } from './theme.vm'; 3 | 4 | const defaultTheme = createTheme({ 5 | palette: { 6 | primary: { 7 | main: '#201f20', 8 | }, 9 | secondary: { 10 | main: '#e8eaeb', 11 | }, 12 | }, 13 | }); 14 | 15 | export const theme: Theme = defaultTheme; 16 | -------------------------------------------------------------------------------- /src/layouts/centered.layout.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { theme } from '@/core/theme'; 3 | 4 | export const root = css` 5 | display: grid; 6 | grid-template-columns: 1fr; 7 | align-items: center; 8 | margin-top: 2rem; 9 | 10 | @media (min-width: ${theme.breakpoints.values.sm}px) { 11 | justify-items: center; 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/components/table.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | 3 | export const table = css` 4 | display: flex; 5 | flex-direction: column; 6 | 7 | & :last-child { 8 | text-align: center; 9 | } 10 | `; 11 | 12 | export const pagination = css` 13 | display: flex; 14 | justify-content: center; 15 | padding: 1rem; 16 | `; 17 | -------------------------------------------------------------------------------- /src/scenes/book.scene.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { AppLayout } from '@/layouts'; 4 | import { BookContainer } from '@/pods/book'; 5 | 6 | export const BookScene: React.FC = () => { 7 | const { id } = useParams(); 8 | return ( 9 | 10 | 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/scenes/edit-book.scene.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { AppLayout } from '@/layouts'; 4 | import { EditBookContainer } from '@/pods/edit-book'; 5 | 6 | export const EditBookScene: React.FC = () => { 7 | const { id } = useParams(); 8 | return ( 9 | 10 | 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/scenes/edit-author.scene.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { AppLayout } from '@/layouts'; 4 | import { EditAuthorContainer } from '@/pods/edit-author'; 5 | 6 | export const EditAuthorScene: React.FC = () => { 7 | const { id } = useParams(); 8 | return ( 9 | 10 | 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/pods/edit-author/edit-author.validations.ts: -------------------------------------------------------------------------------- 1 | import { Validators, createFormValidation } from '@lemoncode/fonk'; 2 | 3 | const validationSchema = { 4 | field: { 5 | firstName: [{ validator: Validators.required, message: 'Campo requerido' }], 6 | lastName: [{ validator: Validators.required, message: 'Campo requerido' }], 7 | }, 8 | }; 9 | 10 | export const formValidation = createFormValidation(validationSchema); 11 | -------------------------------------------------------------------------------- /src/common/components/spinner/spinner.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | 3 | export const modal = css` 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | `; 8 | 9 | export const loaderContainer = css` 10 | border-radius: 15%; 11 | background-color: #fff; 12 | width: 100px; 13 | height: 100px; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | outline: none; 18 | `; 19 | -------------------------------------------------------------------------------- /src/pods/book/components/edit-review.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { paperClasses, backdropClasses } from '@mui/material'; 3 | 4 | export const root = css` 5 | & .${paperClasses.root} { 6 | width: 600px; 7 | } 8 | 9 | & .${backdropClasses.root} { 10 | background-color: rgba(0, 0, 0, 0.4); 11 | } 12 | `; 13 | 14 | export const dialogContent = css` 15 | display: flex; 16 | flex-direction: column; 17 | gap: 24px; 18 | `; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "isolatedModules": true, 9 | "outDir": "dist", 10 | "jsx": "react-jsx", 11 | "baseUrl": "src", 12 | "noEmitOnError": true, 13 | "paths": { 14 | "@/*": ["*"] 15 | } 16 | }, 17 | "include": ["./src/**/*", "./vite-env.d.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /src/layouts/app.layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppBarComponent } from '@/common-app/app-bar'; 3 | import * as classes from './app.layout.styles'; 4 | 5 | interface Props { 6 | children: React.ReactNode; 7 | } 8 | 9 | export const AppLayout: React.FC = props => { 10 | const { children } = props; 11 | 12 | return ( 13 |
14 | 15 | {children} 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/pods/book-list/book-list.mapppers.ts: -------------------------------------------------------------------------------- 1 | import * as apiModel from '@/core/mocks'; 2 | import * as vm from './book-list.vm'; 3 | 4 | export const mapBookListFromApiToVm = (bookList: apiModel.Book[]): vm.BookVm[] => 5 | bookList.map(book => mapBookFromApiToVm(book)); 6 | 7 | const mapBookFromApiToVm = (book: apiModel.Book): vm.BookVm => ({ 8 | id: book.id.toString(), 9 | title: book.title, 10 | imageUrl: book.imageUrl, 11 | imageAltText: book.imageAltText, 12 | }); 13 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/edit-book-mappers.ts: -------------------------------------------------------------------------------- 1 | import { Book } from './api'; 2 | import { BookVm } from './edit-book-list.vm'; 3 | 4 | const mapBookFromApiToVm = (book: Book): BookVm => ({ 5 | id: book.id.toString(), 6 | title: book.title, 7 | authors: book.authors.map(author => `${author.firstName} ${author.lastName}`), 8 | }); 9 | 10 | export const mapBookListFromApiToVm = (bookList: Book[]): BookVm[] => 11 | Boolean(bookList) ? bookList.map(book => mapBookFromApiToVm(book)) : []; 12 | -------------------------------------------------------------------------------- /src/pods/edit-book/api/edit-book.api-model.ts: -------------------------------------------------------------------------------- 1 | export interface Book { 2 | id?: string; 3 | title: string; 4 | authors: { 5 | id: string; 6 | firstName: string; 7 | lastName: string; 8 | }[]; 9 | description: string; 10 | imageUrl: string; 11 | imageAltText: string; 12 | } 13 | 14 | export interface SaveBook { 15 | id?: number; 16 | title: string; 17 | authorIds: number[]; 18 | description: string; 19 | tempImageFileName: string; 20 | imageAltText: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/api/edit-book-list.api.ts: -------------------------------------------------------------------------------- 1 | import { Book } from './edit-book-list.api-model'; 2 | import axios from 'axios'; 3 | 4 | export const getBookList = async (): Promise => { 5 | const baseUrl = '/api/books/novelties'; 6 | const { data } = await axios.get(`${baseUrl}?limit=6`); 7 | return data; 8 | }; 9 | 10 | export const deleteBook = async (id: string): Promise => { 11 | const { data } = await axios.delete(`/api/books/${id}`); 12 | return data; 13 | }; 14 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/components/table-header.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TableCell, TableHead, TableRow } from '@mui/material'; 3 | 4 | export const TableHeader: React.FC = () => { 5 | return ( 6 | 7 | 8 | Nombre 9 | Apellidos 10 | Número de libros 11 | Comandos 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/common-app/app-bar/app-bar.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | 3 | export const root = css` 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | `; 8 | 9 | export const rightContainer = css` 10 | display: flex; 11 | gap: 16px; 12 | `; 13 | 14 | export const link = css` 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | text-decoration: none; 19 | color: inherit; 20 | 21 | &:visited { 22 | color: inherit; 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /src/common/hooks/pagination.hook.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const usePagination = (initialPage: number, loadData: (page: number) => void) => { 4 | const [currentPage, setCurrentPage] = React.useState(initialPage); 5 | 6 | const handlePageChange = (event: React.ChangeEvent, newPage: number) => { 7 | setCurrentPage(newPage); 8 | }; 9 | 10 | React.useEffect(() => { 11 | loadData(currentPage); 12 | }, [loadData, currentPage]); 13 | 14 | return { currentPage, handlePageChange }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/edit-author-list.mappers.ts: -------------------------------------------------------------------------------- 1 | import * as apiModel from './api'; 2 | import * as vm from './edit-author-list.vm'; 3 | 4 | const mapAuthorFromApiToVm = (author: apiModel.Author): vm.Author => ({ 5 | id: author.id, 6 | firstName: author.firstName, 7 | lastName: author.lastName, 8 | bookCount: author.bookCount, 9 | }); 10 | 11 | export const mapAuthorListFromApiToVm = (authorList: apiModel.Author[]): vm.Author[] => 12 | Array.isArray(authorList) ? authorList.map(author => mapAuthorFromApiToVm(author)) : []; 13 | -------------------------------------------------------------------------------- /src/pods/edit-author/api/edit-autor.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Author } from './edit-autor.api-model'; 3 | 4 | export const saveAuthor = (author: Author): Promise => 5 | author.id 6 | ? axios.put(`/api/authors`, author).then(response => response.data) 7 | : axios.post('/api/authors', author).then(response => response.data); 8 | 9 | export const getAuthor = async (id: string): Promise => { 10 | const { data } = await axios.get(`/api/authors/${id}`); 11 | return data; 12 | }; 13 | -------------------------------------------------------------------------------- /src/core/notification/notification.model.ts: -------------------------------------------------------------------------------- 1 | export interface NotificationInfo { 2 | open: boolean; 3 | message: string; 4 | variant: 'critical' | 'error' | 'info' | 'success'; 5 | autoHideDuration?: number; 6 | } 7 | 8 | export const createEmptyNotificationInfo = (): NotificationInfo => ({ 9 | open: false, 10 | message: '', 11 | variant: 'error', 12 | }); 13 | 14 | export interface NotificationContextModel { 15 | notify: (message: string, variant?: NotificationInfo['variant'], autoHideDuration?: number) => void; 16 | close: () => void; 17 | state: NotificationInfo; 18 | } 19 | -------------------------------------------------------------------------------- /src/pods/edit-author/edit-author.mappers.ts: -------------------------------------------------------------------------------- 1 | import * as apiModel from './api'; 2 | import * as vm from './edit-author.vm'; 3 | 4 | export const mapAuthorFromVmToApi = (author: vm.AuthorVm): apiModel.Author => ({ 5 | id: Boolean(author.id) ? author.id : undefined, 6 | firstName: author.firstName, 7 | lastName: author.lastName, 8 | }); 9 | 10 | export const mapAuthorFromApiToVm = (author: apiModel.Author): vm.AuthorVm => 11 | Boolean(author) 12 | ? { 13 | id: author.id, 14 | firstName: author.firstName, 15 | lastName: author.lastName, 16 | } 17 | : vm.createEmptyAuthor(); 18 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | react({ 8 | babel: { 9 | plugins: ['@emotion'], 10 | }, 11 | }), 12 | ], 13 | resolve: { 14 | alias: { 15 | '@': fileURLToPath(new URL('./src', import.meta.url)), 16 | }, 17 | }, 18 | server: { 19 | proxy: { 20 | '/api': { 21 | target: 'https://localhost:7223', 22 | changeOrigin: true, 23 | secure: false, 24 | }, 25 | }, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/core/mocks/author.model.ts: -------------------------------------------------------------------------------- 1 | interface PaginationInfo { 2 | currentPage: number; 3 | resultsPerPage: number; 4 | totalPages: number; 5 | totalRows: number; 6 | hasPreviousPage: boolean; 7 | hasNextPage: boolean; 8 | resultsFrom: number; 9 | resultsTo: number; 10 | } 11 | 12 | export interface Author { 13 | id: number; 14 | firstName: string; 15 | lastName: string; 16 | bookCount: number; 17 | } 18 | 19 | export interface EditAuthorListResponse { 20 | results: Author[]; 21 | paginationInfo: PaginationInfo; 22 | } 23 | 24 | export interface EditAuthorListParams { 25 | page: number; 26 | pageSize: number; 27 | } -------------------------------------------------------------------------------- /src/pods/edit-author-list/api/edit-author-list.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { EditAuthorListParams, EditAuthorListResponse } from './edit-author-list.model'; 3 | 4 | export const getEditAuthorList = async ({ page, pageSize }: EditAuthorListParams): Promise => { 5 | const baseUrl = '/api/authors'; 6 | const { data } = await axios.get(`${baseUrl}?page=${page}&pageSize=${pageSize}`); 7 | return data; 8 | }; 9 | 10 | export const deleteAuthor = async (id: string): Promise => { 11 | const baseUrl = '/api/authors'; 12 | await axios.delete(`${baseUrl}/${id}`); 13 | }; 14 | -------------------------------------------------------------------------------- /src/common/hooks/validation-errors.hook.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { FormValidationResult } from '@lemoncode/fonk'; 3 | 4 | export const useValidationErrors = (initialErrors: T) => { 5 | const [errors, setErrors] = useState(initialErrors); 6 | 7 | const handleErrors = (formValidationResult: FormValidationResult) => { 8 | const newErrors = { ...errors }; 9 | Object.keys(formValidationResult.recordErrors).forEach(field => { 10 | newErrors[field] = formValidationResult.recordErrors[field]; 11 | }); 12 | setErrors(newErrors); 13 | }; 14 | 15 | return { errors, onErrors: handleErrors }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/pods/login/login.validations.ts: -------------------------------------------------------------------------------- 1 | import { Validators, createFormValidation } from '@lemoncode/fonk'; 2 | 3 | const validationSchema = { 4 | field: { 5 | email: [ 6 | { 7 | validator: Validators.required, 8 | message: 'Campo requerido', 9 | }, 10 | { 11 | validator: Validators.email, 12 | message: 'Formato de email no válido', 13 | }, 14 | ], 15 | password: [ 16 | { 17 | validator: Validators.required, 18 | message: 'Campo requerido', 19 | }, 20 | ], 21 | }, 22 | }; 23 | 24 | export const formValidation = createFormValidation(validationSchema); 25 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/api/edit-author-list.model.ts: -------------------------------------------------------------------------------- 1 | interface PaginationInfo { 2 | currentPage: number; 3 | resultsPerPage: number; 4 | totalPages: number; 5 | totalRows: number; 6 | hasPreviousPage: boolean; 7 | hasNextPage: boolean; 8 | resultsFrom: number; 9 | resultsTo: number; 10 | } 11 | 12 | export interface Author { 13 | id: number; 14 | firstName: string; 15 | lastName: string; 16 | bookCount: number; 17 | } 18 | 19 | export interface EditAuthorListResponse { 20 | results: Author[]; 21 | paginationInfo: PaginationInfo; 22 | } 23 | 24 | export interface EditAuthorListParams { 25 | page: number; 26 | pageSize: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/pods/edit-book/edit-book.validations.ts: -------------------------------------------------------------------------------- 1 | import { Validators, createFormValidation } from '@lemoncode/fonk'; 2 | 3 | const validationSchema = { 4 | field: { 5 | title: [{ validator: Validators.required, message: 'Campo requerido' }], 6 | authors: [{ validator: Validators.required, message: 'Campo requerido' }], 7 | description: [{ validator: Validators.required, message: 'Campo requerido' }], 8 | imageUrl: [{ validator: Validators.required, message: 'Campo requerido' }], 9 | imageAltText: [{ validator: Validators.required, message: 'Campo requerido' }], 10 | }, 11 | }; 12 | 13 | export const formValidation = createFormValidation(validationSchema); 14 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SpinnerComponent } from '@/common/components'; 3 | import { AuthProvider } from '@/core/auth'; 4 | import { NotificationProvider } from '@/core/notification'; 5 | import { RouterComponent } from '@/core/router'; 6 | import { ThemeProviderComponent } from '@/core/theme'; 7 | import './app.global-styles'; 8 | 9 | const App: React.FC = () => { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /src/core/theme/theme-provider.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import StyledEngineProvider from '@mui/material/StyledEngineProvider'; 3 | import ThemeProvider from '@mui/material/styles/ThemeProvider'; 4 | import CssBaseline from '@mui/material/CssBaseline'; 5 | import { theme } from './theme'; 6 | 7 | interface Props { 8 | children: React.ReactNode; 9 | } 10 | 11 | export const ThemeProviderComponent: React.FC = (props) => { 12 | const { children } = props; 13 | 14 | return ( 15 | 16 | 17 | 18 | {children} 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/pods/login/login.vm.ts: -------------------------------------------------------------------------------- 1 | import { ValidationResult } from '@lemoncode/fonk'; 2 | 3 | export interface Credentials { 4 | email: string; 5 | password: string; 6 | } 7 | 8 | export const createEmptyCredentials = (): Credentials => ({ 9 | email: '', 10 | password: '', 11 | }); 12 | 13 | const createEmptyValidationResult = (): ValidationResult => ({ 14 | succeeded: true, 15 | type: '', 16 | message: '', 17 | }); 18 | 19 | export interface CredentialsErrors { 20 | email: ValidationResult; 21 | password: ValidationResult; 22 | } 23 | 24 | export const createEmptyCredentialsError = (): CredentialsErrors => ({ 25 | email: createEmptyValidationResult(), 26 | password: createEmptyValidationResult(), 27 | }); 28 | -------------------------------------------------------------------------------- /src/core/notification/notification-dialog.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material'; 3 | 4 | interface Props { 5 | open: boolean; 6 | children: React.ReactNode; 7 | onClose: () => void; 8 | } 9 | 10 | export const NotificationDialog: React.FC = props => { 11 | const { children, open, onClose } = props; 12 | 13 | return ( 14 | 15 | Se ha producido un error 16 | {children} 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/pods/login/login.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { theme } from '@/core/theme'; 3 | 4 | export const root = css` 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | flex-grow: 1; 10 | gap: 32px; 11 | padding: ${theme.spacing(6)}; 12 | `; 13 | 14 | export const paper = css` 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 64px; 20 | flex-grow: 1; 21 | padding: ${theme.spacing(2)}; 22 | max-width: 400px; 23 | margin: 0 auto; 24 | 25 | & > form { 26 | align-self: center; 27 | } 28 | `; 29 | 30 | export const input = css` 31 | margin-bottom: ${theme.spacing(2)}; 32 | `; 33 | -------------------------------------------------------------------------------- /src/pods/edit-author/edit-author.vm.ts: -------------------------------------------------------------------------------- 1 | import { ValidationResult } from '@lemoncode/fonk'; 2 | export interface AuthorVm { 3 | id?: number; 4 | firstName: string; 5 | lastName: string; 6 | } 7 | 8 | export const createEmptyAuthor = (): AuthorVm => ({ 9 | id: undefined, 10 | firstName: '', 11 | lastName: '', 12 | }); 13 | 14 | const createEmptyValidationResult = (): ValidationResult => ({ 15 | succeeded: true, 16 | type: '', 17 | message: '', 18 | }); 19 | 20 | export interface AuthorFieldsErrors { 21 | firstName: ValidationResult; 22 | lastName: ValidationResult; 23 | } 24 | 25 | export const createEmptyFieldsErrors = (): AuthorFieldsErrors => ({ 26 | firstName: createEmptyValidationResult(), 27 | lastName: createEmptyValidationResult(), 28 | }); 29 | -------------------------------------------------------------------------------- /src/common/components/spinner/spinner.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { usePromiseTracker } from 'react-promise-tracker'; 3 | import Modal from '@mui/material/Modal'; 4 | import Loader from 'react-spinners/ScaleLoader'; 5 | import * as classes from './spinner.styles'; 6 | 7 | interface Props { 8 | delay?: number; 9 | } 10 | 11 | export const SpinnerComponent: React.FunctionComponent = props => { 12 | const { delay } = props; 13 | const { promiseInProgress } = usePromiseTracker({ delay }); 14 | 15 | return ( 16 | 17 |
18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | SpinnerComponent.defaultProps = { 25 | delay: 500, 26 | }; 27 | -------------------------------------------------------------------------------- /src/pods/book/api/book.api-model.ts: -------------------------------------------------------------------------------- 1 | export interface Review { 2 | id?: number; 3 | bookId: number; 4 | reviewer: string; 5 | reviewText: string; 6 | creationDate: string; 7 | stars: number; 8 | } 9 | export interface ReviewParams { 10 | results: Review[]; 11 | paginationInfo: { 12 | currentPage: number; 13 | resultsPerPage: number; 14 | totalPages: number; 15 | totalRows: number; 16 | hasPreviousPage: boolean; 17 | hasNextPage: boolean; 18 | resulsFrom: number; 19 | resultsTo: number; 20 | }; 21 | } 22 | 23 | export interface Book { 24 | id?: number; 25 | title: string; 26 | imageUrl: string; 27 | imageAltText: string; 28 | description: string; 29 | authors: { 30 | id: number; 31 | firstName: string; 32 | lastName: string; 33 | }[]; 34 | } 35 | -------------------------------------------------------------------------------- /src/core/router/routes.ts: -------------------------------------------------------------------------------- 1 | interface SwitchRoutes { 2 | root: string; 3 | login: string; 4 | dashboard: string; 5 | bookList: string; 6 | createBook: string; 7 | editBookList: string; 8 | editAuthorList: string; 9 | createAuthor: string; 10 | editAuthor: (id: string) => string; 11 | bookDetail: (id: string) => string; 12 | editBook: (id: string) => string; 13 | } 14 | 15 | export const switchRoutes: SwitchRoutes = { 16 | root: '/', 17 | login: '/login', 18 | dashboard: '/dashboard', 19 | bookList: '/book-list', 20 | createBook: '/create-book', 21 | editBookList: '/edit-book', 22 | editAuthorList: '/edit-author', 23 | createAuthor: '/add-author', 24 | editAuthor: (id: string) => `/edit-author/${id}`, 25 | bookDetail: (id: string) => `/book/${id}`, 26 | editBook: (id: string) => `/edit-book/${id}`, 27 | }; 28 | -------------------------------------------------------------------------------- /src/pods/book/book.vm.ts: -------------------------------------------------------------------------------- 1 | export interface Review { 2 | id?: string; 3 | bookId: string; 4 | reviewer: string; 5 | reviewText: string; 6 | creationDate: string; 7 | stars: number; 8 | } 9 | 10 | export const createEmptyReview = (): Review => ({ 11 | id: undefined, 12 | bookId: '', 13 | reviewer: 'Usuario Anónimo', 14 | reviewText: '', 15 | creationDate: undefined, 16 | stars: 0, 17 | }); 18 | 19 | export interface BookVm { 20 | id?: string; 21 | title: string; 22 | description: string; 23 | imageUrl: string; 24 | imageAltText: string; 25 | authors: { 26 | id: string; 27 | firstName: string; 28 | lastName: string; 29 | }[]; 30 | } 31 | 32 | export const createEmptyBook = (): BookVm => ({ 33 | id: '', 34 | title: '', 35 | description: '', 36 | imageUrl: '', 37 | imageAltText: '', 38 | authors: [], 39 | }); 40 | -------------------------------------------------------------------------------- /src/core/api-configuration/api-configuration.interceptors.tsx: -------------------------------------------------------------------------------- 1 | import axios, { InternalAxiosRequestConfig } from 'axios'; 2 | import { trackPromise } from 'react-promise-tracker'; 3 | 4 | const axiosInstance = axios.create(); 5 | const DEFAULT_AREA = 'DEFAULT_AREA'; 6 | 7 | export const requestInterceptor = (value: InternalAxiosRequestConfig): InternalAxiosRequestConfig => ({ 8 | ...value, 9 | adapter: () => trackPromise(axiosInstance.request(value), DEFAULT_AREA), 10 | }); 11 | 12 | export const requestFailedInterceptor = (error: any) => Promise.reject(error); 13 | 14 | export const useApiConfiguration = () => { 15 | return () => { 16 | axiosInstance.interceptors.request.use(requestInterceptor, requestFailedInterceptor); 17 | }; 18 | }; 19 | 20 | declare module 'axios' { 21 | export interface AxiosRequestConfig { 22 | area?: string; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/common/components/confirmation-dialog/confirmation-dialog.hook.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createEmptyLookup, Lookup } from '@/common/models'; 3 | 4 | export const useConfirmationDialog = () => { 5 | const [isOpen, setIsOpen] = React.useState(false); 6 | const [itemToDelete, setItemToDelete] = React.useState(createEmptyLookup()); 7 | 8 | const handleAccept = React.useCallback(() => { 9 | setItemToDelete(createEmptyLookup()); 10 | }, []); 11 | 12 | const handleClose = React.useCallback(() => setIsOpen(false), []); 13 | 14 | const handleOpenDialog = React.useCallback((item?: Lookup) => { 15 | setIsOpen(true); 16 | setItemToDelete(item); 17 | }, []); 18 | 19 | return { 20 | isOpen, 21 | itemToDelete, 22 | onAccept: handleAccept, 23 | onClose: handleClose, 24 | onOpenDialog: handleOpenDialog, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/pods/book-list/book-list.container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNotificationContext } from '@/core/notification'; 3 | import { BookList } from './book-list.component'; 4 | import { mapBookListFromApiToVm } from './book-list.mapppers'; 5 | import { getBookList } from './api'; 6 | import { BookVm } from './book-list.vm'; 7 | 8 | export const BookListContainer: React.FC = () => { 9 | const [bookList, setBookList] = React.useState([]); 10 | const { notify } = useNotificationContext(); 11 | const loadData = async () => { 12 | try { 13 | const books = await getBookList(); 14 | setBookList(mapBookListFromApiToVm(books)); 15 | } catch (error) { 16 | notify('Error al cargar los libros', 'error'); 17 | } 18 | }; 19 | 20 | React.useEffect(() => { 21 | loadData(); 22 | }, []); 23 | 24 | return ; 25 | }; 26 | -------------------------------------------------------------------------------- /src/app.global-styles.ts: -------------------------------------------------------------------------------- 1 | import { injectGlobal } from '@emotion/css'; 2 | import { theme } from '@/core/theme'; 3 | 4 | injectGlobal` 5 | * { 6 | margin: 0; 7 | padding: 0; 8 | border: 0; 9 | box-sizing: border-box; 10 | } 11 | /* HTML5 display-role reset for older browsers */ 12 | article, aside, details, figcaption, figure, 13 | footer, header, hgroup, menu, nav, section { 14 | display: block; 15 | } 16 | 17 | ol, ul { 18 | list-style: none; 19 | } 20 | blockquote, q { 21 | quotes: none; 22 | } 23 | blockquote:before, blockquote:after, 24 | q:before, q:after { 25 | content: ''; 26 | content: none; 27 | } 28 | table { 29 | border-collapse: collapse; 30 | border-spacing: 0; 31 | } 32 | 33 | body { 34 | display: flex; 35 | flex-direction: column; 36 | flex-grow: 1; 37 | height: 100vh; 38 | } 39 | 40 | #root { 41 | display: flex; 42 | flex-direction: column; 43 | flex-grow: 1; 44 | background-color: ${theme.palette.secondary.main}; 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /src/core/auth/auth.context.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface AuthContextModel { 4 | isUserLogged: boolean; 5 | setIsUserLogged: (isUserLogged: boolean) => void; 6 | logout: () => void; 7 | } 8 | 9 | export const AuthContext = React.createContext({ 10 | isUserLogged: false, 11 | setIsUserLogged: () => {}, 12 | logout: () => {}, 13 | }); 14 | 15 | interface Props { 16 | children: React.ReactNode; 17 | } 18 | 19 | export const AuthProvider: React.FC = ({ children }) => { 20 | const [isUserLogged, setIsUserLogged] = React.useState(false); 21 | 22 | const logout = () => { 23 | setIsUserLogged(false); 24 | }; 25 | 26 | return ( 27 | 34 | {children} 35 | 36 | ); 37 | }; 38 | 39 | export const useAuthContext = () => React.useContext(AuthContext); 40 | -------------------------------------------------------------------------------- /src/pods/book/api/book.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Book, ReviewParams, Review } from './book.api-model'; 3 | 4 | export const getBookById = async (id: string): Promise => { 5 | const baseUrl = '/api/books'; 6 | const { data } = await axios.get(`${baseUrl}/${id}`); 7 | return data; 8 | }; 9 | 10 | export const getReviewsById = async (id: string): Promise => { 11 | const baseUrl = '/api/books'; 12 | const { data } = await axios.get(`${baseUrl}/${id}/reviews?page=1&limit=5`); 13 | return data; 14 | }; 15 | 16 | export const saveReview = async (review: Review): Promise => { 17 | const baseUrl = '/api/reviews'; 18 | 19 | review.id ? await axios.put(`${baseUrl}`, review) : await axios.post(`${baseUrl}`, review); 20 | 21 | return true; 22 | }; 23 | 24 | export const deleteReview = async (id: string): Promise => { 25 | const baseUrl = '/api/reviews'; 26 | await axios.delete(`${baseUrl}/${id}`); 27 | return true; 28 | }; 29 | -------------------------------------------------------------------------------- /src/common/components/snackbar/snackbar.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from '@emotion/css'; 3 | import { Snackbar as SnackbarMUI, SnackbarProps as SnackbarMUIProps, Portal, Alert, AlertProps } from '@mui/material'; 4 | import * as innerClasses from './snackbar.styles'; 5 | 6 | interface SnackbarProps extends SnackbarMUIProps { 7 | severity?: AlertProps['severity']; 8 | } 9 | 10 | export const Snackbar: React.FC = props => { 11 | const { onClose, severity, children, className, ...rest } = props; 12 | const handleClose = event => reason => { 13 | if (reason === 'clickaway') { 14 | return; 15 | } 16 | 17 | onClose(event, reason); 18 | }; 19 | 20 | return ( 21 | 22 | 23 | 24 | {children} 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | Snackbar.defaultProps = { 32 | anchorOrigin: { vertical: 'top', horizontal: 'right' }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/pods/edit-book/api/edit-book.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Author, EditAuthorListResponse } from '@/core/mocks'; 3 | import { Book, SaveBook } from './edit-book.api-model'; 4 | 5 | export const saveBook = async (book: SaveBook): Promise => { 6 | const { data } = book.id ? await axios.put(`/api/books/${book.id}`, book) : await axios.post('/api/books', book); 7 | return data; 8 | }; 9 | 10 | export const getActhorList = async (): Promise => { 11 | const { data } = await axios.get('/api/authors'); 12 | return data.results; 13 | }; 14 | 15 | export const getBook = async (id: string): Promise => { 16 | const { data } = await axios.get(`/api/books/${id}`); 17 | return data; 18 | }; 19 | 20 | export const saveImage = async (file: File): Promise<{ id: string }> => { 21 | const formData = new FormData(); 22 | formData.append('file', file); 23 | 24 | const { data } = await axios.post('/api/books/newImage', formData, { 25 | headers: { 26 | 'Content-Type': 'multipart/form-data', 27 | }, 28 | }); 29 | return data; 30 | }; 31 | -------------------------------------------------------------------------------- /src/pods/edit-book/edit-book.vm.ts: -------------------------------------------------------------------------------- 1 | import { ValidationResult } from '@lemoncode/fonk'; 2 | import { Lookup } from '@/common/models'; 3 | 4 | export interface BookVm { 5 | id?: string; 6 | title: string; 7 | authors: Lookup[]; 8 | description: string; 9 | imageUrl: string; 10 | imageAltText: string; 11 | } 12 | 13 | export const createEmptyBook = (): BookVm => ({ 14 | id: '', 15 | title: '', 16 | authors: [], 17 | description: '', 18 | imageUrl: '', 19 | imageAltText: '', 20 | }); 21 | 22 | export const createEmptyValidationResult = (): ValidationResult => ({ 23 | succeeded: true, 24 | type: '', 25 | message: '', 26 | }); 27 | 28 | export interface BookFieldsErrors { 29 | title: ValidationResult; 30 | authors: ValidationResult; 31 | description: ValidationResult; 32 | imageUrl: ValidationResult; 33 | imageAltText: ValidationResult; 34 | } 35 | 36 | export const createEmptyFieldsErrors = (): BookFieldsErrors => ({ 37 | title: createEmptyValidationResult(), 38 | authors: createEmptyValidationResult(), 39 | description: createEmptyValidationResult(), 40 | imageUrl: createEmptyValidationResult(), 41 | imageAltText: createEmptyValidationResult(), 42 | }); 43 | -------------------------------------------------------------------------------- /src/pods/dashboard/dashboard.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | 3 | export const root = css` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | gap: 64px; 9 | height: 100%; 10 | padding: 48px; 11 | background-color: #f5f5f5; 12 | `; 13 | 14 | export const title = css` 15 | align-self: flex-start; 16 | font-size: 24px; 17 | font-weight: bold; 18 | `; 19 | 20 | export const cardContainer = css` 21 | display: flex; 22 | justify-content: center; 23 | flex-grow: 1; 24 | gap: 64px; 25 | width: 100%; 26 | `; 27 | 28 | export const card = css` 29 | display: flex; 30 | flex-direction: column; 31 | justify-content: center; 32 | align-items: center; 33 | gap: 16px; 34 | width: 300px; 35 | height: 300px; 36 | padding: 16px; 37 | border-radius: 8px; 38 | box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.35); 39 | background-color: #ffffff; 40 | text-decoration: none; 41 | color: inherit; 42 | 43 | &:visited { 44 | color: inherit; 45 | } 46 | 47 | &:hover { 48 | box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.35); 49 | } 50 | `; 51 | 52 | export const cardIcon = css` 53 | width: 80px; 54 | height: 80px; 55 | `; 56 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/edit-book-list.container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNotificationContext } from '@/core/notification'; 3 | import { mapBookListFromApiToVm } from './edit-book-mappers'; 4 | import { EditBookListComponent } from './edit-book-list.component'; 5 | import { BookVm } from './edit-book-list.vm'; 6 | import * as api from './api'; 7 | 8 | export const EditBookList: React.FC = () => { 9 | const [bookList, setBookList] = React.useState([]); 10 | const { notify } = useNotificationContext(); 11 | 12 | const loadData = async () => { 13 | try { 14 | const books = await api.getBookList(); 15 | setBookList(mapBookListFromApiToVm(books)); 16 | } catch (error) { 17 | notify('Error al cargar los libros', 'error'); 18 | } 19 | }; 20 | 21 | const handleDelete = async (id: string) => { 22 | try { 23 | await api.deleteBook(id); 24 | notify('Libro eliminado correctamente', 'success'); 25 | loadData(); 26 | } catch (error) { 27 | notify('Error al eliminar el libro', 'error'); 28 | } 29 | }; 30 | 31 | React.useEffect(() => { 32 | loadData(); 33 | }, []); 34 | return ; 35 | }; 36 | -------------------------------------------------------------------------------- /src/pods/edit-book/edit-book.mappers.ts: -------------------------------------------------------------------------------- 1 | import { Author } from '@/core/mocks'; 2 | import { Lookup } from '@/common/models'; 3 | import { BookVm } from './edit-book.vm'; 4 | import { Book, SaveBook } from './api'; 5 | 6 | export const mapActhorFromApiToVm = (actor: Author): Lookup => ({ 7 | id: actor.id.toString(), 8 | name: `${actor.firstName} ${actor.lastName}`, 9 | }); 10 | 11 | export const mapActhorListFromApiToVm = (actorList: Author[]): Lookup[] => 12 | Boolean(actorList) ? actorList.map(mapActhorFromApiToVm) : []; 13 | 14 | export const mapBookFromVmToApi = (book: BookVm): SaveBook => ({ 15 | id: book.id ? Number(book.id) : undefined, 16 | title: book.title, 17 | authorIds: book.authors.map(author => Number(author.id)), 18 | description: book.description, 19 | tempImageFileName: book.id ? undefined : book.imageUrl, 20 | imageAltText: book.imageAltText, 21 | }); 22 | 23 | export const mapBookFromApiToVm = (book: Book): BookVm => ({ 24 | id: book.id.toString(), 25 | title: book.title, 26 | authors: book.authors.map(author => ({ 27 | id: author.id.toString(), 28 | name: `${author.firstName} ${author.lastName}`, 29 | })), 30 | description: book.description, 31 | imageUrl: book.imageUrl, 32 | imageAltText: book.imageAltText, 33 | }); 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front-net-core", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "run-p -l type-check:watch start:dev", 8 | "start:dev": "vite --port 8080", 9 | "build": "run-p -l type-check build:prod", 10 | "build:prod": "npm run clean && vite build", 11 | "type-check": "tsc --noEmit", 12 | "type-check:watch": "npm run type-check -- --watch", 13 | "clean": "rimraf dist" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@types/react": "^18.2.29", 20 | "@types/react-dom": "^18.2.13", 21 | "@vitejs/plugin-react": "^4.1.0", 22 | "npm-run-all": "^4.1.5", 23 | "rimraf": "^5.0.5", 24 | "typescript": "^5.2.2", 25 | "vite": "^4.5.0" 26 | }, 27 | "dependencies": { 28 | "@emotion/css": "^11.11.2", 29 | "@emotion/react": "^11.11.1", 30 | "@emotion/styled": "^11.11.0", 31 | "@lemoncode/fonk": "^1.5.4", 32 | "@lemoncode/fonk-array-required-validator": "^1.0.0", 33 | "@mui/icons-material": "^5.14.14", 34 | "@mui/material": "^5.14.14", 35 | "axios": "^1.6.2", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "react-promise-tracker": "^2.1.1", 39 | "react-router-dom": "^6.17.0", 40 | "react-spinners": "^0.13.8" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/pods/dashboard/dashboard.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Typography } from '@mui/material'; 4 | import BookIcon from '@mui/icons-material/MenuBook'; 5 | import PersonIcon from '@mui/icons-material/Person'; 6 | import { switchRoutes } from '@/core/router'; 7 | import * as classes from './dashboard.styles'; 8 | 9 | export const DashboardComponent: React.FC = () => { 10 | return ( 11 |
12 | 13 | Dashboard 14 | 15 |
16 | 17 | 18 | 19 | Libros 20 | 21 | 22 | 28 | 29 | 30 | Autores 31 | 32 | 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/pods/edit-author/edit-author.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { theme } from '@/core/theme'; 3 | 4 | export const root = css` 5 | display: flex; 6 | flex-direction: column; 7 | gap: ${theme.spacing(3)}; 8 | width: 100%; 9 | max-width: 1280px; 10 | margin: 0 auto; 11 | padding: ${theme.spacing(3)}; 12 | `; 13 | 14 | export const title = css` 15 | font-size: 28px; 16 | font-weight: bold; 17 | `; 18 | 19 | export const textFieldsContainer = css` 20 | display: flex; 21 | flex-direction: column; 22 | gap: ${theme.spacing(3)}; 23 | `; 24 | 25 | export const button = css` 26 | width: 15rem; 27 | `; 28 | 29 | export const fieldContainer = css` 30 | display: flex; 31 | flex-direction: column; 32 | gap: ${theme.spacing(1)}; 33 | `; 34 | 35 | export const hiddeLabel = css` 36 | position: absolute; 37 | width: 1px; 38 | height: 1px; 39 | padding: 0; 40 | margin: -1px; 41 | overflow: hidden; 42 | clip: rect(0, 0, 0, 0); 43 | white-space: nowrap; 44 | border: 0; 45 | `; 46 | 47 | export const goBack = css` 48 | align-self: flex-start; 49 | display: flex; 50 | align-items: center; 51 | gap: 16px; 52 | color: ${theme.palette.primary.main}; 53 | 54 | & > span { 55 | font-size: 16px; 56 | font-weight: bold; 57 | } 58 | `; 59 | 60 | export const error = css` 61 | color: ${theme.palette.error.main}; 62 | font-size: 14px; 63 | `; 64 | -------------------------------------------------------------------------------- /src/pods/book/book.mappers.ts: -------------------------------------------------------------------------------- 1 | import * as apiModel from './api'; 2 | import * as vm from './book.vm'; 3 | 4 | export const mapBookFromApiToVm = (book: apiModel.Book): vm.BookVm => ({ 5 | id: book.id.toString(), 6 | title: book.title, 7 | imageUrl: book.imageUrl, 8 | imageAltText: book.imageAltText, 9 | description: book.description, 10 | authors: book.authors.map(author => ({ 11 | id: author.id.toString(), 12 | firstName: author.firstName, 13 | lastName: author.lastName, 14 | })), 15 | }); 16 | 17 | export const mapReviewFromVmToApi = (review: vm.Review): apiModel.Review => ({ 18 | id: review.id ? parseInt(review.id) : undefined, 19 | bookId: parseInt(review.bookId), 20 | reviewer: review.reviewer, 21 | reviewText: review.reviewText, 22 | creationDate: review.creationDate ? new Date(review.creationDate).toISOString() : undefined, 23 | stars: review.stars, 24 | }); 25 | 26 | const mapReviewFromApiToVm = (review: apiModel.Review): vm.Review => ({ 27 | id: review.id.toString(), 28 | bookId: review.bookId.toString(), 29 | reviewer: review.reviewer, 30 | reviewText: review.reviewText, 31 | creationDate: new Date(review.creationDate).toLocaleDateString('es-ES'), 32 | stars: review.stars, 33 | }); 34 | 35 | export const mapReviewListFromApiToVm = (reviewList: apiModel.Review[]): vm.Review[] => 36 | Boolean(reviewList) ? reviewList.map(review => mapReviewFromApiToVm(review)) : []; 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ejemplo front para la gestión de libros y autores 2 | 3 | ## Introducción 4 | 5 | Este proyecto es una aplicación web que nos permite ver un listado de libros, autores y sus detalles. Si estamos logados como administradores podemos gestionar los libros y autores. 6 | 7 | ## Instalación del proyecto: 8 | 9 | ```bash 10 | npm install 11 | ``` 12 | 13 | ## Ejecución del proyecto: 14 | 15 | ```bash 16 | npm start 17 | ``` 18 | 19 | Podemos acceder a la aplicación en la siguiente url: [http://localhost:8080/](http://localhost:8080/) 20 | 21 | ## Partes de la aplicación: 22 | 23 | La aplicación se compone de dos partes: 24 | 25 | - Una parte pública donde los usuarios pueden ver el listado de libros, autores y sus detalles. 26 | - Una parte privada donde los administradores pueden gestionar los libros, autores y sus detalles. 27 | 28 | La parte de los autores no está implementada todavía. 29 | 30 | Existe una parte de login que accedemos en login, que lo único que requiere es tener cualquier correo con @ y cualquier contraseña. Una vez hecho esto nos redirige a la parte privada de la aplicación. 31 | 32 | Nos navegará a un dashboard donde podremos ver el listado de libros y autores. Ahora mismo solo se puede acceder al listado de libros. 33 | 34 | Tenemos una opción añadir un nuevo libro, que nos redirige a un formulario donde podemos añadir un nuevo libro. En el listado de libros tenemos la opción de editar y eliminar un libro. 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/pods/edit-author/edit-author.container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { AuthorVm, createEmptyAuthor } from './edit-author.vm'; 4 | import { useNotificationContext } from '@/core/notification'; 5 | import { EditAuthor } from './edit-author.component'; 6 | import { mapAuthorFromApiToVm, mapAuthorFromVmToApi } from './edit-author.mappers'; 7 | import * as api from './api'; 8 | 9 | interface Props { 10 | id: string; 11 | } 12 | 13 | export const EditAuthorContainer: React.FC = props => { 14 | const { id } = props; 15 | const navigate = useNavigate(); 16 | const { notify } = useNotificationContext(); 17 | const [author, setAuthor] = React.useState(createEmptyAuthor); 18 | 19 | const handleSave = (author: AuthorVm) => { 20 | const apiAuthor = mapAuthorFromVmToApi(author); 21 | api 22 | .saveAuthor(apiAuthor) 23 | .then(() => notify('Autor guardado correctamente', 'success')) 24 | .then(() => navigate(-1)) 25 | .catch(() => notify('Ha ocurrido un error al guardar el autor', 'error')); 26 | }; 27 | 28 | React.useEffect(() => { 29 | if (id) { 30 | api 31 | .getAuthor(id) 32 | .then(author => { 33 | setAuthor(mapAuthorFromApiToVm(author)); 34 | }) 35 | .catch(() => notify('Ha ocurrido un error al cargar el autor', 'error')); 36 | } 37 | }, [id]); 38 | 39 | return ; 40 | }; 41 | -------------------------------------------------------------------------------- /src/core/router/router.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HashRouter, Routes, Route } from 'react-router-dom'; 3 | import { 4 | LoginScene, 5 | DashboardScene, 6 | BookListScene, 7 | BookScene, 8 | EditBookListScene, 9 | EditBookScene, 10 | EditAuthorListScene, 11 | EditAuthorScene, 12 | } from '@/scenes'; 13 | import { switchRoutes } from './routes'; 14 | 15 | export const RouterComponent: React.FC = () => { 16 | return ( 17 | 18 | 19 | } /> 20 | } /> 21 | } /> 22 | } /> 23 | } /> 24 | } /> 25 | } /> 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | }> 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/pods/edit-book/edit-book.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { theme } from '@/core/theme'; 3 | 4 | export const root = css` 5 | display: flex; 6 | flex-direction: column; 7 | gap: ${theme.spacing(3)}; 8 | width: 100%; 9 | max-width: 1280px; 10 | margin: 0 auto; 11 | padding: ${theme.spacing(3)}; 12 | `; 13 | 14 | export const title = css` 15 | font-size: 28px; 16 | font-weight: bold; 17 | `; 18 | 19 | export const textFieldsContainer = css` 20 | display: flex; 21 | justify-content: space-between; 22 | align-items: center; 23 | gap: ${theme.spacing(3)}; 24 | 25 | & > :nth-child(n) { 26 | width: 100%; 27 | } 28 | `; 29 | 30 | export const chipsContainer = css` 31 | display: flex; 32 | align-items: center; 33 | flex-wrap: wrap; 34 | gap: ${theme.spacing(1)}; 35 | min-width: 100%; 36 | `; 37 | 38 | export const button = css` 39 | width: 200px; 40 | display: flex; 41 | justify-content: center; 42 | align-items: center; 43 | gap: ${theme.spacing(1)}; 44 | `; 45 | 46 | export const goBack = css` 47 | align-self: flex-start; 48 | display: flex; 49 | align-items: center; 50 | gap: 16px; 51 | color: ${theme.palette.primary.main}; 52 | 53 | & > span { 54 | font-size: 16px; 55 | font-weight: bold; 56 | } 57 | `; 58 | 59 | export const hiddeLabel = css` 60 | position: absolute; 61 | width: 1px; 62 | height: 1px; 63 | padding: 0; 64 | margin: -1px; 65 | overflow: hidden; 66 | clip: rect(0, 0, 0, 0); 67 | white-space: nowrap; 68 | border: 0; 69 | `; 70 | -------------------------------------------------------------------------------- /src/pods/edit-book/edit-book.container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Lookup } from '@/common/models'; 4 | import { useNotificationContext } from '@/core/notification'; 5 | import { EditBook } from './edit-book.component'; 6 | import { mapActhorListFromApiToVm, mapBookFromApiToVm, mapBookFromVmToApi } from './edit-book.mappers'; 7 | import { BookVm, createEmptyBook } from './edit-book.vm'; 8 | import * as api from './api'; 9 | 10 | interface Props { 11 | id: string; 12 | } 13 | 14 | export const EditBookContainer: React.FC = props => { 15 | const { id } = props; 16 | const navigate = useNavigate(); 17 | const { notify } = useNotificationContext(); 18 | const [authorList, setAuthorList] = React.useState([]); 19 | const [book, setBook] = React.useState(createEmptyBook()); 20 | 21 | const handleSubmit = (newBook: BookVm) => 22 | api 23 | .saveBook(mapBookFromVmToApi(newBook)) 24 | .then(() => notify('Libro guardado correctamente', 'success')) 25 | .then(() => navigate(-1)) 26 | .catch(() => notify('Error al guardar el libro', 'error')); 27 | 28 | const loadData = async () => await api.getActhorList().then(mapActhorListFromApiToVm).then(setAuthorList); 29 | 30 | const loadBook = async () => await api.getBook(id).then(mapBookFromApiToVm).then(setBook); 31 | 32 | React.useEffect(() => { 33 | loadData(); 34 | if (id) { 35 | loadBook(); 36 | } 37 | }, [id]); 38 | 39 | return ; 40 | }; 41 | -------------------------------------------------------------------------------- /src/common/components/confirmation-dialog/confirmation-dialog.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material'; 3 | import { createEmptyConfirmationDialogLabelProps, ConfirmationDialogLabelProps } from './confirmation-dialog.vm'; 4 | 5 | interface Props { 6 | isOpen: boolean; 7 | title: string | React.ReactNode; 8 | labels: ConfirmationDialogLabelProps; 9 | onClose: () => void; 10 | onAccept?: (event) => void; 11 | className?: string; 12 | children?: React.ReactNode; 13 | } 14 | 15 | export const ConfirmationDialog: React.FC = props => { 16 | const { isOpen, title, labels, onAccept, onClose, className, children } = props; 17 | 18 | const innerLabels = { 19 | ...createEmptyConfirmationDialogLabelProps(), 20 | ...labels, 21 | }; 22 | const handleAccept = event => { 23 | onAccept(event); 24 | onClose(); 25 | }; 26 | 27 | return ( 28 | 29 | {title} 30 | {children} 31 | 32 | 35 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | ConfirmationDialog.defaultProps = { 44 | labels: createEmptyConfirmationDialogLabelProps(), 45 | }; 46 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/components/table-body.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { IconButton, TableRow, TableCell, TableBody as Body } from '@mui/material'; 4 | import { Edit as EditIcon, Delete as DeleteIcon, AddCircle as AddCircleIcon } from '@mui/icons-material'; 5 | import { switchRoutes } from '@/core/router'; 6 | import { Author } from '../edit-author-list.vm'; 7 | 8 | interface Props { 9 | authorList: Author[]; 10 | onDelete: (id: string) => void; 11 | } 12 | 13 | export const TableBody: React.FC = props => { 14 | const { authorList, onDelete } = props; 15 | const navigate = useNavigate(); 16 | return ( 17 | 18 | {authorList.map((author, index) => ( 19 | 20 | {author.firstName} 21 | {author.lastName} 22 | {author.bookCount} 23 | 24 | navigate(switchRoutes.editAuthor(author.id.toString()))} 26 | aria-label={`editar ${author.firstName}`} 27 | size="large" 28 | > 29 | 30 | 31 | onDelete(author.id.toString())} 33 | aria-label={`borrar ${author.firstName}`} 34 | size="large" 35 | > 36 | 37 | 38 | 39 | 40 | ))} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/edit-author-list.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { IconButton, Typography } from '@mui/material'; 4 | import { AddCircle as AddCircleIcon } from '@mui/icons-material'; 5 | import { switchRoutes } from '@/core/router'; 6 | import { TableComponent } from './components'; 7 | import { Author } from './edit-author-list.vm'; 8 | import * as classes from './edit-author-list.styles'; 9 | 10 | interface Props { 11 | totalRows: number; 12 | authorList: Author[]; 13 | initialPage: number; 14 | loadData: (newPage: number) => void; 15 | onDelete: (id: string) => void; 16 | } 17 | 18 | export const EditAuthorList: React.FC = ({ totalRows, authorList, initialPage, loadData, onDelete }) => { 19 | const navigate = useNavigate(); 20 | 21 | const handleAddAuthor = () => navigate(switchRoutes.createAuthor); 22 | 23 | return ( 24 |
25 |
26 | 27 | Edición de Autores 28 | 29 |
30 | 31 | 32 | 33 | Añadir autor 34 | 35 | 36 | 37 | 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/edit-book.list.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { typographyClasses, inputClasses, tableHeadClasses, tableRowClasses, tableCellClasses } from '@mui/material'; 3 | import { theme } from '@/core/theme'; 4 | 5 | export const root = css` 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | gap: 32px; 10 | width: 100%; 11 | max-width: 1280px; 12 | margin: 0 auto; 13 | padding: 16px; 14 | 15 | .${typographyClasses.h1} { 16 | font-size: 28px; 17 | font-weight: bold; 18 | } 19 | 20 | .${typographyClasses.caption} { 21 | font-size: 14px; 22 | font-weight: bold; 23 | } 24 | 25 | .${inputClasses.input} { 26 | background-color: ${theme.palette.background.paper}; 27 | padding: ${theme.spacing(1)}; 28 | } 29 | 30 | .${tableHeadClasses.root} { 31 | background-color: ${theme.palette.primary.main}; 32 | } 33 | 34 | .${tableCellClasses.head} { 35 | font-size: 18px; 36 | font-weight: bold; 37 | color: ${theme.palette.secondary.main}; 38 | } 39 | 40 | .${tableRowClasses.root} { 41 | &:nth-of-type(even) { 42 | background-color: ${theme.palette.grey[100]}; 43 | } 44 | } 45 | `; 46 | 47 | export const add = css` 48 | align-self: flex-end; 49 | display: flex; 50 | justify-content: flex-end; 51 | gap: 16px; 52 | color: ${theme.palette.primary.main}; 53 | `; 54 | 55 | export const hiddeLabel = css` 56 | position: absolute; 57 | width: 1px; 58 | height: 1px; 59 | padding: 0; 60 | margin: -1px; 61 | overflow: hidden; 62 | clip: rect(0, 0, 0, 0); 63 | white-space: nowrap; 64 | border: 0; 65 | `; 66 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/edit-author-list.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { typographyClasses, inputClasses, tableHeadClasses, tableRowClasses, tableCellClasses } from '@mui/material'; 3 | import { theme } from '@/core/theme'; 4 | 5 | export const root = css` 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | gap: 32px; 10 | width: 100%; 11 | max-width: 1280px; 12 | margin: 0 auto; 13 | padding: 16px; 14 | 15 | .${typographyClasses.h1} { 16 | font-size: 28px; 17 | font-weight: bold; 18 | } 19 | 20 | .${typographyClasses.caption} { 21 | font-size: 14px; 22 | font-weight: bold; 23 | } 24 | 25 | .${inputClasses.input} { 26 | background-color: ${theme.palette.background.paper}; 27 | padding: ${theme.spacing(1)}; 28 | } 29 | 30 | .${tableHeadClasses.root} { 31 | background-color: ${theme.palette.primary.main}; 32 | } 33 | 34 | .${tableCellClasses.head} { 35 | font-size: 18px; 36 | font-weight: bold; 37 | color: ${theme.palette.secondary.main}; 38 | } 39 | 40 | .${tableRowClasses.root} { 41 | &:nth-of-type(even) { 42 | background-color: ${theme.palette.grey[100]}; 43 | } 44 | } 45 | `; 46 | 47 | export const add = css` 48 | align-self: flex-end; 49 | display: flex; 50 | justify-content: flex-end; 51 | gap: 16px; 52 | color: ${theme.palette.primary.main}; 53 | `; 54 | 55 | export const hiddeLabel = css` 56 | position: absolute; 57 | width: 1px; 58 | height: 1px; 59 | padding: 0; 60 | margin: -1px; 61 | overflow: hidden; 62 | clip: rect(0, 0, 0, 0); 63 | white-space: nowrap; 64 | border: 0; 65 | `; 66 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/edit-author-list.container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNotificationContext } from '@/core/notification'; 3 | import { INITIAL_PAGE, PAGE_SIZE } from './edit-author-list.constants'; 4 | import { mapAuthorListFromApiToVm } from './edit-author-list.mappers'; 5 | import { EditAuthorList } from './edit-author-list.component'; 6 | import { Author } from './edit-author-list.vm'; 7 | import * as api from './api'; 8 | 9 | export const EditAuthorListContainer: React.FC = () => { 10 | const { notify } = useNotificationContext(); 11 | const [totalRows, setTotalRows] = React.useState(0); 12 | const [authorList, setAuthorList] = React.useState([]); 13 | 14 | const loadData = (newPage: number) => { 15 | try { 16 | api 17 | .getEditAuthorList({ 18 | page: newPage, 19 | pageSize: PAGE_SIZE, 20 | }) 21 | .then(resp => { 22 | setAuthorList(mapAuthorListFromApiToVm(resp.results)); 23 | setTotalRows(resp.paginationInfo.totalRows); 24 | }); 25 | } catch (error) { 26 | notify('Error al cargar los autores', 'error'); 27 | } 28 | }; 29 | 30 | const handleDelete = (id: string) => { 31 | api 32 | .deleteAuthor(id) 33 | .then(() => loadData(INITIAL_PAGE)) 34 | .then(() => notify('Autor eliminado con éxito', 'success')) 35 | .catch(() => notify('Error al eliminar el autor', 'error')); 36 | }; 37 | 38 | return ( 39 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/pods/book-list/book-list.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { cardMediaClasses, cardActionsClasses } from '@mui/material'; 3 | import { theme } from '@/core/theme'; 4 | 5 | export const root = css` 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | gap: 64px; 10 | padding: ${theme.spacing(2)}; 11 | padding-bottom: ${theme.spacing(8)}; 12 | border: 1px solid ${theme.palette.primary.main}; 13 | `; 14 | 15 | export const title = css` 16 | align-self: flex-start; 17 | font-size: 24px; 18 | font-weight: bold; 19 | `; 20 | 21 | export const cardContainer = css` 22 | display: flex; 23 | flex-wrap: wrap; 24 | gap: 64px; 25 | justify-content: center; 26 | max-width: 1280px; 27 | `; 28 | 29 | export const card = css` 30 | display: flex; 31 | flex-direction: column; 32 | align-items: center; 33 | justify-content: space-between; 34 | gap: 16px; 35 | width: 350px; 36 | height: 500px; 37 | padding: ${theme.spacing(2)}; 38 | 39 | .${cardMediaClasses.root} { 40 | height: 70%; 41 | width: 100%; 42 | border-radius: 8px; 43 | background-position: top center; 44 | } 45 | 46 | .${cardActionsClasses.root} { 47 | width: 100%; 48 | justify-content: space-between; 49 | } 50 | `; 51 | 52 | export const link = css` 53 | text-decoration: none; 54 | color: inherit; 55 | font-weight: 500; 56 | font-size: 0.8125rem; 57 | line-height: 1.75; 58 | letter-spacing: 0.02857em; 59 | text-transform: uppercase; 60 | padding: ${theme.spacing(0.5)} ${theme.spacing(0.75)}; 61 | border-radius: 4px; 62 | 63 | :visited { 64 | color: inherit; 65 | } 66 | 67 | :hover { 68 | background-color: rgba(0, 0, 0, 0.04); 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /src/pods/edit-author-list/components/table.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { usePagination } from '@/common/hooks'; 3 | import { TableContainer, Table, Paper, Pagination, PaginationItem } from '@mui/material'; 4 | import { Author } from '../edit-author-list.vm'; 5 | import { PAGE_SIZE } from '../edit-author-list.constants'; 6 | import * as classes from './table.styles'; 7 | import { TableHeader } from './table-header.component'; 8 | import { TableBody } from './table-body.component'; 9 | 10 | interface Props { 11 | authorList: Author[]; 12 | initialPage: number; 13 | totalRows: number; 14 | loadData: (newPage: number) => void; 15 | onDelete: (id: string) => void; 16 | } 17 | 18 | export const TableComponent: React.FC = props => { 19 | const { authorList, initialPage, totalRows, loadData, onDelete } = props; 20 | 21 | const { currentPage, handlePageChange } = usePagination(initialPage, loadData); 22 | const totalPages = Math.ceil(totalRows / PAGE_SIZE); 23 | return ( 24 | 25 | 26 | 27 | 28 |
29 | {PAGE_SIZE < totalRows && ( 30 | ( 35 | 39 | )} 40 | className={classes.pagination} 41 | /> 42 | )} 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/pods/book/book.container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNotificationContext } from '@/core/notification'; 3 | import { mapBookFromApiToVm, mapReviewFromVmToApi, mapReviewListFromApiToVm } from './book.mappers'; 4 | import { BookVm, createEmptyBook, Review } from './book.vm'; 5 | import { Book } from './book.component'; 6 | import * as api from './api'; 7 | 8 | interface Props { 9 | id: string; 10 | } 11 | 12 | export const BookContainer: React.FC = props => { 13 | const { id } = props; 14 | const { notify } = useNotificationContext(); 15 | 16 | const [book, setBook] = React.useState(createEmptyBook); 17 | const [reviews, setReviews] = React.useState([]); 18 | 19 | const loadData = () => 20 | Promise.all([api.getBookById(id), api.getReviewsById(id)]) 21 | .then(([book, reviewParams]) => { 22 | setBook(mapBookFromApiToVm(book)); 23 | setReviews(mapReviewListFromApiToVm(reviewParams.results)); 24 | }) 25 | .catch(() => notify('Error al cargar los datos del libro', 'error')); 26 | 27 | const handleSaveReview = (review: Review) => 28 | api 29 | .saveReview(mapReviewFromVmToApi(review)) 30 | .then(() => { 31 | notify('Reseña guardada con éxito', 'success'); 32 | loadData(); 33 | }) 34 | .catch(() => notify('Error al guardar la reseña', 'error')); 35 | 36 | const handleDeleteReview = (id: string) => 37 | api 38 | .deleteReview(id) 39 | .then(() => { 40 | notify('Reseña eliminada con éxito', 'success'); 41 | loadData(); 42 | }) 43 | .catch(() => notify('Error al eliminar la reseña', 'error')); 44 | 45 | React.useEffect(() => { 46 | loadData(); 47 | }, []); 48 | 49 | return ; 50 | }; 51 | -------------------------------------------------------------------------------- /src/pods/book/book.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { theme } from '@/core/theme'; 3 | 4 | export const root = css` 5 | align-self: center; 6 | display: grid; 7 | grid-template-columns: 0.5fr 1fr; 8 | gap: ${theme.spacing(2)}; 9 | height: 100%; 10 | max-width: 1280px; 11 | padding: ${theme.spacing(4)}; 12 | 13 | & > :last-child { 14 | display: flex; 15 | flex-direction: column; 16 | height: 100%; 17 | flex-grow: 1; 18 | gap: ${theme.spacing(2)}; 19 | } 20 | 21 | @media (max-width: 768px) { 22 | grid-template-columns: 1fr; 23 | } 24 | `; 25 | 26 | export const row = css` 27 | display: flex; 28 | justify-content: space-between; 29 | align-items: center; 30 | `; 31 | 32 | export const iconContainer = css` 33 | display: flex; 34 | `; 35 | 36 | export const icon = css` 37 | font-size: 1.5rem; 38 | cursor: pointer; 39 | `; 40 | 41 | export const title = css` 42 | font-size: 42px; 43 | font-weight: bold; 44 | `; 45 | 46 | export const reviewsContainer = css` 47 | display: flex; 48 | flex-direction: column; 49 | flex-grow: 1; 50 | gap: ${theme.spacing(2)}; 51 | justify-content: flex-end; 52 | `; 53 | 54 | export const reviewContainer = css` 55 | display: flex; 56 | flex-direction: column; 57 | gap: ${theme.spacing(1)}; 58 | padding: ${theme.spacing(2)}; 59 | border-radius: 4px; 60 | background-color: rgba(255, 255, 255, 0.4); 61 | 62 | &:last-child { 63 | border-bottom: none; 64 | } 65 | 66 | & > :first-child { 67 | font-size: 14px; 68 | } 69 | 70 | & > :nth-child(2) { 71 | font-size: 14px; 72 | font-weight: 500; 73 | } 74 | 75 | & > :last-child { 76 | font-size: 14px; 77 | font-style: oblique; 78 | 79 | &:before { 80 | content: '"'; 81 | } 82 | 83 | &:after { 84 | content: '"'; 85 | } 86 | } 87 | `; 88 | -------------------------------------------------------------------------------- /src/common-app/app-bar/app-bar.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate, Link } from 'react-router-dom'; 3 | import { AppBar, Toolbar, IconButton } from '@mui/material'; 4 | import HomeIcon from '@mui/icons-material/Home'; 5 | import UserIcon from '@mui/icons-material/Person'; 6 | import LogoutIcon from '@mui/icons-material/ExitToApp'; 7 | import DashboardIcon from '@mui/icons-material/Dashboard'; 8 | import { useAuthContext } from '@/core/auth'; 9 | import { switchRoutes } from '@/core/router'; 10 | import * as classes from './app-bar.styles'; 11 | 12 | export const AppBarComponent: React.FC = () => { 13 | const navigate = useNavigate(); 14 | const { isUserLogged, setIsUserLogged } = useAuthContext(); 15 | 16 | const handleClick = () => { 17 | setIsUserLogged(false); 18 | navigate(switchRoutes.root); 19 | }; 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 |
28 | {isUserLogged && ( 29 | 30 | 31 | 32 | )} 33 | {!isUserLogged && ( 34 | 35 | 36 | 37 | )} 38 | 39 | 40 | 41 |
42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/core/notification/notification.provider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Snackbar } from '@/common/components'; 3 | import { NotificationContext } from './notification.context'; 4 | import { NotificationInfo, createEmptyNotificationInfo } from './notification.model'; 5 | import { NotificationDialog } from './notification-dialog.component'; 6 | 7 | const useNotification = () => { 8 | const [state, setState] = React.useState(createEmptyNotificationInfo); 9 | 10 | const notify = (message: string, variant: NotificationInfo['variant'] = 'info', autoHideDuration: number = 5000) => { 11 | setState({ 12 | open: true, 13 | message: message, 14 | variant: variant, 15 | autoHideDuration, 16 | }); 17 | }; 18 | 19 | const close = () => { 20 | setState(current => ({ ...current, open: false })); 21 | }; 22 | 23 | return { notify, close, state }; 24 | }; 25 | 26 | interface Props { 27 | children: React.ReactNode; 28 | } 29 | 30 | export const NotificationProvider: React.FC = ({ children }) => { 31 | const notification = useNotification(); 32 | 33 | return ( 34 | 35 | {notification.state.open && notification.state.variant === 'critical' ? ( 36 | 37 | {notification.state.message} 38 | 39 | ) : ( 40 | 46 | <>{notification.state.message} 47 | 48 | )} 49 | {children} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/pods/book-list/book-list.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Button, Card, CardActions, CardContent, CardMedia, Typography } from '@mui/material'; 4 | import { switchRoutes } from '@/core/router'; 5 | import { BookVm } from './book-list.vm'; 6 | import * as classes from './book-list.styles'; 7 | 8 | interface Props { 9 | bookList: BookVm[]; 10 | } 11 | 12 | export const BookList: React.FC = props => { 13 | const { bookList } = props; 14 | return ( 15 |
16 | 17 | Libros 18 | 19 |
20 | {bookList.map(book => ( 21 | 22 | 28 | 29 | 30 | {book.title} 31 | 32 | 33 | 34 | 37 | 43 | Leer más 44 | 45 | 46 | 47 | ))} 48 |
49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/common/components/table/table.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from '@emotion/css'; 3 | import { 4 | Table as MuiTable, 5 | TableBody, 6 | TableCell, 7 | TableContainer, 8 | TableHead, 9 | TableRow, 10 | Pagination, 11 | PaginationItem, 12 | Paper, 13 | } from '@mui/material'; 14 | import { usePagination } from '@/common/hooks'; 15 | import * as classes from './table.styles'; 16 | 17 | export interface Column { 18 | id: string; 19 | label: string; 20 | cellRenderer?: (row: any) => React.ReactNode; 21 | } 22 | 23 | interface Props { 24 | rows: any[]; 25 | columns: Column[]; 26 | totalRows: number; 27 | initialPage: number; 28 | loadData: (newPage: number) => void; 29 | pageSize: number; 30 | className?: string; 31 | } 32 | 33 | export const Table: React.FC = ({ rows, columns, totalRows, initialPage, loadData, pageSize, className }) => { 34 | const { currentPage, handlePageChange } = usePagination(initialPage, loadData); 35 | const totalPages = Math.ceil(totalRows / pageSize); 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | {columns.map(column => ( 43 | {column.label} 44 | ))} 45 | 46 | 47 | 48 | {rows.map((row, index) => ( 49 | 50 | {columns.map(column => ( 51 | {column.cellRenderer ? column.cellRenderer(row) : row[column.id]} 52 | ))} 53 | 54 | ))} 55 | 56 | 57 | {pageSize < totalRows && ( 58 | ( 63 | 67 | )} 68 | className={classes.pagination} 69 | /> 70 | )} 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/pods/book/components/edit-review.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Rating, TextField } from '@mui/material'; 4 | import { Review, createEmptyReview } from '../book.vm'; 5 | import * as classes from './edit-review.styles'; 6 | 7 | interface Props { 8 | review: Review; 9 | isOpen: boolean; 10 | onSaveReview: (review: Review) => void; 11 | onClose: () => void; 12 | } 13 | 14 | export const EditReview: React.FC = props => { 15 | const { review, isOpen, onSaveReview, onClose } = props; 16 | const { id } = useParams(); 17 | 18 | const [formReview, setFormReview] = React.useState(createEmptyReview); 19 | 20 | const handleCancel = () => { 21 | onClose(); 22 | setFormReview(createEmptyReview); 23 | }; 24 | 25 | const handleSubmit = () => { 26 | onSaveReview(formReview); 27 | onClose(); 28 | setFormReview(createEmptyReview); 29 | }; 30 | 31 | React.useEffect(() => { 32 | if (review && id) { 33 | setFormReview({ ...review, bookId: id }); 34 | } 35 | }, [review, id]); 36 | 37 | return ( 38 | 39 | Reseña 40 | 41 | setFormReview({ ...formReview, reviewText: event.target.value })} 50 | aria-label="Campo de texto para crear o editar la reseña" 51 | /> 52 | { 56 | setFormReview({ ...formReview, stars: newValue }); 57 | }} 58 | aria-label="Calificación de la reseña" 59 | /> 60 | 61 | 62 | 65 | 68 | 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/pods/login/login.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Button, Paper, TextField, Typography } from '@mui/material'; 4 | import { useAuthContext } from '@/core/auth'; 5 | import { Credentials, CredentialsErrors, createEmptyCredentials, createEmptyCredentialsError } from './login.vm'; 6 | import { formValidation } from './login.validations'; 7 | import * as classes from './login.styles'; 8 | 9 | export const Login: React.FC = () => { 10 | const navigate = useNavigate(); 11 | const { setIsUserLogged } = useAuthContext(); 12 | 13 | const [formData, setFormData] = React.useState(createEmptyCredentials); 14 | const [errors, setErrors] = React.useState(createEmptyCredentialsError); 15 | 16 | const validateForm = () => 17 | formValidation.validateForm(formData).then(validationResult => { 18 | setErrors(validationResult.fieldErrors as unknown as CredentialsErrors); 19 | return validationResult.succeeded; 20 | }); 21 | 22 | const validateField = (field: keyof Credentials) => 23 | formValidation.validateField(field, formData[field]).then(validationResult => { 24 | setErrors({ 25 | ...errors, 26 | [field]: validationResult, 27 | }); 28 | }); 29 | 30 | const handleOnFieldChange = (field: keyof Credentials) => (e: React.ChangeEvent) => { 31 | validateField(field); 32 | setFormData({ 33 | ...formData, 34 | [field]: e.target.value, 35 | }); 36 | }; 37 | 38 | const handleSubmit = (e: React.FormEvent) => { 39 | e.preventDefault(); 40 | validateForm().then(isValid => { 41 | if (isValid) { 42 | setIsUserLogged(true); 43 | navigate(-1); 44 | } 45 | }); 46 | }; 47 | 48 | return ( 49 |
50 | 51 | 52 | Iniciar Sesión 53 | 54 |
55 | 65 | 75 | 78 | 79 |
80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/core/mocks/book-list.api-mock.ts: -------------------------------------------------------------------------------- 1 | export const bookList = [ 2 | { 3 | id: '1', 4 | title: 'Cien años de soledad', 5 | description: 6 | 'Una novela icónica de realismo mágico que narra la historia de la familia Buendía en el pueblo de Macondo.', 7 | authors: ['Gabriel García Márquez'], 8 | imageUrl: 'cien-anyos-de-soledad.jpg', 9 | imageAltText: 'Cien años de soledad', 10 | reviews: [ 11 | { 12 | id: '1', 13 | reviewer: 'Lector1', 14 | title: 'Obra Maestra', 15 | text: 'Esta novela es una obra maestra de la literatura latinoamericana.', 16 | }, 17 | { 18 | id: '2', 19 | reviewer: 'Lector2', 20 | title: 'Increíble', 21 | text: 'La narrativa de García Márquez es asombrosa.', 22 | }, 23 | ], 24 | }, 25 | { 26 | id: '2', 27 | title: '1984', 28 | description: 29 | 'Una distopía que explora temas de vigilancia estatal y control del pensamiento en un futuro totalitario.', 30 | authors: ['George Orwell'], 31 | imageUrl: '1984.jpg', 32 | imageAltText: '1984', 33 | reviews: [ 34 | { 35 | id: '3', 36 | reviewer: 'Lector3', 37 | title: 'Impactante', 38 | text: 'Este libro es impactante y relevante incluso hoy en día.', 39 | }, 40 | ], 41 | }, 42 | { 43 | id: '3', 44 | title: 'El Hobbit', 45 | description: 'La historia de Bilbo Bolsón y su épica aventura para recuperar un tesoro custodiado por un dragón.', 46 | authors: ['J.R.R. Tolkien'], 47 | imageUrl: 'el-hobbit.jpg', 48 | imageAltText: 'El Hobbit', 49 | reviews: [ 50 | { 51 | id: '4', 52 | reviewer: 'Lector4', 53 | title: 'Fantástico', 54 | text: 'Una historia fantástica que nunca pasa de moda.', 55 | }, 56 | ], 57 | }, 58 | { 59 | id: '4', 60 | title: 'Matar a un ruiseñor', 61 | description: 62 | 'La novela cuenta la historia de un abogado que defiende a un hombre negro acusado de violar a una mujer blanca en el sur de los Estados Unidos.', 63 | authors: ['Harper Lee'], 64 | imageUrl: 'matar-a-un-ruiseñor.jpg', 65 | imageAltText: 'Matar a un ruiseñor', 66 | reviews: [ 67 | { 68 | id: '5', 69 | reviewer: 'Lector5', 70 | title: 'Conmovedor', 71 | text: 'Una narración conmovedora que aborda temas profundos de injusticia.', 72 | }, 73 | ], 74 | }, 75 | { 76 | id: '5', 77 | title: 'Los Juegos del Hambre', 78 | description: 79 | 'La historia se desarrolla en un futuro distópico y sigue a Katniss Everdeen en su lucha por la supervivencia en un concurso mortal.', 80 | authors: ['Suzanne Collins'], 81 | imageUrl: 'los-juegos-del-hambre.jpg', 82 | imageAltText: 'Los juegos del Hambre', 83 | reviews: [ 84 | { 85 | id: '6', 86 | reviewer: 'Lector6', 87 | title: 'Adictivo', 88 | text: 'Esta serie es adictiva y llena de acción.', 89 | }, 90 | ], 91 | }, 92 | { 93 | id: '6', 94 | title: 'A dos metros de ti', 95 | description: 96 | 'Necesitamos estar cerca de las personas que queremos casi tanto como el aire que respiramos. A Stella Grant le gusta tener el control, a pesar de no poder dominar sus propios pulmones, que la han tenido en el hospital la mayor parte de su vida. Por encima de todo, Stella necesita controlar su espacio para mantenerse alejada de cualquier persona o cosa que pueda transmitirle una infección y poner en peligro su trasplante de pulmón. Dos metros de distancia. Sin excepciones.', 97 | authors: ['Rachael Lippincott', 'Mikki Daughtry', 'Tobias Iaconis'], 98 | imageUrl: 'a-dos-metros-de-ti.jpg', 99 | imageAltText: 'A dos metros de ti', 100 | reviews: [ 101 | { 102 | id: '7', 103 | reviewer: 'Lector7', 104 | title: 'Emotivo', 105 | text: 'Una historia emotiva que te hará llorar.', 106 | }, 107 | { 108 | id: '8', 109 | reviewer: 'Lector8', 110 | title: 'Inspirador', 111 | text: 'Una historia inspiradora sobre el amor y la esperanza.', 112 | }, 113 | ], 114 | }, 115 | ]; 116 | -------------------------------------------------------------------------------- /src/core/mocks/book-list.api-mock-backup.ts: -------------------------------------------------------------------------------- 1 | export const bookList = [ 2 | { 3 | id: '1', 4 | title: 'Cien años de soledad', 5 | description: 6 | 'Una novela icónica de realismo mágico que narra la historia de la familia Buendía en el pueblo de Macondo.', 7 | authors: ['Gabriel García Márquez'], 8 | imageUrl: 'cien-anyos-de-soledad.jpg', 9 | imageAltText: 'Cien años de soledad', 10 | reviews: [ 11 | { 12 | id: '1', 13 | reviewer: 'Lector1', 14 | title: 'Obra Maestra', 15 | text: 'Esta novela es una obra maestra de la literatura latinoamericana.', 16 | }, 17 | { 18 | id: '2', 19 | reviewer: 'Lector2', 20 | title: 'Increíble', 21 | text: 'La narrativa de García Márquez es asombrosa.', 22 | }, 23 | ], 24 | }, 25 | { 26 | id: '2', 27 | title: '1984', 28 | description: 29 | 'Una distopía que explora temas de vigilancia estatal y control del pensamiento en un futuro totalitario.', 30 | authors: ['George Orwell'], 31 | imageUrl: '1984.jpg', 32 | imageAltText: '1984', 33 | reviews: [ 34 | { 35 | id: '3', 36 | reviewer: 'Lector3', 37 | title: 'Impactante', 38 | text: 'Este libro es impactante y relevante incluso hoy en día.', 39 | }, 40 | ], 41 | }, 42 | { 43 | id: '3', 44 | title: 'El Hobbit', 45 | description: 'La historia de Bilbo Bolsón y su épica aventura para recuperar un tesoro custodiado por un dragón.', 46 | authors: ['J.R.R. Tolkien'], 47 | imageUrl: 'el-hobbit.jpg', 48 | imageAltText: 'El Hobbit', 49 | reviews: [ 50 | { 51 | id: '4', 52 | reviewer: 'Lector4', 53 | title: 'Fantástico', 54 | text: 'Una historia fantástica que nunca pasa de moda.', 55 | }, 56 | ], 57 | }, 58 | { 59 | id: '4', 60 | title: 'Matar a un ruiseñor', 61 | description: 62 | 'La novela cuenta la historia de un abogado que defiende a un hombre negro acusado de violar a una mujer blanca en el sur de los Estados Unidos.', 63 | authors: ['Harper Lee'], 64 | imageUrl: 'matar-a-un-ruiseñor.jpg', 65 | imageAltText: 'Matar a un ruiseñor', 66 | reviews: [ 67 | { 68 | id: '5', 69 | reviewer: 'Lector5', 70 | title: 'Conmovedor', 71 | text: 'Una narración conmovedora que aborda temas profundos de injusticia.', 72 | }, 73 | ], 74 | }, 75 | { 76 | id: '5', 77 | title: 'Los Juegos del Hambre', 78 | description: 79 | 'La historia se desarrolla en un futuro distópico y sigue a Katniss Everdeen en su lucha por la supervivencia en un concurso mortal.', 80 | authors: ['Suzanne Collins'], 81 | imageUrl: 'los-juegos-del-hambre.jpg', 82 | imageAltText: 'Los juegos del hambre', 83 | reviews: [ 84 | { 85 | id: '6', 86 | reviewer: 'Lector6', 87 | title: 'Adictivo', 88 | text: 'Esta serie es adictiva y llena de acción.', 89 | }, 90 | ], 91 | }, 92 | { 93 | id: '6', 94 | title: 'A dos metros de ti', 95 | description: 96 | 'Necesitamos estar cerca de las personas que queremos casi tanto como el aire que respiramos. A Stella Grant le gusta tener el control, a pesar de no poder dominar sus propios pulmones, que la han tenido en el hospital la mayor parte de su vida. Por encima de todo, Stella necesita controlar su espacio para mantenerse alejada de cualquier persona o cosa que pueda transmitirle una infección y poner en peligro su trasplante de pulmón. Dos metros de distancia. Sin excepciones.', 97 | authors: ['Rachael Lippincott', 'Mikki Daughtry', 'Tobias Iaconis'], 98 | imageUrl: 'a-dos-metros-de-ti.jpg', 99 | imageAltText: 'A dos metros de ti', 100 | reviews: [ 101 | { 102 | id: '7', 103 | reviewer: 'Lector7', 104 | title: 'Emotivo', 105 | text: 'Una historia emotiva que te hará llorar.', 106 | }, 107 | { 108 | id: '8', 109 | reviewer: 'Lector8', 110 | title: 'Inspirador', 111 | text: 'Una historia inspiradora sobre el amor y la esperanza.', 112 | }, 113 | ], 114 | }, 115 | ]; 116 | -------------------------------------------------------------------------------- /src/pods/edit-author/edit-author.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Button, IconButton, TextField, Typography } from '@mui/material'; 4 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 5 | import { switchRoutes } from '@/core/router'; 6 | import { AuthorVm, AuthorFieldsErrors, createEmptyAuthor, createEmptyFieldsErrors } from './edit-author.vm'; 7 | import { formValidation } from './edit-author.validations'; 8 | import * as classes from './edit-author.styles'; 9 | 10 | interface Props { 11 | author: AuthorVm; 12 | onSave: (author: AuthorVm) => void; 13 | } 14 | 15 | export const EditAuthor: React.FC = props => { 16 | const { author, onSave } = props; 17 | const navigate = useNavigate(); 18 | const [formData, setFormData] = React.useState(createEmptyAuthor); 19 | const [errors, setErrors] = React.useState(createEmptyFieldsErrors); 20 | 21 | const isEditingMode = Boolean(author.id); 22 | 23 | const validateForm = () => 24 | formValidation.validateForm(formData).then(validationResult => { 25 | setErrors(validationResult.fieldErrors as unknown as AuthorFieldsErrors); 26 | return validationResult.succeeded; 27 | }); 28 | 29 | const validateField = (field: keyof AuthorVm) => { 30 | formValidation.validateField(field, formData[field]).then(validationResult => { 31 | setErrors({ 32 | ...errors, 33 | [field]: validationResult, 34 | }); 35 | }); 36 | }; 37 | 38 | const handleOnFieldChange = (field: keyof AuthorVm) => (e: React.ChangeEvent) => { 39 | validateField(field); 40 | setFormData({ 41 | ...formData, 42 | [field]: e.target.value, 43 | }); 44 | }; 45 | 46 | const handleSubmit = (e: React.FormEvent) => { 47 | e.preventDefault(); 48 | validateForm().then(success => { 49 | if (success) { 50 | onSave(formData); 51 | } 52 | }); 53 | }; 54 | 55 | React.useEffect(() => { 56 | if (author.id) { 57 | setFormData(author); 58 | } 59 | }, [author]); 60 | 61 | return ( 62 |
63 |
64 | 65 | {isEditingMode ? 'Editar autor' : 'Añadir autor'} 66 | 67 |
68 | 69 |
70 |
71 | 74 | 85 |
86 |
87 | 90 | 101 |
102 | 103 | 106 |
107 | 108 | navigate(switchRoutes.editAuthorList)} 111 | aria-label="Volver al listado de edición de authores" 112 | size="large" 113 | > 114 | 115 | 116 | Regresar 117 | 118 | 119 |
120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /src/pods/book/book.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, IconButton, Typography, Rating } from '@mui/material'; 3 | import EditIcon from '@mui/icons-material/Edit'; 4 | import DeleteIcon from '@mui/icons-material/Delete'; 5 | import { useAuthContext } from '@/core/auth'; 6 | import { EditReview } from './components/edit-review.component'; 7 | import { BookVm, Review, createEmptyReview } from './book.vm'; 8 | import * as classes from './book.styles'; 9 | 10 | interface Props { 11 | book: BookVm; 12 | reviews: Review[]; 13 | onSaveReview: (review: Review) => void; 14 | onDeleteReview: (id: string) => void; 15 | } 16 | 17 | export const Book: React.FC = props => { 18 | const { book, reviews, onSaveReview, onDeleteReview } = props; 19 | const { isUserLogged } = useAuthContext(); 20 | const [isOpen, setIsOpen] = React.useState(false); 21 | const [editingReview, setEditingReview] = React.useState(createEmptyReview); 22 | 23 | const handleEdit = (review: Review) => { 24 | setEditingReview(review); 25 | setIsOpen(true); 26 | }; 27 | 28 | const handleClickOpen = () => setIsOpen(true); 29 | const handleClose = () => setIsOpen(false); 30 | 31 | return ( 32 |
33 | {book.imageAltText} 34 |
35 | 41 | {book?.title} 42 | 43 | {book.authors.map((author, index) => ( 44 | 45 | {author.firstName} {author.lastName} 46 | 47 | ))} 48 | 49 | {book.description} 50 | 51 | {reviews?.length > 0 && ( 52 |
53 | 54 | Reseñas: 55 | 56 | {reviews?.map((review, index) => ( 57 |
63 |
64 | 65 | {review.reviewer} 66 | 67 | {isUserLogged && ( 68 |
69 | handleEdit(review)}> 70 | 71 | 72 | onDeleteReview(review.id)}> 73 | 74 | 75 |
76 | )} 77 |
78 | 82 | 83 | {review.reviewText} 84 | 85 | 86 | {review.creationDate} 87 | 88 |
89 | ))} 90 |
91 | )} 92 | {isUserLogged && ( 93 | <> 94 | 97 | 98 | )} 99 | 100 |
101 |
102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/pods/edit-book-list/edit-book-list.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { 4 | Typography, 5 | Input, 6 | TableContainer, 7 | Paper, 8 | Table, 9 | TableHead, 10 | TableRow, 11 | TableCell, 12 | TableBody, 13 | IconButton, 14 | } from '@mui/material'; 15 | import EditIcon from '@mui/icons-material/Edit'; 16 | import DeleteIcon from '@mui/icons-material/Delete'; 17 | import AddCircleIcon from '@mui/icons-material/AddCircle'; 18 | import { ConfirmationDialog, useConfirmationDialog } from '@/common/components'; 19 | import { useNotificationContext } from '@/core/notification'; 20 | import { switchRoutes } from '@/core/router'; 21 | import { BookVm } from './edit-book-list.vm'; 22 | import * as classes from './edit-book.list.styles'; 23 | 24 | interface Props { 25 | bookList: BookVm[]; 26 | onDelete: (id: string) => void; 27 | } 28 | 29 | export const EditBookListComponent: React.FC = props => { 30 | const { bookList, onDelete } = props; 31 | const [currentBookList, setCurrentBookList] = React.useState(bookList); // [1 32 | const [filteredBookList, setFilteredBookList] = React.useState(bookList); 33 | const navigate = useNavigate(); 34 | const { notify } = useNotificationContext(); 35 | const { isOpen, itemToDelete, onOpenDialog, onAccept, onClose } = useConfirmationDialog(); 36 | 37 | const handleFilter = (event: React.ChangeEvent) => { 38 | const { value } = event.target; 39 | if (value === '') { 40 | setFilteredBookList(currentBookList); 41 | return; 42 | } 43 | const filteredList = currentBookList.filter(book => book.title.toLowerCase().includes(value.toLowerCase())); 44 | setFilteredBookList(filteredList); 45 | }; 46 | 47 | const handleAddBook = () => { 48 | navigate(switchRoutes.createBook); 49 | }; 50 | 51 | const handleEditBook = (id: string) => { 52 | navigate(switchRoutes.editBook(id)); 53 | }; 54 | 55 | const handleDeleteBook = (id: string) => { 56 | onDelete(id); 57 | onAccept(); 58 | }; 59 | 60 | React.useEffect(() => { 61 | setFilteredBookList(currentBookList); 62 | }, [currentBookList]); 63 | 64 | React.useEffect(() => { 65 | setCurrentBookList(bookList); 66 | }, [bookList]); 67 | 68 | return ( 69 |
70 |
71 | 72 | Edición de libros 73 | 74 |
75 | 76 | 79 | 80 | 81 | 82 | 83 | Añadir libro 84 | 85 | 86 | 87 | 88 |
89 | 90 | 91 | 92 | 93 | Título 94 | Autores 95 | Comandos 96 | 97 | 98 | 99 | {filteredBookList?.map(book => ( 100 | 101 | {book.title} 102 | {book.authors.join(', ')} 103 | 104 | handleEditBook(book.id)} 106 | aria-label={`editar ${book.title}`} 107 | size="large" 108 | > 109 | 110 | 111 | onOpenDialog({ id: book.id, name: book.title })} 113 | aria-label={`borrar ${book.title}`} 114 | size="large" 115 | > 116 | 117 | 118 | 119 | 120 | ))} 121 | 122 |
123 |
124 |
125 | {isOpen && ( 126 | handleDeleteBook(itemToDelete.id)} 130 | onClose={onClose} 131 | title="Eliminar" 132 | > 133 | 134 | ¿Seguro que quiere borrar {itemToDelete.name}? 135 | 136 | 137 | )} 138 |
139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /src/pods/edit-book/edit-book.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Typography, TextField, Button, IconButton, Autocomplete } from '@mui/material'; 4 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 5 | import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'; 6 | import { Lookup } from '@/common/models'; 7 | import { useNotificationContext } from '@/core/notification'; 8 | import { 9 | BookFieldsErrors, 10 | BookVm, 11 | createEmptyBook, 12 | createEmptyFieldsErrors, 13 | createEmptyValidationResult, 14 | } from './edit-book.vm'; 15 | import { formValidation } from './edit-book.validations'; 16 | import * as api from './api'; 17 | import * as classes from './edit-book.styles'; 18 | 19 | interface Props { 20 | authorList: Lookup[]; 21 | book: BookVm; 22 | onSubmit: (book: BookVm) => void; 23 | } 24 | 25 | export const EditBook: React.FC = props => { 26 | const { onSubmit, authorList, book } = props; 27 | const navigate = useNavigate(); 28 | const { notify } = useNotificationContext(); 29 | 30 | const [formData, setFormData] = React.useState(createEmptyBook); 31 | const [errors, setErrors] = React.useState(createEmptyFieldsErrors); 32 | 33 | const fileInput = React.useRef(null); 34 | const availableAuthors = authorList.filter( 35 | author => !book.authors.some(selectedAuthor => selectedAuthor.id === author.id) 36 | ); 37 | const handleGoBack = () => navigate(-1); 38 | 39 | const handleFileChange = (event: React.ChangeEvent) => { 40 | const file = event.target.files[0]; 41 | api 42 | .saveImage(file) 43 | .then(imageUrl => { 44 | setFormData({ ...formData, imageUrl: imageUrl.id }); 45 | setErrors({ ...errors, imageUrl: createEmptyValidationResult() }); 46 | }) 47 | .catch(() => notify('Error al subir la imagen', 'error')); 48 | }; 49 | 50 | const validateForm = () => 51 | formValidation.validateForm(formData).then(validationResult => { 52 | setErrors(validationResult.fieldErrors as unknown as BookFieldsErrors); 53 | return validationResult.succeeded; 54 | }); 55 | 56 | const validateField = (field: keyof BookVm) => { 57 | formValidation.validateField(field, formData[field]).then(validationResult => { 58 | setErrors({ 59 | ...errors, 60 | [field]: validationResult, 61 | }); 62 | }); 63 | }; 64 | 65 | const handleOnFieldChange = (field: keyof BookVm) => (e: React.ChangeEvent, value?: Lookup[]) => { 66 | validateField(field); 67 | value ? setFormData({ ...formData, [field]: value }) : setFormData({ ...formData, [field]: e.target.value }); 68 | }; 69 | 70 | const handleSubmit = (e: React.FormEvent) => { 71 | e.preventDefault(); 72 | validateForm().then(success => { 73 | if (success) { 74 | onSubmit(formData); 75 | } 76 | }); 77 | }; 78 | 79 | React.useEffect(() => { 80 | if (book.id) { 81 | setFormData(book); 82 | } 83 | }, [book]); 84 | 85 | return ( 86 |
87 |
88 | 89 | {book.id ? 'Editar libro' : 'Añadir libro'} 90 | 91 |
92 | 93 |
94 | 97 | 106 | option.name} 112 | filterSelectedOptions 113 | onChange={handleOnFieldChange('authors')} 114 | renderInput={params => ( 115 | 121 | )} 122 | /> 123 |
124 | 125 | 128 | {!errors.imageUrl.succeeded && ( 129 | 130 | {errors.imageUrl.message} 131 | 132 | )} 133 | 134 | {formData.imageUrl && ( 135 | <> 136 | Archivo seleccionado: {formData.imageUrl} 137 | 140 | 149 | 150 | )} 151 | 152 | 155 | 166 | 167 | 170 | 171 | 177 | 178 | 179 | Regresar 180 | 181 | 182 | 183 | ); 184 | }; 185 | --------------------------------------------------------------------------------