diff --git a/app/controllers/api/v1/recipes_controller.rb b/app/controllers/api/v1/recipes_controller.rb
index cb2dd51..2ddb1a0 100644
--- a/app/controllers/api/v1/recipes_controller.rb
+++ b/app/controllers/api/v1/recipes_controller.rb
@@ -13,7 +13,18 @@ def create
instructions: JSON.parse(params[:instructions]),
user: current_user
)
+
recipe.image.attach(params[:image]) if params[:image].present?
+
+ JSON.parse(params[:ingredients]).each do |ingredient_data|
+ ingredient = Ingredient.find_or_create_by!(name: ingredient_data['name'].downcase.strip)
+ recipe.ingredient_quantities << IngredientQuantity.new(
+ ingredient: ingredient,
+ quantity: ingredient_data['quantity'],
+ unit: ingredient_data['unit'].downcase.strip
+ )
+ end
+
recipe.save!
render json: nil, status: :created
rescue ActiveRecord::RecordInvalid => e
diff --git a/app/models/ingredient.rb b/app/models/ingredient.rb
new file mode 100644
index 0000000..10bcfda
--- /dev/null
+++ b/app/models/ingredient.rb
@@ -0,0 +1,3 @@
+class Ingredient < ApplicationRecord
+ validates :name, presence: true
+end
diff --git a/app/models/ingredient_quantity.rb b/app/models/ingredient_quantity.rb
new file mode 100644
index 0000000..a5283a9
--- /dev/null
+++ b/app/models/ingredient_quantity.rb
@@ -0,0 +1,6 @@
+class IngredientQuantity < ApplicationRecord
+ belongs_to :recipe
+ belongs_to :ingredient
+
+ validates :quantity, presence: true, numericality: true
+end
diff --git a/app/models/recipe.rb b/app/models/recipe.rb
index 487858c..514ae59 100644
--- a/app/models/recipe.rb
+++ b/app/models/recipe.rb
@@ -4,6 +4,7 @@ class Recipe < ApplicationRecord
belongs_to :user
alias :author :user
has_one_attached :image
+ has_many :ingredient_quantities
# Return the url for the attached image, if any.
def image_url
diff --git a/app/webpack/api/v1/recipes.js b/app/webpack/api/v1/recipes.js
new file mode 100644
index 0000000..75bb266
--- /dev/null
+++ b/app/webpack/api/v1/recipes.js
@@ -0,0 +1,12 @@
+import axios from 'axios';
+
+import { getCsrfToken } from '../../helpers/form-helper';
+
+export function createRecipe(data) {
+ axios.defaults.headers.common['X-CSRF-TOKEN'] = getCsrfToken();
+ return axios.post('/api/v1/recipes', data, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ });
+}
diff --git a/app/webpack/components/RecipeForm.js b/app/webpack/components/RecipeForm.js
deleted file mode 100644
index 574c15a..0000000
--- a/app/webpack/components/RecipeForm.js
+++ /dev/null
@@ -1,178 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button, Col, Form } from 'react-bootstrap';
-import ReactCrop from 'react-image-crop';
-
-import { getCroppedImg } from '../helpers/crop-image';
-import RecipeInstructionInput from './RecipeInstructionInput';
-
-const RecipeForm = ({ history }) => {
- const handleInputChange = (e) => {
- if (e.target.getAttribute('name') == 'instruction') {
- let instructions = formData.instructions;
- let index = e.target.getAttribute('data-index');
- instructions[index] = e.target.value;
- setFormData({...formData, instructions: instructions });
- } else {
- setFormData({...formData, [e.target.name]: e.target.value });
- }
- };
-
- const addInstructionInput = () => {
- let instructions = formData.instructions;
- setFormData({...formData, instructions: [...instructions, ''] });
- };
-
- const [imageSource, setImageSource] = useState(null);
- const [cropData, setCropData] = useState({});
- const [imageRef, setImageRef] = useState();
- const [croppedImageBlob, setCroppedImageBlob] = useState();
- const [formData, setFormData] = useState({
- title: '',
- description: '',
- link: '',
- instructions: [''],
- image: ''
- });
-
- const handleFormSubmit = (e) => {
- e.preventDefault();
-
- let data = new FormData();
- for (const key in formData) {
- if (key == 'instructions') {
- data.append(key, JSON.stringify(formData[key]));
- } else {
- data.append(key, formData[key]);
- }
- }
- data.append('image', croppedImageBlob);
-
- const csrfToken = document.querySelector('[name=csrf-token]').content;
- axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken;
- axios.post('/api/v1/recipes', data, {
- headers: {
- 'Content-Type': 'multipart/form-data'
- }
- })
- .then(() => history.push('/'))
- .catch(error => console.error(error));
- };
-
- // Set the src state when the user selects a file.
- const onSelectFile = (e) => {
- if (e.target.files && e.target.files.length > 0) {
- const reader = new FileReader();
- reader.addEventListener('load', () => setImageSource(reader.result));
- reader.readAsDataURL(e.target.files[0]);
- }
- };
-
- const onImageLoaded = (image) => {
- setImageRef(image);
- setCropData({
- width: image.width,
- aspect: 1,
- });
- return false;
- };
-
- const onCropChange = (_crop, percentCrop) => setCropData(percentCrop);
-
- const onCropComplete = async (crop) => {
- if (imageRef && crop.width && crop.height) {
- try {
- const imageBlob = await getCroppedImg(imageRef, crop);
- setCroppedImageBlob(imageBlob);
- } catch (err) {
- console.error('Failed to crop image', err);
- }
- }
- };
-
- return (
-
-
-
-
- Title
-
-
-
-
- Description
-
-
-
-
- Link
-
-
-
-
- Instructions
-
- {
- formData.instructions.map((value, index) => {
- return (
-
- );
- })
- }
-
-
-
-
- Photo
-
-
-
-
-
- {imageSource && (
-
- )}
-
-
-
-
-
-
- );
-};
-
-RecipeForm.propTypes = {
- history: PropTypes.object.isRequired,
-};
-
-export default RecipeForm;
diff --git a/app/webpack/components/RecipeInstructionInput.js b/app/webpack/components/RecipeInstructionInput.js
deleted file mode 100644
index d36ef86..0000000
--- a/app/webpack/components/RecipeInstructionInput.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Form } from 'react-bootstrap';
-
-const RecipeInstructionInput = ({ index, value, onChange }) => {
- return (
-
-
-
-
- );
-};
-
-RecipeInstructionInput.propTypes = {
- index: PropTypes.number.isRequired,
- value: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
-};
-
-export default RecipeInstructionInput;
diff --git a/app/webpack/components/recipe-form/RecipeForm.js b/app/webpack/components/recipe-form/RecipeForm.js
new file mode 100644
index 0000000..c5a5c54
--- /dev/null
+++ b/app/webpack/components/recipe-form/RecipeForm.js
@@ -0,0 +1,168 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button, Col, Form } from 'react-bootstrap';
+
+import { createRecipe } from '../../api/v1/recipes';
+import RecipeImageCropper from './RecipeImageCropper';
+import RecipeIngredients from './RecipeIngredients';
+import RecipeInstructions from './RecipeInstructions';
+import RecipePhotoUploader from './RecipePhotoUploader';
+
+const RecipeForm = ({ history }) => {
+ const [imageSource, setImageSource] = useState(null);
+ const [croppedImageBlob, setCroppedImageBlob] = useState();
+ const [formData, setFormData] = useState({
+ title: '',
+ description: '',
+ link: '',
+ ingredients: [],
+ instructions: [''],
+ });
+
+ const handleInputChange = ({ target: { name: field, value } }) => {
+ setFormData((currentData) => ({ ...currentData, [field]: value }));
+ };
+
+ const addNewInstruction = () => {
+ setFormData((currentData) => ({
+ ...currentData,
+ instructions: currentData.instructions.concat(''),
+ }));
+ };
+
+ const updateInstruction = (index, newValue) => {
+ setFormData((currentData) => ({
+ ...currentData,
+ instructions: currentData.instructions.map((value, i) => (
+ index === i ? newValue : value
+ )),
+ }));
+ };
+
+ const addNewIngredient = () => {
+ setFormData((currentData) => ({
+ ...currentData,
+ ingredients: currentData.ingredients.concat({
+ name: '',
+ quantity: 1,
+ unit: '',
+ }),
+ }));
+ };
+
+ const removeIngredient = (index) => {
+ setFormData((currentData) => ({
+ ...currentData,
+ ingredients: currentData.ingredients.filter((value, i) => index !== i),
+ }));
+ };
+
+ const updateIngredient = (index, newValue) => {
+ setFormData((currentData) => ({
+ ...currentData,
+ ingredients: currentData.ingredients.map((value, i) => (
+ index === i ? newValue : value
+ )),
+ }));
+ };
+
+ const handleFormSubmit = (e) => {
+ e.preventDefault();
+
+ const data = new FormData();
+ for (const key in formData) {
+ if (['instructions', 'ingredients'].includes(key)) {
+ data.append(key, JSON.stringify(formData[key]));
+ } else {
+ data.append(key, formData[key]);
+ }
+ }
+
+ if (croppedImageBlob) {
+ data.append('image', croppedImageBlob);
+ }
+
+ createRecipe(data)
+ .then(() => {
+ history.push('/');
+ })
+ .catch(error => {
+ console.error(error);
+ });
+ };
+
+ return (
+
+
+
+
+ Title
+
+
+
+
+ Description
+
+
+
+
+ Link
+
+
+
+
+ Ingredients
+
+
+
+ Instructions
+
+
+
+ Photo
+
+
+
+
+
+ {imageSource &&
+ }
+
+
+
+
+
+
+ );
+};
+
+RecipeForm.propTypes = {
+ history: PropTypes.object.isRequired,
+};
+
+export default RecipeForm;
diff --git a/app/webpack/components/recipe-form/RecipeImageCropper.js b/app/webpack/components/recipe-form/RecipeImageCropper.js
new file mode 100644
index 0000000..44f2e63
--- /dev/null
+++ b/app/webpack/components/recipe-form/RecipeImageCropper.js
@@ -0,0 +1,51 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import ReactCrop from 'react-image-crop';
+
+import { getCroppedImg } from '../../helpers/crop-image';
+
+const RecipeImageCropper = ({ imageSource, setCroppedImageBlob }) => {
+ const [cropData, setCropData] = useState({});
+ const [imageRef, setImageRef] = useState();
+
+ const onCropChange = (_crop, percentCrop) => setCropData(percentCrop);
+
+ const onImageLoaded = (image) => {
+ setImageRef(image);
+ setCropData({
+ width: image.width,
+ aspect: 1,
+ });
+ return false;
+ };
+
+ const onCropComplete = async (crop) => {
+ if (imageRef && crop.width && crop.height) {
+ try {
+ const imageBlob = await getCroppedImg(imageRef, crop);
+ setCroppedImageBlob(imageBlob);
+ } catch (err) {
+ console.error('Failed to crop image', err);
+ }
+ }
+ };
+
+ return (
+
+ );
+};
+
+RecipeImageCropper.propTypes = {
+ imageSource: PropTypes.string.isRequired,
+ setCroppedImageBlob: PropTypes.func.isRequired,
+};
+
+export default RecipeImageCropper;
\ No newline at end of file
diff --git a/app/webpack/components/recipe-form/RecipeIngredients.js b/app/webpack/components/recipe-form/RecipeIngredients.js
new file mode 100644
index 0000000..c2309a4
--- /dev/null
+++ b/app/webpack/components/recipe-form/RecipeIngredients.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, ListGroup, ListGroupItem } from 'react-bootstrap';
+
+const RecipeIngredients = ({ addNewIngredient, ingredients, removeIngredient, updateIngredient }) => (
+ <>
+
+ {ingredients.map(({ name: ingredientName, quantity, unit }, index) => (
+
+
+
+
+ Amount
+ updateIngredient(index, {
+ name: ingredientName,
+ quantity: Number(e.target.value),
+ unit,
+ })}>
+
+
+
+ Unit
+ updateIngredient(index, {
+ name: ingredientName,
+ quantity,
+ unit: e.target.value,
+ })}>
+
+
+
+ Ingredient
+ updateIngredient(index, {
+ name: e.target.value,
+ quantity,
+ unit,
+ })}>
+
+
+
+
+ ))}
+
+
+ >
+);
+
+RecipeIngredients.propTypes = {
+ addNewIngredient: PropTypes.func.isRequired,
+ ingredients: PropTypes.arrayOf(PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ quantity: PropTypes.number.isRequired,
+ unit: PropTypes.string.isRequired,
+ })).isRequired,
+ removeIngredient: PropTypes.func.isRequired,
+ updateIngredient: PropTypes.func.isRequired,
+};
+
+export default RecipeIngredients;
diff --git a/app/webpack/components/recipe-form/RecipeInstructions.js b/app/webpack/components/recipe-form/RecipeInstructions.js
new file mode 100644
index 0000000..9c5f312
--- /dev/null
+++ b/app/webpack/components/recipe-form/RecipeInstructions.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Form } from 'react-bootstrap';
+
+const RecipeInstructions = ({ addNewInstruction, instructions, updateInstruction }) => (
+ <>
+
+ {instructions.map((value, index) => (
+ -
+ updateInstruction(index, e.target.value)}>
+
+
+ ))}
+
+
+ >
+);
+
+RecipeInstructions.propTypes = {
+ addNewInstruction: PropTypes.func.isRequired,
+ instructions: PropTypes.arrayOf(PropTypes.string).isRequired,
+ updateInstruction: PropTypes.func.isRequired,
+};
+
+export default RecipeInstructions;
diff --git a/app/webpack/components/recipe-form/RecipePhotoUploader.js b/app/webpack/components/recipe-form/RecipePhotoUploader.js
new file mode 100644
index 0000000..0bf51d5
--- /dev/null
+++ b/app/webpack/components/recipe-form/RecipePhotoUploader.js
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const RecipePhotoUploader = ({ setImageSource }) => (
+ {
+ if (e.target.files && e.target.files.length > 0) {
+ const reader = new FileReader();
+ reader.addEventListener('load', () => setImageSource(reader.result));
+ reader.readAsDataURL(e.target.files[0]);
+ }
+ }}
+ />
+);
+
+RecipePhotoUploader.propTypes = {
+ setImageSource: PropTypes.func.isRequired,
+};
+
+export default RecipePhotoUploader;
diff --git a/app/webpack/helpers/form-helper.js b/app/webpack/helpers/form-helper.js
new file mode 100644
index 0000000..4b7908a
--- /dev/null
+++ b/app/webpack/helpers/form-helper.js
@@ -0,0 +1,3 @@
+export function getCsrfToken() {
+ return document.querySelector('[name=csrf-token]').content;
+}
diff --git a/app/webpack/routers/AppRouter.js b/app/webpack/routers/AppRouter.js
index 54b5af6..ddb751b 100644
--- a/app/webpack/routers/AppRouter.js
+++ b/app/webpack/routers/AppRouter.js
@@ -5,7 +5,7 @@ import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Header from '../components/Header';
import Home from '../components/Home';
-import RecipeForm from '../components/RecipeForm';
+import RecipeForm from '../components/recipe-form/RecipeForm';
import NotFound from '../components/NotFound';
import SignUp from '../components/SignUp';
import Login from '../components/Login';
diff --git a/app/webpack/stylesheets/image_cropper.scss b/app/webpack/stylesheets/image_cropper.scss
index 75f5519..b362d05 100644
--- a/app/webpack/stylesheets/image_cropper.scss
+++ b/app/webpack/stylesheets/image_cropper.scss
@@ -1,16 +1,14 @@
-.imageCropper {
- .ReactCrop {
- background-color: black;
- img.ReactCrop__image {
- min-height: 40vh;
- max-height: 70vh;
- }
- }
- @media(min-width: 768px) {
- margin-top: 25px;
- padding-left: 20px !important;
- }
- @media(max-width: 767px) {
- margin-bottom: 10px;
+.ReactCrop {
+ background-color: black;
+ img.ReactCrop__image {
+ min-height: 40vh;
+ max-height: 70vh;
}
}
+@media(min-width: 768px) {
+ margin-top: 25px;
+ padding-left: 20px !important;
+}
+@media(max-width: 767px) {
+ margin-bottom: 10px;
+}
diff --git a/app/webpack/tests/components/RecipeForm.test.js b/app/webpack/tests/components/recipe-form/RecipeForm.test.js
similarity index 93%
rename from app/webpack/tests/components/RecipeForm.test.js
rename to app/webpack/tests/components/recipe-form/RecipeForm.test.js
index 221634e..d401bbd 100644
--- a/app/webpack/tests/components/RecipeForm.test.js
+++ b/app/webpack/tests/components/recipe-form/RecipeForm.test.js
@@ -2,7 +2,7 @@ import axios from 'axios';
import React from 'react';
import { shallow } from 'enzyme';
import flushPromises from 'flush-promises';
-import RecipeForm from '../../components/RecipeForm';
+import RecipeForm from '../../../components/recipe-form/RecipeForm';
jest.mock('axios');
diff --git a/app/webpack/tests/components/recipe-form/RecipeImageCropper.test.js b/app/webpack/tests/components/recipe-form/RecipeImageCropper.test.js
new file mode 100644
index 0000000..01afd98
--- /dev/null
+++ b/app/webpack/tests/components/recipe-form/RecipeImageCropper.test.js
@@ -0,0 +1,89 @@
+import React from 'react';
+import ReactCrop from 'react-image-crop';
+import { shallow } from 'enzyme';
+import RecipeImageCropper from '../../../components/recipe-form/RecipeImageCropper';
+import { getCroppedImg } from '../../../helpers/crop-image';
+
+jest.mock('../../../helpers/crop-image');
+
+describe('RecipeImageCropper', () => {
+ it('should pass the image source to the react cropper', () => {
+ const imageSource = 'fake-image-source';
+
+ const wrapper = shallow();
+
+ const reactCropElem = wrapper.find(ReactCrop);
+ expect(reactCropElem.props()).toHaveProperty('src', imageSource);
+ });
+
+ it('should set the crop data when the image loads', () => {
+ const setCroppedImageBlob = jest.fn();
+ const imageBlob = 'fake-image-blob';
+ getCroppedImg.mockResolvedValue(imageBlob);
+
+ const wrapper = shallow();
+
+ const { onImageLoaded, crop: originalCropData } = wrapper.find(ReactCrop).props();
+ expect(originalCropData).toEqual({});
+
+ const imageData = { width: 20, height: 20 };
+ onImageLoaded(imageData);
+
+ // Note: Need to find the ReactCrop element a second time here, as it is a new instance due to
+ // re-rendering after onImageLoaded calles a callback which came from useState.
+ const { crop: newCropData } = wrapper.find(ReactCrop).props();
+ expect(newCropData).toEqual({ width: imageData.width, aspect: 1 });
+ });
+
+ it('should set the crop data onChange', () => {
+ const setCroppedImageBlob = jest.fn();
+ const imageBlob = 'fake-image-blob';
+ getCroppedImg.mockResolvedValue(imageBlob);
+
+ const wrapper = shallow();
+
+ const { onChange, crop: originalCropData } = wrapper.find(ReactCrop).props();
+ expect(originalCropData).toEqual({});
+
+ const percentCrop = { unit: '%' };
+ onChange({}, percentCrop);
+
+ // Note: Need to find the ReactCrop element a second time here, as it is a new instance due to
+ // re-rendering after onImageLoaded calles a callback which came from useState.
+ const { crop: newCropData } = wrapper.find(ReactCrop).props();
+ expect(newCropData).toEqual(percentCrop);
+ });
+
+ it('should call setCroppedImageBlob when cropping is complete', async () => {
+ const setCroppedImageBlob = jest.fn();
+ const imageBlob = 'fake-image-blob';
+ getCroppedImg.mockResolvedValue(imageBlob);
+
+ const wrapper = shallow();
+
+ const { onImageLoaded } = wrapper.find(ReactCrop).props();
+ const imageData = { width: 20, height: 20 };
+ onImageLoaded(imageData);
+
+ // Note: Need to find the ReactCrop element a second time here, as it is a new instance due to
+ // re-rendering after onImageLoaded calles a callback which came from useState.
+ const { onComplete } = wrapper.find(ReactCrop).props();
+ const cropData = { width: 10, height: 10 };
+ await onComplete(cropData);
+
+ expect(getCroppedImg).toHaveBeenCalledWith(imageData, cropData);
+ expect(setCroppedImageBlob).toHaveBeenCalledWith(imageBlob);
+ });
+});
diff --git a/app/webpack/tests/components/recipe-form/RecipeIngredients.test.js b/app/webpack/tests/components/recipe-form/RecipeIngredients.test.js
new file mode 100644
index 0000000..a50c469
--- /dev/null
+++ b/app/webpack/tests/components/recipe-form/RecipeIngredients.test.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import RecipeIngredients from '../../../components/recipe-form/RecipeIngredients';
+
+const ingredients = [{
+ name: 'broccoli',
+ quantity: 1,
+ unit: 'head',
+},
+{
+ name: 'butter',
+ quantity: 1.5,
+ unit: 'tbsp',
+}];
+
+describe('RecipeIngredients', () => {
+ it('should render inputs for each ingredient', () => {
+ const wrapper = shallow( {}}
+ ingredients={ingredients}
+ removeIngredient={() => {}}
+ updateIngredient={() => {}}
+ />);
+
+ ['name', 'quantity', 'unit'].forEach((field) => {
+ const inputs = wrapper.find(`.ingredient-${field}-input`);
+ expect(inputs).toHaveLength(ingredients.length);
+ expect(inputs.at(0).props()).toHaveProperty('defaultValue', ingredients[0][field]);
+ expect(inputs.at(1).props()).toHaveProperty('defaultValue', ingredients[1][field]);
+ });
+ });
+
+ it('should call the updateIngredient when the quantity changes', () => {
+ const updateIngredient = jest.fn();
+ const wrapper = shallow( {}}
+ ingredients={ingredients}
+ removeIngredient={() => {}}
+ updateIngredient={updateIngredient}
+ />);
+
+ wrapper.find('.ingredient-quantity-input').at(1).simulate('change', {
+ target: { value: '2.5' }
+ });
+
+ expect(updateIngredient).toHaveBeenCalledWith(1, {
+ name: 'butter',
+ quantity: 2.5,
+ unit: 'tbsp',
+ });
+ });
+
+ it('should call the updateIngredient when the unit changes', () => {
+ const updateIngredient = jest.fn();
+ const wrapper = shallow( {}}
+ ingredients={ingredients}
+ removeIngredient={() => {}}
+ updateIngredient={updateIngredient}
+ />);
+
+ wrapper.find('.ingredient-unit-input').at(1).simulate('change', {
+ target: { value: 'tsp' }
+ });
+
+ expect(updateIngredient).toHaveBeenCalledWith(1, {
+ name: 'butter',
+ quantity: 1.5,
+ unit: 'tsp',
+ });
+ });
+ it('should call the updateIngredient when the unit changes', () => {
+ const updateIngredient = jest.fn();
+ const wrapper = shallow( {}}
+ ingredients={ingredients}
+ removeIngredient={() => {}}
+ updateIngredient={updateIngredient}
+ />);
+
+ wrapper.find('.ingredient-name-input').at(1).simulate('change', {
+ target: { value: 'olive oil' }
+ });
+
+ expect(updateIngredient).toHaveBeenCalledWith(1, {
+ name: 'olive oil',
+ quantity: 1.5,
+ unit: 'tbsp',
+ });
+ });
+
+ it('should call addNewIngredient when the add button is clicked', () => {
+ const addNewIngredient = jest.fn();
+ const wrapper = shallow( {}}
+ updateIngredient={() => {}}
+ />);
+
+ wrapper.find('.add-btn').simulate('click');
+
+ expect(addNewIngredient).toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/app/webpack/tests/components/recipe-form/RecipeInstructions.test.js b/app/webpack/tests/components/recipe-form/RecipeInstructions.test.js
new file mode 100644
index 0000000..5835f0d
--- /dev/null
+++ b/app/webpack/tests/components/recipe-form/RecipeInstructions.test.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import RecipeInstructions from '../../../components/recipe-form/RecipeInstructions';
+
+describe('RecipeInstructions', () => {
+ it('should render a textarea for each instruction', () => {
+ const instructions = ['foo', 'bar'];
+ const wrapper = shallow( {}}
+ instructions={instructions}
+ updateInstruction={() => {}}
+ />);
+
+ const textareas = wrapper.find('.instruction-input');
+ expect(textareas).toHaveLength(instructions.length);
+ expect(textareas.at(0).props()).toHaveProperty('defaultValue', instructions[0]);
+ expect(textareas.at(1).props()).toHaveProperty('defaultValue', instructions[1]);
+ });
+
+ it('should call the updateInstruction when an instruction changes', () => {
+ const updateInstruction = jest.fn();
+ const wrapper = shallow( {}}
+ instructions={['foo', '']}
+ updateInstruction={updateInstruction}
+ />);
+
+ const textareas = wrapper.find('.instruction-input');
+ textareas.at(1).simulate('change', {
+ target: { value: 'bar' }
+ });
+
+ expect(updateInstruction).toHaveBeenCalledWith(1, 'bar');
+ });
+
+ it('should call addNewInstruction when the add button is clicked', () => {
+ const addNewInstruction = jest.fn();
+ const wrapper = shallow( {}}
+ />);
+
+ wrapper.find('.add-btn').simulate('click');
+
+ expect(addNewInstruction).toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/app/webpack/tests/components/recipe-form/RecipePhotoUploader.test.js b/app/webpack/tests/components/recipe-form/RecipePhotoUploader.test.js
new file mode 100644
index 0000000..9a560a2
--- /dev/null
+++ b/app/webpack/tests/components/recipe-form/RecipePhotoUploader.test.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import RecipePhotoUploader from '../../../components/recipe-form/RecipePhotoUploader';
+
+describe('RecipePhotoUploader', () => {
+ it('should call setImageSource with the first file loaded by the input', () => {
+ const fakeBlob = { blob: 'I am a blob' };
+ const setImageSource = jest.fn();
+
+ const readAsDataURL = jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => null);
+ const addEventListener = jest.spyOn(FileReader.prototype, 'addEventListener');
+
+ const wrapper = shallow();
+ wrapper.simulate('change', { target: { files: [fakeBlob] } });
+
+ expect(readAsDataURL).toHaveBeenCalledWith(fakeBlob);
+ expect(addEventListener).toHaveBeenCalledWith('load', expect.any(Function));
+
+ const eventListener = addEventListener.mock.calls[0][1];
+ eventListener();
+
+ expect(setImageSource).toHaveBeenCalled();
+ });
+
+ it('should do nothing on change if there are not files', () => {
+ const readAsDataURL = jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => null);
+ const addEventListener = jest.spyOn(FileReader.prototype, 'addEventListener');
+
+ const wrapper = shallow( {}} />);
+ wrapper.simulate('change', { target: { files: [] } });
+
+ expect(readAsDataURL).not.toHaveBeenCalled();
+ expect(addEventListener).not.toHaveBeenCalled();
+ });
+});
diff --git a/db/migrate/20200711220238_create_ingredients.rb b/db/migrate/20200711220238_create_ingredients.rb
new file mode 100644
index 0000000..ef77157
--- /dev/null
+++ b/db/migrate/20200711220238_create_ingredients.rb
@@ -0,0 +1,9 @@
+class CreateIngredients < ActiveRecord::Migration[6.0]
+ def change
+ create_table :ingredients do |t|
+ t.string :name, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20200711220820_create_ingredient_quantities.rb b/db/migrate/20200711220820_create_ingredient_quantities.rb
new file mode 100644
index 0000000..6f38274
--- /dev/null
+++ b/db/migrate/20200711220820_create_ingredient_quantities.rb
@@ -0,0 +1,12 @@
+class CreateIngredientQuantities < ActiveRecord::Migration[6.0]
+ def change
+ create_table :ingredient_quantities do |t|
+ t.references :recipe, null: false, foreign_key: true
+ t.references :ingredient, null: false, foreign_key: true
+ t.float :quantity, null: false
+ t.string :unit
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index becc2f0..8858cb9 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2020_06_28_175636) do
+ActiveRecord::Schema.define(version: 2020_07_11_220820) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
@@ -33,6 +33,23 @@
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
+ create_table "ingredient_quantities", force: :cascade do |t|
+ t.integer "recipe_id", null: false
+ t.integer "ingredient_id", null: false
+ t.float "quantity", null: false
+ t.string "unit"
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["ingredient_id"], name: "index_ingredient_quantities_on_ingredient_id"
+ t.index ["recipe_id"], name: "index_ingredient_quantities_on_recipe_id"
+ end
+
+ create_table "ingredients", force: :cascade do |t|
+ t.string "name", null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ end
+
create_table "recipes", force: :cascade do |t|
t.string "title"
t.string "link"
@@ -54,5 +71,7 @@
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "ingredient_quantities", "ingredients"
+ add_foreign_key "ingredient_quantities", "recipes"
add_foreign_key "recipes", "users"
end
diff --git a/test/models/ingredient_quantity_test.rb b/test/models/ingredient_quantity_test.rb
new file mode 100644
index 0000000..ff1ffaa
--- /dev/null
+++ b/test/models/ingredient_quantity_test.rb
@@ -0,0 +1,14 @@
+require 'test_helper'
+
+class IngredientQuantityTest < ActiveSupport::TestCase
+ test 'quantity is required and must a number' do
+ ingredient_quantity = IngredientQuantity.new(recipe: Recipe.new, ingredient: Ingredient.new)
+ refute_predicate ingredient_quantity, :valid?
+
+ ingredient_quantity.quantity = 12.4
+ assert_predicate ingredient_quantity, :valid?
+
+ ingredient_quantity.quantity = 1
+ assert_predicate ingredient_quantity, :valid?
+ end
+end
diff --git a/test/models/ingredient_test.rb b/test/models/ingredient_test.rb
new file mode 100644
index 0000000..b4fbbab
--- /dev/null
+++ b/test/models/ingredient_test.rb
@@ -0,0 +1,11 @@
+require 'test_helper'
+
+class IngredientTest < ActiveSupport::TestCase
+ test 'name is required' do
+ ingredient = Ingredient.new
+ refute_predicate ingredient, :valid?
+
+ ingredient.name = 'Foo'
+ assert_predicate ingredient, :valid?
+ end
+end