diff --git a/package-lock.json b/package-lock.json index 6810ed6..496cdc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { - "name": "goit-react-hw-06-phonebook", - "version": "0.1.0", + "name": "goit-react-hw-07-phonebook", + "version": "0.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "goit-react-hw-06-phonebook", - "version": "0.1.0", + "name": "goit-react-hw-07-phonebook", + "version": "0.2.0", "dependencies": { "@nextui-org/react": "^2.2.9", "@reduxjs/toolkit": "^2.2.0", "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^12.1.4", "@testing-library/user-event": "^13.5.0", + "axios": "^1.6.7", "framer-motion": "^11.0.5", "lucide-react": "^0.331.0", "nanoid": "^5.0.4", @@ -20,7 +21,6 @@ "react-dom": "^18.1.0", "react-redux": "^9.1.0", "react-scripts": "5.0.1", - "redux-persist": "^6.0.0", "web-vitals": "^2.1.3" }, "devDependencies": { @@ -6671,6 +6671,29 @@ "node": ">=12" } }, + "node_modules/axios": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "dependencies": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -9638,9 +9661,9 @@ "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==" }, "node_modules/follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", @@ -14262,6 +14285,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -14716,14 +14744,6 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, - "node_modules/redux-persist": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", - "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", - "peerDependencies": { - "redux": ">4.0.0" - } - }, "node_modules/redux-thunk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", @@ -22468,6 +22488,28 @@ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.2.tgz", "integrity": "sha512-LVAaGp/wkkgYJcjmHsoKx4juT1aQvJyPcW09MLCjVTh3V2cc6PnyempiLMNH5iMdfIX/zdbjUx2KDjMLCTdPeA==" }, + "axios": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "requires": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -24685,9 +24727,9 @@ "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==" }, "follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==" }, "foreground-child": { "version": "3.1.1", @@ -27853,6 +27895,11 @@ } } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -28166,12 +28213,6 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, - "redux-persist": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", - "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", - "requires": {} - }, "redux-thunk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", diff --git a/package.json b/package.json index c9aea08..69ed462 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^12.1.4", "@testing-library/user-event": "^13.5.0", + "axios": "^1.6.7", "framer-motion": "^11.0.5", "lucide-react": "^0.331.0", "nanoid": "^5.0.4", @@ -16,7 +17,6 @@ "react-dom": "^18.1.0", "react-redux": "^9.1.0", "react-scripts": "5.0.1", - "redux-persist": "^6.0.0", "web-vitals": "^2.1.3" }, "scripts": { diff --git a/src/components/ContactForm.jsx b/src/components/ContactForm.jsx index f99c8d8..6298abb 100644 --- a/src/components/ContactForm.jsx +++ b/src/components/ContactForm.jsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { addContact } from '../redux/reducers/contactsSlice'; -import { nanoid } from 'nanoid'; +import { addContact } from '../redux/operations/operations'; import { selectContacts } from '../redux/selectors/contactsSelectors'; import ModalAlert from './ModalAlert'; @@ -16,6 +15,7 @@ import { Plus } from 'lucide-react'; */ const ContactForm = () => { const dispatch = useDispatch(); + const contacts = useSelector(selectContacts); const [name, setName] = useState(''); const [phone, setPhone] = useState(''); @@ -31,12 +31,11 @@ const ContactForm = () => { const handleSubmit = event => { event.preventDefault(); - const existingContact = contacts.find( + const existingContact = contacts.items.find( contact => contact.name === name || contact.phone === phone ); if (!existingContact) { - dispatch(addContact({ id: nanoid(), name, phone })); setName(''); setPhone(''); } else { @@ -52,6 +51,8 @@ const ContactForm = () => { setIsModalOpen(true); } } + + dispatch(addContact({ name, phone })); }; return ( diff --git a/src/components/ContactList.jsx b/src/components/ContactList.jsx index 4632e6e..dddd93d 100644 --- a/src/components/ContactList.jsx +++ b/src/components/ContactList.jsx @@ -1,5 +1,6 @@ +import { useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { deleteContact } from '../redux/reducers/contactsSlice'; +import { deleteContact, fetchContacts } from '../redux/operations/operations'; import { selectContacts, selectFilter, @@ -12,6 +13,7 @@ import { TableBody, TableRow, TableCell, + Spinner, } from '@nextui-org/react'; import { Button } from '@nextui-org/react'; import { Trash2 } from 'lucide-react'; @@ -21,16 +23,17 @@ import { Trash2 } from 'lucide-react'; * @returns {JSX.Element} The JSX element representing the contact list. */ const ContactList = () => { - const contacts = useSelector(selectContacts); const filter = useSelector(selectFilter); const dispatch = useDispatch(); - /** - * Filters contacts based on the filter input. - * @type {Array} - */ - const filteredContacts = contacts.filter(contact => - contact.name.toLowerCase().includes(filter.toLowerCase()) + const { items, isLoading, error } = useSelector(selectContacts); + + useEffect(() => { + dispatch(fetchContacts()); + }, [dispatch]); + + const filteredContacts = items.filter(i => + i.name.toLowerCase().includes(filter?.toLowerCase()) ); /** @@ -38,7 +41,7 @@ const ContactList = () => { * @type {Array} */ const sortedContacts = filteredContacts - .slice() + ?.slice() .sort((a, b) => a.name.localeCompare(b.name)); /** @@ -46,9 +49,7 @@ const ContactList = () => { * @param {string} id - The id of the contact to be deleted. * @returns {void} */ - const handleDelete = id => { - dispatch(deleteContact(id)); - }; + const handleDelete = id => dispatch(deleteContact(id)); return ( { ACTIONS + {isLoading && ( + + + + + + + + )} + + {error && ( + + + {error} + + + + + )} + {sortedContacts.map(contact => ( {contact.name} diff --git a/src/index.js b/src/index.js index a2401c1..27e56a2 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; -import { PersistGate } from 'redux-persist/integration/react'; -import { store, persistor } from './redux/store'; +import { store } from './redux/store'; import App from './components/App'; import { NextUIProvider } from '@nextui-org/react'; import './index.css'; @@ -17,11 +16,8 @@ ReactDOM.createRoot(document.getElementById('root')).render( {/* Redux Provider for store */} - {/* PersistGate for persisting Redux store */} - - {/* Main application component */} - - + {/* Main application component */} + diff --git a/src/redux/operations/operations.js b/src/redux/operations/operations.js new file mode 100644 index 0000000..4b52154 --- /dev/null +++ b/src/redux/operations/operations.js @@ -0,0 +1,41 @@ +import axios from 'axios'; +import { createAsyncThunk } from '@reduxjs/toolkit'; + +axios.defaults.baseURL = 'https://65d1fef9987977636bfbc75f.mockapi.io'; + +export const fetchContacts = createAsyncThunk( + 'contacts/fetchAll', + + async (_, thunkAPI) => { + try { + const response = await axios.get('/contacts'); + return response.data; + } catch (e) { + return thunkAPI.rejectWithValue(e.message); + } + } +); + +export const addContact = createAsyncThunk( + 'contacts/addContact', + async ({ name, phone }, thunkAPI) => { + try { + const response = await axios.post('/contacts', { name, phone }); + return response.data; + } catch (e) { + return thunkAPI.rejectWithValue(e.message); + } + } +); + +export const deleteContact = createAsyncThunk( + 'contacts/deleteContact', + async (contactId, thunkAPI) => { + try { + const response = await axios.delete(`/contacts/${contactId}`); + return response.data; + } catch (e) { + return thunkAPI.rejectWithValue(e.message); + } + } +); diff --git a/src/redux/reducers/contactsSlice.js b/src/redux/reducers/contactsSlice.js index 6566881..b0275f3 100644 --- a/src/redux/reducers/contactsSlice.js +++ b/src/redux/reducers/contactsSlice.js @@ -1,15 +1,28 @@ import { createSlice } from '@reduxjs/toolkit'; +import { + fetchContacts, + addContact, + deleteContact, +} from '../operations/operations'; /** * Initial state for the contacts slice. * @constant {Array} */ -const initialState = [ - { id: 'id-1', name: 'Steve Jobs', phone: '459-12-56' }, - { id: 'id-2', name: 'Bill Gates', phone: '443-89-12' }, - { id: 'id-3', name: 'Elon Musk', phone: '645-17-79' }, - { id: 'id-4', name: 'Mark Zuckerberg', phone: '227-91-26' }, -]; +const initialState = { + items: [], + isLoading: false, + error: null, +}; + +const handlePending = state => { + state.isLoading = true; +}; + +const handleRejected = (state, action) => { + state.isLoading = false; + state.error = action.payload; +}; /** * Redux slice for managing contacts. @@ -18,37 +31,33 @@ const initialState = [ const contactsSlice = createSlice({ name: 'contacts', initialState, - reducers: { - /** - * Reducer function for adding a new contact. - * @function - * @param {Array} state - Current state of contacts. - * @param {object} action - The action containing the new contact to add. - */ - addContact: (state, action) => { - state.push(action.payload); - }, - /** - * Reducer function for deleting a contact. - * @function - * @param {Array} state - Current state of contacts. - * @param {object} action - The action containing the id of the contact to delete. - */ - deleteContact: (state, action) => { - const index = state.findIndex(contact => contact.id === action.payload); - - state.splice(index, 1); - }, + extraReducers: builder => { + builder + .addCase(fetchContacts.pending, handlePending) + .addCase(fetchContacts.fulfilled, (state, action) => { + state.isLoading = false; + state.error = null; + state.items = action.payload; + }) + .addCase(fetchContacts.rejected, handleRejected) + .addCase(addContact.pending, handlePending) + .addCase(addContact.fulfilled, (state, action) => { + state.isLoading = false; + state.error = null; + state.items.push(action.payload); + }) + .addCase(addContact.rejected, handleRejected) + .addCase(deleteContact.pending, handlePending) + .addCase(deleteContact.fulfilled, (state, action) => { + state.isLoading = false; + state.error = null; + const index = state.items.findIndex( + contact => contact.id === action.payload.id + ); + state.items.splice(index, 1); + }) + .addCase(deleteContact.rejected, handleRejected); }, }); -/** - * Action creators for the contacts slice. - */ -export const { addContact, deleteContact } = contactsSlice.actions; - -/** - * Reducer for the contacts slice. - * @constant {function} - */ -export default contactsSlice.reducer; +export const contactsReducer = contactsSlice.reducer; diff --git a/src/redux/selectors/contactsSelectors.js b/src/redux/selectors/contactsSelectors.js index 2842101..05928d2 100644 --- a/src/redux/selectors/contactsSelectors.js +++ b/src/redux/selectors/contactsSelectors.js @@ -1,15 +1,7 @@ -/** - * Selects the contacts from the Redux state. - * @function - * @param {object} state - The Redux state. - * @returns {Array} The contacts array from the state. - */ export const selectContacts = state => state.contacts; -/** - * Selects the filter from the Redux state. - * @function - * @param {object} state - The Redux state. - * @returns {string} The filter value from the state. - */ +export const selectIsLoading = state => state.tasks.isLoading; + +export const seelctError = state => state.tasks.error; + export const selectFilter = state => state.filter; diff --git a/src/redux/store.js b/src/redux/store.js index 47667ed..a1b0293 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -1,69 +1,10 @@ -/** - * Redux store configuration and persistence setup. - * @module reduxSetup - */ - -import { configureStore, combineReducers } from '@reduxjs/toolkit'; -import { - persistStore, - persistReducer, - FLUSH, - REHYDRATE, - PAUSE, - PERSIST, - PURGE, - REGISTER, -} from 'redux-persist'; -import storage from 'redux-persist/lib/storage'; -import contactsReducer from './reducers/contactsSlice'; +import { configureStore } from '@reduxjs/toolkit'; +import { contactsReducer } from './reducers/contactsSlice'; import filterReducer from './reducers/filterSlice'; -/** - * Configuration for Redux persist. - * @constant {object} - * @property {string} key - The key for persisting data. - * @property {number} version - The version of persisted data. - * @property {object} storage - The storage engine for persisting data. - */ -const persistConfig = { - key: 'phonebook', - version: 1, - storage, -}; - -/** - * Root reducer combining all reducers. - * @constant {function} - */ -const rootReducer = combineReducers({ - contacts: contactsReducer, - filter: filterReducer, +export const store = configureStore({ + reducer: { + contacts: contactsReducer, + filter: filterReducer, + }, }); - -/** - * Persisted reducer with configured persistence. - * @constant {function} - */ -const persistedReducer = persistReducer(persistConfig, rootReducer); - -/** - * Redux store with persisted reducer and middleware. - * @constant {object} - */ -const store = configureStore({ - reducer: persistedReducer, - middleware: getDefaultMiddleware => - getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], - }, - }), -}); - -/** - * Redux persistor for persisting Redux store. - * @constant {object} - */ -const persistor = persistStore(store); - -export { store, persistor };