diff --git a/src/components/rules/CreateItemForm.tsx b/src/components/rules/CreateItemForm.tsx index 490457c..bcad4f9 100644 --- a/src/components/rules/CreateItemForm.tsx +++ b/src/components/rules/CreateItemForm.tsx @@ -1,7 +1,158 @@ +import '@/styles/rules/form.css' +import { useEffect, useState } from 'react' +import { RootState, useAppDispatch } from '@/store' +import { addItem, setAddItemRequestStatus } from '@/modules/rules' +import { InputLabel, Input, Button } from '@mui/material' +import AddIcon from '@mui/icons-material/Add' +import { useSelector } from 'react-redux' +import { useNavigate } from 'react-router' +import { RequestStatus } from '@/types' + +type FormValues = { + name: string + price: number + linesPerMillisecond: number +} + +type FormErrors = { + name?: string + price?: string + linesPerMillisecond?: string +} + export function CreateItemForm() { + const dispatch = useAppDispatch() + const navigate = useNavigate() + const requestStatus = useSelector((state: RootState) => state.rules.addItemRequestStatus) + + useEffect(() => { + if (requestStatus === RequestStatus.Succeeded) { + dispatch(setAddItemRequestStatus(RequestStatus.Idle)) + + navigate('/rules') + } + }, [requestStatus, navigate, dispatch]) + + const [formValues, setFormValues] = useState({ + name: '', + price: 0, + linesPerMillisecond: 0, + }) + + const [formErrors, setFormErrors] = useState({}) + + const handleChange: React.ComponentProps<'input'>['onChange'] = (e) => { + const { name, value } = e.target + setFormValues({ + ...formValues, + [name]: value, + }) + } + + const validateForm = () => { + const errors: FormErrors = {} + + if (!formValues.name) { + errors.name = 'Name is required' + } + + if (!formValues.price) { + errors.price = 'Price is required' + } + + if (formValues.price < 0) { + errors.price = 'Price must be a positive number' + } + + if (!formValues.linesPerMillisecond) { + errors.linesPerMillisecond = 'Lines per millisecond is required' + } + + if (formValues.linesPerMillisecond <= 0) { + errors.linesPerMillisecond = 'Lines per millisecond must be greater than 0' + } + + setFormErrors(errors) + + return Object.keys(errors).length === 0 + } + + const handleSubmit: React.ComponentProps<'form'>['onSubmit'] = (e) => { + e.preventDefault() + + if (!validateForm()) { + return + } + + dispatch(addItem(formValues)) + } + return ( -
- CREATE ITEM FORM -
+
+
+ Name * + + {formErrors.name && ( +

+ {formErrors.name} +

+ )} +
+
+ Price * + + {formErrors.price && ( +

+ {formErrors.price} +

+ )} +
+
+ Lines per millisecond * + + {formErrors.linesPerMillisecond && ( +

+ {formErrors.linesPerMillisecond} +

+ )} +
+ +
) } diff --git a/src/components/rules/EditItemForm.tsx b/src/components/rules/EditItemForm.tsx index 276d0bf..ea4940a 100644 --- a/src/components/rules/EditItemForm.tsx +++ b/src/components/rules/EditItemForm.tsx @@ -1,7 +1,174 @@ +import '@/styles/rules/form.css' +import { useEffect, useState } from 'react' +import { RootState, useAppDispatch } from '@/store' +import { InputLabel, Input, Button } from '@mui/material' +import SaveIcon from '@mui/icons-material/Save' +import { useSelector } from 'react-redux' +import { useNavigate, useParams } from 'react-router' +import { RequestStatus } from '@/types' +import { setEditItemRequestStatus, editItem } from '@/modules/rules' + +type FormValues = { + id: number + name: string + price: number + linesPerMillisecond: number +} + +type FormErrors = { + name?: string + price?: string + linesPerMillisecond?: string +} + export function EditItemForm() { + const dispatch = useAppDispatch() + const navigate = useNavigate() + const { id: itemId } = useParams() + + const item = useSelector((state: RootState) => { + if (itemId == null) { + return null + } + + return state.rules.items.find(item => item.id === Number.parseInt(itemId)) + }) + + const requestStatus = useSelector((state: RootState) => state.rules.editItemRequestStatus) + + useEffect(() => { + if (requestStatus === RequestStatus.Succeeded) { + dispatch(setEditItemRequestStatus(RequestStatus.Idle)) + + navigate('/rules') + } + }, [requestStatus, navigate, dispatch]) + + const [formValues, setFormValues] = useState({ + id: item?.id ?? 0, + name: item?.name ?? '', + price: item?.price ?? 0, + linesPerMillisecond: item?.linesPerMillisecond ?? 0, + }) + + const [formErrors, setFormErrors] = useState({}) + + const handleChange: React.ComponentProps<'input'>['onChange'] = (e) => { + const { name, value } = e.target + setFormValues({ + ...formValues, + [name]: value, + }) + } + + const validateForm = () => { + const errors: FormErrors = {} + + if (!formValues.name) { + errors.name = 'Name is required' + } + + if (!formValues.price) { + errors.price = 'Price is required' + } + + if (formValues.price < 0) { + errors.price = 'Price must be a positive number' + } + + if (!formValues.linesPerMillisecond) { + errors.linesPerMillisecond = 'Lines per millisecond is required' + } + + if (formValues.linesPerMillisecond <= 0) { + errors.linesPerMillisecond = 'Lines per millisecond must be greater than 0' + } + + setFormErrors(errors) + + return Object.keys(errors).length === 0 + } + + const handleSubmit: React.ComponentProps<'form'>['onSubmit'] = (e) => { + e.preventDefault() + + if (!validateForm()) { + return + } + + dispatch(editItem(formValues)) + } + + if (item == null) { + return

Item with id: {itemId} not found

+ } + return ( -
- EDIT ITEM FORM -
+
+
+ Name * + + {formErrors.name && ( +

+ {formErrors.name} +

+ )} +
+
+ Price * + + {formErrors.price && ( +

+ {formErrors.price} +

+ )} +
+
+ Lines per millisecond * + + {formErrors.linesPerMillisecond && ( +

+ {formErrors.linesPerMillisecond} +

+ )} +
+ +
) } diff --git a/src/components/rules/ItemsList.tsx b/src/components/rules/ItemsList.tsx index b58714c..32ea07d 100644 --- a/src/components/rules/ItemsList.tsx +++ b/src/components/rules/ItemsList.tsx @@ -1,4 +1,4 @@ -import { RootState } from '@/store' +import { RootState, useAppDispatch } from '@/store' import numberFormat from '@/tests/numberFormat' import { TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody, IconButton, Fab } from '@mui/material' import EditIcon from '@mui/icons-material/Edit' @@ -6,11 +6,20 @@ import DeleteIcon from '@mui/icons-material/Delete' import AddIcon from '@mui/icons-material/Add' import { useSelector } from 'react-redux' import { useNavigate } from 'react-router' +import { deleteItem } from '@/modules/rules' +import { Item, RequestStatus } from '@/types' export function ItemsList() { - const items = useSelector((state: RootState) => state.rules.items) + const dispatch = useAppDispatch() const navigate = useNavigate() + const items = useSelector((state: RootState) => state.rules.items) + const requestStatus = useSelector((state: RootState) => state.rules.deleteItemRequestStatus) + + const handleDelete = (item: Item) => { + dispatch(deleteItem(item.id)) + } + return ( <> @@ -39,6 +48,8 @@ export function ItemsList() { handleDelete(item)} > diff --git a/src/modules/rules.ts b/src/modules/rules.ts index cee91da..3f51733 100644 --- a/src/modules/rules.ts +++ b/src/modules/rules.ts @@ -1,13 +1,19 @@ import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit' -import { Item } from '@/types' +import { RequestStatus, type Item, type TRequestStatus } from '@/types' // Initial state type RulesState = { items: Item[] + addItemRequestStatus: TRequestStatus + editItemRequestStatus: TRequestStatus + deleteItemRequestStatus: TRequestStatus } const INITIAL_STATE: RulesState = { items: [], + addItemRequestStatus: RequestStatus.Idle, + editItemRequestStatus: RequestStatus.Idle, + deleteItemRequestStatus: RequestStatus.Idle, } // Side Effects / thunks @@ -21,6 +27,58 @@ export const fetchItems = createAsyncThunk( }, ) +export const addItem = createAsyncThunk( + 'rules/addItem', + async (itemData: Omit, { dispatch }) => { + dispatch(setAddItemRequestStatus(RequestStatus.Loading)) + + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(itemData), + }) + + const newItem = await response.json() as Item + + dispatch(itemReceived(newItem)) + dispatch(setAddItemRequestStatus(RequestStatus.Succeeded)) + }, +) + +export const editItem = createAsyncThunk( + 'rules/editItem', + async (itemData: Item, { dispatch }) => { + dispatch(setEditItemRequestStatus(RequestStatus.Loading)) + + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items/${itemData.id}`, { + method: 'PUT', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(itemData), + }) + const updatedItem = await response.json() as Item + + dispatch(itemUpdated(updatedItem)) + dispatch(setEditItemRequestStatus(RequestStatus.Succeeded)) + }, +) + +export const deleteItem = createAsyncThunk( + 'rules/deleteItem', + async (itemId: number, { dispatch }) => { + await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items/${itemId}`, { + method: 'DELETE', + }) + + dispatch(itemDeleted(itemId)) + }, +) + const rules = createSlice({ name: 'rule', initialState: INITIAL_STATE, @@ -28,15 +86,44 @@ const rules = createSlice({ fetchedItems: (state, action: PayloadAction) => { state.items = action.payload }, + itemReceived: (state, action: PayloadAction) => { + state.items.push(action.payload) + }, + itemUpdated: (state, action: PayloadAction) => { + const index = state.items.findIndex(item => item.id === action.payload.id) + + if (index !== -1) { + state.items[index] = action.payload + } + }, + itemDeleted: (state, action: PayloadAction) => { + state.items = state.items.filter(item => item.id !== action.payload) + }, + setAddItemRequestStatus: (state, action: PayloadAction) => { + state.addItemRequestStatus = action.payload + }, + setEditItemRequestStatus: (state, action: PayloadAction) => { + state.editItemRequestStatus = action.payload + }, + setDeleteItemRequestStatus: (state, action: PayloadAction) => { + state.deleteItemRequestStatus = action.payload + }, }, }) const { fetchedItems, + setAddItemRequestStatus, + setEditItemRequestStatus, + itemReceived, + itemUpdated, + itemDeleted, } = rules.actions export { fetchedItems, + setAddItemRequestStatus, + setEditItemRequestStatus, } export default rules.reducer diff --git a/src/styles/rules/form.css b/src/styles/rules/form.css new file mode 100644 index 0000000..40a4fe8 --- /dev/null +++ b/src/styles/rules/form.css @@ -0,0 +1,25 @@ +.form { + display: flex; + flex-direction: column; + gap: 1rem; + + .input { + width: 100%; + padding: 0.5rem; + margin: 0.25rem 0; + border: 1px solid #ccc; + border-radius: 0.25rem; + box-sizing: border-box; + background-color: white; + } + + button { + margin-inline: auto; + } +} + +.error-message { + color: red; + font-size: 0.875rem; + margin-top: 0.25rem; +} diff --git a/src/tests/MyForm.tsx b/src/tests/MyForm.tsx new file mode 100644 index 0000000..0c90b1c --- /dev/null +++ b/src/tests/MyForm.tsx @@ -0,0 +1,55 @@ +import { InputLabel, Input, Button } from '@mui/material' +import { useState } from 'react' + +type FormValues = { + firstName: string + lastName: string +} + +export function MyForm() { + const [formValues, setFormValues] = useState({ + firstName: '', + lastName: '', + }) + + const handleChange: React.ComponentProps<'input'>['onChange'] = (e) => { + const { name, value } = e.target + + setFormValues({ + ...formValues, + [name]: value, + }) + } + + const handleSubmit = () => { + // ... + } + + return ( +
+ First Name + + Last Name + + +
+ ) +} diff --git a/src/types.ts b/src/types.ts index f11fe0d..08a32bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,3 +8,12 @@ export type Item = { export type OwnedItems = { [key: string]: number } + +export const RequestStatus = { + Idle: 'idle', + Loading: 'loading', + Succeeded: 'succeeded', + Failed: 'failed', +} as const + +export type TRequestStatus = typeof RequestStatus[keyof typeof RequestStatus]