diff --git a/package.json b/package.json
index 52129cb..5f3ffa2 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"cheerio": "^1.0.0-rc.10",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
- "dotenv": "^10.0.0",
+ "dotenv": "^16.1.4",
"express": "^4.17.2",
"express-validator": "^6.14.0",
"jsonwebtoken": "^9.0.0",
diff --git a/src/client/.eslintrc.json b/src/client/.eslintrc.json
index 7050097..408a21a 100644
--- a/src/client/.eslintrc.json
+++ b/src/client/.eslintrc.json
@@ -4,7 +4,11 @@
"es6": true,
"node": true
},
- "extends": ["eslint:recommended", "plugin:react/recommended"],
+ "plugins": [
+ "@typescript-eslint"
+],
+ "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/eslint-recommended",
+ "plugin:@typescript-eslint/recommended"],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
diff --git a/src/client/package.json b/src/client/package.json
index 2273591..c443ce9 100644
--- a/src/client/package.json
+++ b/src/client/package.json
@@ -7,15 +7,24 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
+ "@types/jest": "^29.5.2",
+ "@types/node": "^20.2.5",
+ "@types/react": "^18.2.8",
+ "@types/react-dom": "^18.2.4",
"http-proxy-middleware": "^2.0.6",
- "prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.6.2",
"react-scripts": "5.0.1",
+ "typescript": "^5.1.3",
"web-vitals": "^2.1.4"
},
+ "overrides": {
+ "react-scripts": {
+ "typescript": ">3.2.1"
+ }
+ },
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
@@ -39,5 +48,10 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "devDependencies": {
+ "@typescript-eslint/eslint-plugin": "^5.59.9",
+ "@typescript-eslint/parser": "^5.59.9",
+ "eslint": "^8.42.0"
}
}
diff --git a/src/client/src/App.jsx b/src/client/src/App.tsx
similarity index 81%
rename from src/client/src/App.jsx
rename to src/client/src/App.tsx
index 9d78445..7833aba 100644
--- a/src/client/src/App.jsx
+++ b/src/client/src/App.tsx
@@ -4,11 +4,11 @@ import { AppRoutes, ProtectedAppRoutes } from './features/AppRoutes';
import Header from './components/Header';
import Footer from './components/Footer';
import ProtectedRoute from './features/ProtectedRoute';
-import { useSelector } from 'react-redux';
import ErrorMessage from './components/ErrorMessage';
+import { useAppSelector } from './app/store';
function App() {
- const user = useSelector(state => state.account.user) || null;
+ const user = useAppSelector(state => state.account.user) || null;
return (
@@ -25,7 +25,7 @@ function App() {
{element}
} />;
})}
- } />
+ } />
diff --git a/src/client/src/app/actions/account/addFavoriteRecipe.js b/src/client/src/app/actions/account/addFavoriteRecipe.js
deleted file mode 100644
index ebc91f1..0000000
--- a/src/client/src/app/actions/account/addFavoriteRecipe.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-
-export const addFavoriteRecipe = createAsyncThunk(
- 'account/addFavoriteRecipe',
- async ({ recipe }, thunkAPI) => {
- if (!thunkAPI.extra.userService.token) {
- thunkAPI.extra.userService.setToken(thunkAPI.getState().account.token);
- }
- const thunk = thunkAPI.extra.thunkErrorWrapper(
- thunkAPI.extra.userService.addFavoriteRecipe,
- thunkAPI.rejectWithValue,
- thunkAPI.extra.userService
- );
- await thunk(recipe);
- },
-);
diff --git a/src/client/src/app/actions/account/addFavoriteRecipe.ts b/src/client/src/app/actions/account/addFavoriteRecipe.ts
new file mode 100644
index 0000000..8727d5e
--- /dev/null
+++ b/src/client/src/app/actions/account/addFavoriteRecipe.ts
@@ -0,0 +1,22 @@
+import { createAppAsyncThunk } from '../../utils/createAppAsyncThunk';
+import { AuthStoreError } from '../../slices/types/Auth/AuthState';
+import { thunkErrorWrapper } from '../../utils/thunkErrorWrapper';
+import { RecipeModel } from '../../../services/models/RecipeModel';
+
+export const addFavoriteRecipe = createAppAsyncThunk(
+ 'account/addFavoriteRecipe',
+ async ({ userId, recipe }, thunkAPI) => {
+ if (!thunkAPI.extra.userService.token) {
+ thunkAPI.extra.userService.setToken(thunkAPI.getState().account.token);
+ }
+ const thunk = thunkErrorWrapper(
+ thunkAPI.extra.userService.addFavoriteRecipe,
+ thunkAPI.rejectWithValue,
+ thunkAPI.extra.userService
+ );
+ await thunk(userId, recipe.id);
+ return recipe;
+ },
+ );
diff --git a/src/client/src/app/actions/account/deleteFavoriteRecipe.js b/src/client/src/app/actions/account/deleteFavoriteRecipe.js
deleted file mode 100644
index d5748df..0000000
--- a/src/client/src/app/actions/account/deleteFavoriteRecipe.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-
-export const deleteFavoriteRecipe = createAsyncThunk(
- 'account/deleteFavoriteRecipe',
- async ({ recipe }, thunkAPI) => {
- if (!thunkAPI.extra.userService.token) {
- thunkAPI.extra.userService.setToken(thunkAPI.getState().account.token);
- }
- const thunk = thunkAPI.extra.thunkErrorWrapper(
- thunkAPI.extra.userService.deleteFavoriteRecipe,
- thunkAPI.rejectWithValue,
- thunkAPI.extra.userService
- );
- await thunk(recipe);
- },
-);
diff --git a/src/client/src/app/actions/account/deleteFavoriteRecipe.ts b/src/client/src/app/actions/account/deleteFavoriteRecipe.ts
new file mode 100644
index 0000000..fb46831
--- /dev/null
+++ b/src/client/src/app/actions/account/deleteFavoriteRecipe.ts
@@ -0,0 +1,22 @@
+import { AuthStoreError } from '../../slices/types/Auth/AuthState';
+import { createAppAsyncThunk } from '../../utils/createAppAsyncThunk';
+import { thunkErrorWrapper } from '../../utils/thunkErrorWrapper';
+import { RecipeModel } from '../../../services/models/RecipeModel';
+
+export const deleteFavoriteRecipe = createAppAsyncThunk(
+ 'account/deleteFavoriteRecipe',
+ async ({ recipe }, thunkAPI) => {
+ if (!thunkAPI.extra.userService.token) {
+ thunkAPI.extra.userService.setToken(thunkAPI.getState().account.token);
+ }
+ const thunk = thunkErrorWrapper(
+ thunkAPI.extra.userService.deleteFavoriteRecipe,
+ thunkAPI.rejectWithValue,
+ thunkAPI.extra.userService
+ );
+ await thunk(recipe.id);
+ return recipe;
+ },
+ );
diff --git a/src/client/src/app/actions/account/loadFavoriteRecipes.js b/src/client/src/app/actions/account/loadFavoriteRecipes.js
deleted file mode 100644
index b03703d..0000000
--- a/src/client/src/app/actions/account/loadFavoriteRecipes.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-
-export const loadFavoriteRecipes = createAsyncThunk(
- 'account/loadUserFavorites',
- async (_, thunkAPI) => {
- if (!thunkAPI.extra.userService.token) {
- thunkAPI.extra.userService.setToken(thunkAPI.getState().account.token);
- }
- const thunk = thunkAPI.extra.thunkErrorWrapper(
- thunkAPI.extra.userService.getFavoriteRecipes,
- thunkAPI.rejectWithValue,
- thunkAPI.extra.userService
- );
- await thunk();
- },
-);
diff --git a/src/client/src/app/actions/account/loadFavoriteRecipes.ts b/src/client/src/app/actions/account/loadFavoriteRecipes.ts
new file mode 100644
index 0000000..6819b94
--- /dev/null
+++ b/src/client/src/app/actions/account/loadFavoriteRecipes.ts
@@ -0,0 +1,21 @@
+import { createAppAsyncThunk } from '../../utils/createAppAsyncThunk';
+import { RecipeModel } from '../../../services/models/RecipeModel';
+import { AuthStoreError } from '../../slices/types/Auth/AuthState';
+import { thunkErrorWrapper } from '../../utils/thunkErrorWrapper';
+
+export const loadFavoriteRecipes = createAppAsyncThunk(
+ 'account/loadUserFavorites',
+ async (_, thunkAPI) => {
+ if (!thunkAPI.extra.userService.token) {
+ thunkAPI.extra.userService.setToken(thunkAPI.getState().account.token);
+ }
+ const thunk = thunkErrorWrapper(
+ thunkAPI.extra.userService.getFavoriteRecipes,
+ thunkAPI.rejectWithValue,
+ thunkAPI.extra.userService
+ );
+ return await thunk();
+ },
+ );
diff --git a/src/client/src/app/actions/account/loginUser.js b/src/client/src/app/actions/account/loginUser.js
deleted file mode 100644
index e0817d7..0000000
--- a/src/client/src/app/actions/account/loginUser.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-
-export const loginUser = createAsyncThunk(
- 'account/login',
- async ({ credentials }, thunkAPI) => {
- const thunk = thunkAPI.extra.thunkErrorWrapper(
- thunkAPI.extra.authService.login,
- thunkAPI.rejectWithValue,
- thunkAPI.extra.authService
- );
- await thunk(credentials);
- }
-);
diff --git a/src/client/src/app/actions/account/loginUser.ts b/src/client/src/app/actions/account/loginUser.ts
new file mode 100644
index 0000000..4e72d2b
--- /dev/null
+++ b/src/client/src/app/actions/account/loginUser.ts
@@ -0,0 +1,19 @@
+import { createAppAsyncThunk } from '../../utils/createAppAsyncThunk';
+import { AuthStoreError } from '../../slices/types/Auth/AuthState';
+import { AuthFulfilled } from '../../slices/types/Auth/AuthFulfilled';
+import { UserDTO } from '../../../services/DTO/UserDTO';
+import { thunkErrorWrapper } from '../../utils/thunkErrorWrapper';
+
+export const loginUser = createAppAsyncThunk(
+ 'account/login',
+ async ({ email, password }: UserDTO, thunkAPI) => {
+ const thunk = thunkErrorWrapper(
+ thunkAPI.extra.authService.login,
+ thunkAPI.rejectWithValue,
+ thunkAPI.extra.authService
+ );
+ return await thunk(email, password);
+ }
+ );
diff --git a/src/client/src/app/actions/account/registerUser.js b/src/client/src/app/actions/account/registerUser.js
deleted file mode 100644
index 15f7a62..0000000
--- a/src/client/src/app/actions/account/registerUser.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-
-export const registerUser = createAsyncThunk(
- 'account/register',
- async ({ credentials }, thunkAPI) => {
- const thunk = thunkAPI.extra.thunkErrorWrapper(
- thunkAPI.extra.authService.register,
- thunkAPI.rejectWithValue,
- thunkAPI.extra.authService
- );
- await thunk(credentials);
- },
-);
diff --git a/src/client/src/app/actions/account/registerUser.ts b/src/client/src/app/actions/account/registerUser.ts
new file mode 100644
index 0000000..d9950fa
--- /dev/null
+++ b/src/client/src/app/actions/account/registerUser.ts
@@ -0,0 +1,18 @@
+import { createAppAsyncThunk } from '../../utils/createAppAsyncThunk';
+import { AuthFulfilled } from '../../slices/types/Auth/AuthFulfilled';
+import { UserDTO } from '../../../services/DTO/UserDTO';
+import { AuthStoreError } from '../../slices/types/Auth/AuthState';
+
+export const registerUser = createAppAsyncThunk(
+ 'account/register',
+ async ({ username, password }, thunkAPI) => {
+ const thunk = thunkAPI.extra.thunkErrorWrapper(
+ thunkAPI.extra.authService.register,
+ thunkAPI.rejectWithValue,
+ thunkAPI.extra.authService
+ );
+ return await thunk(username, password);
+ }
+ );
diff --git a/src/client/src/app/actions/recipes/loadRecipes.js b/src/client/src/app/actions/recipes/loadRecipes.js
deleted file mode 100644
index 27bf016..0000000
--- a/src/client/src/app/actions/recipes/loadRecipes.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-
-export const loadRecipes = createAsyncThunk(
- 'recipes/loadRecipes',
- async ({ queryString }, { getState, rejectWithValue }) => {
- const token = getState()?.account.token;
- const headers = {
- 'Content-Type': 'application/json',
- 'authorization': token,
- };
- const params = new URLSearchParams({ recipeName: queryString });
- try {
- const response = await fetch('/recipes?' + params, {
- method: 'GET',
- headers,
- });
- console.log(await response.json());
- if (!response.ok) {
- return rejectWithValue(await response.json());
- }
- return {
- ...await response.json(),
- };
- } catch (error) {
- return rejectWithValue(error.response.data.message ?? error.message);
- }
-
- },
-);
diff --git a/src/client/src/app/actions/recipes/loadRecipes.ts b/src/client/src/app/actions/recipes/loadRecipes.ts
new file mode 100644
index 0000000..3213afb
--- /dev/null
+++ b/src/client/src/app/actions/recipes/loadRecipes.ts
@@ -0,0 +1,22 @@
+import { RecipeModel } from '../../../services/models/RecipeModel';
+import { createAppAsyncThunk } from '../../utils/createAppAsyncThunk';
+import { RecipeStoreError } from '../../slices/types/Recipe/RecipeState';
+import { thunkErrorWrapper } from '../../utils/thunkErrorWrapper';
+
+export const loadRecipes = createAppAsyncThunk(
+ 'recipes/loadRecipes',
+ async (queryString: string, thunkAPI) => {
+ if (!thunkAPI.extra.recipeService.token) {
+ thunkAPI.extra.recipeService.setToken(thunkAPI.getState().account.token);
+ }
+ const thunk = thunkErrorWrapper(
+ thunkAPI.extra.recipeService.getRecipesByName,
+ thunkAPI.rejectWithValue,
+ thunkAPI.extra.recipeService
+ );
+ const params = new URLSearchParams({ recipeName: queryString });
+ return await thunk(params);
+ },
+ );
diff --git a/src/client/src/app/slices/accountSlice.js b/src/client/src/app/slices/accountSlice.ts
similarity index 53%
rename from src/client/src/app/slices/accountSlice.js
rename to src/client/src/app/slices/accountSlice.ts
index a8c002c..0e6acf1 100644
--- a/src/client/src/app/slices/accountSlice.js
+++ b/src/client/src/app/slices/accountSlice.ts
@@ -1,31 +1,41 @@
-import { createSlice } from '@reduxjs/toolkit';
+import { Action, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { loginUser } from '../actions/account/loginUser';
import { registerUser } from '../actions/account/registerUser';
import { loadFavoriteRecipes } from '../actions/account/loadFavoriteRecipes';
import { addFavoriteRecipe } from '../actions/account/addFavoriteRecipe';
import { deleteFavoriteRecipe } from '../actions/account/deleteFavoriteRecipe';
+import { AuthState } from './types/Auth/AuthState';
+import { AuthFulfilled } from './types/Auth/AuthFulfilled';
-const initialState = {
- user: JSON.parse(localStorage.getItem('cyberChefUser')),
- token: localStorage.getItem('cyberChefToken'),
+const storageNames = {
+ user: 'cyberChefUser',
+ token: 'cyberChefToken',
+};
+
+const initialState: AuthState = {
+ user: JSON.parse(localStorage.getItem(storageNames.user) ?? 'null'),
+ token: localStorage.getItem(storageNames.token),
userRecipes: [],
loading: false,
error: null,
};
-const storageNames = {
- user: 'cyberChefUser',
- token: 'cyberChefToken',
-};
+const isRejectedAction = (action: Action) => action.type.endsWith('rejected');
+const isPendingAction = (action: Action) => action.type.endsWith('pending');
-const isRejectedAction = action => action.type.endsWith('rejected');
-const isPendingAction = action => action.type.endsWith('pending');
+const saveUser = (state: AuthState, action: PayloadAction) => {
+ state.loading = false;
+ state.user = action.payload.user;
+ state.token = action.payload.accessToken;
+ localStorage.setItem(storageNames.user, JSON.stringify(state.user));
+ localStorage.setItem(storageNames.token, state.token as string);
+};
const accountSlice = createSlice({
name: 'account',
initialState,
reducers: {
- logout(state) {
+ logout(state: AuthState) {
state.user = null;
state.token = null;
localStorage.removeItem(storageNames.user);
@@ -34,26 +44,18 @@ const accountSlice = createSlice({
},
extraReducers: builder => {
builder.addCase(loginUser.fulfilled, (state, action) => {
- state.loading = false;
- state.user = action.payload.user;
- state.token = action.payload.accessToken;
- localStorage.setItem(storageNames.user, JSON.stringify(state.user));
- localStorage.setItem(storageNames.token, state.token);
+ saveUser(state, action);
});
- builder.addCase(registerUser.fulfilled, (state, action) => {
- state.loading = false;
- state.user = action.payload.user;
- state.token = action.payload.accessToken;
- localStorage.setItem(storageNames.user, JSON.stringify(state.user));
- localStorage.setItem(storageNames.token, state.token);
+ builder.addCase(registerUser.fulfilled, (state: AuthState, action) => {
+ saveUser(state, action);
});
- builder.addCase(loadFavoriteRecipes.fulfilled, (state, action) => {
+ builder.addCase(loadFavoriteRecipes.fulfilled, (state: AuthState, action) => {
state.userRecipes = action.payload;
});
- builder.addCase(addFavoriteRecipe.fulfilled, (state, action) => {
+ builder.addCase(addFavoriteRecipe.fulfilled, (state: AuthState, action) => {
state.userRecipes.push(action.payload);
});
- builder.addCase(deleteFavoriteRecipe.fulfilled, (state, action) => {
+ builder.addCase(deleteFavoriteRecipe.fulfilled, (state: AuthState, action) => {
state.userRecipes = state.userRecipes.filter(item => item.id !== action.payload.id);
});
builder.addMatcher(isRejectedAction, (state, action) => {
diff --git a/src/client/src/app/slices/recipesSlice.js b/src/client/src/app/slices/recipesSlice.ts
similarity index 88%
rename from src/client/src/app/slices/recipesSlice.js
rename to src/client/src/app/slices/recipesSlice.ts
index f51d90c..6157116 100644
--- a/src/client/src/app/slices/recipesSlice.js
+++ b/src/client/src/app/slices/recipesSlice.ts
@@ -1,7 +1,8 @@
import { createSlice } from '@reduxjs/toolkit';
import { loadRecipes } from '../actions/recipes/loadRecipes';
+import { RecipeState } from './types/Recipe/RecipeState';
-const initialState = {
+const initialState: RecipeState = {
recipes: [],
loading: false,
error: null,
diff --git a/src/client/src/app/slices/types/Auth/AuthFulfilled.ts b/src/client/src/app/slices/types/Auth/AuthFulfilled.ts
new file mode 100644
index 0000000..9c9516b
--- /dev/null
+++ b/src/client/src/app/slices/types/Auth/AuthFulfilled.ts
@@ -0,0 +1,6 @@
+import { User } from '../../../../services/models/User';
+
+export interface AuthFulfilled {
+ user: User,
+ accessToken: string,
+}
diff --git a/src/client/src/app/slices/types/Auth/AuthState.ts b/src/client/src/app/slices/types/Auth/AuthState.ts
new file mode 100644
index 0000000..9ac30f4
--- /dev/null
+++ b/src/client/src/app/slices/types/Auth/AuthState.ts
@@ -0,0 +1,11 @@
+import { User } from '../../../../services/models/User';
+import { RecipeModel } from '../../../../services/models/RecipeModel';
+
+export type AuthStoreError = Error | undefined | null;
+export interface AuthState {
+ user: User | null,
+ token: string | null,
+ userRecipes: RecipeModel[],
+ loading: boolean,
+ error: AuthStoreError
+}
diff --git a/src/client/src/app/slices/types/Recipe/RecipeState.ts b/src/client/src/app/slices/types/Recipe/RecipeState.ts
new file mode 100644
index 0000000..378b1eb
--- /dev/null
+++ b/src/client/src/app/slices/types/Recipe/RecipeState.ts
@@ -0,0 +1,8 @@
+import { RecipeModel } from '../../../../services/models/RecipeModel';
+
+export type RecipeStoreError = Error | null | undefined;
+export interface RecipeState {
+ recipes: RecipeModel[],
+ loading: boolean,
+ error: RecipeStoreError
+}
diff --git a/src/client/src/app/store.js b/src/client/src/app/store.js
deleted file mode 100644
index 948cf7f..0000000
--- a/src/client/src/app/store.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { combineReducers, configureStore } from '@reduxjs/toolkit';
-import accountReducer from './slices/accountSlice';
-import recipesReducer from './slices/recipesSlice';
-import AuthService from '../services/AuthService';
-import RecipeService from '../services/RecipeService';
-import { thunkErrorWrapper } from './utils/thunkErrorWrapper';
-
-const recipeService = new RecipeService(process.env.BASE_URL);
-const authService = new AuthService(process.env.BASE_URL);
-
-const rootReducer = combineReducers({
- account: accountReducer,
- recipes: recipesReducer,
-});
-
-
-const store = configureStore({
- reducer: rootReducer,
- middleware: getDefaultMiddleware =>
- getDefaultMiddleware({
- thunk: {
- extraArgument: { recipeService, authService, thunkErrorWrapper },
- },
- }),
-});
-
-export { store, recipeService, authService };
diff --git a/src/client/src/app/store.ts b/src/client/src/app/store.ts
new file mode 100644
index 0000000..24f8a38
--- /dev/null
+++ b/src/client/src/app/store.ts
@@ -0,0 +1,47 @@
+import { Action, combineReducers, configureStore, ThunkAction } from '@reduxjs/toolkit';
+import accountReducer from './slices/accountSlice';
+import recipesReducer from './slices/recipesSlice';
+import AuthService from '../services/AuthService';
+import RecipeService from '../services/RecipeService';
+import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
+import UserService from '../services/UserService';
+
+const baseURL = process.env.REACT_APP_BASE_URL || 'http://localhost:3000/';
+
+const recipeService = new RecipeService(baseURL);
+const authService = new AuthService(baseURL);
+const userService = new UserService(baseURL);
+
+
+const rootReducer = combineReducers({
+ account: accountReducer,
+ recipes: recipesReducer,
+});
+
+
+const store = configureStore({
+ reducer: rootReducer,
+ middleware: getDefaultMiddleware =>
+ getDefaultMiddleware({
+ thunk: {
+ extraArgument: { recipeService, authService, userService },
+ },
+ }),
+});
+
+export type AppDispatch = typeof store.dispatch;
+export type RootState = ReturnType;
+export const useAppDispatch: () => AppDispatch = useDispatch;
+export const useAppSelector: TypedUseSelectorHook = useSelector;
+
+export type AppThunk = ThunkAction<
+ ReturnType,
+ RootState,
+ unknown,
+ Action
+>;
+
+export { store, recipeService, authService, userService };
+export default store;
+
+
diff --git a/src/client/src/app/utils/createAppAsyncThunk.ts b/src/client/src/app/utils/createAppAsyncThunk.ts
new file mode 100644
index 0000000..9dd9d65
--- /dev/null
+++ b/src/client/src/app/utils/createAppAsyncThunk.ts
@@ -0,0 +1,12 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import RecipeService from '../../services/RecipeService';
+import AuthService from '../../services/AuthService';
+import UserService from '../../services/UserService';
+import { AppDispatch, RootState } from '../store';
+
+export const createAppAsyncThunk = createAsyncThunk.withTypes<{
+ state: RootState,
+ dispatch: AppDispatch,
+ rejectValue: string,
+ extra: { recipeService: RecipeService, authService: AuthService, userService: UserService, thunkErrorWrapper: any }
+}>();
diff --git a/src/client/src/app/utils/thunkErrorWrapper.js b/src/client/src/app/utils/thunkErrorWrapper.js
deleted file mode 100644
index ed23936..0000000
--- a/src/client/src/app/utils/thunkErrorWrapper.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export const thunkErrorWrapper = (method, rejectWithValue, context = null) => async (...args) => {
- try {
- const response = await method.apply(context, args);
- if (response.error) {
- return rejectWithValue(response.error);
- }
- return response;
- } catch (error) {
- return rejectWithValue(error.response.data.message ?? error.message);
- }
-};
diff --git a/src/client/src/app/utils/thunkErrorWrapper.ts b/src/client/src/app/utils/thunkErrorWrapper.ts
new file mode 100644
index 0000000..5d7423c
--- /dev/null
+++ b/src/client/src/app/utils/thunkErrorWrapper.ts
@@ -0,0 +1,17 @@
+import BaseService from '../../services/BaseService';
+
+export function thunkErrorWrapper(method: (...args : Args[]) => ReturnType,
+ rejectWithValue: (value: Error) => any,
+ context: BaseService | null = null) {
+ return async (...args : Args[]) => {
+ try {
+ const response = await method.apply(context, args);
+ if ((response as any).error) {
+ return rejectWithValue((response as any).error);
+ }
+ return response;
+ } catch (error: any) {
+ return rejectWithValue(error.response.data.message ?? error.message);
+ }
+ };
+}
diff --git a/src/client/src/components/Banner.jsx b/src/client/src/components/Banner.tsx
similarity index 100%
rename from src/client/src/components/Banner.jsx
rename to src/client/src/components/Banner.tsx
diff --git a/src/client/src/components/ErrorMessage.jsx b/src/client/src/components/ErrorMessage.tsx
similarity index 58%
rename from src/client/src/components/ErrorMessage.jsx
rename to src/client/src/components/ErrorMessage.tsx
index dca725b..d510dfc 100644
--- a/src/client/src/components/ErrorMessage.jsx
+++ b/src/client/src/components/ErrorMessage.tsx
@@ -1,12 +1,11 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { CSSProperties } from 'react';
-ErrorMessage.propTypes = {
- error: PropTypes.object
-};
+interface Props {
+ error: Error
+}
-function ErrorMessage({ error }) {
- const styles = {
+function ErrorMessage({ error }: Props) {
+ const styles: CSSProperties = {
margin: '1rem',
fontSize: '1.25rem',
color: 'red',
diff --git a/src/client/src/components/Footer.jsx b/src/client/src/components/Footer.tsx
similarity index 100%
rename from src/client/src/components/Footer.jsx
rename to src/client/src/components/Footer.tsx
diff --git a/src/client/src/components/Header.jsx b/src/client/src/components/Header.tsx
similarity index 86%
rename from src/client/src/components/Header.jsx
rename to src/client/src/components/Header.tsx
index f6ded98..c006fe1 100644
--- a/src/client/src/components/Header.jsx
+++ b/src/client/src/components/Header.tsx
@@ -1,9 +1,9 @@
import React from 'react';
import { Link } from 'react-router-dom';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../app/store';
function Header() {
- const user = useSelector(state => state.account.user);
+ const user = useAppSelector(state => state.account.user);
return (
diff --git a/src/client/src/components/Loader.jsx b/src/client/src/components/Loader.tsx
similarity index 100%
rename from src/client/src/components/Loader.jsx
rename to src/client/src/components/Loader.tsx
diff --git a/src/client/src/components/Recipe.jsx b/src/client/src/components/Recipe.tsx
similarity index 72%
rename from src/client/src/components/Recipe.jsx
rename to src/client/src/components/Recipe.tsx
index 6ed2b5a..93aae30 100644
--- a/src/client/src/components/Recipe.jsx
+++ b/src/client/src/components/Recipe.tsx
@@ -1,19 +1,13 @@
import React from 'react';
-import PropTypes from 'prop-types';
+import { RecipeModel } from '../services/models/RecipeModel';
import { Link } from 'react-router-dom';
+interface Props {
+ recipe: RecipeModel,
+ clickHandler?: (recipe: RecipeModel) => void,
+ deleteHandler?: (recipe: RecipeModel) => void
+}
-Recipe.propTypes = {
- recipe: PropTypes.shape({
- id: PropTypes.number,
- name: PropTypes.string,
- image_link: PropTypes.string,
- products: PropTypes.string,
- }),
- clickHandler: PropTypes.func,
- deleteHandler: PropTypes.func,
-};
-
-function Recipe({ recipe, clickHandler, deleteHandler }) {
+function Recipe({ recipe, clickHandler, deleteHandler }: Props) {
return (
diff --git a/src/client/src/components/Search.jsx b/src/client/src/components/Search.tsx
similarity index 67%
rename from src/client/src/components/Search.jsx
rename to src/client/src/components/Search.tsx
index 2bca826..bd0871a 100644
--- a/src/client/src/components/Search.jsx
+++ b/src/client/src/components/Search.tsx
@@ -1,18 +1,18 @@
import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-Search.propTypes = {
- searchCallback: PropTypes.func,
-};
+interface Props {
+ searchCallback: (event: React.FormEvent
, formData: { queryString: string }) => void
+}
-function Search({ searchCallback }) {
+function Search({ searchCallback } : Props) {
const [formData, setFormData] = useState({
queryString: '',
});
- const handleChange = e => {
+ const handleChange = (e: React.FormEvent) => {
+ const target = e.target as HTMLInputElement;
setFormData({
...formData,
- [e.target.name]: e.target.value.trim(),
+ [target.name]: target.value.trim(),
});
};
diff --git a/src/client/src/features/AppRoutes.js b/src/client/src/features/AppRoutes.tsx
similarity index 100%
rename from src/client/src/features/AppRoutes.js
rename to src/client/src/features/AppRoutes.tsx
diff --git a/src/client/src/features/ProtectedRoute.jsx b/src/client/src/features/ProtectedRoute.jsx
deleted file mode 100644
index adc16ee..0000000
--- a/src/client/src/features/ProtectedRoute.jsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-import { Navigate } from 'react-router-dom';
-import PropTypes from 'prop-types';
-
-const ProtectedRoute = ({ user, children }) => {
- if (!user) {
- return ;
- }
- return children;
-};
-
-ProtectedRoute.propTypes = {
- user: PropTypes.shape({
- userRecipes: PropTypes.array.isRequired,
- username: PropTypes.string,
- }),
- children: PropTypes.element.isRequired,
-};
-
-export default ProtectedRoute;
diff --git a/src/client/src/features/ProtectedRoute.tsx b/src/client/src/features/ProtectedRoute.tsx
new file mode 100644
index 0000000..d80b9b7
--- /dev/null
+++ b/src/client/src/features/ProtectedRoute.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { Navigate } from 'react-router-dom';
+import { User } from '../services/models/User';
+
+interface Props {
+ user: User | null,
+ children: React.FunctionComponent | React.JSX.Element
+}
+
+
+const ProtectedRoute = ({ user, children }: Props) => {
+ if (!user) {
+ return ;
+ }
+ return <>{children}>;
+};
+
+
+export default ProtectedRoute;
diff --git a/src/client/src/features/ValidationError.js b/src/client/src/features/ValidationError.js
deleted file mode 100644
index 43a15f5..0000000
--- a/src/client/src/features/ValidationError.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export default class ValidationError {
- constructor(type, description) {
- this.type = type;
- this.description = description;
- }
- static defaultPasswordLength = 8;
- static validate(data) {
- const emailRegex = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/;
- const validationErrors = [];
- if (!emailRegex.test(data.email)) validationErrors.push(new ValidationError(
- 'email', 'Invalid email',
- ));
- if (data.password.length < ValidationError.defaultPasswordLength)
- validationErrors.push(new ValidationError(
- 'password', 'Password is shorter than 8 symbols',
- ));
- if (!data.password || !data.password.length) validationErrors.push(new ValidationError(
- 'password', 'Password is required',
- ));
- if (data.confirm && data.confirm !== data.password) validationErrors.push(new ValidationError(
- 'confirm', 'Password does not match',
- ));
- return validationErrors;
- }
-
- toString() {
- return this.description;
- }
-}
diff --git a/src/client/src/features/ValidationError.ts b/src/client/src/features/ValidationError.ts
new file mode 100644
index 0000000..8137e31
--- /dev/null
+++ b/src/client/src/features/ValidationError.ts
@@ -0,0 +1,40 @@
+export enum ValidationErrors {
+ Email = 'email',
+ Password = 'password',
+ Confirm = 'confirm',
+}
+
+export enum ValidationDescriptions {
+ Email = 'Invalid email',
+ PasswordShort = 'Password is shorter than 8 symbols',
+ PasswordEmpty = 'Password is required',
+ Confirm = 'Password does not match',
+}
+
+export default class ValidationError {
+ type: string;
+ description: string;
+ constructor(type: ValidationErrors, description: ValidationDescriptions) {
+ this.type = type;
+ this.description = description;
+ }
+ static defaultPasswordLength = 8;
+ static validate(data: { email: string; password: string; confirm?: string; }) {
+ const emailRegex = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/;
+ const validationErrors = [];
+ if (!emailRegex.test(data.email)) validationErrors.push(new ValidationError(
+ ValidationErrors.Email, ValidationDescriptions.Email,
+ ));
+ if (data.password.length < ValidationError.defaultPasswordLength)
+ validationErrors.push(new ValidationError(
+ ValidationErrors.Password, ValidationDescriptions.PasswordShort,
+ ));
+ if (!data.password || !data.password.length) validationErrors.push(new ValidationError(
+ ValidationErrors.Password, ValidationDescriptions.PasswordEmpty,
+ ));
+ if (data.confirm && data.confirm !== data.password) validationErrors.push(new ValidationError(
+ ValidationErrors.Confirm, ValidationDescriptions.Confirm,
+ ));
+ return validationErrors;
+ }
+}
diff --git a/src/client/src/index.js b/src/client/src/index.tsx
similarity index 91%
rename from src/client/src/index.js
rename to src/client/src/index.tsx
index d50b235..baf0c4a 100644
--- a/src/client/src/index.js
+++ b/src/client/src/index.tsx
@@ -7,7 +7,7 @@ import reportWebVitals from './reportWebVitals';
import './index.css';
import { BrowserRouter } from 'react-router-dom';
-const container = document.getElementById('root');
+const container = document.getElementById('root') as HTMLElement;
const root = createRoot(container);
root.render(
diff --git a/src/client/src/pages/Home.jsx b/src/client/src/pages/Home.tsx
similarity index 100%
rename from src/client/src/pages/Home.jsx
rename to src/client/src/pages/Home.tsx
diff --git a/src/client/src/pages/Login.jsx b/src/client/src/pages/Login.tsx
similarity index 81%
rename from src/client/src/pages/Login.jsx
rename to src/client/src/pages/Login.tsx
index 9b6f1bf..59c073a 100644
--- a/src/client/src/pages/Login.jsx
+++ b/src/client/src/pages/Login.tsx
@@ -1,27 +1,28 @@
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
-import { useDispatch, useSelector } from 'react-redux';
import ValidationError from '../features/ValidationError';
import { loginUser } from '../app/actions/account/loginUser';
import ErrorMessage from '../components/ErrorMessage';
+import { useAppDispatch, useAppSelector } from '../app/store';
function Login() {
- const { error, user } = useSelector(state => state.account);
+ const { error, user } = useAppSelector(state => state.account);
const [formData, setFormData] = useState({
email: '',
password: '',
});
- const [errors, setErrors] = useState([]);
+ const [errors, setErrors] = useState([]);
const navigate = useNavigate();
- const dispatch = useDispatch();
- const handleChange = e => {
+ const dispatch = useAppDispatch();
+ const handleChange = (event: React.FormEvent) => {
+ const target = event.target as HTMLInputElement;
setFormData({
...formData,
- [e.target.name]: e.target.value.trim(),
+ [target.name]: target.value.trim(),
});
};
- const handleSubmit = event => {
+ const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
setErrors([]);
const validationErrors = ValidationError.validate(formData);
@@ -44,7 +45,7 @@ function Login() {