diff --git a/.browserslistrc b/.browserslistrc
new file mode 100644
index 0000000..214388f
--- /dev/null
+++ b/.browserslistrc
@@ -0,0 +1,3 @@
+> 1%
+last 2 versions
+not dead
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..c24743d
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,7 @@
+[*.{js,jsx,ts,tsx,vue}]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true
+max_line_length = 100
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..d2f00e3
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,22 @@
+module.exports = {
+ root: true,
+ env: {
+ node: true
+ },
+ extends: ["plugin:vue/essential", "plugin:prettier/recommended", "eslint:recommended"],
+ parserOptions: {
+ parser: "babel-eslint"
+ },
+ rules: {
+ "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
+ "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off"
+ },
+ overrides: [
+ {
+ files: ["**/__tests__/*.{j,t}s?(x)", "**/tests/unit/**/*.spec.{j,t}s?(x)"],
+ env: {
+ jest: true
+ }
+ }
+ ]
+};
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4ec8281
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+.DS_Store
+node_modules
+/dist
+
+/tests/e2e/videos/
+/tests/e2e/screenshots/
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..6665a53
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+13.12.0
diff --git a/INSTALL.md b/INSTALL.md
new file mode 100644
index 0000000..3c9ecc6
--- /dev/null
+++ b/INSTALL.md
@@ -0,0 +1,39 @@
+# fruit-project
+
+## Project setup
+```
+yarn install
+```
+
+### Compiles and hot-reloads for development
+```
+yarn serve
+```
+
+### Compiles and minifies for production
+```
+yarn build
+```
+
+### Run your unit tests
+```
+yarn test:unit
+```
+
+### Run your end-to-end tests
+```
+yarn test:e2e
+```
+
+### Lints and fixes files
+```
+yarn lint
+```
+
+### Run fruit-api
+```
+yarn run api
+```
+
+### Customize configuration
+See [Configuration Reference](https://cli.vuejs.org/config/).
diff --git a/README.md b/README.md
index 8fd8899..0232699 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+[](https://app.netlify.com/sites/elated-raman-ffa420/deploys)
# fruit-project
A test project for Cycloid.io
@@ -10,26 +11,57 @@ You've been given the task of creating a simple application with create/read/del
## Task
- [x] Create a private repository on Github.
-- [ ] Create a new Vue project using Vue-CLI within the new repository: you may use any preprocessors, testing frameworks, linting etc that you like.
-- [ ] Create a branch off of master, commit all your work to this new branch.
-- [ ] When you are finished, open a PR to master (but do not merge it). In the PR description, copy, paste and answer the following:
+- [x] Create a new Vue project using Vue-CLI within the new repository: you may use any preprocessors, testing frameworks, linting etc that you like.
+- [x] Create a branch off of master, commit all your work to this new branch.
+- [x] When you are finished, open a PR to master (but do not merge it). In the PR description, copy, paste and answer the following:
-```
- ### Your name
+---
+## Feedbacks
- ### What was challenging?
+### Your name
+NoƩ Viricel
- ### What did you enjoy doing?
+### What was challenging?
+- Cypress.io, as I never used it before, I started to learn the basis from their documentation. It looks very interesting in a product quality point of view, I never had the occasion to go that far in a product development process.
+- A bit tricky at first to choose which API data to keep.
+
+### What did you enjoy doing?
+I appreciated the opportunity to imagine an app from a raw array of data. With no graphical support of any kind. It gave me the opportunity to truly showcase my creativity and skills on different subject.
+I have taken the time to confirm knowledge and go further into testing data store (Vuex).
+
+*_**Spoiler:** I admit that I tried to stick to your graphic environement_*
+
+### If you had more time, what would you improve/do differently?
+ - Improve "Component's dynamic imports" browser support by using Async components, it would offer more control over Component's loading / error states.
+ - Use CSS Framework (I'm fond of AntDesign and Tailwind CSS)
+ - CSS Module as the app will grow, offering styles more modularity.
+ - Vue.js Composition API on a larger app.
+ - Form and Props validation.
+ - Page Transitions
+ - Better loading state management
+ - Setup .env file for environnement isolation (dev / test / prod).
+ - Work on CSS Accessibility
+ - Caching & Lazy Loading
+ - Inject axios globally (as $http)
+ - Notification System (Errors, Infos, Warning, Success)
+ - More unit test
+ - Definitely more End-to-End test :)
+ - and probably more ...
- ### If you had more time, what would you improve/do differently?
### How much time (more or less) it took you to complete the task?
+ I've made this app in 5 days of code, more or less 25 hours ... (I did count commits timestamps :weary:)
+
- ### What do you think about the task itself? (Was it a good experience? If not why?)
+ ### What do you think about the task itself? (Was it a good experience? If not why?
+ I believe it's a good exercise with enough constraints to let you express yourself (in a coder's mind I mean).
+ I think that this task allows to approach each stage of a Frontent project conception.
+
+ *I rather be tested like that than with a bulk of logic tests (that I find arbitrary) as I am more in a situation to think and develop ideas.*
### Summary in a gif
-```
-- [ ] Invite us to your repository: @chayaline, @emilyrosina, @adamwardecki, @dangzo, @thomas-lhuillier, @SavanovicN & @Sergeon
+
+- [x] Invite us to your repository: @chayaline, @emilyrosina, @adamwardecki, @dangzo, @thomas-lhuillier, @SavanovicN & @Sergeon
## The specification
@@ -57,10 +89,10 @@ The API is using the ESM module loader so please make sure that you have at leas
```
npm i fruit-api
```
-- [ ] Add `"api": "fruit-api"` to the scripts section of your package.json file.
-- [ ] Run `npm run api` to serve the fruit-api you can view the API documentation at https://localhost:3000 (or another port number, if 3000 is already in use).
+- [x] Add `"api": "fruit-api"` to the scripts section of your package.json file.
+- [x] Run `npm run api` to serve the fruit-api you can view the API documentation at https://localhost:3000 (or another port number, if 3000 is already in use).
**Recommended documentation**
| Scaffolding | State Management | Routing |
| --------------------------------- | ------------------------------ | -------------------------------------- |
-| [Vue-CLI](https://cli.vuejs.org/) | [Vuex](https://vuex.vuejs.org) | [Vue-router](https://router.vuejs.org) |
\ No newline at end of file
+| [Vue-CLI](https://cli.vuejs.org/) | [Vuex](https://vuex.vuejs.org) | [Vue-router](https://router.vuejs.org) |
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 0000000..397abca
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ presets: ["@vue/cli-plugin-babel/preset"]
+};
diff --git a/coverage/clover.xml b/coverage/clover.xml
new file mode 100644
index 0000000..d8bd1b2
--- /dev/null
+++ b/coverage/clover.xml
@@ -0,0 +1,87 @@
+
+
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 | + + + + + + + +1x + + + + + + + +1x + + + + | <template>
+ <div>
+ <h1>Fruits Directory</h1>
+ {{ this.fruits }}
+ </div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+export default {
+ name: "Fruits",
+ computed: {
+ ...mapState(["fruits"])
+ },
+ async created() {
+ await this.$store.dispatch("getFruits");
+ }
+};
+</script>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + +5x + + + + + + + +5x +5x + + +1x + + + +1x +2x + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <div class="modal">
+ <h2>New Fruit</h2>
+ <form id="new-fruit" @submit.prevent="checkForm">
+ <!-- name -->
+ <div class="form-field">
+ <label for="fruit-name">Name</label>
+ <input
+ id="fruit-name"
+ ref="autofocus"
+ type="text"
+ v-model="fruit.name"
+ placeholder="Ex: Banana"
+ required
+ />
+ </div>
+
+ <!-- image finder (with Unsplash API) -->
+ <ImageUnsplash
+ @getValue="url => (fruit.image = url)"
+ label="Image"
+ containerClass="form-field"
+ placeholder="Search: strawberry, apple ..."
+ :required="true"
+ />
+
+ <div class="form-group">
+ <!-- taste -->
+ <div class="form-field">
+ <label for="fruit-taste">Taste</label>
+ <input
+ id="fruit-taste"
+ type="text"
+ v-model="fruit.taste"
+ placeholder="Ex: sweet"
+ required
+ />
+ </div>
+
+ <!-- color -->
+ <div class="form-field">
+ <label for="fruit-color">Color</label>
+ <input id="fruit-color" type="color" v-model="fruit.color" placeholder="Color" required />
+ </div>
+ </div>
+
+ <div class="form-group">
+ <!-- price -->
+ <div class="form-field">
+ <label for="fruit-price">Price ($)</label>
+ <input
+ id="fruit-price"
+ type="number"
+ v-model="fruit.price"
+ placeholder="Ex: $13"
+ required
+ min="0"
+ />
+ </div>
+
+ <!-- expires -->
+ <div class="form-field">
+ <label for="fruit-expires">Expiration Date</label>
+ <input id="fruit-expires" type="date" v-model="fruit.expires" required />
+ </div>
+ </div>
+
+ <!-- description -->
+ <div class="form-field">
+ <label for="fruit-description">Description</label>
+ <textarea
+ id="fruit-description"
+ v-model="fruit.description"
+ placeholder="Ex: malesuada pellentesque elit eget ..."
+ required
+ />
+ </div>
+
+ <div class="actions">
+ <button class="btn btn--cancel" type="button" @click="() => $store.commit('toggleModal')">
+ Cancel
+ </button>
+ <button class="btn btn--success" type="submit">Save</button>
+ </div>
+ </form>
+ </div>
+</template>
+
+<script>
+import ImageUnsplash from "./Form/ImageUnsplash/ImageUnsplash";
+
+export default {
+ name: "AddFruit",
+ components: { ImageUnsplash },
+ data() {
+ return {
+ fruit: {
+ color: "#000000",
+ isFruit: true
+ }
+ };
+ },
+ mounted() {
+ this.$refs["autofocus"].focus();
+ document.body.classList.add("is-overlayed");
+ },
+ destroyed() {
+ document.body.classList.remove("is-overlayed");
+ },
+ methods: {
+ async checkForm(e) {
+ this.fruit.expires = new Date(this.fruit.expires);
+ await this.$store
+ .dispatch("addFruit", this.fruit)
+ .then(() => this.$store.commit("toggleModal"));
+ e.preventDefault();
+ }
+ }
+};
+</script>
+
+<style lang="less">
+.modal {
+ position: fixed;
+ top: @headerHeight;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: @color-2;
+ padding: 1rem;
+ z-index: 14;
+ overflow-y: scroll;
+
+ form {
+ border: 2px solid @color-1;
+ border-radius: 10px;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='88' viewBox='0 0 88 88'%3E%3Cg fill='%230c9696' fill-opacity='0.35'%3E%3Cpath fill-rule='evenodd' d='M29.42 29.41c.36-.36.58-.85.58-1.4V0h-4v26H0v4h28c.55 0 1.05-.22 1.41-.58h.01zm0 29.18c.36.36.58.86.58 1.4V88h-4V62H0v-4h28c.56 0 1.05.22 1.41.58zm29.16 0c-.36.36-.58.85-.58 1.4V88h4V62h26v-4H60c-.55 0-1.05.22-1.41.58h-.01zM62 26V0h-4v28c0 .55.22 1.05.58 1.41.37.37.86.59 1.41.59H88v-4H62zM18 36c0-1.1.9-2 2-2h10a2 2 0 1 1 0 4H20a2 2 0 0 1-2-2zm0 16c0-1.1.9-2 2-2h10a2 2 0 1 1 0 4H20a2 2 0 0 1-2-2zm16-26a2 2 0 0 1 2-2 2 2 0 0 1 2 2v4a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-4zm16 0a2 2 0 0 1 2-2 2 2 0 0 1 2 2v4a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-4zM34 58a2 2 0 0 1 2-2 2 2 0 0 1 2 2v4a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-4zm16 0a2 2 0 0 1 2-2 2 2 0 0 1 2 2v4a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-4zM34 78a2 2 0 0 1 2-2 2 2 0 0 1 2 2v6a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-6zm16 0a2 2 0 0 1 2-2 2 2 0 0 1 2 2v6a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-6zM34 4a2 2 0 0 1 2-2 2 2 0 0 1 2 2v6a2 2 0 0 1-2 2 2 2 0 0 1-2-2V4zm16 0a2 2 0 0 1 2-2 2 2 0 0 1 2 2v6a2 2 0 0 1-2 2 2 2 0 0 1-2-2V4zm-8 82a2 2 0 1 1 4 0v2h-4v-2zm0-68a2 2 0 1 1 4 0v10a2 2 0 1 1-4 0V18zM66 4a2 2 0 1 1 4 0v8a2 2 0 1 1-4 0V4zm0 72a2 2 0 1 1 4 0v8a2 2 0 1 1-4 0v-8zm-48 0a2 2 0 1 1 4 0v8a2 2 0 1 1-4 0v-8zm0-72a2 2 0 1 1 4 0v8a2 2 0 1 1-4 0V4zm24-4h4v2a2 2 0 1 1-4 0V0zm0 60a2 2 0 1 1 4 0v10a2 2 0 1 1-4 0V60zm14-24c0-1.1.9-2 2-2h10a2 2 0 1 1 0 4H58a2 2 0 0 1-2-2zm0 16c0-1.1.9-2 2-2h10a2 2 0 1 1 0 4H58a2 2 0 0 1-2-2zm-28-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm8 26a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm16 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM36 20a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm16 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-8-8a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 68a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm16-34a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm16-12a2 2 0 1 0 0 4 6 6 0 1 1 0 12 2 2 0 1 0 0 4 10 10 0 1 0 0-20zm-64 0a2 2 0 1 1 0 4 6 6 0 1 0 0 12 2 2 0 1 1 0 4 10 10 0 1 1 0-20zm56-12a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 48a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-48 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0-48a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm24 32a10 10 0 1 1 0-20 10 10 0 0 1 0 20zm0-4a6 6 0 1 0 0-12 6 6 0 0 0 0 12zm36-36a6 6 0 1 1 0-12 6 6 0 0 1 0 12zm0-4a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM10 44c0-1.1.9-2 2-2h8a2 2 0 1 1 0 4h-8a2 2 0 0 1-2-2zm56 0c0-1.1.9-2 2-2h8a2 2 0 1 1 0 4h-8a2 2 0 0 1-2-2zm8 24c0-1.1.9-2 2-2h8a2 2 0 1 1 0 4h-8a2 2 0 0 1-2-2zM3 68c0-1.1.9-2 2-2h8a2 2 0 1 1 0 4H5a2 2 0 0 1-2-2zm0-48c0-1.1.9-2 2-2h8a2 2 0 1 1 0 4H5a2 2 0 0 1-2-2zm71 0c0-1.1.9-2 2-2h8a2 2 0 1 1 0 4h-8a2 2 0 0 1-2-2zm6 66a6 6 0 1 1 0-12 6 6 0 0 1 0 12zm0-4a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM8 86a6 6 0 1 1 0-12 6 6 0 0 1 0 12zm0-4a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0-68A6 6 0 1 1 8 2a6 6 0 0 1 0 12zm0-4a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm36 36a2 2 0 1 0 0-4 2 2 0 0 0 0 4z'/%3E%3C/g%3E%3C/svg%3E");
+ background-attachment: scroll;
+ background-color: rgba(0, 0, 0, 0.06);
+ box-shadow: 0 1px 4px 1px #d2d2f2;
+ padding: 1rem;
+ margin-bottom: 1rem;
+
+ @media screen and (min-width: @sm) {
+ width: 425px;
+ margin: 0 auto 2rem auto;
+ }
+
+ [type="color"] {
+ height: 48px;
+ }
+
+ .form-field {
+ padding: 0.75rem 1rem;
+ border-radius: 4px;
+ box-sizing: border-box;
+ background-color: @color-2;
+ margin: 1rem 0;
+
+ label {
+ display: block;
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+ }
+
+ textarea,
+ input {
+ width: 100%;
+ box-sizing: border-box;
+ border: none;
+ border-radius: 4px;
+ background-color: lighten(@text-color, 70%);
+ padding: 0.75rem 1rem;
+ color: darken(@color-1, 20%);
+ }
+ }
+
+ .form-group {
+ display: flex;
+ align-items: center;
+ margin: 1rem 0;
+ background-color: @color-2;
+
+ .form-field {
+ background-color: none;
+ margin: 0;
+ width: 50%;
+ }
+ }
+
+ .actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+
+ .btn {
+ border: none;
+ border-radius: 4px;
+ color: @color-2;
+ padding: 0.5rem 1.35rem;
+ margin: 0 0.5rem;
+
+ &--success {
+ background-color: #2ecc71;
+ }
+
+ &--cancel {
+ background-color: darken(#cecece, 15%);
+ }
+ }
+ }
+ }
+}
+</style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 | + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <VueContentLoading
+ :style="{ backgroundColor: '#ffffff', padding: '1rem', borderRadius: '10px' }"
+ primary="#cecece"
+ :width="300"
+ :height="180"
+ >
+ <rect x="0" y="0" rx="4" ry="4" width="300" height="180" />
+ </VueContentLoading>
+</template>
+
+<script>
+import VueContentLoading from "vue-content-loading";
+
+export default {
+ name: "ImageSkeleton",
+ components: {
+ VueContentLoading
+ }
+};
+</script>
+
+<style lang="less"></style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <div :class="containerClass" class="image-unsplash">
+ <label for="search-unsplash">{{ label }}</label>
+ <!-- Loading placeholder -->
+ <ImageSkeleton v-if="loading" />
+
+ <!-- Image preview -->
+ <img
+ class="preview"
+ v-if="!loading && !error && preview"
+ :src="preview.urls.regular"
+ :alt="preview.alt_description"
+ />
+
+ <!-- API error messages -->
+ <p class="error" v-if="error">{{ error }}</p>
+
+ <!-- Search input -->
+ <div class="search-box">
+ <input
+ id="search-unsplash"
+ type="search"
+ :placeholder="placeholder"
+ @change="handleSearch"
+ :required="required"
+ />
+ </div>
+ </div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+export default {
+ name: "ImageUnsplash",
+ components: {
+ ImageSkeleton: () => import("./ImageSkeleton")
+ },
+ props: {
+ containerClass: {
+ type: String,
+ default: null
+ },
+ label: {
+ type: String,
+ default: "Image (from Unsplash)"
+ },
+ placeholder: {
+ type: String,
+ default: "Type anything !"
+ },
+ required: {
+ type: Boolean,
+ default: false
+ }
+ },
+ data() {
+ return {
+ error: null,
+ preview: null
+ };
+ },
+ computed: {
+ ...mapState(["loading"])
+ },
+ methods: {
+ async handleSearch(e) {
+ this.error = null;
+ if (e.target.value != "") {
+ await this.$store
+ .dispatch("getImageFromUnsplash", e.target.value)
+ .then(res => {
+ this.preview = res;
+ this.$emit("getValue", res.urls.regular);
+ })
+ .catch(err => (this.error = err.message));
+ } else this.preview = null;
+ }
+ }
+};
+</script>
+
+<style scoped lang="less">
+.image-unsplash {
+ .preview,
+ .skeleton {
+ margin-bottom: 1rem;
+ }
+
+ .preview {
+ width: 100%;
+ object-fit: scale-down;
+ height: 180px;
+ }
+
+ .error {
+ color: @text-error;
+ text-align: center;
+ }
+
+ .search-box {
+ position: relative;
+ z-index: 0;
+
+ &:focus-within::after {
+ opacity: 1;
+ }
+
+ &::after {
+ content: url("data:image/svg+xml,%3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 350.439 350.439' style='enable-background:new 0 0 350.439 350.439;' xml:space='preserve'%3E%3Cg%3E%3Cpath d='M312.856,36.464H202.507c-4.87,0-8.83,3.961-8.83,8.832v138.422H52.371c-4.87,0-8.832,3.973-8.832,8.843v108.023 c0,4.87,3.961,8.831,8.832,8.831l260.474-0.071c4.873,0,8.838-3.962,8.838-8.843l0.011-255.211 C321.701,40.425,317.737,36.464,312.856,36.464z M175.216,253.617c0,5.724-4.635,10.353-10.356,10.353h-57.814 c-0.392,0-0.775-0.022-1.155-0.065v0.887l8.294,21.002c0.281,0.7,0.057,1.51-0.551,1.965c-0.301,0.229-0.658,0.344-1.013,0.344 c-0.364,0-0.722-0.12-1.025-0.355l-41.251-31.715c-0.416-0.318-0.657-0.816-0.657-1.33c0-0.531,0.241-1.023,0.657-1.345 l41.251-31.712c0.6-0.463,1.433-0.463,2.038-0.011c0.603,0.459,0.827,1.258,0.551,1.958l-7.776,19.694 c0.21-0.011,0.418-0.033,0.637-0.033h47.462v-34.715c0-5.724,4.637-10.353,10.352-10.353c5.721,0,10.356,4.629,10.356,10.353 V253.617z M332.215,7.41H174.623c-10.062,0-18.221,8.159-18.221,18.225v126.679H18.223C8.159,152.314,0,160.479,0,170.541v154.267 c0,10.063,8.159,18.222,18.223,18.222h157.588c0.346,0,0.667-0.087,1.008-0.099h155.375c10.065,0,18.228-8.164,18.228-18.232 V180.074c0-0.066,0.018-0.121,0.018-0.176V25.635C350.439,15.569,342.273,7.41,332.215,7.41z M329.535,300.512 c0,9.205-7.483,16.69-16.69,16.69H204.052c-0.078,0.043-0.383,0.076-0.705,0.076H52.371c-9.202,0-16.688-7.485-16.688-16.689 V192.571c0-9.209,7.485-16.689,16.688-16.689h133.453V45.295c0-9.201,7.485-16.687,16.684-16.687h110.349 c9.204,0,16.689,7.486,16.689,16.687L329.535,300.512z'/%3E%3C/g%3E%3C/svg%3E");
+ position: absolute;
+ z-index: 1;
+ top: 50%;
+ right: 0.5rem;
+ width: 24px;
+ height: 24px;
+ transform: translateY(-50%);
+ opacity: 0.4;
+ }
+
+ [type="search"] {
+ padding-right: 2.35rem;
+ border: none;
+ background-color: lighten(#cecece, 15%);
+ }
+ }
+
+ [type="file"] {
+ border: none;
+ }
+}
+</style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| ImageSkeleton.vue | +
+
+ |
+ 0% | +0/1 | +100% | +0/0 | +100% | +0/0 | +0% | +0/1 | +
| ImageUnsplash.vue | +
+
+ |
+ 10% | +1/10 | +0% | +0/2 | +0% | +0/5 | +10% | +1/10 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 | + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <div class="grid">
+ <GridItem :key="item.id" v-for="item in data" :item="item" />
+ </div>
+</template>
+
+<script>
+import GridItem from "./GridItem";
+
+export default {
+ name: "Grid",
+ components: {
+ GridItem
+ },
+ props: {
+ data: Array
+ }
+};
+</script>
+
+<style scoped lang="less">
+.grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ column-gap: 1rem;
+ row-gap: 1.5rem;
+
+ @media screen and (min-width: @md) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media screen and (min-width: @lg) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ @media screen and (min-width: @xl) {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+</style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <div>
+ <button class="delete-btn" @click="showPrompt">
+ š®
+ </button>
+ <div v-if="openPrompt" :class="{ open: openPrompt }" class="prompt">
+ <h3>You will delete '{{ item.name }}'?</h3>
+ <div class="actions">
+ <button class="btn btn--cancel" @click="openPrompt = false">No</button>
+ <button class="btn btn--success" @click="removeFruit(item.id)">Yes</button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ name: "DeleteItem",
+ props: {
+ item: Object,
+ redirect: {
+ type: Function,
+ default: () => {}
+ }
+ },
+ data() {
+ return {
+ openPrompt: false
+ };
+ },
+ methods: {
+ showPrompt() {
+ this.openPrompt = true;
+ },
+ removeFruit(id) {
+ this.$store.dispatch("removeFruit", id).then(() => this.redirect());
+ }
+ }
+};
+</script>
+
+<style scoped lang="less">
+.prompt {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 2;
+ background: rgb(230, 107, 107);
+ background: linear-gradient(90deg, rgba(230, 107, 107, 1) 0%, rgba(214, 48, 49, 1) 100%);
+ width: 0;
+ height: 0;
+ transition: all 0.4s ease-in-out;
+ color: @color-2;
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ justify-content: center;
+
+ &.open {
+ width: 100%;
+ height: 100%;
+ }
+
+ .btn {
+ border: none;
+ border-radius: 4px;
+ color: @color-2;
+ padding: 0.5rem 1.35rem;
+ margin: 0 0.5rem;
+
+ &--success {
+ background-color: #2ecc71;
+ }
+
+ &--cancel {
+ background-color: darken(#cecece, 15%);
+ }
+ }
+}
+
+.delete-btn {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ right: 0;
+ width: 55px;
+ padding: 0.75rem 0.75rem 1.35rem 1.35rem;
+ background: rgb(230, 107, 107);
+ background: linear-gradient(180deg, rgba(230, 107, 107, 0.6) 0%, rgba(214, 48, 49, 1) 100%);
+ border: none;
+ border-bottom-left-radius: 100%;
+}
+</style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <div :class="{ error: !loading && !data.length }" class="grid">
+ <!-- Loading placeholder -->
+ <GridItemSkeleton v-if="loading" />
+ <GridItemSkeleton v-if="loading" />
+
+ <!-- Items -->
+ <GridItem v-else :key="item.id" v-for="item in data" :item="item" />
+
+ <!-- Error messages -->
+ <p v-if="!loading && !data.length" class="alert"><b>š” Restart API</b>: 0 fruits !</p>
+ </div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+import GridItemSkeleton from "./GridItemSkeleton";
+
+export default {
+ name: "Grid",
+ components: {
+ GridItemSkeleton,
+ GridItem: () => import("./GridItem")
+ },
+ props: {
+ data: Array
+ },
+ computed: {
+ ...mapState(["loading"])
+ }
+};
+</script>
+
+<style scoped lang="less">
+.grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ column-gap: 1rem;
+ row-gap: 1.5rem;
+
+ &.error {
+ display: block;
+ }
+
+ @media screen and (min-width: @md) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media screen and (min-width: @lg) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ @media screen and (min-width: @xl) {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .alert {
+ box-sizing: border-box;
+ margin: 0 auto;
+ width: 80%;
+ background-color: @color-3;
+ color: @color-2;
+ padding: 1rem 1.5rem;
+ border-radius: 4px;
+
+ @media screen and (min-width: @sm) {
+ width: 50%;
+ max-width: 535px;
+ }
+
+ pre {
+ padding: 1rem;
+ border-radius: 4px;
+ background-color: rgb(45, 48, 55);
+
+ span {
+ color: @color-3;
+ }
+ }
+ }
+}
+</style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 | + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <article class="grid-item">
+ <DeleteItem v-if="deleteMode" :item="item" />
+ <router-link :to="`/fruit/${item.id}`">
+ <div class="thumbnail">
+ <img :src="item.image" :alt="item.name" />
+ </div>
+ <section>
+ <h3>
+ {{ item.name }}
+ <span class="tag" :style="{ backgroundColor: item.color }">{{ item.taste }}</span>
+ </h3>
+
+ <hr />
+
+ <p class="price">${{ item.price | noDecimal }}</p>
+ </section>
+ </router-link>
+ </article>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+export default {
+ name: "GridItem",
+ components: { DeleteItem: () => import("./DeleteItem") },
+ props: {
+ item: Object
+ },
+ computed: {
+ ...mapState(["deleteMode"])
+ },
+ filters: {
+ noDecimal(value) {
+ return parseInt(value).toFixed();
+ }
+ }
+};
+</script>
+
+<style scoped lang="less">
+.grid-item {
+ position: relative;
+ z-index: 0;
+ background-color: @color-2;
+ border-radius: 10px;
+ overflow: hidden;
+
+ &:hover .thumbnail img {
+ transform: scale(1.1);
+ }
+
+ a {
+ display: block;
+ color: inherit;
+
+ .thumbnail {
+ height: 285px;
+ overflow: hidden;
+ box-shadow: 0 1px 4px 1px #d2d2f2;
+
+ img {
+ border-radius: 10px 10px 0 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ max-width: 100%;
+ transition: transform 0.4s ease-in-out;
+ }
+ }
+
+ section {
+ padding: 1.5rem 1rem;
+
+ h3 {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 0.55rem;
+ }
+
+ hr {
+ width: 100px;
+ color: #cece;
+ margin: 1.35rem auto;
+ }
+
+ .tag {
+ border-radius: 25px;
+ padding: 0.35rem 0.75rem;
+ margin-left: 0.75rem;
+ color: #ffffff;
+ font-size: 14px;
+ font-weight: bold;
+ text-align: center;
+ text-transform: lowercase;
+ }
+
+ .price {
+ text-align: center;
+ margin: 0;
+ font-size: 28px;
+ font-weight: bold;
+ color: #ff9700;
+ }
+ }
+ }
+}
+</style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 | + + + + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <VueContentLoading
+ :style="{ backgroundColor: '#ffffff', padding: '1rem', borderRadius: '10px' }"
+ primary="#cecece"
+ :width="300"
+ :height="220"
+ >
+ <rect x="0" y="0" rx="4" ry="4" width="100%" height="100" />
+ <rect x="30" y="110" rx="4" ry="4" width="80%" height="15" />
+ <rect x="100px" y="150" width="100" height="2" />
+ <rect x="100px" y="175" rx="4" ry="4" width="100" height="35" />
+ </VueContentLoading>
+</template>
+
+<script>
+import VueContentLoading from "vue-content-loading";
+
+export default {
+ name: "GridItemSkeleton",
+ components: {
+ VueContentLoading
+ }
+};
+</script>
+
+<style lang="less"></style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| DeleteItem.vue | +
+
+ |
+ 0% | +0/3 | +100% | +0/0 | +0% | +0/5 | +0% | +0/3 | +
| Grid.vue | +
+
+ |
+ 0% | +0/3 | +100% | +0/0 | +0% | +0/1 | +0% | +0/3 | +
| GridItem.vue | +
+
+ |
+ 66.67% | +2/3 | +100% | +0/0 | +50% | +1/2 | +66.67% | +2/3 | +
| GridItemSkeleton.vue | +
+
+ |
+ 0% | +0/1 | +100% | +0/0 | +100% | +0/0 | +0% | +0/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 | + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | <template>
+ <header id="header" class="main-nav">
+ <router-link to="/">
+ <img src="https://www.cycloid.io/themes/cycloid/images/owl_logo.png" alt="Cycloid.io" />
+ <h1>Fruits</h1>
+ </router-link>
+
+ <div class="actions">
+ <button
+ :disabled="modalIsOpen"
+ class="action-btn action-btn--delete"
+ @click="() => $store.commit('toggleDeleteMode')"
+ >
+ {{ deleteMode ? "🚫" : "🗑" }}
+ </button>
+ <button
+ :disabled="deleteMode"
+ class="action-btn action-btn--add"
+ @click="() => $store.commit('toggleModal')"
+ >
+ {{ modalIsOpen ? "🚫" : "➕" }}
+ </button>
+ </div>
+ </header>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+export default {
+ name: "Header",
+ computed: {
+ ...mapState(["deleteMode", "modalIsOpen"])
+ }
+};
+</script>
+
+<style lang="less">
+header {
+ background-color: @color-2;
+ box-shadow: 0 0px 14px 0px #cecece;
+
+ h1 {
+ margin: 0;
+ font-size: 28px;
+ font-weight: lighter;
+ }
+
+ &.main-nav {
+ position: sticky;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 15;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 1rem;
+ height: @headerHeight;
+ transition: all 0.3s ease-in-out;
+
+ a {
+ display: flex;
+ align-items: center;
+
+ img {
+ width: 35px;
+ height: 35px;
+ margin-right: 1rem;
+ }
+ }
+
+ .actions {
+ display: flex;
+ align-items: center;
+
+ .action-btn {
+ width: 42px;
+ height: 42px;
+ border: none;
+ border-radius: 50%;
+ color: @color-2;
+ font-size: 24px;
+ font-weight: lighter;
+ text-align: center;
+ margin-left: 1rem;
+ padding-top: 2px;
+ background: lighten(#cecece, 10%);
+
+ &:disabled {
+ opacity: 0.4;
+ }
+
+ &--delete {
+ border: 1px solid @text-error;
+ }
+
+ &--add {
+ border: 1px solid @color-1;
+ }
+ }
+ }
+ }
+}
+</style>
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| AddFruit.vue | +
+
+ |
+ 100% | +8/8 | +100% | +0/0 | +100% | +5/5 | +100% | +8/8 | +
| Header.vue | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +0/0 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| components | +
+
+ |
+ 100% | +9/9 | +100% | +0/0 | +100% | +5/5 | +100% | +9/9 | +
| components/Form/ImageUnsplash | +
+
+ |
+ 9.09% | +1/11 | +0% | +0/2 | +0% | +0/5 | +9.09% | +1/11 | +
| components/Grid | +
+
+ |
+ 20% | +2/10 | +100% | +0/0 | +12.5% | +1/8 | +20% | +2/10 | +
| views | +
+
+ |
+ 0% | +0/9 | +0% | +0/2 | +0% | +0/8 | +0% | +0/9 | +