Skip to content

Commit

Permalink
add new recipe form
Browse files Browse the repository at this point in the history
  • Loading branch information
Kyle Zarazan committed Nov 20, 2024
1 parent 42727c1 commit f4991ac
Show file tree
Hide file tree
Showing 16 changed files with 284 additions and 66 deletions.
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 4 in app/controllers/application_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/EmptyLinesAroundClassBody: Extra empty line detected at class body end.
end
24 changes: 24 additions & 0 deletions app/controllers/recipes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Check failure on line 28 in app/controllers/recipes_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.

Check failure on line 28 in app/controllers/recipes_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.
end

Check failure on line 30 in app/controllers/recipes_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/EmptyLinesAroundClassBody: Extra empty line detected at class body end.
end
4 changes: 3 additions & 1 deletion app/javascript/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -17,7 +18,8 @@ const App: React.FC = () => {
<Route index element={<Home />} />
<Route path="search" element={<Search />} />
<Route path="recipes" element={<RecipeTable />} />
<Route path="ingredients" element={<FoodsTable />} />
<Route path="recipes/new" element={<NewRecipeForm />} />
<Route path="foods" element={<FoodsTable />} />
</Route>
</Routes>
</Provider>
Expand Down
43 changes: 11 additions & 32 deletions app/javascript/components/FoodsTable.tsx
Original file line number Diff line number Diff line change
@@ -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<Food[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 <div>Loading...</div>;
}

if (error) {
if (status === 'failed') {
return <div>Error: {error}</div>;
}

Expand All @@ -47,7 +27,6 @@ const FetchDataComponent: React.FC = () => {
{foods.map((item) => (
<li key={item.id}>
{item.id}: {item.name}
{item.created_at}
</li>
))}
</ul>
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const Nav = (): JSX.Element => {
<NavLink name="Home" path="/" />
<NavLink name="Search" path="/search" />
<NavLink name="Recipes" path="/recipes" />
<NavLink name="Ingredients" path="/ingredients" />
<NavLink name="Foods" path="/foods" />
</ul>
</nav>
)
Expand Down
133 changes: 133 additions & 0 deletions app/javascript/components/NewRecipeForm.tsx
Original file line number Diff line number Diff line change
@@ -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<RecipeFormData>({
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 (
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto p-4">
<div className="mb-4">
<label className="block mb-2">Recipe Name:</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full p-2 border rounded"
/>
</div>

<div className="mb-4">
<label className="block mb-2">Description:</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
className="w-full p-2 border rounded"
/>
</div>

<div className="mb-4">
<h3 className="mb-2">Ingredients</h3>
{formData.ingredients_attributes.map((ingredient, index) => (
<div key={index} className="flex gap-4 mb-2">
<select
value={ingredient.food_id}
onChange={(e) => handleIngredientChange(index, 'food_id', parseInt(e.target.value))}
className="p-2 border rounded"
>
<option value="">Select Food</option>
{foods.map((food) => (
<option key={food.id} value={food.id}>{food.name}</option>
))}
</select>

<input
type="text"
value={ingredient.measurement}
onChange={(e) => handleIngredientChange(index, 'measurement', e.target.value)}
placeholder="Measurement"
className="p-2 border rounded w-24"
/>
</div>
))}
<button
type="button"
onClick={addIngredient}
className="bg-blue-500 text-white px-4 py-2 rounded mt-2"
>
Add Ingredient
</button>
</div>

<button
type="submit"
className="bg-green-500 text-white px-6 py-2 rounded"
>
Create Recipe
</button>
</form>
);
};

export default NewRecipeForm;
7 changes: 4 additions & 3 deletions app/javascript/components/RecipeTable.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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') {
Expand Down
48 changes: 48 additions & 0 deletions app/javascript/store/foodsSlice.ts
Original file line number Diff line number Diff line change
@@ -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
49 changes: 30 additions & 19 deletions app/javascript/store/recipesSlice.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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'
Expand Down
Loading

0 comments on commit f4991ac

Please sign in to comment.