From 3ab2e87ebc187e1019da974c678457bc2c967b5c Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 11 Jul 2020 15:05:43 -0700 Subject: [PATCH 1/9] add ingredient model --- app/models/ingredient.rb | 3 +++ db/migrate/20200711220238_create_ingredients.rb | 9 +++++++++ db/schema.rb | 8 +++++++- test/models/ingredient_test.rb | 11 +++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 app/models/ingredient.rb create mode 100644 db/migrate/20200711220238_create_ingredients.rb create mode 100644 test/models/ingredient_test.rb 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/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/schema.rb b/db/schema.rb index becc2f0..d014855 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_220238) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false @@ -33,6 +33,12 @@ t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true 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" 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 From 8fab70fae9271a61728701b615abb3a75e888860 Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 11 Jul 2020 15:22:44 -0700 Subject: [PATCH 2/9] add a model for ingredient quantity --- app/models/ingredient_quantity.rb | 6 ++++++ app/models/recipe.rb | 1 + ...20200711220820_create_ingredient_quantities.rb | 12 ++++++++++++ db/schema.rb | 15 ++++++++++++++- test/models/ingredient_quantity_test.rb | 14 ++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 app/models/ingredient_quantity.rb create mode 100644 db/migrate/20200711220820_create_ingredient_quantities.rb create mode 100644 test/models/ingredient_quantity_test.rb 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/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 d014855..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_07_11_220238) 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,17 @@ 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 @@ -60,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 From 8fd785f0c03affcf4b4ac33b759feebbaa2e49c1 Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 11 Jul 2020 15:27:48 -0700 Subject: [PATCH 3/9] move recipe form to a new folder In preparation for breaking the recipe form up into smaller components, move it to its own folder. --- app/webpack/components/{ => recipe-form}/RecipeForm.js | 2 +- .../components/{ => recipe-form}/RecipeInstructionInput.js | 0 app/webpack/routers/AppRouter.js | 2 +- .../tests/components/{ => recipe-form}/RecipeForm.test.js | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename app/webpack/components/{ => recipe-form}/RecipeForm.js (98%) rename app/webpack/components/{ => recipe-form}/RecipeInstructionInput.js (100%) rename app/webpack/tests/components/{ => recipe-form}/RecipeForm.test.js (93%) diff --git a/app/webpack/components/RecipeForm.js b/app/webpack/components/recipe-form/RecipeForm.js similarity index 98% rename from app/webpack/components/RecipeForm.js rename to app/webpack/components/recipe-form/RecipeForm.js index 574c15a..277784f 100644 --- a/app/webpack/components/RecipeForm.js +++ b/app/webpack/components/recipe-form/RecipeForm.js @@ -4,7 +4,7 @@ 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 { getCroppedImg } from '../../helpers/crop-image'; import RecipeInstructionInput from './RecipeInstructionInput'; const RecipeForm = ({ history }) => { diff --git a/app/webpack/components/RecipeInstructionInput.js b/app/webpack/components/recipe-form/RecipeInstructionInput.js similarity index 100% rename from app/webpack/components/RecipeInstructionInput.js rename to app/webpack/components/recipe-form/RecipeInstructionInput.js 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/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'); From a82b33f4d10d233c896cc9677106c2124a2c6c12 Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 11 Jul 2020 15:42:40 -0700 Subject: [PATCH 4/9] refactor: split api call out for recipe form --- app/webpack/api/v1/recipes.js | 12 ++++++++++ .../components/recipe-form/RecipeForm.js | 22 +++++++++---------- app/webpack/helpers/form-helper.js | 3 +++ 3 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 app/webpack/api/v1/recipes.js create mode 100644 app/webpack/helpers/form-helper.js 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/recipe-form/RecipeForm.js b/app/webpack/components/recipe-form/RecipeForm.js index 277784f..feb5796 100644 --- a/app/webpack/components/recipe-form/RecipeForm.js +++ b/app/webpack/components/recipe-form/RecipeForm.js @@ -1,9 +1,9 @@ -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 { createRecipe } from '../../api/v1/recipes'; import { getCroppedImg } from '../../helpers/crop-image'; import RecipeInstructionInput from './RecipeInstructionInput'; @@ -39,7 +39,7 @@ const RecipeForm = ({ history }) => { const handleFormSubmit = (e) => { e.preventDefault(); - let data = new FormData(); + const data = new FormData(); for (const key in formData) { if (key == 'instructions') { data.append(key, JSON.stringify(formData[key])); @@ -49,16 +49,14 @@ const RecipeForm = ({ history }) => { } 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)); - }; + createRecipe(data) + .then(() => { + history.push('/'); + }) + .catch(error => { + console.error(error); + }); + } // Set the src state when the user selects a file. const onSelectFile = (e) => { 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; +} From 75d5307a5f6ca0ffa7ff07880250593d1bedfcb9 Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 11 Jul 2020 16:41:26 -0700 Subject: [PATCH 5/9] split recipe image cropper into its own file --- .../components/recipe-form/RecipeForm.js | 43 ++------- .../recipe-form/RecipeImageCropper.js | 51 +++++++++++ app/webpack/stylesheets/image_cropper.scss | 26 +++--- .../recipe-form/RecipeImageCropper.test.js | 89 +++++++++++++++++++ 4 files changed, 158 insertions(+), 51 deletions(-) create mode 100644 app/webpack/components/recipe-form/RecipeImageCropper.js create mode 100644 app/webpack/tests/components/recipe-form/RecipeImageCropper.test.js diff --git a/app/webpack/components/recipe-form/RecipeForm.js b/app/webpack/components/recipe-form/RecipeForm.js index feb5796..c0f5737 100644 --- a/app/webpack/components/recipe-form/RecipeForm.js +++ b/app/webpack/components/recipe-form/RecipeForm.js @@ -1,10 +1,9 @@ import PropTypes from 'prop-types'; import React, { useState } from 'react'; import { Button, Col, Form } from 'react-bootstrap'; -import ReactCrop from 'react-image-crop'; import { createRecipe } from '../../api/v1/recipes'; -import { getCroppedImg } from '../../helpers/crop-image'; +import RecipeImageCropper from './RecipeImageCropper'; import RecipeInstructionInput from './RecipeInstructionInput'; const RecipeForm = ({ history }) => { @@ -25,8 +24,6 @@ const RecipeForm = ({ history }) => { }; const [imageSource, setImageSource] = useState(null); - const [cropData, setCropData] = useState({}); - const [imageRef, setImageRef] = useState(); const [croppedImageBlob, setCroppedImageBlob] = useState(); const [formData, setFormData] = useState({ title: '', @@ -56,7 +53,7 @@ const RecipeForm = ({ history }) => { .catch(error => { console.error(error); }); - } + }; // Set the src state when the user selects a file. const onSelectFile = (e) => { @@ -67,28 +64,6 @@ const RecipeForm = ({ history }) => { } }; - 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 (
@@ -149,17 +124,11 @@ const RecipeForm = ({ history }) => { - {imageSource && ( - - )} + } 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/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/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); + }); +}); From f7b39249d49003c028bcb8d655798a0edeec7e13 Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 11 Jul 2020 18:15:18 -0700 Subject: [PATCH 6/9] split photo input element into its own file --- .../components/recipe-form/RecipeForm.js | 16 ++------- .../recipe-form/RecipePhotoUploader.js | 22 ++++++++++++ .../recipe-form/RecipePhotoUploader.test.js | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 app/webpack/components/recipe-form/RecipePhotoUploader.js create mode 100644 app/webpack/tests/components/recipe-form/RecipePhotoUploader.test.js diff --git a/app/webpack/components/recipe-form/RecipeForm.js b/app/webpack/components/recipe-form/RecipeForm.js index c0f5737..9c9ed02 100644 --- a/app/webpack/components/recipe-form/RecipeForm.js +++ b/app/webpack/components/recipe-form/RecipeForm.js @@ -5,6 +5,7 @@ import { Button, Col, Form } from 'react-bootstrap'; import { createRecipe } from '../../api/v1/recipes'; import RecipeImageCropper from './RecipeImageCropper'; import RecipeInstructionInput from './RecipeInstructionInput'; +import RecipePhotoUploader from './RecipePhotoUploader'; const RecipeForm = ({ history }) => { const handleInputChange = (e) => { @@ -55,15 +56,6 @@ const RecipeForm = ({ history }) => { }); }; - // 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]); - } - }; - return ( @@ -115,11 +107,7 @@ const RecipeForm = ({ history }) => { Photo - + 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/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(); + }); +}); From f860ec93b2db51512f99f9f7e1163769e58e996d Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 11 Jul 2020 19:11:19 -0700 Subject: [PATCH 7/9] split recipe instructions into its own component --- .../components/recipe-form/RecipeForm.js | 57 +++++++++---------- .../recipe-form/RecipeInstructionInput.js | 26 --------- .../recipe-form/RecipeInstructions.js | 31 ++++++++++ .../recipe-form/RecipeInstructions.test.js | 48 ++++++++++++++++ 4 files changed, 105 insertions(+), 57 deletions(-) delete mode 100644 app/webpack/components/recipe-form/RecipeInstructionInput.js create mode 100644 app/webpack/components/recipe-form/RecipeInstructions.js create mode 100644 app/webpack/tests/components/recipe-form/RecipeInstructions.test.js diff --git a/app/webpack/components/recipe-form/RecipeForm.js b/app/webpack/components/recipe-form/RecipeForm.js index 9c9ed02..c184868 100644 --- a/app/webpack/components/recipe-form/RecipeForm.js +++ b/app/webpack/components/recipe-form/RecipeForm.js @@ -4,26 +4,10 @@ import { Button, Col, Form } from 'react-bootstrap'; import { createRecipe } from '../../api/v1/recipes'; import RecipeImageCropper from './RecipeImageCropper'; -import RecipeInstructionInput from './RecipeInstructionInput'; +import RecipeInstructions from './RecipeInstructions'; import RecipePhotoUploader from './RecipePhotoUploader'; 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 [croppedImageBlob, setCroppedImageBlob] = useState(); const [formData, setFormData] = useState({ @@ -34,6 +18,26 @@ const RecipeForm = ({ history }) => { image: '' }); + 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 handleFormSubmit = (e) => { e.preventDefault(); @@ -90,20 +94,11 @@ const RecipeForm = ({ history }) => { Instructions -
    - { - formData.instructions.map((value, index) => { - return ( - - ); - }) - } -
