diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0d95db2..ee1ccf0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern + end diff --git a/app/controllers/recipes_controller.rb b/app/controllers/recipes_controller.rb index 3d54c7a..5488352 100644 --- a/app/controllers/recipes_controller.rb +++ b/app/controllers/recipes_controller.rb @@ -4,4 +4,28 @@ def index @recipes = Recipe.all.preload(ingredients: :food) end + def create + @recipe = Recipe.new(recipe_params) + if @recipe.save + render :create, status: :created + else + render :errors, status: :unprocessable_entity + end + end + + def update + @recipe = Recipe.find(params[:id]) + if @recipe.update(recipe_params) + render :create, status: :ok + else + render :errors, status: :unprocessable_entity + end + end + + private + + def recipe_params + params.require(:recipe).permit(:name, :description, ingredients_attributes: [:id, :food_id, :amount, :unit, :_destroy]) + end + end diff --git a/app/javascript/components/App.tsx b/app/javascript/components/App.tsx index b8cdc3d..6fa279a 100644 --- a/app/javascript/components/App.tsx +++ b/app/javascript/components/App.tsx @@ -8,6 +8,7 @@ import Home from "./Home"; import Search from "./Search"; import RecipeTable from "./RecipeTable"; import FoodsTable from "./FoodsTable"; +import NewRecipeForm from './NewRecipeForm'; const App: React.FC = () => { return ( @@ -17,7 +18,8 @@ const App: React.FC = () => { } /> } /> } /> - } /> + } /> + } /> diff --git a/app/javascript/components/FoodsTable.tsx b/app/javascript/components/FoodsTable.tsx index bbf1ee3..ad0af83 100644 --- a/app/javascript/components/FoodsTable.tsx +++ b/app/javascript/components/FoodsTable.tsx @@ -1,42 +1,22 @@ -import React, { useState, useEffect } from 'react'; - -interface Food { - id: number; - name: string; - created_at: string; - modified_at: string; -} +import React, { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '../store/hooks' +import { fetchFoods } from '../store/foodsSlice' const FetchDataComponent: React.FC = () => { - const [foods, setFoods] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const dispatch = useAppDispatch() + const { items: foods, status, error } = useAppSelector((state) => state.foods) - // Fetch data when the component mounts useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch('http://localhost:3000/api/foods'); - if (!response.ok) { - throw new Error('Failed to fetch data'); - } - const result: Food[] = await response.json(); - setFoods(result); // Set the fetched data to the state - setLoading(false); // Data loaded, set loading to false - } catch (err: any) { - setError(err.message); // If error occurs, set the error message - setLoading(false); // Stop loading in case of error - } - }; - - fetchData(); - }, []); // Empty dependency array to run only once on mount + if (status === 'idle') { + dispatch(fetchFoods()) + } + }, [dispatch]) - if (loading) { + if (status === 'loading') { return Loading...; } - if (error) { + if (status === 'failed') { return Error: {error}; } @@ -47,7 +27,6 @@ const FetchDataComponent: React.FC = () => { {foods.map((item) => ( {item.id}: {item.name} - {item.created_at} ))} diff --git a/app/javascript/components/Layout.tsx b/app/javascript/components/Layout.tsx index fc63002..cc0569c 100644 --- a/app/javascript/components/Layout.tsx +++ b/app/javascript/components/Layout.tsx @@ -24,7 +24,7 @@ const Nav = (): JSX.Element => { - + ) diff --git a/app/javascript/components/NewRecipeForm.tsx b/app/javascript/components/NewRecipeForm.tsx new file mode 100644 index 0000000..63e0c26 --- /dev/null +++ b/app/javascript/components/NewRecipeForm.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { createRecipe } from '../store/recipesSlice'; +import { Ingredient } from '../types/types'; + +const getCsrfToken = (): string => { + const element = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement; + return element ? element.content : ''; +}; + +export interface RecipeFormData { + name: string; + description: string; + ingredients_attributes: Ingredient[]; +} + +const NewRecipeForm: React.FC = () => { + const dispatch = useAppDispatch(); + const { items: foods } = useAppSelector((state) => state.foods); + const csrfToken = getCsrfToken(); + + const [formData, setFormData] = useState({ + name: '', + description: '', + ingredients_attributes: [] + }); + + const addIngredient = () => { + setFormData({ + ...formData, + ingredients_attributes: [ + ...formData.ingredients_attributes, + { food_id: 0, measurement: '' } + ] + }); + }; + + const handleIngredientChange = (index: number, field: keyof Ingredient, value: string | number) => { + const newIngredients = [...formData.ingredients_attributes]; + newIngredients[index] = { + food_id: newIngredients[index]?.food_id || 0, + measurement: newIngredients[index]?.measurement || '', + [field]: value + }; + setFormData({ + ...formData, + ingredients_attributes: newIngredients + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(createRecipe({ + data: formData, + csrfToken + })) + .unwrap() + .then(() => { + setFormData({ + name: '', + description: '', + ingredients_attributes: [] + }); + }) + .catch((error: Error) => { + alert('Failed to create recipe: ' + error.message); + }); + }; + return ( + + + Recipe Name: + setFormData({...formData, name: e.target.value})} + className="w-full p-2 border rounded" + /> + + + + Description: + setFormData({...formData, description: e.target.value})} + className="w-full p-2 border rounded" + /> + + + + Ingredients + {formData.ingredients_attributes.map((ingredient, index) => ( + + handleIngredientChange(index, 'food_id', parseInt(e.target.value))} + className="p-2 border rounded" + > + Select Food + {foods.map((food) => ( + {food.name} + ))} + + + handleIngredientChange(index, 'measurement', e.target.value)} + placeholder="Measurement" + className="p-2 border rounded w-24" + /> + + ))} + + Add Ingredient + + + + + Create Recipe + + + ); +}; + +export default NewRecipeForm; diff --git a/app/javascript/components/RecipeTable.tsx b/app/javascript/components/RecipeTable.tsx index dcc9acd..288647a 100644 --- a/app/javascript/components/RecipeTable.tsx +++ b/app/javascript/components/RecipeTable.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { useEffect } from 'react' +import React, { useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '../store/hooks' import { fetchRecipes } from '../store/recipesSlice' @@ -8,7 +7,9 @@ const RecipeTable: React.FC = () => { const { items: recipes, status, error } = useAppSelector((state) => state.recipes) useEffect(() => { - dispatch(fetchRecipes()) + if (status === 'idle') { + dispatch(fetchRecipes()) + } }, [dispatch]) if (status === 'loading') { diff --git a/app/javascript/store/foodsSlice.ts b/app/javascript/store/foodsSlice.ts new file mode 100644 index 0000000..3e76019 --- /dev/null +++ b/app/javascript/store/foodsSlice.ts @@ -0,0 +1,48 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' + +export const fetchFoods = createAsyncThunk( + 'foods/fetchFoods', + async () => { + const response = await fetch('/api/foods') + return response.json() + } +) + +interface Food { + id: number; + name: string; +} + +interface FoodsState { + items: Food[] + status: 'idle' | 'loading' | 'succeeded' | 'failed' + error: string | null +} + +const initialState: FoodsState = { + items: [], + status: 'idle', + error: null +} + +const foodsSlice = createSlice({ + name: 'foods', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchFoods.pending, (state) => { + state.status = 'loading' + }) + .addCase(fetchFoods.fulfilled, (state, action) => { + state.status = 'succeeded' + state.items = action.payload + }) + .addCase(fetchFoods.rejected, (state, action) => { + state.status = 'failed' + state.error = action.error.message || null + }) + } +}) + +export default foodsSlice.reducer diff --git a/app/javascript/store/recipesSlice.ts b/app/javascript/store/recipesSlice.ts index 6e91301..4131320 100644 --- a/app/javascript/store/recipesSlice.ts +++ b/app/javascript/store/recipesSlice.ts @@ -1,29 +1,40 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import { RecipeFormData } from '../components/NewRecipeForm'; +import { Recipe } from '../types/types'; + +export const createRecipe = createAsyncThunk( + 'recipes/create', + async ({ data, csrfToken }: { data: RecipeFormData; csrfToken: string }) => { + const response = await fetch('/api/recipes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'An error occurred'); + } + + return response.json(); + } +); export const fetchRecipes = createAsyncThunk( 'recipes/fetchRecipes', async () => { - const response = await fetch('/api/recipes') - return response.json() + const response = await fetch('/api/recipes'); + return response.json(); } -) - -interface Ingredient { - id: number; - food_name: string; - measurement: string; -} - -interface Recipe { - id: number; - name: string; - ingredients: Ingredient[]; -} +); interface RecipesState { - items: Recipe[] - status: 'idle' | 'loading' | 'succeeded' | 'failed' - error: string | null + items: Recipe[]; + status: 'idle' | 'loading' | 'succeeded' | 'failed'; + error: string | null; } const initialState: RecipesState = { @@ -43,7 +54,7 @@ const recipesSlice = createSlice({ }) .addCase(fetchRecipes.fulfilled, (state, action) => { state.status = 'succeeded' - state.items = action.payload + state.items = action.payload.recipes }) .addCase(fetchRecipes.rejected, (state, action) => { state.status = 'failed' diff --git a/app/javascript/store/store.ts b/app/javascript/store/store.ts index e54d7de..062c7dc 100644 --- a/app/javascript/store/store.ts +++ b/app/javascript/store/store.ts @@ -1,9 +1,11 @@ import { configureStore } from '@reduxjs/toolkit' import recipesReducer from './recipesSlice' +import foodsReducer from './foodsSlice' export const store = configureStore({ reducer: { recipes: recipesReducer, + foods: foodsReducer, }, }) diff --git a/app/javascript/types/types.ts b/app/javascript/types/types.ts new file mode 100644 index 0000000..0d249e2 --- /dev/null +++ b/app/javascript/types/types.ts @@ -0,0 +1,12 @@ +export interface Ingredient { + id?: number; + food_id?: number; + food_name?: string; + measurement: string; +} + +export interface Recipe { + id: number; + name: string; + ingredients: Ingredient[]; +} diff --git a/app/views/recipes/_recipe.json.jbuilder b/app/views/recipes/_recipe.json.jbuilder new file mode 100644 index 0000000..29b7825 --- /dev/null +++ b/app/views/recipes/_recipe.json.jbuilder @@ -0,0 +1,6 @@ +json.extract! recipe, :id, :name + +json.ingredients recipe.ingredients do |ingredient| + json.extract! ingredient, :id, :measurement + json.food_name ingredient.food.name +end diff --git a/app/views/recipes/create.json.jbuilder b/app/views/recipes/create.json.jbuilder new file mode 100644 index 0000000..5428a80 --- /dev/null +++ b/app/views/recipes/create.json.jbuilder @@ -0,0 +1,4 @@ +json.success true +json.recipe do + json.partial! "recipes/recipe", recipe: @recipe +end diff --git a/app/views/recipes/errors.json.jbuilder b/app/views/recipes/errors.json.jbuilder new file mode 100644 index 0000000..bc7f6a1 --- /dev/null +++ b/app/views/recipes/errors.json.jbuilder @@ -0,0 +1,2 @@ +json.success false +json.errors @recipe.errors.full_messages diff --git a/app/views/recipes/index.json.jbuilder b/app/views/recipes/index.json.jbuilder index da95c1b..70e518a 100644 --- a/app/views/recipes/index.json.jbuilder +++ b/app/views/recipes/index.json.jbuilder @@ -1,10 +1,3 @@ -json.array! @recipes do |recipe| - json.id recipe.id - json.name recipe.name - - json.ingredients recipe.ingredients do |ingredient| - json.id ingredient.id - json.food_name ingredient.food.name - json.mesaurement ingredient.measurement - end +json.recipes @recipes do |recipe| + json.partial! "recipes/recipe", recipe: recipe end diff --git a/config/routes.rb b/config/routes.rb index f3f2ae5..47d4842 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,7 @@ root "home#index" scope "/api" do - resources :recipes, only: [:index] + resources :recipes, only: [:index, :create, :update] resources :foods, only: [:index] end