├── .eslintignore ├── config ├── prod.env.js ├── dev.env.js └── index.js ├── src ├── assets │ ├── baturro.gif │ ├── stones.svg │ └── stones-small-red-white_letters.svg ├── statics │ └── stones.png ├── app │ ├── services │ │ ├── MapService.js │ │ ├── jota-instances.js │ │ ├── jota-commands.js │ │ ├── __mocks__ │ │ │ ├── JotaRouter.js │ │ │ ├── jota-api.js │ │ │ ├── gigs-sample.js │ │ │ └── create-fake-gig.js │ │ ├── jota-payloads.js │ │ ├── __tests__ │ │ │ ├── jota-payloads.spec.js │ │ │ ├── jota-api.integration.spec.js │ │ │ └── date-utils.spec.js │ │ ├── jota-api.js │ │ ├── JotaRouter.js │ │ └── date-utils.js │ ├── shared-components │ │ ├── LoadSpinner.vue │ │ ├── FormButton.vue │ │ ├── __test__ │ │ │ ├── codely │ │ │ │ ├── TestingSlots.spec.js │ │ │ │ └── TestingClickButton.spec.js │ │ │ ├── LoadSpinner.spec.js │ │ │ └── FormButton.spec.js │ │ ├── BackToTopButton.vue │ │ ├── Toolbar.vue │ │ ├── TextInput.vue │ │ ├── DateTimeInput.vue │ │ └── SideBar.vue │ ├── fake-backend │ │ ├── __mocks__ │ │ │ └── JotaCore.js │ │ └── JotaCore.js │ ├── pages │ │ ├── NewGig │ │ │ ├── customValidations.js │ │ │ ├── __test__ │ │ │ │ ├── customValidations.spec.js │ │ │ │ ├── codely │ │ │ │ │ ├── TestingSubmit.spec.js │ │ │ │ │ └── TestingValidations.spec.js │ │ │ │ └── NewGig.spec.js │ │ │ └── NewGig.vue │ │ ├── Days │ │ │ ├── GigRowFunctional.vue │ │ │ ├── __test__ │ │ │ │ ├── GigRow.spec.js │ │ │ │ ├── codely │ │ │ │ │ ├── TestingContainer.spec.js │ │ │ │ │ └── TestingRouteNavigation.spec.js │ │ │ │ ├── Day.spec.js │ │ │ │ └── Days.spec.js │ │ │ ├── GigRow.vue │ │ │ ├── Day.vue │ │ │ └── Days.vue │ │ ├── GigDetail │ │ │ ├── __test__ │ │ │ │ ├── codely │ │ │ │ │ ├── TestingWithBackendStubbedByJest.spec.js │ │ │ │ │ ├── TestingRouterWithManualMock.spec.js │ │ │ │ │ └── TestingWithBackendAsProp.spec.js │ │ │ │ └── GigDetail.spec.js │ │ │ └── GigDetail.vue │ │ └── Error404.vue │ └── GlobalComponentsLoader.js ├── setupJest.js ├── index.html ├── themes │ ├── app.ios.styl │ ├── app.mat.styl │ ├── quasar.variables.styl │ └── app.variables.styl ├── jestGlobalMocks.js ├── App.vue ├── main.js └── router.js ├── .gitignore ├── .editorconfig ├── .babelrc ├── jest.config.js ├── .stylintrc ├── .eslintrc.js ├── test ├── helpers.js └── render-utils.js ├── README.md └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | dist/*.js 4 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production"' 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/baturro.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingstones/codelytv-vue-basic/HEAD/src/assets/baturro.gif -------------------------------------------------------------------------------- /src/statics/stones.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingstones/codelytv-vue-basic/HEAD/src/statics/stones.png -------------------------------------------------------------------------------- /src/app/services/MapService.js: -------------------------------------------------------------------------------- 1 | export function openMap(url) { 2 | window.open(url, '_blank', 'location=yes') 3 | } 4 | -------------------------------------------------------------------------------- /src/app/services/jota-instances.js: -------------------------------------------------------------------------------- 1 | import { GigService, Matcher } from '../fake-backend/JotaCore' 2 | 3 | export const gigService = new GigService(new Matcher()) 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .thumbs.db 3 | node_modules/ 4 | dist/ 5 | npm-debug.log* 6 | cordova/platforms 7 | cordova/plugins 8 | .idea 9 | cordova 10 | .quasar 11 | /coverage -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var prodEnv = require('./prod.env') 3 | 4 | module.exports = merge(prodEnv, { 5 | NODE_ENV: '"development"' 6 | }) 7 | -------------------------------------------------------------------------------- /src/app/services/jota-commands.js: -------------------------------------------------------------------------------- 1 | // this commands have the same name that they have in our backend 2 | export const RETRIEVE_DAYS = 'retrieve_days' 3 | export const CREATE_GIG = 'create_gig' 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/setupJest.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | import { sleep } from '../test/render-utils'; 3 | 4 | //Avoid weird async error with Quasar buttons 5 | beforeEach(async ()=> await sleep(1)) 6 | -------------------------------------------------------------------------------- /src/app/services/__mocks__/JotaRouter.js: -------------------------------------------------------------------------------- 1 | const getParam = () => 'any id' 2 | const navigateToAllGigs = jest.fn() 3 | const navigateToGig = jest.fn() 4 | module.exports = { 5 | JotaRouter: () => { 6 | return { getParam, navigateToAllGigs, navigateToGig } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/shared-components/LoadSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 15 | -------------------------------------------------------------------------------- /src/app/fake-backend/__mocks__/JotaCore.js: -------------------------------------------------------------------------------- 1 | import { fakeGigsByDay } from '../../services/__mocks__/gigs-sample' 2 | 3 | class GigService { 4 | retrieveNextGigs() { 5 | return Promise.resolve(fakeGigsByDay) 6 | } 7 | } 8 | 9 | class Matcher {} 10 | 11 | module.exports = { GigService, Matcher } 12 | -------------------------------------------------------------------------------- /src/app/pages/NewGig/customValidations.js: -------------------------------------------------------------------------------- 1 | import { withParams } from 'vuelidate/lib' 2 | 3 | export const isFutureDatetime = withParams({ type: 'isFutureDatetime' }, selectedDatetime => { 4 | if (selectedDatetime === '') return true 5 | let selected = new Date(selectedDatetime).getTime() 6 | let now = Date.now() 7 | return selected > now 8 | }) 9 | -------------------------------------------------------------------------------- /src/app/services/jota-payloads.js: -------------------------------------------------------------------------------- 1 | export function createGigPayload(title, dateTime) { 2 | return { 3 | title: title, 4 | day: dateTime.substring(0, 10), 5 | description: 'Fake gig created by jota-api', 6 | place: 'Fake place', 7 | image_url: 'https://pbs.twimg.com/profile_images/727103317168394240/fY7WRP5T_400x400.jpg' 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/shared-components/FormButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /src/app/services/__mocks__/jota-api.js: -------------------------------------------------------------------------------- 1 | import { fakeGigsByDay, FIRST_GIG } from './gigs-sample' 2 | 3 | const createGig = jest.fn((payload) => { 4 | return Promise.resolve(payload) 5 | }) 6 | 7 | const retrieveAGig = jest.fn((gigId) => { 8 | return Promise.resolve(FIRST_GIG) 9 | }) 10 | 11 | const retrieveDays = jest.fn(() => { 12 | return Promise.resolve(fakeGigsByDay) 13 | }) 14 | 15 | module.exports = { createGig, retrieveDays, retrieveAGig } 16 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": ["transform-runtime", "dynamic-import-node"], 12 | "env": { 13 | "test": { 14 | "presets": ["env", "stage-2"], 15 | "plugins": [ 16 | ["module-resolver", { 17 | "root": ["./src"] 18 | }]] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/services/__tests__/jota-payloads.spec.js: -------------------------------------------------------------------------------- 1 | import { stubNow } from '../../../../test/helpers' 2 | import { createGigPayload } from '../jota-payloads' 3 | 4 | describe('jota payloads', () => { 5 | 6 | beforeEach(() => stubNow('2017-09-18')) 7 | 8 | it('Builds create Gig Payload', () => { 9 | const payload = createGigPayload('a title', '2017-09-18T19:32') 10 | 11 | expect(payload.title).toBe('a title') 12 | expect(payload.day).toBe('2017-09-18') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/app/shared-components/__test__/codely/TestingSlots.spec.js: -------------------------------------------------------------------------------- 1 | import FormButton from '@/app/shared-components/FormButton.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | 4 | describe('FormButton.vue', () => { 5 | it('renders label inside slot', async () => { 6 | 7 | const utils = renderComponent(FormButton, { 8 | slots: { default: 'CLICK ME' } 9 | }) 10 | 11 | expect(await utils.findByText(/click me/i)).toBeInTheDocument() 12 | 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/app/shared-components/BackToTopButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 23 | 25 | -------------------------------------------------------------------------------- /src/app/pages/Days/GigRowFunctional.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /src/app/pages/Days/__test__/GigRow.spec.js: -------------------------------------------------------------------------------- 1 | import GigRow from '@/app/pages/Days/GigRow.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import {screen} from '@testing-library/vue' 4 | 5 | describe('GigRow', () => { 6 | 7 | it('renders gig content', async () => { 8 | const GIG = {id: 'an id', title: 'a title', place: 'a place'} 9 | 10 | renderComponent(GigRow, { props: {gig: GIG} }) 11 | 12 | expect(await screen.findByText('a title')).toBeInTheDocument() 13 | expect(await screen.findByText('a place')).toBeInTheDocument() 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Jota JS 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/app/pages/Days/__test__/codely/TestingContainer.spec.js: -------------------------------------------------------------------------------- 1 | import Days from '@/app/pages/Days/Days.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import { FIRST_DAY } from '../../../../services/__mocks__/gigs-sample' 4 | 5 | jest.mock('@/app/services/jota-api') 6 | 7 | test('renders all gigs titles for the first day of gigs', async() => { 8 | const {screen} = renderComponent(Days) 9 | 10 | const FIRST_DAY_GIG_TITLES = FIRST_DAY.gigs.map(gig => gig.title) 11 | FIRST_DAY_GIG_TITLES.forEach(async text => { 12 | expect(await screen.findByText(text)).toBeInTheDocument() 13 | }) 14 | }) -------------------------------------------------------------------------------- /src/app/shared-components/Toolbar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | 26 | 29 | -------------------------------------------------------------------------------- /src/app/pages/Days/__test__/Day.spec.js: -------------------------------------------------------------------------------- 1 | import Day from '@/app/pages/Days/Day.vue' 2 | import { FIRST_DAY } from '../../../services/__mocks__/gigs-sample' 3 | import { localizedFromIso } from '../../../services/date-utils' 4 | import { renderComponent } from '@test/render-utils' 5 | 6 | describe('Day', () => { 7 | 8 | it('renders gig date in spanish format', async() => { 9 | const {findByText} = await renderComponent(Day, { 10 | props: { day: FIRST_DAY, isLoading: false, onClick: jest.fn } 11 | }) 12 | 13 | expect(await findByText(localizedFromIso(FIRST_DAY.date))).toBeInTheDocument() 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/app/pages/Days/GigRow.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | 19 | 25 | -------------------------------------------------------------------------------- /src/app/shared-components/__test__/LoadSpinner.spec.js: -------------------------------------------------------------------------------- 1 | import LoadSpinner from '@/app/shared-components/LoadSpinner.vue' 2 | import { render } from '@testing-library/vue' 3 | 4 | describe('LoadSpinner.vue', () => { 5 | 6 | it('shows loading', async () => { 7 | const {queryByText} = render(LoadSpinner, {props: { isLoading: false }}) 8 | 9 | expect(queryByText(/Loading.../i)).not.toBeInTheDocument() 10 | }) 11 | 12 | it('does not show loading', async () => { 13 | const {findByText} = render(LoadSpinner, {props: { isLoading: true }}) 14 | 15 | expect(await findByText((/Loading.../i))).toBeInTheDocument() 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/app/pages/GigDetail/__test__/codely/TestingWithBackendStubbedByJest.spec.js: -------------------------------------------------------------------------------- 1 | import GigDetail from '@/app/pages/GigDetail/GigDetail.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import { FIRST_GIG } from '../../../../services/__mocks__/gigs-sample' 4 | 5 | jest.mock('@/app/services/jota-api') 6 | 7 | it('renders details from a Gig', async () => { 8 | const router = {currentRoute : {params: {id: FIRST_GIG.id}}} 9 | 10 | const {screen} = renderComponent(GigDetail, {router}) 11 | 12 | expect(await screen.findByText(FIRST_GIG.title)).toBeInTheDocument() 13 | expect(await screen.findByText(FIRST_GIG.place)).toBeInTheDocument() 14 | }) 15 | -------------------------------------------------------------------------------- /src/themes/app.ios.styl: -------------------------------------------------------------------------------- 1 | // This file is included in the build if src/main.js imports it. 2 | // Otherwise the default iOS CSS file is bundled. 3 | // Check "DEFAULT / CUSTOM STYLE" in src/main.js 4 | 5 | // App Shared Variables 6 | // -------------------------------------------------- 7 | // Shared Stylus variables go in the app.variables.styl file 8 | @import 'app.variables' 9 | 10 | // Quasar iOS Design Stylus 11 | // -------------------------------------------------- 12 | // Custom App variables must be declared before importing Quasar. 13 | // Quasar will use its default values when a custom variable isn't provided. 14 | @import '~quasar-framework/dist/quasar.ios.styl' 15 | -------------------------------------------------------------------------------- /src/themes/app.mat.styl: -------------------------------------------------------------------------------- 1 | // This file is included in the build if src/main.js imports it. 2 | // Otherwise the default Material CSS file is bundled. 3 | // Check "DEFAULT / CUSTOM STYLE" in src/main.js 4 | 5 | // App Shared Variables 6 | // -------------------------------------------------- 7 | // Shared Stylus variables go in the app.variables.styl file 8 | @import 'app.variables' 9 | 10 | // Quasar Material Design Stylus 11 | // -------------------------------------------------- 12 | // Custom App variables must be declared before importing Quasar. 13 | // Quasar will use its default values when a custom variable isn't provided. 14 | @import '~quasar-framework/dist/quasar.mat.styl' 15 | -------------------------------------------------------------------------------- /src/app/pages/GigDetail/__test__/GigDetail.spec.js: -------------------------------------------------------------------------------- 1 | import GigDetail from '@/app/pages/GigDetail/GigDetail.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import { FIRST_GIG } from '../../../services/__mocks__/gigs-sample' 4 | 5 | jest.mock('@/app/services/jota-api') 6 | 7 | describe('Gig Detail', () => { 8 | 9 | it('renders details from a Gig', async () => { 10 | const router = {currentRoute : {params: {id: FIRST_GIG.id}}} 11 | 12 | const {findByText} = await renderComponent(GigDetail, {router}) 13 | 14 | expect(await findByText(FIRST_GIG.title)).toBeInTheDocument() 15 | expect(await findByText(FIRST_GIG.place)).toBeInTheDocument() 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/app/pages/GigDetail/__test__/codely/TestingRouterWithManualMock.spec.js: -------------------------------------------------------------------------------- 1 | import GigDetail from '@/app/pages/GigDetail/GigDetail.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import { FIRST_GIG } from '../../../../services/__mocks__/gigs-sample' 4 | 5 | jest.mock('@/app/services/jota-api') 6 | 7 | test('renders details from a Gig', async () => { 8 | const router = {currentRoute : {params: {id: FIRST_GIG.id}}} 9 | 10 | const {screen} = renderComponent(GigDetail, 11 | { 12 | router 13 | } ) 14 | 15 | expect(await screen.findByText(FIRST_GIG.title)).toBeInTheDocument() 16 | expect(await screen.findByText(FIRST_GIG.place)).toBeInTheDocument() 17 | }) 18 | 19 | -------------------------------------------------------------------------------- /src/app/pages/NewGig/__test__/customValidations.spec.js: -------------------------------------------------------------------------------- 1 | import { isFutureDatetime } from '../customValidations' 2 | 3 | describe('isFutureDatetime', () => { 4 | 5 | beforeEach(() => { 6 | Date.now = jest.fn(()=> new Date('2017-10-12T04:41:20')) 7 | }) 8 | 9 | it('with tomorrow', () => { 10 | expect(isFutureDatetime('2017-10-13T00:00:00')).toBe(true) 11 | }) 12 | 13 | it('with today', () => { 14 | expect(isFutureDatetime('2017-10-12T00:00:00')).toBe(false) 15 | }) 16 | 17 | it('with yesterday', () => { 18 | expect(isFutureDatetime('2017-10-11T00:00:00')).toBe(false) 19 | }) 20 | 21 | it('with empty date', () => { 22 | expect(isFutureDatetime('')).toBe(true) 23 | }) 24 | 25 | }) 26 | -------------------------------------------------------------------------------- /src/jestGlobalMocks.js: -------------------------------------------------------------------------------- 1 | const mock = () => { 2 | let storage = {} 3 | return { 4 | getItem: key => key in storage ? storage[key] : null, 5 | setItem: (key, value) => storage[key] = value || '', 6 | removeItem: key => delete storage[key], 7 | clear: () => storage = {}, 8 | } 9 | } 10 | 11 | Object.defineProperty(window, 'localStorage', {value: mock()}) 12 | Object.defineProperty(window, 'sessionStorage', {value: mock()}) 13 | Object.defineProperty(window, 'getComputedStyle', { 14 | value: () => ['-webkit-appearance'] 15 | }) 16 | 17 | window.getComputedStyle = function(el1, el2) { 18 | return [ 19 | 'transitionDuration' 20 | ] 21 | } 22 | 23 | Object.defineProperty(window, 'localStorage', {value: mock()}) 24 | 25 | -------------------------------------------------------------------------------- /src/themes/quasar.variables.styl: -------------------------------------------------------------------------------- 1 | // 2 | // Webpack alias "variables" points to this file. 3 | // So you can import it in your app's *.vue files 4 | // inside the 12 | 13 | 14 | // First we load app's Stylus variables 15 | @import 'app.variables' 16 | 17 | // Then we load Quasar Stylus variables. 18 | // Any variables defined in "app.variables.styl" 19 | // will override Quasar's ones. 20 | // 21 | // NOTICE that we only import Core Quasar Variables 22 | // like colors, media breakpoints, and so. 23 | // No component variable will be included. 24 | @import '~quasar/dist/core.variables' 25 | -------------------------------------------------------------------------------- /src/app/services/jota-api.js: -------------------------------------------------------------------------------- 1 | import { gigService } from './jota-instances' 2 | 3 | let fakeDays = gigService.retrieveNextGigs() 4 | 5 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) 6 | 7 | export function retrieveDays() { 8 | return delay(1500).then(() => fakeDays) 9 | } 10 | 11 | export function retrieveAGig(gigId) { 12 | return delay(1000).then(() => gigService.retrieveAGig(gigId)) 13 | } 14 | 15 | export function createGig(payload) { 16 | addGigToFakeSample(payload) 17 | return delay(1000).then(() => payload) 18 | } 19 | 20 | function addGigToFakeSample(gig) { 21 | if (fakeDays[gig.day]) { 22 | fakeDays[gig.day].gigs.unshift(gig) 23 | } 24 | else { 25 | fakeDays[gig.day] = { date: gig.day, gigs: [gig] } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/pages/Days/Day.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 25 | 26 | 33 | -------------------------------------------------------------------------------- /src/app/pages/GigDetail/__test__/codely/TestingWithBackendAsProp.spec.js: -------------------------------------------------------------------------------- 1 | import GigDetail from '@/app/pages/GigDetail/GigDetail.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import { createFakeGig } from '../../../../services/__mocks__/create-fake-gig' 4 | 5 | test('renders details from a Gig', async () => { 6 | const GIG = createFakeGig() 7 | const retrieveAGigStub = jest.fn(() => Promise.resolve(GIG)) 8 | const router = {currentRoute : {params: {id: GIG.id}}} 9 | 10 | const {screen} = renderComponent(GigDetail, 11 | { 12 | props: {retrieveAGig: retrieveAGigStub}, 13 | router 14 | } ) 15 | 16 | expect(await screen.findByText(GIG.title)).toBeInTheDocument() 17 | expect(await screen.findByText(GIG.place)).toBeInTheDocument() 18 | }) 19 | 20 | -------------------------------------------------------------------------------- /src/app/services/JotaRouter.js: -------------------------------------------------------------------------------- 1 | import VueRouter from 'vue-router' 2 | 3 | export const JotaRouter = (router) => { 4 | if (!router) router = new VueRouter() 5 | return { navigateToGig, navigateToCreateGig, navigateToAllGigs, getParam } 6 | 7 | function navigateToGig(gigId) { 8 | console.log('ENTRO POR AQUI CO') 9 | router.push('gig/' + gigId) 10 | } 11 | 12 | function navigateToCreateGig() { 13 | router.push({path: '/newGig'}) 14 | } 15 | 16 | function navigateToAllGigs() { 17 | router.push({path: '/all'}) 18 | } 19 | 20 | function getParam(id) { 21 | if (!router) return '' 22 | return router.currentRoute.params[id] 23 | } 24 | } 25 | 26 | export const jotaRouterMixin = { 27 | created: function () { 28 | this.jotaRouter = JotaRouter(this.$router) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/pages/Days/Days.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 33 | 34 | 36 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 30 | 31 | 36 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | "js", 4 | "json", 5 | "vue" 6 | ], 7 | coverageReporters: ['json', 'html', 'text', 'text-summary', 'lcov'], 8 | collectCoverageFrom: [ 9 | '/src/**/*.js', 10 | '!**/node_modules/**', 11 | '!**/vendor/**', 12 | ], 13 | moduleNameMapper: { 14 | "^@/(.*)$": "/src/$1", 15 | "^@test/(.*)$": "/test/$1" 16 | }, 17 | setupFilesAfterEnv: ['/src/setupJest.js'], 18 | transform: { 19 | "^.+\\.js$": "/node_modules/babel-jest", 20 | ".*\\.(vue)$": "/node_modules/vue-jest" 21 | }, 22 | transformIgnorePatterns: [ 23 | "node_modules/(?!quasar-framework)" 24 | ], 25 | testPathIgnorePatterns: [ 26 | "/node_modules/", 27 | "test/e2e" 28 | ], 29 | // snapshotSerializers: [ 30 | // "/node_modules/jest-serializer-vue" 31 | // ] 32 | } -------------------------------------------------------------------------------- /.stylintrc: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": "never", 3 | "brackets": "never", 4 | "colons": "never", 5 | "colors": "always", 6 | "commaSpace": "always", 7 | "commentSpace": "always", 8 | "cssLiteral": "never", 9 | "depthLimit": false, 10 | "duplicates": true, 11 | "efficient": "always", 12 | "extendPref": false, 13 | "globalDupe": true, 14 | "indentPref": 2, 15 | "leadingZero": "never", 16 | "maxErrors": false, 17 | "maxWarnings": false, 18 | "mixed": false, 19 | "namingConvention": false, 20 | "namingConventionStrict": false, 21 | "none": "never", 22 | "noImportant": false, 23 | "parenSpace": "never", 24 | "placeholder": false, 25 | "prefixVarsWithDollar": "always", 26 | "quotePref": "single", 27 | "semicolons": "never", 28 | "sortOrder": false, 29 | "stackedProperties": "never", 30 | "trailingWhitespace": "never", 31 | "universal": "never", 32 | "valid": true, 33 | "zeroUnits": "never", 34 | "zIndexNormalize": false 35 | } 36 | -------------------------------------------------------------------------------- /src/app/services/__tests__/jota-api.integration.spec.js: -------------------------------------------------------------------------------- 1 | import { createGig, retrieveAGig, retrieveDays } from '../jota-api' 2 | import { isoToday } from '../date-utils' 3 | import { createGigPayload } from '../jota-payloads' 4 | 5 | describe('jota api', () => { 6 | 7 | it('Returns valid gigs', async () => { 8 | const days = await retrieveDays() 9 | 10 | expect(days).toBeInstanceOf(Object) 11 | expect(days[isoToday()].gigs.length).toBeGreaterThan(0) 12 | // We should also check gig structure has not changed 13 | }) 14 | 15 | it('Returns a gig by id', async () => { 16 | const gig = await retrieveAGig(1) 17 | 18 | expect(gig).toBeInstanceOf(Object) 19 | expect(typeof gig.title).toBe('string') 20 | }) 21 | 22 | it('Creates a gig', async () => { 23 | const gig = await createGig(createGigPayload('a title', '2007-10-12T12:00')) 24 | 25 | expect(gig).toBeInstanceOf(Object) 26 | expect(typeof gig.title).toBe('string') 27 | expect(gig.title).toBe('a title') 28 | expect(gig.day).toBe('2007-10-12') 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/app/shared-components/__test__/codely/TestingClickButton.spec.js: -------------------------------------------------------------------------------- 1 | import FormButton from '@/app/shared-components/FormButton.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import userEvent from '@testing-library/user-event' 4 | import { render } from '@testing-library/vue' 5 | import { mount } from '@vue/test-utils' 6 | 7 | describe('When clicking', () => { 8 | 9 | it('calls callback if enabled', async () => { 10 | 11 | const clickSpy = jest.fn() 12 | const utils = renderComponent(FormButton, { 13 | props: {onClick: clickSpy}, 14 | slots: {default: 'Click Me'} 15 | }) 16 | 17 | userEvent.click(await utils.findByText(/Click me/i)) 18 | 19 | expect(clickSpy).toHaveBeenCalled() 20 | }) 21 | 22 | it('does not call callback if disabled', async () => { 23 | 24 | const clickSpy = jest.fn() 25 | const utils = renderComponent(FormButton, { 26 | props: {onClick: clickSpy, disabled: true}, 27 | slots: {default: 'Click Me'} 28 | }) 29 | 30 | expect(clickSpy).not.toHaveBeenCalled() 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | sourceType: 'module' 6 | }, 7 | env: { 8 | browser: true, 9 | jest: true 10 | }, 11 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 12 | extends: [ 13 | 'standard' 14 | ], 15 | // required to lint *.vue files 16 | plugins: [ 17 | 'html', 18 | 'import' 19 | ], 20 | globals: { 21 | 'cordova': true, 22 | 'DEV': true, 23 | 'PROD': true, 24 | '__THEME': true, 25 | // defined in src/setupJest.js 26 | keepsSnapshot: true, 27 | }, 28 | // add your custom rules here 29 | 'rules': { 30 | 'space-before-function-paren': ['off'], 31 | // allow paren-less arrow functions 32 | 'arrow-parens': 0, 33 | 'one-var': 0, 34 | 'import/first': 0, 35 | 'import/named': 2, 36 | 'import/namespace': 2, 37 | 'import/default': 2, 38 | 'import/export': 2, 39 | // allow debugger during development 40 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 41 | 'brace-style': [2, 'stroustrup', { 'allowSingleLine': true }] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/themes/app.variables.styl: -------------------------------------------------------------------------------- 1 | // This file is included in the build if src/main.js imports 2 | // either app.mat.styl or app.ios.styl. 3 | // Check "DEFAULT / CUSTOM STYLE" in src/main.js 4 | 5 | // App Shared Variables 6 | // -------------------------------------------------- 7 | // To customize the look and feel of this app, you can override 8 | // the Stylus variables found in Quasar's source Stylus files. Setting 9 | // variables before Quasar's Stylus will use these variables rather than 10 | // Quasar's default Stylus variable values. Stylus variables specific 11 | // to the themes belong in either the app.ios.styl or app.mat.styl files. 12 | 13 | 14 | // App Shared Color Variables 15 | // -------------------------------------------------- 16 | // It's highly recommended to change the default colors 17 | // to match your app's branding. 18 | 19 | $primary = #795548 20 | $secondary = #FF5722 21 | 22 | $neutral = #E0E1E2 23 | $positive = #21BA45 24 | $negative = #DB2828 25 | $info = #31CCEC 26 | $warning = #F2C037 27 | 28 | $layout-aside-background = #FFFFFF 29 | $body-background = #FFFFFF 30 | $item-font-size = 1.1rem 31 | 32 | //$body-color = $secondary 33 | //$toolbar-color = red 34 | // $item-stripe-color = yellow 35 | //$item-highlight-color = alpha($secondary, 30%) 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // === DEFAULT / CUSTOM STYLE === 2 | // WARNING! always comment out ONE of the two require() calls below. 3 | // 1. use next line to activate CUSTOM STYLE (./src/themes) 4 | import { jotaRouterMixin } from './app/services/JotaRouter' 5 | 6 | require(`./themes/app.${__THEME}.styl`) 7 | // 2. or, use next line to activate DEFAULT QUASAR STYLE 8 | // require(`quasar/dist/quasar.${__THEME}.css`) 9 | // ============================== 10 | 11 | // Uncomment the following lines if you need IE11/Edge support 12 | // require(`quasar/dist/quasar.ie`) 13 | // require(`quasar/dist/quasar.ie.${__THEME}.css`) 14 | 15 | // Import all transitions (it will include unused ones KB's) 16 | import 'quasar-extras/animate' 17 | 18 | import Vue from 'vue' 19 | import Quasar from 'quasar-framework' 20 | import Vuelidate from 'vuelidate' 21 | import router from './router' 22 | 23 | Vue.use(Quasar) 24 | Vue.config.productionTip = false 25 | Vue.use(Vuelidate) 26 | Vue.mixin(jotaRouterMixin) 27 | 28 | if (__THEME === 'mat') { 29 | require('quasar-extras/roboto-font') 30 | } 31 | import 'quasar-extras/material-icons' 32 | import 'quasar-extras/ionicons' 33 | // import 'quasar-extras/fontawesome' 34 | 35 | Quasar.start(() => { 36 | /* eslint-disable no-new */ 37 | new Vue({ 38 | el: '#q-app', 39 | router, 40 | render: h => h(require('./App').default) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/app/services/date-utils.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment') 2 | require('moment/locale/es') 3 | const ISO_FORMAT = 'YYYY-MM-DD' 4 | 5 | // Default locale: 6 | let locale = 'es' 7 | export function setLocale(localeCode) { 8 | locale = localeCode 9 | } 10 | 11 | export function isoToday() { 12 | return moment().format(ISO_FORMAT) 13 | } 14 | 15 | export function isoTomorrow() { 16 | return moment().add(1, 'days').format(ISO_FORMAT) 17 | } 18 | 19 | export function isoFuture(daysFromNow) { 20 | return moment().add(daysFromNow, 'days').format(ISO_FORMAT) 21 | } 22 | 23 | export function localizedToday() { 24 | return localize(moment()) 25 | } 26 | 27 | export function localizedTomorrow() { 28 | return localize(moment().add(1, 'days')) 29 | } 30 | 31 | export function localizedFromIso(isoDate) { 32 | return localize(moment(isoDate, ISO_FORMAT)) 33 | } 34 | 35 | function localize(date) { 36 | return removeLeadingZeros(toTitleCase(date.locale(locale).format(`dddd, DD [${localizedSeparator(locale)}] MMMM`))) 37 | } 38 | 39 | function removeLeadingZeros(formattedDate) { 40 | return formattedDate.replace(/ 0+/g, ' ') 41 | } 42 | 43 | function localizedSeparator(locale) { 44 | if (locale === 'en') return 'of' 45 | return 'de' 46 | } 47 | 48 | function toTitleCase(str) { 49 | return str.replace(/\w\S*/g, function(txt) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() }) 50 | } 51 | -------------------------------------------------------------------------------- /src/app/pages/Days/__test__/codely/TestingRouteNavigation.spec.js: -------------------------------------------------------------------------------- 1 | import Days from '@/app/pages/Days/Days.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import { FIRST_DAY } from '../../../../services/__mocks__/gigs-sample' 4 | import {screen} from '@testing-library/vue' 5 | import userEvent from '@testing-library/user-event' 6 | 7 | jest.mock('@/app/services/jota-api') 8 | 9 | describe('Should allow', () => { 10 | 11 | it('navigating to first gig detail', async () => { 12 | const FIRST_GIG = FIRST_DAY.gigs[0] 13 | const navigateToGigSpy = jest.fn() 14 | const screen = await renderDays({navigateToGig: navigateToGigSpy}) 15 | 16 | await screen.openGig(FIRST_GIG.title) 17 | 18 | expect(navigateToGigSpy).toHaveBeenCalledWith(FIRST_GIG.id) 19 | }) 20 | 21 | it('navigating to second gig detail', async () => { 22 | const SECOND_GIG = FIRST_DAY.gigs[1] 23 | const navigateToGigSpy = jest.fn() 24 | const screen = await renderDays({navigateToGig: navigateToGigSpy}) 25 | 26 | await screen.openGig(SECOND_GIG.title) 27 | 28 | expect(navigateToGigSpy).toHaveBeenCalledWith(SECOND_GIG.id) 29 | }) 30 | }) 31 | 32 | async function renderDays(jotaRouterInstance) { 33 | await renderComponent(Days, { 34 | jotaRouter: ()=> jotaRouterInstance 35 | }) 36 | const openGig = async title => userEvent.click((await screen.findByText(title))) 37 | 38 | return {...screen, openGig} 39 | } -------------------------------------------------------------------------------- /src/app/services/__tests__/date-utils.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | localizedToday, isoToday, isoTomorrow, localizedFromIso, localizedTomorrow, 3 | setLocale 4 | } from '../date-utils' 5 | import { stubNow } from '../../../../test/helpers' 6 | 7 | describe('date utils', () => { 8 | 9 | beforeEach(() => stubNow('2017-09-02')) 10 | 11 | it('Prints today in ISO', () => { 12 | expect(isoToday()).toBe('2017-09-02') 13 | }) 14 | 15 | it('Prints tomorrow in ISO', () => { 16 | expect(isoTomorrow()).toBe('2017-09-03') 17 | }) 18 | 19 | describe('When locale is english', () => { 20 | 21 | beforeEach(() => setLocale('en')) 22 | 23 | it('Prints todays date', () => { 24 | expect(localizedToday()).toBe('Saturday, 2 Of September') 25 | }) 26 | 27 | it('Prints tomorrows date', () => { 28 | expect(localizedTomorrow()).toBe('Sunday, 3 Of September') 29 | }) 30 | }) 31 | 32 | describe('When locale is spanish', () => { 33 | 34 | beforeEach(() => setLocale('es')) 35 | 36 | it('Prints todays date', () => { 37 | expect(localizedToday()).toBe('Sábado, 2 De Septiembre') 38 | }) 39 | 40 | it('Prints tomorrows date', () => { 41 | expect(localizedTomorrow()).toBe('Domingo, 3 De Septiembre') 42 | }) 43 | 44 | it('translate ISO date', () => { 45 | expect(localizedFromIso('2017-09-18')).toBe('Lunes, 18 De Septiembre') 46 | expect(localizedFromIso('2017-11-04')).toBe('Sábado, 4 De Noviembre') 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/app/pages/Error404.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 44 | 45 | 63 | -------------------------------------------------------------------------------- /src/app/shared-components/__test__/FormButton.spec.js: -------------------------------------------------------------------------------- 1 | import FormButton from '@/app/shared-components/FormButton.vue' 2 | import { renderComponent } from '@test/render-utils' 3 | import userEvent from '@testing-library/user-event' 4 | 5 | describe('FormButton.vue', () => { 6 | 7 | const SLOT_CONTENT = 'click me' 8 | 9 | it('renders label', async () => { 10 | 11 | const {findByText} = renderComponent(FormButton, { 12 | props: {isLoading: false, disabled: false}, 13 | slots: {default: SLOT_CONTENT} 14 | }) 15 | 16 | expect(await findByText(SLOT_CONTENT)).toBeInTheDocument() 17 | }) 18 | 19 | describe('When clicking', () => { 20 | 21 | it('calls callback if enabled', async () => { 22 | const clickSpy = jest.fn() 23 | const {findByText} = renderComponent(FormButton, { 24 | props: {isLoading: false, disabled: false, onClick: clickSpy}, 25 | slots: {default: SLOT_CONTENT} 26 | }) 27 | 28 | userEvent.click(await findByText(SLOT_CONTENT)) 29 | 30 | expect(clickSpy).toHaveBeenCalled() 31 | }) 32 | 33 | it('does not call callback if disabled', async () => { 34 | const clickSpy = jest.fn() 35 | const {findByText} = renderComponent(FormButton, { 36 | props: {isLoading: false, disabled: true, onClick: clickSpy}, 37 | slots: {default: SLOT_CONTENT} 38 | }) 39 | 40 | userEvent.click(await findByText(SLOT_CONTENT)) 41 | 42 | expect(clickSpy).not.toHaveBeenCalled() 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | import VueRouter from 'vue-router' 2 | import { mount as _mount, shallow as _shallow } from '@vue/test-utils' 3 | 4 | export function resolvedPromise(promiseResult) { 5 | return jest.fn(() => Promise.resolve(promiseResult)) 6 | } 7 | 8 | export function rejectedPromise(promiseError) { 9 | return jest.fn(() => Promise.reject(promiseError)) 10 | } 11 | 12 | export function stubNow(isoDate) { 13 | Date.now = jest.fn(() => new Date(isoDate).valueOf()) 14 | } 15 | 16 | export function stubDate(isoDate) { 17 | Date.now = jest.fn(() => new Date(isoDate).valueOf()) 18 | } 19 | 20 | export function Wrap(component) { 21 | 22 | return {mount, shallow, withProps, withSlots, withRouter, withStore, config} 23 | 24 | function withProps(props) { 25 | this.props = props 26 | return this 27 | } 28 | 29 | function withStore(store) { 30 | this.store = store 31 | this.router = new VueRouter() 32 | sync(this.store, this.router) 33 | return this 34 | } 35 | 36 | function withRouter(router) { 37 | this.router = router 38 | return this 39 | } 40 | 41 | function withSlots(slots) { 42 | this.slots = slots 43 | return this 44 | } 45 | 46 | function mount() { 47 | return _mount(component, this.config()) 48 | } 49 | 50 | function shallow() { 51 | return _shallow(component, this.config()) 52 | } 53 | 54 | function config() { 55 | return { propsData: this.props, slots: this.slots, router: this.router, store: this.store } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/shared-components/TextInput.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 55 | 60 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | 4 | Vue.use(VueRouter) 5 | 6 | function load (component) { 7 | // '@' is aliased to src/, 8 | // We will use lady loading when the app needs it 9 | // for now is not worthy to add code splitting 10 | return () => import(`@/app/pages/${component}.vue`) 11 | } 12 | import Days from '@/app/pages/Days/Days.vue' 13 | import NewGig from '@/app/pages/NewGig/NewGig' 14 | import GigDetail from '@/app/pages/GigDetail/GigDetail' 15 | 16 | export const NEW_GIG_PATH = '/newGig' 17 | export const ALL_GIGS_PATH = '/all' 18 | 19 | const router = new VueRouter({ 20 | /* 21 | * NOTE! VueRouter "history" mode DOESN'T works for Cordova builds, 22 | * it is only to be used only for websites. 23 | * 24 | * If you decide to go with "history" mode, please also open /config/index.js 25 | * and set "build.publicPath" to something other than an empty string. 26 | * Example: '/' instead of current '' 27 | * 28 | * If switching back to default "hash" mode, don't forget to set the 29 | * build publicPath back to '' so Cordova builds work again. 30 | */ 31 | // mode: 'history', 32 | routes: 33 | [ 34 | { path: '/', component: Days, title: 'root' }, 35 | { path: ALL_GIGS_PATH, component: Days, title: 'all' }, 36 | { path: '/gig/:id', component: GigDetail, title: 'gig' }, 37 | { path: NEW_GIG_PATH, component: NewGig, title: 'newGig' }, 38 | { path: '*', component: load('Error404') } 39 | ], 40 | scrollBehavior (to, from, savedPosition) { 41 | return { x: 0, y: 0 } 42 | } 43 | }) 44 | 45 | export default router 46 | -------------------------------------------------------------------------------- /src/app/shared-components/DateTimeInput.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 58 | 63 | -------------------------------------------------------------------------------- /src/app/shared-components/SideBar.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 49 | 50 | 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JotasJS Basic 2 | 3 | > Demo project for the CodelyTV Pro video course `Building an app with VueJS and Jest using TDD` you may find in: [Crea una app con VueJS y Jest aplicando TDD](https://pro.codely.tv/library/crea-una-app-con-vuejs-y-jest-aplicando-tdd/65211/path/) 4 | 5 | _Note that although all the videos and course contents are in Spanish, this repo is only available in English._ 6 | 7 | ## 🦎🦎🦎🦎🦎🦎🦎🦎🦎🦎 DISCLAIMER (Jan 2021) 🦎🦎🦎🦎🦎 8 | 9 | This project is very old and tests were a bit outdated 😓 10 | 11 | We just migrated every test to Vue Testing Library and set `testing-library` as the default branch 🔥 12 | 13 | (You can always jump to `jotas` branch if you want to check the old fashioned tests). 14 | 15 | ## Getting Started! 16 | 17 | ``` bash 18 | # install dependencies 19 | $ yarn install 20 | or 21 | $ npm install 22 | 23 | # run tests 24 | $ yarn unit 25 | or 26 | $ npm run unit 27 | 28 | # run tests in watch mode 29 | $ yarn unit:watch 30 | or 31 | $ npm run unit:watch 32 | 33 | # run tests in with coverage 34 | $ yarn unit:coverage 35 | or 36 | $ npm run unit:coverage 37 | 38 | # serve with hot reload at localhost:8080 39 | $ yarn dev mat 40 | or 41 | $ npm run dev mat 42 | 43 | 44 | # 'mat' was the material theme, try iOS with 45 | $ yarn dev ios 46 | or 47 | $ npm run dev ios 48 | 49 | # build for production with minification 50 | $ quasar build 51 | ``` 52 | 53 | ## Going Mobile! 54 | 55 | You may want to wrap the App into a native mobile App. Given you already have Cordova and an Android or iOS SDK installed in your system, run: 56 | 57 | 58 | ``` 59 | quasar wrap cordova 60 | cordova platform add android 61 | cordova run android 62 | ``` 63 | 64 | For full details, take a look to the Quasar [guide](http://quasar-framework.org/guide/cordova-wrapper.html). 65 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | 3 | module.exports = { 4 | // Webpack aliases 5 | aliases: { 6 | quasar: path.resolve(__dirname, '../node_modules/quasar-framework/'), 7 | src: path.resolve(__dirname, '../src'), 8 | assets: path.resolve(__dirname, '../src/assets'), 9 | '@': path.resolve(__dirname, '../src'), 10 | variables: path.resolve(__dirname, '../src/themes/quasar.variables.styl') 11 | }, 12 | 13 | // Progress Bar Webpack plugin format 14 | // https://github.com/clessg/progress-bar-webpack-plugin#options 15 | progressFormat: ' [:bar] ' + ':percent'.bold + ' (:msg)', 16 | 17 | // Default theme to build with ('ios' or 'mat') 18 | defaultTheme: 'mat', 19 | 20 | build: { 21 | env: require('./prod.env'), 22 | publicPath: '', 23 | productionSourceMap: false, 24 | 25 | // Remove unused CSS 26 | // Disable it if it has side-effects for your specific app 27 | purifyCSS: true 28 | }, 29 | dev: { 30 | env: require('./dev.env'), 31 | cssSourceMap: true, 32 | // auto open browser or not 33 | openBrowser: true, 34 | publicPath: '/', 35 | port: 8080, 36 | 37 | // If for example you are using Quasar Play 38 | // to generate a QR code then on each dev (re)compilation 39 | // you need to avoid clearing out the console, so set this 40 | // to "false", otherwise you can set it to "true" to always 41 | // have only the messages regarding your last (re)compilation. 42 | clearConsoleOnRebuild: false, 43 | 44 | // Proxy your API if using any. 45 | // Also see /build/script.dev.js and search for "proxy api requests" 46 | // https://github.com/chimurai/http-proxy-middleware 47 | proxyTable: {} 48 | } 49 | } 50 | 51 | /* 52 | * proxyTable example: 53 | * 54 | proxyTable: { 55 | // proxy all requests starting with /api 56 | '/api': { 57 | target: 'https://some.address.com/api', 58 | changeOrigin: true, 59 | pathRewrite: { 60 | '^/api': '' 61 | } 62 | } 63 | } 64 | */ 65 | -------------------------------------------------------------------------------- /src/app/pages/NewGig/__test__/codely/TestingSubmit.spec.js: -------------------------------------------------------------------------------- 1 | import NewGig from '@/app/pages/NewGig/NewGig.vue' 2 | import { createGig as createGigSpy } from '@/app/services/jota-api' 3 | import { createGigPayload } from '@/app/services/jota-payloads' 4 | import { renderComponent, tomorrow, tomorrowDayOfMonth } from '@test/render-utils' 5 | import userEvent from '@testing-library/user-event' 6 | jest.mock('@/app/services/jota-api') 7 | 8 | describe('When clicking save button', () => { 9 | it('calls backend with appropriate command', async () => { 10 | 11 | const {typeGigName, setGigDate, findCreateGigButton} = await renderNewGig() 12 | 13 | typeGigName(nameWithValidLength()) 14 | await setGigDate(tomorrowDayOfMonth()) 15 | 16 | userEvent.click(await findCreateGigButton()) 17 | expect(createGigSpy).toHaveBeenCalledWith(createGigPayload(nameWithValidLength(), tomorrow().toISOString())) 18 | }) 19 | }) 20 | 21 | async function renderNewGig() { 22 | const {screen} = renderComponent(NewGig) 23 | 24 | //it would be much better to use a label but for now q-input does not bind label with input 25 | // (we should modify q-input to force that binding or maybe using aria-label as a workaround) 26 | const nameInput = (await screen.findAllByRole('textbox'))[0] 27 | 28 | const typeGigName = (name)=> { 29 | userEvent.type(nameInput, name) 30 | } 31 | 32 | const setGigDate = async (dayText) => { 33 | userEvent.click(await screen.findByText(/Date and time/i)) 34 | userEvent.click(await screen.findByText(dayText)) 35 | userEvent.click(await screen.findByText(/set/i)) 36 | //Wait for date set and rendered 37 | await screen.findByText(/\//i) 38 | } 39 | 40 | const findCreateGigButton = async ()=> (await screen.findByText(/Create Gig/i)).closest('button') 41 | 42 | return {screen, typeGigName, setGigDate, findCreateGigButton} 43 | } 44 | 45 | function nameWithValidLength() { 46 | return nameWithLength(5) 47 | } 48 | 49 | function nameWithLength(length) { 50 | return 'x'.repeat(length) 51 | } 52 | -------------------------------------------------------------------------------- /src/app/services/__mocks__/gigs-sample.js: -------------------------------------------------------------------------------- 1 | export const fakeGigsByDay = 2 | { 3 | '2017-09-18': { 4 | 'date': '2017-09-18', 5 | 'gigs': [ 6 | { 7 | 'id': '123456', 8 | 'title': 'Anarchy in the Jota S', 9 | 'lat_lng': '41.641851935961654,-0.8751129897638315', 10 | 'address': 'BCN', 11 | 'description': 'Coding Stones cantando Joticas', 12 | 'price': '', 13 | 'image_url': 'http://www.zaragoza.es/cont/paginas/actividades/imagen/web_320x480px.png', 14 | 'place': 'SCBCN', 15 | 'slug': 'stones', 16 | 'schedule_id': 5280, 17 | 'hour': '19:30', 18 | 'day': '2017-09-18', 19 | 'schedule': [ 20 | { 21 | 'id': 5280, 22 | 'day': '2017-09-18', 23 | 'hour': '19:30', 24 | 'gig_id': 5927, 25 | 'created_at': '2017-09-07T15:57:35.419Z', 26 | 'updated_at': '2017-09-07T15:57:35.419Z' 27 | } 28 | ] 29 | }, 30 | { 31 | 'id': '2222222', 32 | 'title': 'Jotas de Codely TV', 33 | 'lat_lng': '41.641851935961654,-0.8751129897638315', 34 | 'address': 'BCN', 35 | 'description': 'Javi y Rafa cantando jotas', 36 | 'price': '', 37 | 'image_url': 'http://www.wtf.es/wtf.png', 38 | 'affiliate_url': null, 39 | 'place': 'Around the world', 40 | 'slug': 'stones', 41 | 'schedule_id': 5280, 42 | 'hour': '19:30', 43 | 'day': '2017-09-18', 44 | 'schedule': [ 45 | { 46 | 'id': 5280, 47 | 'day': '2017-09-18', 48 | 'hour': '19:30', 49 | 'gig_id': 5927, 50 | 'created_at': '2017-09-07T15:57:35.419Z', 51 | 'updated_at': '2017-09-07T15:57:35.419Z' 52 | } 53 | ] 54 | } 55 | ] 56 | } 57 | } 58 | 59 | export const FIRST_DAY = fakeGigsByDay[Object.keys(fakeGigsByDay)[0]] 60 | export const FIRST_GIG = FIRST_DAY.gigs[0] 61 | export const DAY_LIST = Object.values(fakeGigsByDay) 62 | -------------------------------------------------------------------------------- /src/app/pages/NewGig/__test__/codely/TestingValidations.spec.js: -------------------------------------------------------------------------------- 1 | import NewGig from '@/app/pages/NewGig/NewGig.vue' 2 | import DateTimeInput from '@/app/shared-components/DateTimeInput.vue' 3 | import { renderComponent, yesterdayDayOfMonth } from '@test/render-utils' 4 | import userEvent from '@testing-library/user-event' 5 | import Vue from 'vue' 6 | 7 | jest.mock('@/app/services/jota-api') 8 | 9 | describe('shows validation error', () => { 10 | 11 | it('when title is too short', async() => { 12 | const {typeGigName, screen} = await renderNewGig() 13 | 14 | typeGigName(tooShortName()) 15 | 16 | expect(await screen.findByText('Minimum 5 characters.')).toBeInTheDocument() 17 | }) 18 | 19 | it('when datetime is in the past', async () => { 20 | 21 | const {screen, setGigDate} = await renderNewGig() 22 | 23 | await setGigDate(yesterdayDayOfMonth()) 24 | 25 | expect(await screen.findByText('You cannot set a gig in a past date :(')).toBeInTheDocument() 26 | }) 27 | }) 28 | 29 | async function renderNewGig() { 30 | const {screen} = renderComponent(NewGig) 31 | 32 | //it would be much better to use a label but for now q-input does not bind label with input 33 | // (we should modify q-input to force that binding or maybe using aria-label as a workaround) 34 | const nameInput = (await screen.findAllByRole('textbox'))[0] 35 | 36 | const typeGigName = (name)=> { 37 | userEvent.type(nameInput, name) 38 | } 39 | 40 | const setGigDate = async (dayText) => { 41 | userEvent.click(await screen.findByText(/Date and time/i)) 42 | userEvent.click(await screen.findByText(dayText)) 43 | userEvent.click(await screen.findByText(/set/i)) 44 | //Wait for date set and rendered 45 | await screen.findByText(/\//i) 46 | } 47 | 48 | const findCreateGigButton = async ()=> (await screen.findByText(/Create Gig/i)).closest('button') 49 | 50 | return {screen, typeGigName, setGigDate, findCreateGigButton} 51 | } 52 | 53 | function tooShortName() { 54 | return nameWithLength(3) 55 | } 56 | 57 | function nameWithLength(length) { 58 | return 'x'.repeat(length) 59 | } 60 | 61 | function wait() { 62 | return Vue.nextTick() 63 | } 64 | -------------------------------------------------------------------------------- /src/app/fake-backend/JotaCore.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable eqeqeq */ 2 | import { createFakeDays } from '../services/__mocks__/create-fake-gig' 3 | 4 | export class Gig { 5 | constructor(args) { 6 | Object.assign(this, args) 7 | } 8 | } 9 | 10 | export class GigService { 11 | constructor(matcher) { 12 | this._gigs = [] 13 | this._gigs_by_day = [] 14 | this._matcher = matcher 15 | } 16 | 17 | retrieveNextGigs() { 18 | const days = createFakeDays() 19 | Object.keys(days).forEach((date) => this._gigs.push(...days[date].gigs)) 20 | return days 21 | } 22 | 23 | retrieveAGig(id) { 24 | return new Promise((resolve, reject) => { 25 | let gig = this._gigs.find((gig) => { return (gig.id == parseInt(id)) }) 26 | 27 | if (gig) { 28 | resolve(gig) 29 | } 30 | else { 31 | throw Error('Gig not found') 32 | } 33 | }) 34 | } 35 | 36 | searchGigsGroupedByDay(term) { 37 | return new Promise((resolve, reject) => { 38 | const daysWithGigs = [] 39 | this._gigs_by_day.forEach((day) => { 40 | const gigs = day.gigs 41 | .filter((gig) => { 42 | return this._gigIsMatching(gig, term) 43 | }).map((gig) => { 44 | return new Gig(gig) 45 | }) 46 | 47 | if (gigs.length > 0) { 48 | daysWithGigs.push({day: day.day, gigs: gigs}) 49 | } 50 | }) 51 | resolve(daysWithGigs) 52 | }) 53 | } 54 | 55 | searchGigs(term) { 56 | return new Promise((resolve, reject) => { 57 | let matches = [] 58 | this._gigs.forEach((gig) => { 59 | if (this._gigIsMatching(gig, term)) { 60 | matches.push(new Gig(gig)) 61 | } 62 | }) 63 | resolve(matches) 64 | }) 65 | } 66 | 67 | _gigIsMatching(gig, term) { 68 | return this._matcher.hasTheTerm(gig.title, term) || this._matcher.hasTheTerm(gig.place, term) 69 | } 70 | } 71 | 72 | export class Matcher { 73 | constructor() { 74 | this.FROM = 'ÃÀÁÄÂÈÉËÊÌÍÏÎÒÓÖÔÙÚÜÛ' 75 | this.TO = 'AAAAAEEEEIIIIOOOOUUUU' 76 | } 77 | 78 | hasTheTerm(text, term) { 79 | if (text) { 80 | text = this.normalize(text.toUpperCase()) 81 | term = this.normalize(term.toUpperCase()) 82 | return (text.indexOf(term) > -1) 83 | } 84 | } 85 | 86 | normalize(aString) { 87 | this.FROM.split('').forEach((changeFrom, index) => { 88 | const changeTo = this.TO[index] 89 | aString = aString.replace(changeFrom, changeTo) 90 | }) 91 | return aString 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/app/pages/NewGig/NewGig.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 86 | -------------------------------------------------------------------------------- /src/app/pages/Days/__test__/Days.spec.js: -------------------------------------------------------------------------------- 1 | import Days from '@/app/pages/Days/Days.vue' 2 | import { FIRST_DAY, DAY_LIST } from '../../../services/__mocks__/gigs-sample' 3 | import { localizedFromIso } from '../../../services/date-utils' 4 | import {within} from '@testing-library/vue' 5 | import { renderComponent } from '@test/render-utils' 6 | import userEvent from '@testing-library/user-event' 7 | jest.mock('@/app/services/jota-api') 8 | 9 | describe('Days', () => { 10 | const FIRST_DAY_GIG_TITLES = FIRST_DAY.gigs.map(gig => gig.title) 11 | 12 | it('renders all gigs in the first day', async() => { 13 | const screen = renderComponent(Days) 14 | 15 | FIRST_DAY_GIG_TITLES.forEach(async text => { 16 | expect(await screen.findByText(text)).toBeInTheDocument() 17 | }) 18 | }) 19 | 20 | describe('When clicking buttons', () => { 21 | 22 | it('navigates to first gig detail', async () => { 23 | const FIRST_GIG = FIRST_DAY.gigs[0] 24 | const navigateToGigSpy = jest.fn() 25 | const screen = await renderDays({navigateToGig: navigateToGigSpy}) 26 | 27 | await screen.openGig(FIRST_GIG.title) 28 | 29 | expect(navigateToGigSpy).toHaveBeenCalledWith(FIRST_GIG.id) 30 | }) 31 | 32 | it('navigates to second gig detail', async () => { 33 | const SECOND_GIG = FIRST_DAY.gigs[1] 34 | const navigateToGigSpy = jest.fn() 35 | const screen = await renderDays({navigateToGig: navigateToGigSpy}) 36 | 37 | await screen.openGig(SECOND_GIG.title) 38 | 39 | expect(navigateToGigSpy).toHaveBeenCalledWith(SECOND_GIG.id) 40 | }) 41 | }) 42 | 43 | // /* Different examples of more accurate tests that need 44 | // to explicitly run over the DOM structure 45 | // */ 46 | it('render days in localized format', async () => { 47 | const screen = await renderDays() 48 | DAY_LIST.forEach(async (day) => { 49 | expect(await screen.findByText(localizedFromIso(day.date))).toBeInTheDocument() 50 | }) 51 | }) 52 | 53 | it('render gigs for each day', async () => { 54 | const screen = await renderDays() 55 | DAY_LIST.forEach(day => { 56 | const gigTitlesInDay = day.gigs.map((gig) => gig.title + ' ' + gig.place) 57 | const dayElement = screen.getByText(localizedFromIso(day.date)) 58 | gigTitlesInDay.forEach(async title => { 59 | expect( 60 | await within(dayElement).findByText(title) 61 | ).toBeInTheDocument() 62 | }) 63 | }) 64 | }) 65 | }) 66 | 67 | async function renderDays(jotaRouterInstance) { 68 | const screen = await renderComponent(Days, { 69 | jotaRouter: ()=> jotaRouterInstance 70 | }) 71 | const openGig = async title => userEvent.click((await screen.findByText(title))) 72 | 73 | return {...screen, openGig} 74 | } 75 | -------------------------------------------------------------------------------- /src/app/pages/GigDetail/GigDetail.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 75 | 76 | 91 | -------------------------------------------------------------------------------- /test/render-utils.js: -------------------------------------------------------------------------------- 1 | import {prettyDOM, render} from '@testing-library/vue'; 2 | import {createLocalVue} from '@vue/test-utils'; 3 | import Vuex from 'vuex'; 4 | import Vuelidate from 'vuelidate' 5 | import Quasar from 'quasar-framework' 6 | import { jotaRouterMixin } from '@/app/services/JotaRouter' 7 | import { registerGlobalComponents } from '@/app/GlobalComponentsLoader' 8 | import {screen} from '@testing-library/vue' 9 | 10 | // import Router from 'vue-router'; 11 | // import {routes} from '@/router'; 12 | 13 | export const sleep = (ms) => { 14 | return new Promise(resolve => setTimeout(resolve, ms)) 15 | }; 16 | 17 | export function debug(doc = document) { 18 | // Using global document when no param passed 19 | const maxLines = 100000 20 | console.log(prettyDOM(doc, maxLines)) 21 | } 22 | 23 | export const resolvePromises = () => 24 | new Promise((resolve) => setImmediate(resolve)) 25 | 26 | const defaultRouter = { 27 | replace: jest.fn(), 28 | push: jest.fn() 29 | }; 30 | 31 | export const buildDefaultRoute = () => ({ 32 | query: {}, 33 | name: 'mocked-route' 34 | }); 35 | 36 | export const yesterdayDayOfMonth = () => { 37 | const date = new Date() 38 | date.setDate(date.getDate() - 1) 39 | return date.getDate().toString() 40 | } 41 | 42 | export const tomorrow = () => { 43 | const date = new Date() 44 | date.setDate(date.getDate() + 1) 45 | return date 46 | } 47 | 48 | //Acertijo 49 | export const tomorrowDayOfMonth = () => tomorrow().getDate().toString() 50 | 51 | export function renderComponent (component, setup={}) { 52 | const localVue = createLocalVue() 53 | const defaultSetup = { 54 | localVue, 55 | mocks: { 56 | $route: setup.route || buildDefaultRoute(), 57 | $router: setup.router || defaultRouter 58 | }, 59 | // Stubbing some components to simplify test rendering 60 | stubs: { 61 | 'router-view': true, 62 | } 63 | } 64 | 65 | initializePlugins(localVue) 66 | 67 | const mixin = setup.jotaRouter ? buildMixinWithMockedJotaRouter(setup.jotaRouter) : jotaRouterMixin 68 | initializeJotaRouterMixin(localVue, mixin) 69 | 70 | const utils = render(component, { 71 | ...defaultSetup, ...setup, 72 | }); 73 | return {...utils, debug, screen} 74 | } 75 | 76 | function initializePlugins(vueInstance) { 77 | vueInstance.use(Vuelidate) 78 | vueInstance.use(Quasar) 79 | // http://forum.quasar-framework.org/topic/278/turn-off-the-annoying-vue-production-tip/7 80 | vueInstance.config.productionTip = false 81 | vueInstance.use(Vuex) 82 | // vueInstance.use(VueRouter) 83 | registerGlobalComponents(vueInstance) 84 | } 85 | 86 | function initializeJotaRouterMixin(vueInstance, jotaRouterMixin) { 87 | vueInstance.mixin(jotaRouterMixin) 88 | } 89 | 90 | export function buildMixinWithMockedJotaRouter(jotaRouter) { 91 | return { 92 | created: function () { 93 | this.jotaRouter = jotaRouter({}) 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /src/app/GlobalComponentsLoader.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { 3 | QList, 4 | QListHeader, 5 | QItem, 6 | QBtn, 7 | QToolbar, 8 | QToolbarTitle, 9 | QInput, 10 | QField, 11 | QDatetime, 12 | QAjaxBar, 13 | QIcon, 14 | QCard, 15 | QCardSeparator, 16 | QCardMedia, 17 | QCardMain, 18 | QCardTitle, 19 | QTransition, 20 | QSideLink, 21 | QScrollArea, 22 | QItemSide, 23 | QItemMain, 24 | QItemTile, 25 | QItemSeparator, 26 | QLayout, 27 | QSpinner, 28 | QSpinnerMat, 29 | QFixedPosition 30 | } from 'quasar-framework' 31 | import LoadSpinner from '@/app/shared-components/LoadSpinner.vue' 32 | import FormButton from '@/app/shared-components/FormButton.vue' 33 | import BackToTopButton from '@/app/shared-components/BackToTopButton.vue' 34 | import SideBar from '@/app/shared-components/SideBar.vue' 35 | import Toolbar from '@/app/shared-components/Toolbar.vue' 36 | import GigRow from '@/app/pages/Days/GigRow.vue' 37 | import Day from '@/app/pages/Days/Day.vue' 38 | import TextInput from '@/app/shared-components/TextInput.vue' 39 | import DateTimeInput from '@/app/shared-components/DateTimeInput.vue' 40 | 41 | export function registerGlobalComponents(vueInstance = Vue) { 42 | vueInstance.component('LoadSpinner', LoadSpinner) 43 | vueInstance.component('Day', Day) 44 | vueInstance.component('GigRow', GigRow) 45 | vueInstance.component('QList', QList) 46 | vueInstance.component('QListHeader', QListHeader) 47 | vueInstance.component('QItem', QItem) 48 | vueInstance.component('QIcon', QIcon) 49 | vueInstance.component('QBtn', QBtn) 50 | vueInstance.component('QToolbar', QToolbar) 51 | vueInstance.component('QToolbarTitle', QToolbarTitle) 52 | vueInstance.component('QInput', QInput) 53 | vueInstance.component('QField', QField) 54 | vueInstance.component('QDatetime', QDatetime) 55 | vueInstance.component('QCard', QCard) 56 | vueInstance.component('QCardMedia', QCardMedia) 57 | vueInstance.component('QCardSeparator', QCardSeparator) 58 | vueInstance.component('QCardMain', QCardMain) 59 | vueInstance.component('QCardTitle', QCardTitle) 60 | vueInstance.component('QAjaxBar', QAjaxBar) 61 | vueInstance.component('QTransition', QTransition) 62 | vueInstance.component('QSideLink', QSideLink) 63 | vueInstance.component('QScrollArea', QScrollArea) 64 | vueInstance.component('QItemSide', QItemSide) 65 | vueInstance.component('QItemTile', QItemTile) 66 | vueInstance.component('QItemMain', QItemMain) 67 | vueInstance.component('QItemSeparator', QItemSeparator) 68 | vueInstance.component('QLayout', QLayout) 69 | vueInstance.component('QSpinner', QSpinner) 70 | vueInstance.component('QSpinnerMat', QSpinnerMat) 71 | vueInstance.component('QFixedPosition', QFixedPosition) 72 | vueInstance.component('FormButton', FormButton) 73 | vueInstance.component('BackToTopButton', BackToTopButton) 74 | vueInstance.component('Toolbar', Toolbar) 75 | vueInstance.component('TextInput', TextInput) 76 | vueInstance.component('DateTimeInput', DateTimeInput) 77 | vueInstance.component('SideBar', SideBar) 78 | } 79 | -------------------------------------------------------------------------------- /src/app/services/__mocks__/create-fake-gig.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import { isoFuture } from '../date-utils' 3 | 4 | let UUID = 0 5 | 6 | export function createFakeGig(date) { 7 | UUID += 1 8 | return { 9 | id: (UUID).toString(), 10 | title: randomTitle(), 11 | description: faker.lorem.paragraph(15), 12 | image_url: randomImageUrl(), 13 | lat_lng: `${faker.address.latitude()},${faker.address.longitude()}`, 14 | address: fakeAddress(), 15 | price: randomNumber(5, 60) + '€', 16 | place: randomPlace(), 17 | hour: randomNumber(4, 11) + 'PM', 18 | day: date 19 | } 20 | } 21 | 22 | function fakeAddress() { 23 | return faker.address.streetAddress() + 24 | '
' + faker.address.city() + ', ' + faker.address.country() + 25 | '
' + faker.address.zipCode() 26 | } 27 | 28 | function randomNumber(min, max) { 29 | return faker.random.number({min, max}) 30 | } 31 | 32 | export function createFakeDay(date) { 33 | return { 34 | date: date, 35 | gigs: Array(randomNumber(1, 10)).fill().map(() => createFakeGig(date)) 36 | } 37 | } 38 | 39 | export function createFakeDays() { 40 | let days = {} 41 | Array(randomNumber(3, 10)).fill().forEach((number, index) => { 42 | days[isoFuture(index)] = createFakeDay(isoFuture(index)) 43 | }) 44 | return days 45 | } 46 | 47 | function randomImageUrl() { 48 | return imageUrls[randomNumber(0, imageUrls.length - 1)] 49 | } 50 | 51 | const imageUrls = [ 52 | 'https://i.pinimg.com/736x/0e/de/f8/0edef8dd970d352a95a8caeaa55aa81c--marina-dancing.jpg', 53 | 'https://i.pinimg.com/564x/95/83/8b/95838bc6e43c5229b7a677a78c70e09a.jpg', 54 | 'https://i.pinimg.com/564x/08/d5/dd/08d5ddd4bb0c8a59b7a4021ba87a38e8.jpg', 55 | 'https://i.pinimg.com/564x/31/c7/4e/31c74e89d905b300e3832b439ce7ffc0.jpg', 56 | 'https://i.pinimg.com/564x/97/0b/73/970b736b7d75062ab9a525ad5f3c3497.jpg', 57 | 'https://i.pinimg.com/564x/cf/b7/b6/cfb7b6fc2000dfdbb9f4aeb8800f7e87.jpg', 58 | 'https://i.pinimg.com/564x/59/bb/59/59bb5945d68e8d913b2130f3628949b9.jpg' 59 | ] 60 | 61 | function randomPlace() { 62 | return places[randomNumber(0, places.length - 1)] 63 | } 64 | 65 | const places = [ 66 | 'Sala Razzmatazz', 67 | 'Codely Headquarters', 68 | 'Pabellón Principe Felipe', 69 | 'Estadio Vicente Calderón', 70 | 'Palacio Municipal de los Deportes Huesca', 71 | 'Joy Slava', 72 | 'Sala Z', 73 | 'Estadio de Wembley', 74 | 'CBGB New York', 75 | 'Teatro Principal de Zaragoza' 76 | ] 77 | 78 | function randomTitle() { 79 | return firstTerm[randomNumber(0, firstTerm.length - 1)] + ' ' + 80 | secondTerm[randomNumber(0, secondTerm.length - 1)] + ' ' + 81 | thirdTerm[randomNumber(0, thirdTerm.length - 1)] 82 | } 83 | 84 | const firstTerm = [ 85 | 'Grupo', 'Rondalla', 'Agrupación', 'Amigos', 'Folklore', 'Colectivo', 'Banda', 'Ronda', 86 | 'Rondalla', 'Escuela', 'Dulzaineros' 87 | ] 88 | 89 | const secondTerm = [ 90 | 'Folclore', 'Baluarte', 'Danzantes', 'Joteros', 'Jota', 'Baile', 'Bandurrias', 'Copla' 91 | ] 92 | 93 | const thirdTerm = [ 94 | 'de Zaragoza', 'de Aragón', 'Turolense', 'Oscense', 'Fematera', 'Rondadera' 95 | ] 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "JotaQuasar", 3 | "productName": "Jota JS", 4 | "version": "0.0.1", 5 | "description": "Jota Sample with Quasar", 6 | "author": "Coding Stones", 7 | "scripts": { 8 | "clean": "node build/script.clean.js", 9 | "dev": "node build/script.dev.js", 10 | "build": "node build/script.build.js", 11 | "lint": "eslint --ext .js,.vue src", 12 | "unit": "jest --config=jest.config.js", 13 | "unit:watch": "jest --config=jest.config.js --watchAll", 14 | "unit:coverage": "jest --config=jest.config.js --coverage" 15 | }, 16 | "dependencies": { 17 | "babel-runtime": "^6.25.0", 18 | "faker": "^4.1.0", 19 | "fetch-jsonp": "^1.1.3", 20 | "js-beautify": "^1.7.3", 21 | "moment": "^2.19.1", 22 | "quasar-extras": "0.x", 23 | "quasar-framework": "^0.14.6", 24 | "vue": "^2.5.2", 25 | "vue-router": "^3.0.1", 26 | "vuelidate": "^0.6.1", 27 | "vuex": "^3.0.0", 28 | "vuex-router-sync": "^5.0.0" 29 | }, 30 | "devDependencies": { 31 | "@testing-library/jest-dom": "^5.11.9", 32 | "@testing-library/user-event": "^12.6.0", 33 | "@testing-library/vue": "^5.6.1", 34 | "@vue/test-utils": "^1.0.0-beta.26", 35 | "autoprefixer": "^6.4.0", 36 | "babel-core": "^6.0.0", 37 | "babel-eslint": "^7.0.0", 38 | "babel-jest": "^21.0.2", 39 | "babel-loader": "^7.0.0", 40 | "babel-plugin-dynamic-import-node": "^1.1.0", 41 | "babel-plugin-module-resolver": "^2.7.1", 42 | "babel-plugin-transform-runtime": "^6.0.0", 43 | "babel-preset-env": "^1.6.0", 44 | "babel-preset-es2015": "^6.0.0", 45 | "babel-preset-stage-2": "^6.0.0", 46 | "colors": "^1.1.2", 47 | "connect-history-api-fallback": "^1.1.0", 48 | "css-loader": "^0.28.0", 49 | "es6-promise": "^4.1.1", 50 | "eslint": "^4.7.2", 51 | "eslint-config-standard": "^10.2.1", 52 | "eslint-friendly-formatter": "^3.0.0", 53 | "eslint-loader": "^1.9.0", 54 | "eslint-plugin-html": "^3.1.1", 55 | "eslint-plugin-import": "^2.7.0", 56 | "eslint-plugin-node": "^5.1.1", 57 | "eslint-plugin-promise": "^3.5.0", 58 | "eslint-plugin-standard": "^3.0.1", 59 | "eventsource-polyfill": "^0.9.6", 60 | "express": "^4.15.5", 61 | "extract-text-webpack-plugin": "^3.0.0", 62 | "file-loader": "^0.11.1", 63 | "friendly-errors-webpack-plugin": "^1.1.3", 64 | "glob": "^7.1.2", 65 | "html-webpack-plugin": "^2.30.1", 66 | "http-proxy-middleware": "^0.17.0", 67 | "jest": "^26.6.3", 68 | "json-loader": "^0.5.7", 69 | "module-resolver": "^1.0.0", 70 | "opn": "^5.0.0", 71 | "optimize-css-assets-webpack-plugin": "^3.0.0", 72 | "postcss-loader": "^1.0.0", 73 | "progress-bar-webpack-plugin": "^1.10.0", 74 | "purify-css": "^1.2.6", 75 | "shelljs": "^0.7.0", 76 | "stylus": "^0.54.5", 77 | "stylus-loader": "^3.0.1", 78 | "synchronous-promise": "^1.0.17", 79 | "url-loader": "^0.5.7", 80 | "vue-jest": "^1.0.2", 81 | "vue-loader": "~13.3.0", 82 | "vue-server-renderer": "^2.5.2", 83 | "vue-style-loader": "^3.0.3", 84 | "vue-template-compiler": "^2.5.2", 85 | "webpack": "^3.5.2", 86 | "webpack-dev-middleware": "^1.12.0", 87 | "webpack-hot-middleware": "^2.18.2", 88 | "webpack-merge": "^4.1.0" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app/pages/NewGig/__test__/NewGig.spec.js: -------------------------------------------------------------------------------- 1 | import NewGig from '@/app/pages/NewGig/NewGig.vue' 2 | import userEvent from '@testing-library/user-event' 3 | import { createGig as createGigSpy } from '../../../services/jota-api' 4 | jest.mock('@/app/services/jota-api') 5 | import { createGigPayload } from '../../../services/jota-payloads' 6 | import { renderComponent, sleep, tomorrow, tomorrowDayOfMonth, yesterdayDayOfMonth } from '@test/render-utils' 7 | import { fireEvent } from '@testing-library/vue' 8 | 9 | describe('shows validation error', () => { 10 | 11 | describe('when validating title', () => { 12 | 13 | xit('and title is cleared', async () => { 14 | const {typeGigName, clearGigName, findByText, findAllByRole, findByTestId} = await renderNewGig() 15 | typeGigName('a') 16 | clearGigName() 17 | sleep(1000) 18 | // typeGigName({keyCode: '46'}) 19 | const input = (await findAllByRole('textbox'))[0] 20 | 21 | console.log('after clear', input) 22 | console.log('after clear value', input.value) 23 | 24 | // console.log(await (await findByTestId('debug')).innerHTML) 25 | 26 | expect(await findByText('Name is required')).toBeInTheDocument() 27 | }) 28 | 29 | it('and title is too short', async() => { 30 | const {typeGigName, screen} = await renderNewGig() 31 | 32 | typeGigName(tooShortName()) 33 | 34 | expect(await screen.findByText('Minimum 5 characters.')).toBeInTheDocument() 35 | }) 36 | 37 | it('and title is too long', async () => { 38 | const {typeGigName, screen} = await renderNewGig() 39 | 40 | typeGigName(tooLongName()) 41 | 42 | expect(await screen.findByText('Maximum 20 characters.')).toBeInTheDocument() 43 | }) 44 | }) 45 | 46 | describe('when validating datetime', () => { 47 | it('and datetime is cleared', async () => { 48 | 49 | const {screen, setGigDate, clearGigDate} = await renderNewGig() 50 | 51 | await setGigDate(tomorrowDayOfMonth()) 52 | 53 | await clearGigDate() 54 | 55 | expect(await screen.findByText('Date and time of gig are required.')).toBeInTheDocument() 56 | }) 57 | 58 | it('and datetime is in the past', async () => { 59 | 60 | const {screen, setGigDate} = await renderNewGig() 61 | 62 | await setGigDate(yesterdayDayOfMonth()) 63 | 64 | expect(await screen.findByText('You cannot set a gig in a past date :(')).toBeInTheDocument() 65 | }) 66 | }) 67 | }) 68 | 69 | describe('Create Gig button', () => { 70 | it('is disabled by default', async () => { 71 | 72 | const {findCreateGigButton} = await renderNewGig() 73 | 74 | expect(await findCreateGigButton()).toHaveClass('disabled') 75 | }) 76 | 77 | it('is disabled when form not fully filled', async () => { 78 | const {typeGigName, findCreateGigButton} = await renderNewGig() 79 | 80 | typeGigName(nameWithValidLength()) 81 | 82 | expect(await findCreateGigButton()).toHaveClass('disabled') 83 | }) 84 | 85 | it('is disabled when form has errors', async () => { 86 | const {typeGigName, findCreateGigButton} = await renderNewGig() 87 | 88 | typeGigName(tooShortName()) 89 | 90 | expect(await findCreateGigButton()).toHaveClass('disabled') 91 | }) 92 | 93 | it('is enabled when form is fully filled without errors', async () => { 94 | const {typeGigName, setGigDate, findCreateGigButton} = await renderNewGig() 95 | 96 | typeGigName(nameWithValidLength()) 97 | await setGigDate(tomorrowDayOfMonth()) 98 | 99 | expect(await findCreateGigButton()).not.toHaveClass('disabled'); 100 | }) 101 | }) 102 | 103 | describe('When clicking create gig button', () => { 104 | it('calls backend with appropriate command', async () => { 105 | const {typeGigName, setGigDate, findCreateGigButton} = await renderNewGig() 106 | 107 | typeGigName(nameWithValidLength()) 108 | await setGigDate(tomorrowDayOfMonth()) 109 | 110 | userEvent.click(await findCreateGigButton()) 111 | expect(createGigSpy).toHaveBeenCalledWith(createGigPayload(nameWithValidLength(), tomorrow().toISOString())) 112 | }) 113 | }) 114 | 115 | async function renderNewGig() { 116 | const {screen} = renderComponent(NewGig) 117 | 118 | //it would be much better to use a label but for now q-input does not bind label with input 119 | // (we should modify q-input to force that binding or maybe using aria-label as a workaround) 120 | const nameInput = (await screen.findAllByRole('textbox'))[0] 121 | 122 | const typeGigName = (name)=> { 123 | userEvent.type(nameInput, name) 124 | } 125 | 126 | const clearGigName = () => { 127 | userEvent.clear(nameInput) 128 | fireEvent.blur(nameInput) 129 | } 130 | 131 | const setGigDate = async (dayText) => { 132 | userEvent.click(await screen.findByText(/Date and time/i)) 133 | userEvent.click(await screen.findByText(dayText)) 134 | userEvent.click(await screen.findByText(/set/i)) 135 | //Wait for date set and rendered 136 | // await findByText(/\//i) 137 | await sleep(100) 138 | } 139 | 140 | const clearGigDate = async ()=> { 141 | userEvent.click(await screen.findByText(/\//i)) 142 | userEvent.click(await screen.findByText(/clear/i)) 143 | } 144 | 145 | const findCreateGigButton = async ()=> (await screen.findByText(/Create Gig/i)).closest('button') 146 | 147 | return {screen, typeGigName, clearGigName, setGigDate, clearGigDate, findCreateGigButton} 148 | } 149 | 150 | function nameWithValidLength() { 151 | return nameWithLength(5) 152 | } 153 | 154 | function tooShortName() { 155 | return nameWithLength(3) 156 | } 157 | 158 | function tooLongName() { 159 | return nameWithLength(21) 160 | } 161 | 162 | function nameWithLength(length) { 163 | return 'x'.repeat(length) 164 | } 165 | -------------------------------------------------------------------------------- /src/assets/stones.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/stones-small-red-white_letters.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | --------------------------------------------------------------------------------