├── .eslintrc.cjs ├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── src ├── App.vue ├── api │ ├── api.js │ ├── favorites.js │ ├── images.js │ ├── index.js │ └── votes.js ├── assets │ ├── icons │ │ ├── icon-folder-search.png │ │ └── icon-home.png │ └── styles │ │ └── main.scss ├── components │ ├── DetailsRow.vue │ ├── NavItem.vue │ ├── NavMain.vue │ └── __tests__ │ │ └── NavItem-scaffold.spec.js ├── main.js ├── router │ └── index.js ├── stores │ ├── favorites.js │ └── votes.js ├── utils.js └── views │ ├── HomeView.vue │ └── ImageView │ ├── ImageDetails.vue │ ├── ImageList.vue │ ├── ImageView.vue │ └── __tests__ │ ├── ImageDetails-scaffold.spec.js │ ├── ImageDetails.spec.js │ ├── ImageList-scaffold.spec.js │ └── ImageList.spec.js ├── tailwind.config.js └── vite.config.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | 'plugin:vue/vue3-essential', 6 | 'eslint:recommended' 7 | ], 8 | env: { 9 | 'vue/setup-compiler-macros': true, 10 | node: true 11 | }, 12 | globals: { 13 | vi: 'readonly' 14 | }, 15 | rules: { 16 | 'quote-props': [2, 'as-needed'], 17 | quotes: ['error', 'single'], 18 | semi: ['error', 'always'] 19 | }, 20 | overrides: [ 21 | { 22 | files: [ 23 | '**/__tests__/*.{j,t}s?(x)', 24 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 25 | ], 26 | env: { 27 | jest: true 28 | } 29 | } 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | .env 21 | 22 | # Editor directories and files 23 | .vscode/* 24 | !.vscode/extensions.json 25 | .idea 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vueconf-2022-demo-app 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin). 8 | 9 | ## Customize configuration 10 | 11 | See [Vite Configuration Reference](https://vitejs.dev/config/). 12 | 13 | ## Project Setup 14 | 15 | ```sh 16 | npm install 17 | ``` 18 | 19 | ### Compile and Hot-Reload for Development 20 | 21 | ```sh 22 | npm run dev 23 | ``` 24 | 25 | ### Compile and Minify for Production 26 | 27 | ```sh 28 | npm run build 29 | ``` 30 | 31 | ### Run Unit Tests with [Vitest](https://vitest.dev/) 32 | 33 | ```sh 34 | npm run test:unit 35 | ``` 36 | 37 | ### Lint with [ESLint](https://eslint.org/) 38 | 39 | ```sh 40 | npm run lint 41 | ``` 42 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | VueConf 2022 Demo App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vueconf-2022-demo-app", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "preview": "vite preview --port 5050", 8 | "test:unit": "vitest --environment jsdom", 9 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" 10 | }, 11 | "dependencies": { 12 | "@lob/ui-components": "^1.0.0-beta.8", 13 | "axios": "^0.27.2", 14 | "pinia": "^2.0.13", 15 | "vue": "^3.2.33", 16 | "vue-router": "^4.0.14" 17 | }, 18 | "devDependencies": { 19 | "@pinia/testing": "^0.0.12", 20 | "@testing-library/jest-dom": "^5.16.4", 21 | "@testing-library/user-event": "^14.2.0", 22 | "@testing-library/vue": "^6.5.1", 23 | "@vitejs/plugin-vue": "^2.3.1", 24 | "@vue/test-utils": "^2.0.0-rc.20", 25 | "autoprefixer": "^10.4.7", 26 | "eslint": "^8.5.0", 27 | "eslint-plugin-vue": "^8.2.0", 28 | "jsdom": "^19.0.0", 29 | "postcss": "^8.4.13", 30 | "sass": "^1.51.0", 31 | "tailwind-plugin-lob": "^0.0.27", 32 | "tailwindcss": "^3.0.24", 33 | "vite": "^2.9.5", 34 | "vitest": "^0.12.9" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bethqiang/vueconf-2022-demo-app/0a872f2701f915037b0041cc9b684ca1cc8b77e8/public/favicon.ico -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 30 | -------------------------------------------------------------------------------- /src/api/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const _api = axios.create({ 4 | baseURL: import.meta.env.VITE_API_URL 5 | }); 6 | 7 | const getHeaders = function () { 8 | return { 9 | 'x-api-key': import.meta.env.VITE_API_KEY 10 | }; 11 | }; 12 | 13 | export default { 14 | get: function (url, params = {}) { 15 | const headers = getHeaders(); 16 | return _api.get(url, { params, headers }) 17 | .then((response) => { 18 | return { 19 | data: response.data, 20 | count: response.headers['pagination-count'] 21 | }; 22 | }); 23 | }, 24 | post: function (url, params) { 25 | const headers = getHeaders(); 26 | 27 | // We don't want to send a destructured version of FormData 28 | let data = { ...params }; 29 | if (params instanceof FormData) { 30 | data = params; 31 | } 32 | 33 | return _api.post(url, data, { headers }); 34 | }, 35 | delete: function (url) { 36 | const headers = getHeaders('DEL', url); 37 | return _api.delete(url, { headers }); 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /src/api/favorites.js: -------------------------------------------------------------------------------- 1 | import api from './api'; 2 | 3 | const FAVORITES_ROOT = 'favourites'; 4 | 5 | export default { 6 | findAll: async function () { 7 | return await api.get(FAVORITES_ROOT); 8 | }, 9 | favorite: async function (payload) { 10 | return await api.post(FAVORITES_ROOT, payload); 11 | }, 12 | delete: async function (favoriteId) { 13 | return await api.delete(`${FAVORITES_ROOT}/${favoriteId}`); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/api/images.js: -------------------------------------------------------------------------------- 1 | import api from './api'; 2 | 3 | const IMAGES_ROOT = 'images'; 4 | 5 | export default { 6 | findAll: async function (payload) { 7 | return await api.get(`${IMAGES_ROOT}/search`, payload); 8 | }, 9 | findById: async function (id) { 10 | return await api.get(`${IMAGES_ROOT}/${id}`); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | export { default as imagesApi } from './images'; 2 | export { default as favoritesApi } from './favorites'; 3 | export { default as votesApi } from './votes'; 4 | -------------------------------------------------------------------------------- /src/api/votes.js: -------------------------------------------------------------------------------- 1 | import api from './api'; 2 | 3 | const VOTES_ROOT = 'votes'; 4 | 5 | export default { 6 | findAll: async function () { 7 | return await api.get(VOTES_ROOT); 8 | }, 9 | vote: async function (payload) { 10 | return await api.post(VOTES_ROOT, payload); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/assets/icons/icon-folder-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bethqiang/vueconf-2022-demo-app/0a872f2701f915037b0041cc9b684ca1cc8b77e8/src/assets/icons/icon-folder-search.png -------------------------------------------------------------------------------- /src/assets/icons/icon-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bethqiang/vueconf-2022-demo-app/0a872f2701f915037b0041cc9b684ca1cc8b77e8/src/assets/icons/icon-home.png -------------------------------------------------------------------------------- /src/assets/styles/main.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/components/DetailsRow.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 24 | -------------------------------------------------------------------------------- /src/components/NavItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/NavMain.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/__tests__/NavItem-scaffold.spec.js: -------------------------------------------------------------------------------- 1 | describe('NavItem', () => { 2 | it('renders an icon', () => {}); 3 | it('renders a title', () => {}); 4 | 5 | describe('when active', () => { 6 | it('is underlined and has a darker background', () => {}); 7 | }); 8 | 9 | describe('when not active', () => { 10 | it('is not underlined and has the same background as the MainNav', () => {}); 11 | }); 12 | 13 | }); 14 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import '@/assets/styles/main.scss'; 2 | 3 | import { createApp } from 'vue'; 4 | import { createPinia } from 'pinia'; 5 | 6 | import App from './App.vue'; 7 | import router from './router'; 8 | 9 | import components from '@lob/ui-components'; 10 | import '@lob/ui-components/dist/ui-components.css'; 11 | 12 | const app = createApp(App); 13 | 14 | app.use(createPinia()); 15 | app.use(router); 16 | app.use(components); 17 | 18 | app.mount('#app'); 19 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router'; 2 | import HomeView from '../views/HomeView.vue'; 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(import.meta.env.BASE_URL), 6 | routes: [ 7 | { 8 | path: '/', 9 | name: 'home', 10 | component: HomeView 11 | }, 12 | { 13 | path: '/images', 14 | name: 'images', 15 | component: () => import('../views/ImageView/ImageView.vue'), 16 | children: [ 17 | { 18 | path: '', 19 | name: 'image list', 20 | component: () => import('../views/ImageView/ImageList.vue') 21 | }, 22 | { 23 | path: ':id', 24 | name: 'image details', 25 | component: () => import('../views/ImageView/ImageDetails.vue') 26 | } 27 | ] 28 | } 29 | ] 30 | }); 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /src/stores/favorites.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | 3 | export default defineStore({ 4 | id: 'favorites', 5 | state: () => ({ 6 | _favorites: [] 7 | }), 8 | getters: { 9 | favorites: (state) => state._favorites 10 | }, 11 | actions: { 12 | set (favorites) { 13 | this._favorites = favorites; 14 | } 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /src/stores/votes.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | 3 | export default defineStore({ 4 | id: 'votes', 5 | state: () => ({ 6 | _votes: [] 7 | }), 8 | getters: { 9 | votes: (state) => state._votes 10 | }, 11 | actions: { 12 | set (votes) { 13 | this._votes = votes; 14 | } 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { render as VTLRender } from '@testing-library/vue'; 2 | import components from '@lob/ui-components'; 3 | 4 | export function render (Component, options = {}) { 5 | const { global = {}, ...otherOptions } = options; 6 | const { plugins = [], mocks = {}, ...otherGlobalOptions } = global; 7 | return VTLRender(Component, { global: { plugins: [components, ...plugins], mocks: { ...mocks }, ...otherGlobalOptions }, ...otherOptions }); 8 | } 9 | 10 | export function formatBreeds (breeds) { 11 | if (breeds?.length) { 12 | const breedsArr = breeds.map(({ name }) => name); 13 | return breedsArr.join(', '); 14 | } else { 15 | return 'Unknown'; 16 | } 17 | } 18 | 19 | export function isEmptyObject (object) { 20 | return object && object.constructor === Object && Object.keys(object).length === 0; 21 | } 22 | -------------------------------------------------------------------------------- /src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/views/ImageView/ImageDetails.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 174 | -------------------------------------------------------------------------------- /src/views/ImageView/ImageList.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 127 | -------------------------------------------------------------------------------- /src/views/ImageView/ImageView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | -------------------------------------------------------------------------------- /src/views/ImageView/__tests__/ImageDetails-scaffold.spec.js: -------------------------------------------------------------------------------- 1 | describe('ImageDetails', () => { 2 | describe('before data has loaded', () => { 3 | it('renders a loading indicator', () => {}); 4 | }); 5 | 6 | describe('when data has been loaded successfully', () => { 7 | it('renders a heading with the image ID', () => {}); 8 | it('renders the image', () => {}); 9 | 10 | describe('if there are breeds', () => { 11 | it('renders the breeds', () => {}); 12 | }); 13 | 14 | describe('if there are no breeds', () => { 15 | it('renders \'Unknown\'', () => {}); 16 | }); 17 | 18 | describe('favorite section', () => { 19 | it('renders a favorite button', () => {}); 20 | it('renders a delete favorite button', () => {}); 21 | 22 | describe('if the image hasn\'t been favorited', () => { 23 | it('should show \'Not Favorited\'', () => {}); 24 | it('favorite button should not be red', () => {}); 25 | it('delete favorite button should be disabled', () => {}); 26 | 27 | describe('clicking the favorite button', () => { 28 | it('should call the API to favorite the image', () => {}); 29 | }); 30 | }); 31 | 32 | describe('if the image has been favorited', () => { 33 | it('should show \'Favorited\'', () => {}); 34 | it('favorite button should be red', () => {}); 35 | it('delete favorite button should be enabled', () => {}); 36 | 37 | describe('clicking the delete button', () => { 38 | it('should call the API to delete the favorite', () => {}); 39 | }); 40 | }); 41 | }); 42 | 43 | describe('vote section', () => { 44 | it('renders upvote and downvote buttons', () => {}); 45 | 46 | describe('if the image hasn\'t been voted on', () => { 47 | it('should show \'Not Voted\'', () => {}); 48 | it('upvote button should not be green', () => {}); 49 | it('downvote button should not be red', () => {}); 50 | }); 51 | 52 | describe('if the image has been upvoted', () => { 53 | it('should show \'Upvoted\'', () => {}); 54 | it('upvote button should be green', () => {}); 55 | }); 56 | 57 | describe('if the image has been downvoted', () => { 58 | it('should show \'Downvoted\'', () => {}); 59 | it('downvote button should be red', () => {}); 60 | }); 61 | 62 | describe('clicking the upvote button', () => { 63 | it('should call the API to upvote the image', () => {}); 64 | }); 65 | 66 | describe('clicking the downvote button', () => { 67 | it('should call the API to downvote the image', () => {}); 68 | }); 69 | }); 70 | }); 71 | 72 | describe('when there was an error loading data', () => { 73 | it('renders an error alert', () => {}); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/views/ImageView/__tests__/ImageDetails.spec.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render } from '@/utils'; 3 | import { createTestingPinia } from '@pinia/testing'; 4 | import userEvent from '@testing-library/user-event'; 5 | import ImageDetails from '../ImageDetails.vue'; 6 | import { imagesApi, favoritesApi, votesApi } from '@/api'; 7 | import useFavoritesStore from '@/stores/favorites'; 8 | import useVotesStore from '@/stores/votes'; 9 | 10 | vi.mock('vue-router', () => ({ 11 | useRoute: () => ({ 12 | params: { id: 'HJ7Pzg5EQ' } 13 | }), 14 | })); 15 | 16 | const store = createTestingPinia({ 17 | initialState: { 18 | favorites: [], 19 | votes: [] 20 | } 21 | }); 22 | 23 | const mockImageReturn = { 24 | data: { 25 | id: 'HJ7Pzg5EQ', 26 | url: 'https://cdn2.thedogapi.com/images/HJ7Pzg5EQ_1280.jpg', 27 | breeds: [{ name: 'Golden Retriever' }] 28 | } 29 | }; 30 | 31 | const mockFavoritesReturn = [{ 32 | id: 41298, 33 | user_id: 'i8sceq', 34 | image_id: 'HJ7Pzg5EQ', 35 | sub_id: null, 36 | created_at: '2022-06-06T15:36:05.000Z', 37 | image: { 38 | id: 'HJ7Pzg5EQ', 39 | url: 'https://cdn2.thedogapi.com/images/HJ7Pzg5EQ.jpg' 40 | } 41 | }]; 42 | 43 | const mockVotesUpvoteReturn = [{ 44 | id: 100517, 45 | image_id: 'HJ7Pzg5EQ', 46 | sub_id: null, 47 | created_at: '2022-06-06T15:39:34.000Z', 48 | value: 1, 49 | country_code: 'US' 50 | }]; 51 | 52 | const mockVotesDownvoteReturn = [{ 53 | id: 100517, 54 | image_id: 'HJ7Pzg5EQ', 55 | sub_id: null, 56 | created_at: '2022-06-06T15:39:34.000Z', 57 | value: 0, 58 | country_code: 'US' 59 | }]; 60 | 61 | const renderComponent = (options = {}) => render(ImageDetails, { global: { plugins: [store] }, ...options }); 62 | 63 | describe('ImageDetails', () => { 64 | 65 | beforeEach(() => { 66 | vi.spyOn(imagesApi, 'findById').mockResolvedValue(mockImageReturn); 67 | }); 68 | 69 | describe('before data has loaded', () => { 70 | 71 | it('renders a loading indicator', () => { 72 | const { getByTestId } = renderComponent(); 73 | const loading = getByTestId('loading-indicator'); 74 | expect(loading).toHaveAttribute('aria-busy', 'true'); 75 | }); 76 | 77 | }); 78 | 79 | describe('when data has been loaded successfully', () => { 80 | 81 | it('renders a heading with the image ID', async () => { 82 | const { findByRole } = renderComponent(); 83 | const heading = await findByRole('heading', { name: `Image ${mockImageReturn.data.id}` }); 84 | expect(heading).toBeInTheDocument(); 85 | }); 86 | 87 | it('renders the image', async () => { 88 | const { findByRole } = renderComponent(); 89 | const image = await findByRole('img'); 90 | expect(image).toHaveAttribute('src', mockImageReturn.data.url); 91 | }); 92 | 93 | it('renders the breeds', async () => { 94 | const { findByText } = renderComponent(); 95 | const breedsText = await findByText('Golden Retriever'); 96 | expect(breedsText).toBeInTheDocument; 97 | }); 98 | 99 | describe('favorite section', () => { 100 | 101 | it('renders a favorite button', async() => { 102 | const { findByRole } = renderComponent(); 103 | const favoriteButton = await findByRole('button', { name: 'Favorite' }); 104 | expect(favoriteButton).toBeInTheDocument(); 105 | }); 106 | 107 | it('renders a delete favorite button', async () => { 108 | const { findByRole } = renderComponent(); 109 | const deleteFavoriteButton = await findByRole('button', { name: 'Delete Favorite' }); 110 | expect(deleteFavoriteButton).toBeInTheDocument(); 111 | }); 112 | 113 | describe('if the image hasn\'t been favorited', () => { 114 | 115 | it('should show \'Not Favorited\'', async () => { 116 | const { findByText } = renderComponent(); 117 | const notFavoritedText = await findByText('Not Favorited'); 118 | expect(notFavoritedText).toBeInTheDocument(); 119 | }); 120 | 121 | it('favorite button should not be red', async () => { 122 | const { findByRole } = renderComponent(); 123 | const favoriteButton = await findByRole('button', { name: 'Favorite' }); 124 | expect(favoriteButton).not.toHaveClass('!border-coral-700 !bg-coral-700'); 125 | }); 126 | 127 | it('delete favorite button should be disabled', async () => { 128 | const { findByRole } = renderComponent(); 129 | const deleteFavoriteButton = await findByRole('button', { name: 'Delete Favorite' }); 130 | expect(deleteFavoriteButton).toBeDisabled(); 131 | }); 132 | 133 | describe('clicking the favorite button', () => { 134 | 135 | it('should call the API to favorite the image', async () => { 136 | vi.spyOn(favoritesApi, 'favorite').mockResolvedValue({ success: true }); 137 | vi.spyOn(favoritesApi, 'findAll').mockResolvedValue(mockFavoritesReturn); 138 | 139 | const { findByRole } = renderComponent(); 140 | const favoriteButton = await findByRole('button', { name: 'Favorite' }); 141 | await userEvent.click(favoriteButton); 142 | expect(favoritesApi.favorite).toHaveBeenCalledWith({ image_id: mockImageReturn.data.id }); 143 | }); 144 | 145 | }); 146 | 147 | }); 148 | 149 | describe('if the image has been favorited', () => { 150 | 151 | beforeEach(() => { 152 | const favoritesStore = useFavoritesStore(); 153 | favoritesStore.favorites = mockFavoritesReturn; 154 | }); 155 | 156 | it('should show \'Favorited\'', async () => { 157 | const { findByText } = renderComponent(); 158 | const favoritedText = await findByText('Favorited'); 159 | expect(favoritedText).toBeInTheDocument(); 160 | }); 161 | 162 | it('favorite button should be red', async () => { 163 | const { findByRole } = renderComponent(); 164 | const favoriteButton = await findByRole('button', { name: 'Favorite' }); 165 | expect(favoriteButton).toHaveClass('!border-coral-700 !bg-coral-700'); 166 | }); 167 | 168 | it('delete favorite button should be enabled', async () => { 169 | const { findByRole } = renderComponent(); 170 | const deleteFavoriteButton = await findByRole('button', { name: 'Delete Favorite' }); 171 | expect(deleteFavoriteButton).not.toBeDisabled(); 172 | }); 173 | 174 | describe('clicking the delete button', () => { 175 | 176 | it('should call the API to delete the favorite', async () => { 177 | vi.spyOn(favoritesApi, 'delete').mockResolvedValue({ success: true }); 178 | vi.spyOn(favoritesApi, 'findAll').mockResolvedValue([]); 179 | 180 | const { findByRole } = renderComponent(); 181 | const deleteFavoriteButton = await findByRole('button', { name: 'Delete Favorite' }); 182 | await userEvent.click(deleteFavoriteButton); 183 | expect(favoritesApi.delete).toHaveBeenCalledWith(mockFavoritesReturn[0].id); 184 | }); 185 | 186 | }); 187 | 188 | }); 189 | 190 | }); 191 | 192 | describe('vote section', () => { 193 | 194 | it('renders upvote and downvote buttons', async () => { 195 | const { findByRole } = renderComponent(); 196 | const upvoteButton = await findByRole('button', { name: 'Upvote' }); 197 | expect(upvoteButton).toBeInTheDocument(); 198 | const downvoteButton = await findByRole('button', { name: 'Downvote' }); 199 | expect(downvoteButton).toBeInTheDocument(); 200 | }); 201 | 202 | describe('if the image hasn\'t been voted on', () => { 203 | 204 | it('should show \'Not Voted\'', async () => { 205 | const { findByText } = renderComponent(); 206 | const breedsText = await findByText('Not Voted'); 207 | expect(breedsText).toBeInTheDocument; 208 | }); 209 | 210 | it('upvote button should not be green', async () => { 211 | const { findByRole } = renderComponent(); 212 | const upvoteButton = await findByRole('button', { name: 'Upvote' }); 213 | expect(upvoteButton).not.toHaveClass('!border-mint-700 !bg-mint-700'); 214 | }); 215 | 216 | it('downvote button should not be red', async () => { 217 | const { findByRole } = renderComponent(); 218 | const downvoteButton = await findByRole('button', { name: 'Downvote' }); 219 | expect(downvoteButton).not.toHaveClass('!border-lemon-700 !bg-lemon-700'); 220 | }); 221 | 222 | }); 223 | 224 | describe('if the image has been upvoted', () => { 225 | 226 | beforeEach(() => { 227 | const votesStore = useVotesStore(); 228 | votesStore.votes = mockVotesUpvoteReturn; 229 | }); 230 | 231 | it('should show \'Upvoted\'', async () => { 232 | const { findByText } = renderComponent(); 233 | const breedsText = await findByText('Upvoted'); 234 | expect(breedsText).toBeInTheDocument; 235 | }); 236 | 237 | it('upvote button should be green', async () => { 238 | const { findByRole } = renderComponent(); 239 | const upvoteButton = await findByRole('button', { name: 'Upvote' }); 240 | expect(upvoteButton).toHaveClass('!border-mint-700 !bg-mint-700'); 241 | }); 242 | 243 | }); 244 | 245 | describe('if the image has been downvoted', () => { 246 | 247 | beforeEach(() => { 248 | const votesStore = useVotesStore(); 249 | votesStore.votes = mockVotesDownvoteReturn; 250 | }); 251 | 252 | it('should show \'Downvoted\'', async () => { 253 | const { findByText } = renderComponent(); 254 | const breedsText = await findByText('Downvoted'); 255 | expect(breedsText).toBeInTheDocument; 256 | }); 257 | 258 | it('downvote button should be yellow', async () => { 259 | const { findByRole } = renderComponent(); 260 | const downvoteButton = await findByRole('button', { name: 'Downvote' }); 261 | expect(downvoteButton).toHaveClass('!border-lemon-700 !bg-lemon-700'); 262 | }); 263 | 264 | }); 265 | 266 | describe('clicking the upvote button', () => { 267 | 268 | it('should call the API to upvote the image', async () => { 269 | vi.spyOn(votesApi, 'vote').mockResolvedValue({ success: true }); 270 | vi.spyOn(votesApi, 'findAll').mockResolvedValue(mockVotesUpvoteReturn); 271 | 272 | const { findByRole } = renderComponent(); 273 | const upvoteButton = await findByRole('button', { name: 'Upvote' }); 274 | await userEvent.click(upvoteButton); 275 | expect(votesApi.vote).toHaveBeenCalledWith({ image_id: mockImageReturn.data.id, value: 1 }); 276 | }); 277 | 278 | }); 279 | 280 | describe('clicking the downvote button', () => { 281 | 282 | it('should call the API to downvote the image', async () => { 283 | vi.spyOn(votesApi, 'vote').mockResolvedValue({ success: true }); 284 | vi.spyOn(votesApi, 'findAll').mockResolvedValue(mockVotesUpvoteReturn); 285 | 286 | const { findByRole } = renderComponent(); 287 | const downvoteButton = await findByRole('button', { name: 'Downvote' }); 288 | await userEvent.click(downvoteButton); 289 | expect(votesApi.vote).toHaveBeenCalledWith({ image_id: mockImageReturn.data.id, value: 0 }); 290 | }); 291 | 292 | }); 293 | 294 | }); 295 | 296 | }); 297 | 298 | }); 299 | -------------------------------------------------------------------------------- /src/views/ImageView/__tests__/ImageList-scaffold.spec.js: -------------------------------------------------------------------------------- 1 | describe('ImageList', () => { 2 | describe('before data has loaded', () => { 3 | it('renders a loading indicator', () => {}); 4 | }); 5 | 6 | describe('when data has been loaded successfully', () => { 7 | 8 | describe('table row', () => { 9 | it('renders one for each dog returned from the API and the header row', () => {}); 10 | it('renders an image', () => {}); 11 | 12 | describe('if there are breeds', () => { 13 | it('renders the breeds', () => {}); 14 | }); 15 | 16 | describe('if there are no breeds', () => { 17 | it('renders \'Unknown\'', () => {}); 18 | }); 19 | 20 | describe('when clicked', () => { 21 | it('goes to the details page', () => {}); 22 | }); 23 | }); 24 | 25 | describe('pagination', () => { 26 | it('renders', () => {}); 27 | 28 | describe('when next page is clicked', () => { 29 | it('changes the URL to the next page', () => {}); 30 | it('calls the API to fetch the next page of data', () => {}); 31 | }); 32 | 33 | describe('when previous page is clicked', () => { 34 | it('changes the URL to the previous page', () => {}); 35 | it('calls the API to fetch the previous page of data', () => {}); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('when there was an error loading data', () => { 41 | it('renders an error alert', () => {}); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/views/ImageView/__tests__/ImageList.spec.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render } from '@/utils'; 3 | import { createTestingPinia } from '@pinia/testing'; 4 | import userEvent from '@testing-library/user-event'; 5 | import ImageList from '../ImageList.vue'; 6 | import { imagesApi } from '@/api'; 7 | 8 | const pushMock = vi.fn(); 9 | 10 | vi.mock('vue-router', () => ({ 11 | useRoute: () => ({ 12 | path: '/images', 13 | query: {} 14 | }), 15 | useRouter: () => ({ 16 | push: pushMock 17 | }) 18 | })); 19 | 20 | const store = createTestingPinia({ 21 | initialState: { 22 | favorites: [], 23 | votes: [] 24 | } 25 | }); 26 | 27 | const mockImagesReturn = { 28 | data: [{ 29 | id: 'B1-llgq4m', 30 | url: 'https://cdn2.thedogapi.com/images/B1-llgq4m_1280.jpg', 31 | breeds: [{ name: 'Australian Shepherd' }] 32 | }, { 33 | id: 'HJ7Pzg5EQ', 34 | url: 'https://cdn2.thedogapi.com/images/HJ7Pzg5EQ_1280.jpg', 35 | breeds: [{ name: 'Golden Retriever' }] 36 | }, { 37 | id: 'AwKd_0wL4', 38 | url: 'https://cdn2.thedogapi.com/images/AwKd_0wL4.jpg' 39 | }], 40 | count: 3 41 | }; 42 | 43 | const renderComponent = (options = {}) => render(ImageList, { global: { plugins: [store] }, ...options }); 44 | 45 | describe('ImageList', () => { 46 | 47 | describe('before data has loaded', () => { 48 | 49 | beforeEach(() => { 50 | vi.spyOn(imagesApi, 'findAll').mockResolvedValue(mockImagesReturn); 51 | }); 52 | 53 | it('renders a loading indicator', () => { 54 | const { getByTestId } = renderComponent(); 55 | const loading = getByTestId('loading-indicator'); 56 | expect(loading).toHaveAttribute('aria-busy', 'true'); 57 | }); 58 | 59 | }); 60 | 61 | describe('when data has been loaded successfully', () => { 62 | 63 | describe('table row', () => { 64 | 65 | beforeEach(() => { 66 | vi.spyOn(imagesApi, 'findAll').mockResolvedValue(mockImagesReturn); 67 | }); 68 | 69 | it('renders one for each dog returned from the API and the header row', async () => { 70 | const { findAllByRole } = renderComponent(); 71 | const rows = await findAllByRole('row'); 72 | expect(rows.length).toEqual(mockImagesReturn.data.length + 1); 73 | }); 74 | 75 | // it('renders an image', () => {}); 76 | 77 | // describe('if there are breeds', () => { 78 | // it('renders the breeds', () => {}); 79 | // }); 80 | 81 | // describe('if there are no breeds', () => { 82 | // it('renders \'Unknown\'', () => {}); 83 | // }); 84 | 85 | describe('when clicked', () => { 86 | 87 | it('goes to the details page', async () => { 88 | const { findByTestId } = renderComponent(); 89 | const row = await findByTestId(`row-${mockImagesReturn.data[0].id}`); 90 | await userEvent.click(row); 91 | expect(pushMock).toHaveBeenLastCalledWith(`/images/${mockImagesReturn.data[0].id}`); 92 | }); 93 | 94 | }); 95 | 96 | }); 97 | 98 | describe('pagination', () => { 99 | 100 | const mockReturn = { 101 | data: Array.from({ length: 11 }).map(() => mockImagesReturn.data[0]), 102 | count: 11 103 | }; 104 | 105 | // it('renders', () => {}); 106 | 107 | describe('when next page is clicked', () => { 108 | 109 | beforeEach(async () => { 110 | vi.spyOn(imagesApi, 'findAll').mockResolvedValue(mockReturn); 111 | }); 112 | 113 | it('changes the URL to the next page', async () => { 114 | const { findByRole } = renderComponent(); 115 | const nextButton = await findByRole('button', { name: 'Go to next page' }); 116 | await userEvent.click(nextButton); 117 | expect(pushMock).toHaveBeenLastCalledWith('/images?page=2'); 118 | }); 119 | 120 | it('calls the API to fetch the next page of data', async () => { 121 | const { findByRole } = renderComponent(); 122 | const nextButton = await findByRole('button', { name: 'Go to next page' }); 123 | await userEvent.click(nextButton); 124 | // API is zero-based, so need to subtract one from the page 125 | expect(imagesApi.findAll).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 })); 126 | }); 127 | 128 | }); 129 | 130 | describe('when previous page is clicked', () => { 131 | 132 | it('changes the URL to the previous page', async () => { 133 | const { findByRole } = renderComponent(); 134 | const nextButton = await findByRole('button', { name: 'Go to next page' }); 135 | await userEvent.click(nextButton); 136 | const previousButton = await findByRole('button', { name: 'Go to previous page' }); 137 | await userEvent.click(previousButton); 138 | expect(pushMock).toHaveBeenLastCalledWith('/images'); 139 | }); 140 | 141 | it('calls the API to fetch the previous page of data', async () => { 142 | const { findByRole } = renderComponent(); 143 | const nextButton = await findByRole('button', { name: 'Go to next page' }); 144 | await userEvent.click(nextButton); 145 | const previousButton = await findByRole('button', { name: 'Go to previous page' }); 146 | await userEvent.click(previousButton); 147 | // API is zero-based, so need to subtract one from the page 148 | expect(imagesApi.findAll).toHaveBeenLastCalledWith(expect.objectContaining({ page: 0 })); 149 | }); 150 | 151 | }); 152 | 153 | }); 154 | 155 | }); 156 | 157 | // describe('when there was an error loading data', () => { 158 | // it('renders an error alert', () => {}); 159 | // }); 160 | }); 161 | 162 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './index.html', 4 | './src/**/*.{vue,js}' 5 | ], 6 | plugins: [ 7 | require('tailwind-plugin-lob') 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'url'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import vue from '@vitejs/plugin-vue'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [vue()], 9 | resolve: { 10 | alias: { 11 | '@': fileURLToPath(new URL('./src', import.meta.url)) 12 | } 13 | }, 14 | test: { 15 | environment: 'jsdom', 16 | globals: true 17 | } 18 | }); 19 | --------------------------------------------------------------------------------