diff --git a/README.md b/README.md index 7435989..3e81b47 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,69 @@ -# Pick your own project - -Build an app of your choice. - -- You are free to use any technologies covered so far, but feel free to try new technologies you find interesting. -- Keep it simple. Aim to get the basic functionality working on day one. You can then extend it on days two and three. -- Feel free to use an external API to provide additional functionality to your app. Use an API that either does not have any authentication or uses API keys. -- This is an opportunity to practice the parts you have challenging so far and improve your understanding of them. -- Use pen and paper to draw a diagram of the webpage layout before starting to code. Have a think about what components you will need in advance. -- Use prop-types and stateless components where appropriate. -- Try to use Sass to create a separate stylesheet for each component. -- Try to add some unit testing. Some parts will be easier to test than others, focus on those first. -- Think about how to organise your data in advance -- Make sure your app is responsive -- Commit frequently -- Create pull request at the end -- Demos will be at 4pm on Friday -- Keep it simple - -## Technical notes - -* Run `npm install` after cloning to download all dependencies -* Use `npm run dev -- --watch` to build React -* Use `npm test` or `npm test -- --watchAll` to run tests - -## README - -* Produce a README.md which explains - * what the project does - * what technologies it uses - * how to build it and run it - * any unresolved issues the user should be aware of - -## Inspiration - -- Take a look at [https://public-apis.jeremyfairbank.com/](https://public-apis.jeremyfairbank.com/) or [https://github.com/toddmotto/public-apis](https://github.com/toddmotto/public-apis) for possible APIs to use. - -## Default option - -If you are struggling to think of a project to build. Try to create a Top Trumps using the [Star Wars API](https://swapi.co/) which allows one user to play the game against the computer. - -- On load, fetch all vehicles from [https://swapi.co/api/vehicles/](https://swapi.co/api/vehicles/) end point. -- Randomise the cards and deal half to player and half to computer. -- Display top card to user -- Allow user to pick an attribute from their card such as `cost_in_credits`, `length`, `max_atmosphering_speed`, `crew`, `passengers`, `cargo_capacity`. -- If the value for chosen attribute is higher on the user's card than on computer's top card, they win the computer's card and it should be taken from computer's deck and added to the bottom of the user's deck. If the attribute is higher on the computer's top card, then user's card should be taken from the user's deck and added to the bottom of computer's deck. -- Game continues until either user or computer has all the cards. -- Implement some features of your choosing. +# Fridge + + + + +## About + +Project brief was to create our own project using the technologies learned so far. I have decided to make a web app utilising React and the Edamam API to create a recipe +finder. You add items to your fridge/stock and are able to find recipes using these ingredients. + +# Hosted on surge + +http://fridge-fetch.surge.sh/ + +Note: This app has been designed with mobile-use in mind. It is reccomended users use dev tools to simulate a mobile browser view. + + +## Running from source + +* Clone this repository + +* Run `npm install` to install dependencies. + +* Run `npm run dev` to build with webpack + +* Access localhost:8080 to view + +Note: This app has been designed with mobile-use in mind. It is reccomended users use dev tools to simulate a mobile browser view. + +## Features + +* Ingredients in stock are stored in local storage. + +* Select any number of stocked ingredients and return recipes using those ingredients. + +* Recipe display looks at your entire stock (ingredients need not be selected) and tells you how many of the required ingredients you have. Clicking on this text reveals an easy to read display - ingredients not in stock are in red, in stock are in green. + +* Loading screen due to slow response from API + +* Favourites menu + +## Implementation + +* Written with React. I have made some components stateless, but this requires further work. +* Some testing is in place using Enzyme. +* All styles are written with .scss, utilising nesting and importing colour sheets + +## Known issues + +* If you have "chicken" in stock, ingredients such as "chicken soup" will be marked as in stock. This could be fixed with: + * Using some sort of conditional list, to specify if stock words are followed by another particular word then they are not relevant (i.e if "rice" followed by "wine") + * Using Edamam API's Ingredient/nutrition system. This requires further reading as to whether this would be suitable + +* RecipeIngredientDisplay does not have keys for individual ingredients + +* You can currently add anything to the fridge regardless of whether it is a known ingredient or not. My QA team (i.e. friends) have already informed me there are no recipes from the API that list "smegma" as an ingredient. + +* No desktop/tablet design + + +## Future Plans + +* I would like to put some further work into the ingredient adding side of things, such that they could be added with measurements, and perhaps picked from a dropdown list to save generic ingredients such as "chicken" being added (i.e specify chicken breast/thigh). + * Similarly, a mass ingredient add tool would help save time if user had just completed a big shop + + +* If measurement of ingredient was in place, I would like to implement a "cook" button that subtracted the amount of an ingredient specified by recipe from the user's stock list. + +* More information regarding nutritional and dietary considerations on each recipe, more information regarding cooking time and servings made (all in standard API response) diff --git a/images/ajax-loader.gif b/images/ajax-loader.gif new file mode 100644 index 0000000..3f675ae Binary files /dev/null and b/images/ajax-loader.gif differ diff --git a/images/chef-icon.svg b/images/chef-icon.svg new file mode 100644 index 0000000..8b73894 --- /dev/null +++ b/images/chef-icon.svg @@ -0,0 +1,13 @@ + + + + background + + + + Layer 1 + + + + + \ No newline at end of file diff --git a/images/chef_icon.xcf b/images/chef_icon.xcf new file mode 100644 index 0000000..2d75b24 Binary files /dev/null and b/images/chef_icon.xcf differ diff --git a/index.html b/index.html index f8102eb..3547fd6 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,9 @@ - YouJuke + + + Fridge
diff --git a/jest.config.js b/jest.config.js index 6643d7e..65e7511 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { "testURL": "http://localhost/", setupFiles: [ - '/tests/setup.js' + '/tests/setup.js', "jest-localstorage-mock" ], "moduleNameMapper": { "^.+\\.(css|less|scss)$": "identity-obj-proxy" diff --git a/package-lock.json b/package-lock.json index a9fe824..719fa3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,9 +62,9 @@ "dev": true }, "@types/node": { - "version": "9.4.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-9.4.6.tgz", - "integrity": "sha512-CTUtLb6WqCCgp6P59QintjHWqzf4VL1uPA27bipLAPxFqrtK1gEYllePzTICGqQ8rYsCbpnsNypXjjDzGAAjEQ==", + "version": "10.11.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.11.4.tgz", + "integrity": "sha512-ojnbBiKkZFYRfQpmtnnWTMw+rzGp/JiystjluW9jgN3VzRwilXddJ6aGQ9V/7iuDG06SBgn7ozW9k3zcAnYjYQ==", "dev": true }, "@webassemblyjs/ast": { @@ -546,6 +546,17 @@ "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", "dev": true }, + "array.prototype.flat": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.1.tgz", + "integrity": "sha512-rVqIs330nLJvfC7JqYvEWwqVr5QjYF1ib02i3YJtR/fICO6527Tjpc/e4Mvmxh3GIePPreRXMdaGyC99YphWEw==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.10.0", + "function-bind": "^1.1.1" + } + }, "arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", @@ -1624,7 +1635,6 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", - "dev": true, "requires": { "core-js": "^2.4.0", "regenerator-runtime": "^0.11.0" @@ -1633,8 +1643,7 @@ "core-js": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.3.tgz", - "integrity": "sha1-isw4NFgk8W2DZbfJtCWRaOjtYD4=", - "dev": true + "integrity": "sha1-isw4NFgk8W2DZbfJtCWRaOjtYD4=" } } }, @@ -2427,7 +2436,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -2448,12 +2458,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2468,17 +2480,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -2595,7 +2610,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -2607,6 +2623,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -2621,6 +2638,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -2628,12 +2646,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -2652,6 +2672,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -2732,7 +2753,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -2744,6 +2766,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -2829,7 +2852,8 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -2865,6 +2889,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2884,6 +2909,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2927,12 +2953,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -4163,9 +4191,9 @@ } }, "domhandler": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.1.tgz", - "integrity": "sha1-iS5HAAqZvlW783dP/qBWHYh5wlk=", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", "dev": true, "requires": { "domelementtype": "1" @@ -4290,27 +4318,47 @@ "dev": true }, "enzyme": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.3.0.tgz", - "integrity": "sha512-l8csyPyLmtxskTz6pX9W8eDOyH1ckEtDttXk/vlFWCjv00SkjTjtoUrogqp4yEvMyneU9dUJoOLnqFoiHb8IHA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.6.0.tgz", + "integrity": "sha512-onsINzVLGqKIapTVfWkkw6bYvm1o4CyJ9s8POExtQhAkVa4qFDW6DGCQGRy/5bfZYk+gmUbMNyayXiWDzTkHFQ==", "dev": true, "requires": { + "array.prototype.flat": "^1.2.1", "cheerio": "^1.0.0-rc.2", - "function.prototype.name": "^1.0.3", - "has": "^1.0.1", + "function.prototype.name": "^1.1.0", + "has": "^1.0.3", "is-boolean-object": "^1.0.0", - "is-callable": "^1.1.3", + "is-callable": "^1.1.4", "is-number-object": "^1.0.3", "is-string": "^1.0.4", "is-subset": "^0.1.1", - "lodash": "^4.17.4", - "object-inspect": "^1.5.0", + "lodash.escape": "^4.0.1", + "lodash.isequal": "^4.5.0", + "object-inspect": "^1.6.0", "object-is": "^1.0.1", "object.assign": "^4.1.0", "object.entries": "^1.0.4", "object.values": "^1.0.4", "raf": "^3.4.0", - "rst-selector-parser": "^2.2.3" + "rst-selector-parser": "^2.2.3", + "string.prototype.trim": "^1.1.2" + }, + "dependencies": { + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "is-callable": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", + "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", + "dev": true + } } }, "enzyme-adapter-react-16": { @@ -6841,6 +6889,12 @@ "pretty-format": "^22.4.0" } }, + "jest-localstorage-mock": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.2.0.tgz", + "integrity": "sha512-x+P0vcwr4540bCAYzTEpiD9rs+zh/QZzyiABV+MU6yM2OPwPlrrLyUx/6gValMyt6tg5lX6Z53o2rHWfUht5Xw==", + "dev": true + }, "jest-matcher-utils": { "version": "22.4.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-22.4.0.tgz", @@ -7694,12 +7748,24 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=", + "dev": true + }, "lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", + "dev": true + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8230,6 +8296,12 @@ "minimist": "0.0.8" } }, + "moo": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.4.3.tgz", + "integrity": "sha512-gFD2xGCl8YFgGHsqJ9NKRVdwlioeW3mI1iqfLNYQOv0+6JRwG58Zk9DIGQgyIaffSYaO1xsKnMaYzzNr1KyIAw==", + "dev": true + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -8322,11 +8394,12 @@ "dev": true }, "nearley": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.13.0.tgz", - "integrity": "sha512-ioYYogSaZhFlCpRizQgY3UT3G1qFXmHGY/5ozoFE3dMfiCRAeJfh+IPE3/eh9gCZvqLhPCWb4bLt7Bqzo+1mLQ==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.15.1.tgz", + "integrity": "sha512-8IUY/rUrKz2mIynUGh8k+tul1awMKEjeHHC5G3FHvvyAW6oq4mQfNp2c0BMea+sYZJvYcrrM6GmZVIle/GRXGw==", "dev": true, "requires": { + "moo": "^0.4.3", "nomnom": "~1.6.2", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6", @@ -8665,9 +8738,9 @@ "dev": true }, "object-inspect": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.5.0.tgz", - "integrity": "sha512-UmOFbHbwvv+XHj7BerrhVq+knjceBdkvU5AriwLMvhv2qi+e7DJzxfBeFpILEjVzCp+xA+W/pIf06RGPWlZNfw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", + "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==", "dev": true }, "object-is": { @@ -10040,6 +10113,26 @@ "prop-types": "^15.6.0" } }, + "react-loader-spinner": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/react-loader-spinner/-/react-loader-spinner-2.0.6.tgz", + "integrity": "sha512-/8VNhfjI0iDk1sm9WWMm1ieFD/1bHmXMVrSx1Hx254qrQKsBk6EEK09emxGUQdvUsz87i54AjPLMdI0qEB4wZQ==", + "requires": { + "babel-runtime": "^6.6.1", + "prop-types": "^15.6.2" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } + } + }, "react-reconciler": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.7.0.tgz", @@ -10249,8 +10342,7 @@ "regenerator-runtime": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", - "dev": true + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" }, "regenerator-transform": { "version": "0.10.1", @@ -10899,7 +10991,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -10920,12 +11013,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -10940,12 +11035,14 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", @@ -11079,6 +11176,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -11093,6 +11191,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -11100,7 +11199,8 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", @@ -11204,7 +11304,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -11356,6 +11457,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -12248,6 +12350,17 @@ } } }, + "string.prototype.trim": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz", + "integrity": "sha1-0E3iyJ4Tf019IG8Ia17S+ua+jOo=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.5.0", + "function-bind": "^1.0.2" + } + }, "string_decoder": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", @@ -12987,10 +13100,9 @@ } }, "uuid": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", - "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==", - "dev": true + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" }, "v8-compile-cache": { "version": "1.1.2", diff --git a/package.json b/package.json index 02dece7..965931e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "classnames": "^2.2.6", "prop-types": "^15.6.1", "react": "^16.2.0", - "react-dom": "^16.2.0" + "react-dom": "^16.2.0", + "react-loader-spinner": "^2.0.6", + "uuid": "^3.3.2" }, "devDependencies": { "babel-jest": "^22.4.1", @@ -28,7 +30,7 @@ "babel-preset-env": "^1.6.1", "babel-preset-react": "^6.24.1", "css-loader": "^0.28.11", - "enzyme": "^3.3.0", + "enzyme": "^3.6.0", "enzyme-adapter-react-16": "^1.1.1", "eslint": "^4.18.2", "eslint-loader": "^2.0.0", @@ -36,6 +38,7 @@ "identity-obj-proxy": "^3.0.0", "jest": "^22.4.2", "jest-fetch-mock": "^1.6.6", + "jest-localstorage-mock": "^2.2.0", "node-sass": "^4.9.1", "react-test-renderer": "^16.2.0", "sass-loader": "^7.0.3", diff --git a/src/components/App.js b/src/components/App.js index a4aaba8..addaf72 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -1,4 +1,14 @@ +//Application ID: cc90edfa +//Application Key: 3ccb0e8ad4f72be21ef79e0df985a0e +//Example: https://api.edamam.com/search?q=chicken&app_id=cc90edfa&app_key=3ccb0e8ad4f72be21ef79e0df985a0e + + import React from 'react'; +import Header from './Header.js' +import IngredientAdd from './IngredientAdd.js' +import Fridge from "./Fridge.js" +import SearchRecipes from "./SearchRecipes.js" +import Loading from './Loading.js' import '../styles/components/app.scss'; @@ -6,12 +16,148 @@ class App extends React.Component { constructor(){ super(); + + this.state = { + + display: 'fridge', // 'fridge' or 'recipes' or 'favourites' + + stock: [], + + activeIngredients: [], + + searchResults: [], + + loading: false, + + favourites: [], + + page: 0 + + } + + this.retrieveItem = this.retrieveItem.bind(this) + this.removeItem = this.removeItem.bind(this) + this.retrievePage = this.retrievePage.bind(this) + this.fetchRecipes = this.fetchRecipes.bind(this) + this.changeDisplay = this.changeDisplay.bind(this) } + + +componentDidMount(){ + const currentStock = window.localStorage.getItem('stock'); + const stockArray = currentStock ? JSON.parse(currentStock) : []; + const currentFaves = window.localStorage.getItem('favourites') + const favesArray = currentFaves ? JSON.parse(currentFaves) : []; + this.setState({ + stock: stockArray, + favourites: favesArray + }); +} + + changeDisplay(displayType){ + this.setState({ + display: displayType + }) + } + + retrievePage(page){ + this.setState({ + page + }) + } + + + + retrieveItem(ingredient, property){ + const newList = this.state[property].concat(ingredient); + + this.setState({ + [property]: newList + }); + if(property !== 'activeIngredients'){ + window.localStorage.setItem(property, JSON.stringify(newList)) + } + } + + removeItem(ingredient, property){ + let newList; + property !== 'favourites' + ? newList = this.state[property].filter(item => item.key !== ingredient.key) + : newList = this.state[property].filter(item => item.recipe.uri !== ingredient.recipe.uri) + this.setState({ + [property]: newList + }) + if(property !== 'activeIngredients'){ + window.localStorage.setItem(property, JSON.stringify(newList)) + } + + } + + fetchRecipes(page = 0){ + const searchString = this.state.activeIngredients.map(item => item.ingredient) + .join(",") + this.setState({ + loading: true + }) + return fetch(`https://api.edamam.com/search?q=${searchString}&from=${page}&app_id=2d8fec19&app_key=483c6a76cb6386a4eaf149a5505868b8`) + .then(response => response.json()) + .then(body => { + this.setState({ + searchResults: body.hits, + loading: false + + }) + }) + } + + + render(){ return (
- App goes here +
+ {this.state.display === 'fridge' + ?( + + + ) + : null } + + {this.state.loading === true + ? + : null + } + + {this.state.display === 'recipes' && this.state.loading === false + ? + : null} + + {this.state.display === 'favourites' + ? + : null + } +
) } diff --git a/src/components/Fridge.js b/src/components/Fridge.js new file mode 100644 index 0000000..021ec45 --- /dev/null +++ b/src/components/Fridge.js @@ -0,0 +1,48 @@ +import React from "react" +import FridgeItem from "./FridgeItem.js" + +import '../styles/components/Fridge.scss'; + +class Fridge extends React.Component{ + constructor(){ + super() + + this.handleClick = this.handleClick.bind(this) + } + + + handleClick(event){ + this.props.fetchRecipes() + this.props.changeDisplay('recipes') + } + + render(){ + return( +
+
    + {this.props.stock.map(item => { + const isSelected = this.props.activeIngredients.find(ingredient => ingredient.key === item.key); + return ( + + ) + })} + {this.props.stock.length === 0 + ?

    No food? Try adding ingredients above

    + : + + } + + + +
+
+ ) + } + +} + +export default Fridge; diff --git a/src/components/FridgeItem.js b/src/components/FridgeItem.js new file mode 100644 index 0000000..9405277 --- /dev/null +++ b/src/components/FridgeItem.js @@ -0,0 +1,44 @@ +import React from "react" +import cx from "classnames" + +import '../styles/components/FridgeItem.scss' + +class FridgeItem extends React.Component{ + constructor(){ + super() + + + this.handleClick = this.handleClick.bind(this) + this.handleRemove = this.handleRemove.bind(this) + } + + + + handleClick(event){ + if (!this.props.isSelected){ + this.props.retrieveItem(this.props.item, "activeIngredients") + }else{ + this.props.removeItem(this.props.item, "activeIngredients") + } + } + + handleRemove(event){ + event.preventDefault() + this.props.removeItem(this.props.item, "stock") + } + + + render(){ + const classes = cx({ + 'fridge-item': !this.props.isSelected, + 'fridge-item--selected': this.props.isSelected + + }) + + return( +
  • {this.props.item.ingredient}
  • + ) + } +} + +export default FridgeItem diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 0000000..c48a54e --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,40 @@ +import React from 'react' +import '../styles/components/Header.scss' + +class Header extends React.Component{ + constructor(){ + super() + + this.returnFridge = this.returnFridge.bind(this) + this.returnFavourites = this.returnFavourites.bind(this) + } + + returnFridge(event){ + event.preventDefault() + this.props.changeDisplay('fridge') + } + + returnFavourites(event){ + event.preventDefault() + this.props.changeDisplay('favourites') + } + + + render(){ + + return ( +
    +

    Fridge

    +
    + My
    Fridge
    + Saved
    Recipes
    + +
    +
    + ) + } + +} + + +export default Header diff --git a/src/components/IndividualRecipe.js b/src/components/IndividualRecipe.js new file mode 100644 index 0000000..df88c68 --- /dev/null +++ b/src/components/IndividualRecipe.js @@ -0,0 +1,84 @@ +import React from "react" +import '../styles/components/IndividualRecipe.scss' +import RecipeIngredientDisplay from './RecipeIngredientDisplay.js' + + +class IndividualRecipe extends React.Component{ + constructor(){ + super() + + this.checkAvailableIngredients = this.checkAvailableIngredients.bind(this) + this.displayIngredients = this.displayIngredients.bind(this) + this.handleFave = this.handleFave.bind(this) + this.removeFave = this.removeFave.bind(this) + + this.state = { + matchingIngredients: [], + displayIngredients: false + + + } + + + } + + componentDidMount(){ + this.checkAvailableIngredients() + } + +//may not work when I put proper ingredients in + checkAvailableIngredients(){ + const recipeIngredients = this.props.recipe.ingredients + const ingredientsInStock = this.props.stock + const matchingIngredients = recipeIngredients.filter(item => { + return ingredientsInStock.some(stockItem => { + return item.text.toLowerCase().includes(stockItem.ingredient.toLowerCase()) + }) + }) + this.setState({ + matchingIngredients + }) + } + + displayIngredients(){ + this.setState({ + displayIngredients: !this.state.displayIngredients + }) + } + + handleFave(event){ + event.preventDefault() + this.props.retrieveItem(this.props.entireRecipe, "favourites") + } + + removeFave(event){ + event.preventDefault() + this.props.removeItem(this.props.entireRecipe, "favourites") + } + + render(){ + return ( +
    + +
    +
    {this.props.recipe.label}
    + + See Recipe + {this.props.favourites.some(fave => fave.recipe.label === this.props.recipe.label) + ? + : + } + + {this.state.matchingIngredients.length} out of {this.props.recipe.ingredients.length} ingredients + {this.state.displayIngredients ? : null} +
    + +
    + ) + } +} + +export default IndividualRecipe diff --git a/src/components/IngredientAdd.js b/src/components/IngredientAdd.js new file mode 100644 index 0000000..62d92e7 --- /dev/null +++ b/src/components/IngredientAdd.js @@ -0,0 +1,55 @@ +import React from "react"; + +import '../styles/components/IngredientAdd.scss'; +const uuidv4 = require('uuid/v4'); + +class IngredientAdd extends React.Component{ + constructor(){ + super() + + this.state = { + text: "" + } + + this.handleSubmit = this.handleSubmit.bind(this) + this.handleChange = this.handleChange.bind(this) + } + + handleSubmit(event){ + event.preventDefault() + const ingredientObject = {ingredient: this.state.text, + key: uuidv4()} + this.props.retrieveItem(ingredientObject, 'stock') + this.setState({ + text: "" + }) + + + } + + handleChange(event){ + this.setState({ + text: event.target.value + }) + + } + + + render(){ + return( + + +
    +

    Been Shopping?

    +
    + + +
    +
    + + ) + } +} + + +export default IngredientAdd diff --git a/src/components/Loading.js b/src/components/Loading.js new file mode 100644 index 0000000..1bed864 --- /dev/null +++ b/src/components/Loading.js @@ -0,0 +1,16 @@ +import React from 'react' + +import '../styles/components/Loading.scss'; + +function Loading(props){ + return ( +
    + + +

    Finding Recipes...

    +
    + ) +} + + +export default Loading diff --git a/src/components/RecipeIngredientDisplay.js b/src/components/RecipeIngredientDisplay.js new file mode 100644 index 0000000..2388022 --- /dev/null +++ b/src/components/RecipeIngredientDisplay.js @@ -0,0 +1,24 @@ +import React from "react" +import '../styles/components/RecipeIngredientDisplay.scss' + +class RecipeIngredientDisplay extends React.Component{ + constructor(){ + super() + } + + render(){ + return ( +
    + {this.props.recipeIngredients.map(item => { + if (this.props.matchingIngredients.some(arrVal => item === arrVal)){ + return
  • {item.text}
  • + }else{ + return
  • {item.text}
  • + } + })} +
    + ) + } +} + +export default RecipeIngredientDisplay; diff --git a/src/components/SearchRecipes.js b/src/components/SearchRecipes.js new file mode 100644 index 0000000..d007fa3 --- /dev/null +++ b/src/components/SearchRecipes.js @@ -0,0 +1,54 @@ +import React from "react" +import IndividualRecipe from "./IndividualRecipe.js" +// import Loader from 'react-loader-spinner' + +import '../styles/components/SearchRecipes.scss' + + +class SearchRecipes extends React.Component{ + constructor(){ + super() + + + this.handlePagination= this.handlePagination.bind(this) + + } + + + handlePagination(event){ + event.preventDefault() + let page; + event.target.value === 'next' + ? page = this.props.page + 20 + : page = this.props.page - 20 + this.props.retrievePage(page, "page") + this.props.fetchRecipes(page) + } + + render(){ + return ( + +
    + {this.props.recipeResults.map(result => { + return + })} + {this.props.page > 0 + ? + : null + } + + +
    + ) + } +} + + +export default SearchRecipes; diff --git a/src/styles/_colours.scss b/src/styles/_colours.scss new file mode 100644 index 0000000..ea6c781 --- /dev/null +++ b/src/styles/_colours.scss @@ -0,0 +1,5 @@ +$dark-color: #827D72; +$header-blue: #87D4DE; +$app-grey: #D4DBE1; +$accent-orange: #FBB333; +$off-white: #F7F8FE; diff --git a/src/styles/components/Fridge.scss b/src/styles/components/Fridge.scss new file mode 100644 index 0000000..26dcf44 --- /dev/null +++ b/src/styles/components/Fridge.scss @@ -0,0 +1,22 @@ +@import '../colours'; +.fridge{ + background-color: $app-grey; + height: 70vh; + padding: 2rem; + padding-top: 1rem; + margin: 0rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + border-right: 2px solid $accent-orange; + border-left: 2px solid $accent-orange; + + + &__item-container{ + padding-left: 0px; + } + + &__no-items{ + text-align: center; + } +} diff --git a/src/styles/components/FridgeItem.scss b/src/styles/components/FridgeItem.scss new file mode 100644 index 0000000..be7232f --- /dev/null +++ b/src/styles/components/FridgeItem.scss @@ -0,0 +1,34 @@ +@import '../colours'; + +.fridge-item{ + border: 2px solid $dark-color; + border-radius: 10%; + color: $dark-color; + background-color: $off-white; + list-style: none; + text-align: center; + padding: 0.5rem; + + + + + &--selected{ + list-style: none; + border: 2px solid $dark-color; + border-radius: 10%; + color: $dark-color; + background-color: $accent-orange; + list-style: none; + padding: 0.5rem; + text-align: center; + + } + + &__remove{ + float: right; + + + + } + +} diff --git a/src/styles/components/Header.scss b/src/styles/components/Header.scss new file mode 100644 index 0000000..06c5e35 --- /dev/null +++ b/src/styles/components/Header.scss @@ -0,0 +1,44 @@ +@import '../colours'; + +.header{ + background-color: $header-blue; + display: flex; + flex-direction: row; + border-bottom: 4px solid white; + + &__logo-text{ + font-family: 'Archivo Black', sans-serif; + margin-left: 2rem; + + } + + &__nav{ + display: flex; + flex-direction: row; + justify-content: flex-end; + margin-left: 3rem; + + + + &-link{ + display:inline-block; + padding:0.3em 1.2em; + margin:0 0.3em 0.3em 0; + border-radius:2em; + box-sizing: border-box; + text-decoration:none; + font-family:'Roboto',sans-serif; + font-weight:300; + font-size: 0.8rem; + color:#FFFFFF; + background-color:#4eb5f1; + text-align:center; + transition: all 0.2s; + + &:hover{ + background-color:#4095c6; + } + } + + } + } diff --git a/src/styles/components/IndividualRecipe.scss b/src/styles/components/IndividualRecipe.scss new file mode 100644 index 0000000..6e85131 --- /dev/null +++ b/src/styles/components/IndividualRecipe.scss @@ -0,0 +1,37 @@ +@import '../colours'; + +.individual-recipe{ + display: flex; + flex-direction: row; + border: 2px $header-blue solid; + margin: 1rem; + + &__image{ + width: 40%; + height: 40%; + border: 4px solid $accent-orange; + border-radius: 15%; + margin: 1rem; + } + + &__info{ + display: flex; + flex-direction: column; + align-self: center; + background-color: $off-white; + } + + &__recipe-bar{ + + } +} + + +.fa-star{ + margin-left: 1rem; + font-size: 1.5rem; +} + +.faved{ + color: gold; +} diff --git a/src/styles/components/IngredientAdd.scss b/src/styles/components/IngredientAdd.scss new file mode 100644 index 0000000..0b679f0 --- /dev/null +++ b/src/styles/components/IngredientAdd.scss @@ -0,0 +1,17 @@ +@import '../colours'; + +.ingredient-add{ + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + background-color: $app-grey; + margin: 1rem; + padding: 1rem 1rem 2rem 1rem; + text-align: center; + + + &__form{ + display: flex; + } +} diff --git a/src/styles/components/Loading.scss b/src/styles/components/Loading.scss new file mode 100644 index 0000000..4d1f560 --- /dev/null +++ b/src/styles/components/Loading.scss @@ -0,0 +1,17 @@ +@import '../colours'; + +.loading{ + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 80vh; + + &__spinner{ + align-self: center; + } + + &__text{ + color: $accent-orange; + } +} diff --git a/src/styles/components/RecipeIngredientDisplay.scss b/src/styles/components/RecipeIngredientDisplay.scss new file mode 100644 index 0000000..ff0f71b --- /dev/null +++ b/src/styles/components/RecipeIngredientDisplay.scss @@ -0,0 +1,11 @@ +.recipe-ingredient{ + border: 1px solid black; + background-color: red; + list-style: none; + + &--in-stock{ + border: 1px solid black; + background-color: green; + list-style: none; + } +} diff --git a/src/styles/components/SearchRecipes.scss b/src/styles/components/SearchRecipes.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/styles/components/app.scss b/src/styles/components/app.scss index 0bdf5c0..8c34f28 100644 --- a/src/styles/components/app.scss +++ b/src/styles/components/app.scss @@ -1,3 +1,6 @@ +@import '../colours'; + body { - background-color: Cornsilk; + background-color: $off-white; + font-family: 'Roboto Condensed', sans-serif; } diff --git a/tests/components/App.test.js b/tests/components/App.test.js index 57e3b63..8769708 100644 --- a/tests/components/App.test.js +++ b/tests/components/App.test.js @@ -1,10 +1,49 @@ import React from 'react'; import App from '../../src/components/App'; import renderer from 'react-test-renderer'; +import { shallow } from 'enzyme' + +global.fetch = require('jest-fetch-mock') describe('App', () => { - test('matches the snapshot', () => { - const tree = renderer.create().toJSON(); - expect(tree).toMatchSnapshot(); - }); + beforeEach(() => { + fetch.resetMocks() + }) + + test('initial state is correct', () => { + const wrapper = shallow() + const state = wrapper.state() + expect(state.activeIngredients).toEqual([]) + expect(state.searchResults).toEqual([]) + }) + + + test('retrieveIngredients adds ingredient to correct state property', () =>{ + const ingredient = 'apple' + const wrapper = shallow() + const instance = wrapper.instance() + instance.retrieveItem(ingredient, 'stock') + const stock = wrapper.state('stock') + expect(stock).toEqual([ingredient]) + }) + + + test('fetchRecipes sets search results in state', () => { + const RECIPES = [ + {recipe: 1}, + {recipe: 2} + ] + + fetch.mockResponse( + JSON.stringify({hits: RECIPES}) + ) + + const wrapper = shallow() + const instance = wrapper.instance() + instance.fetchRecipes() + .then(()=>{ + const searchResults = wrapper.state('searchResults') + expect(searchResults).toEqual(RECIPES) + }) + }) }); diff --git a/tests/components/IngredientAdd.test.js b/tests/components/IngredientAdd.test.js new file mode 100644 index 0000000..a951238 --- /dev/null +++ b/tests/components/IngredientAdd.test.js @@ -0,0 +1,31 @@ +import React from 'react'; +import IngredientAdd from '../../src/components/IngredientAdd'; +import renderer from 'react-test-renderer'; +import { shallow } from 'enzyme' + + +describe('IngredientAdd', () => { + test('handleSubmit passes text input', () => { + const mockTextInput = 'apple' + const submitEvent = { + preventDefault: jest.fn() + } + + const changeEvent = { + target: { + value: mockTextInput + } + } + + const mockRetrieveIngredient = jest.fn() + + const wrapper = shallow() + wrapper.find('input').simulate("change", changeEvent) + wrapper.find('form').simulate("submit", submitEvent) + // expect() + + expect(submitEvent.preventDefault).toHaveBeenCalled() + expect(mockRetrieveIngredient.mock.calls[0][0]).toHaveProperty('ingredient', mockTextInput) + + }) +})