- +
Photo diff --git a/app/webpack/components/recipe-form/RecipeInstructionInput.js b/app/webpack/components/recipe-form/RecipeInstructionInput.js deleted file mode 100644 index d36ef86..0000000 --- a/app/webpack/components/recipe-form/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/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/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 From f83256a47b0cec8e43fa5efa28e97eb18adbdc44 Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 11 Jul 2020 20:22:35 -0700 Subject: [PATCH 8/9] add ingredients to recipe form --- .../components/recipe-form/RecipeForm.js | 46 +++++++- .../recipe-form/RecipeIngredients.js | 77 +++++++++++++ .../recipe-form/RecipeIngredients.test.js | 105 ++++++++++++++++++ 3 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 app/webpack/components/recipe-form/RecipeIngredients.js create mode 100644 app/webpack/tests/components/recipe-form/RecipeIngredients.test.js diff --git a/app/webpack/components/recipe-form/RecipeForm.js b/app/webpack/components/recipe-form/RecipeForm.js index c184868..c5a5c54 100644 --- a/app/webpack/components/recipe-form/RecipeForm.js +++ b/app/webpack/components/recipe-form/RecipeForm.js @@ -4,6 +4,7 @@ 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'; @@ -14,8 +15,8 @@ const RecipeForm = ({ history }) => { title: '', description: '', link: '', + ingredients: [], instructions: [''], - image: '' }); const handleInputChange = ({ target: { name: field, value } }) => { @@ -38,18 +39,48 @@ const RecipeForm = ({ history }) => { })); }; + 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 (key == 'instructions') { + if (['instructions', 'ingredients'].includes(key)) { data.append(key, JSON.stringify(formData[key])); } else { data.append(key, formData[key]); } } - data.append('image', croppedImageBlob); + + if (croppedImageBlob) { + data.append('image', croppedImageBlob); + } createRecipe(data) .then(() => { @@ -92,6 +123,15 @@ const RecipeForm = ({ history }) => { onChange={handleInputChange}>
    + + Ingredients + + Instructions ( + <> + + {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/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 From 40816340828a47a6758b631cad598332eac5cf3e Mon Sep 17 00:00:00 2001 From: Kevin Kirchhoff Date: Sat, 1 Aug 2020 14:43:30 -0700 Subject: [PATCH 9/9] create ingredients when form is submitted --- app/controllers/api/v1/recipes_controller.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) 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