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) => ( +
    1. + updateInstruction(index, e.target.value)}> + +
    2. + ))} +
    + + +); + +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