├── .gitignore ├── another_cover.html ├── api-axios.png ├── api-map.png ├── api-server.png ├── api-vuex.png ├── assets ├── banner.jpg └── covers │ ├── Document fonts.zip │ ├── Vue.js_cover.ai │ ├── Vue.js_cover.jpg │ └── Vue.js_cover.png ├── babel.config.js ├── build_ ├── build_epub.sh ├── build_pdf.sh ├── cover.css ├── cover.html ├── cover.png ├── cover2-small.css ├── cover2-small.html ├── cover2.css ├── cover2.html ├── dt-ss-1.png ├── dt-ss-2.png ├── examples ├── App.vue ├── api-requests │ ├── api-requests.spec.js │ ├── login.vue │ ├── services.js │ └── store.js ├── composition-functional │ ├── tic-tac-toe-app.vue │ ├── tic-tac-toe.js │ └── tic-tac-toe.spec.js ├── composition │ ├── tic-tac-toe-app.vue │ ├── tic-tac-toe.js │ └── tic-tac-toe.spec.js ├── events │ ├── counter.spec.js │ └── counter.vue ├── form-validation │ ├── form-validation.spec.js │ ├── form-validation.vue │ ├── form.js │ └── form.spec.js ├── props │ ├── Navbar.vue │ ├── message.vue │ └── props.spec.js ├── provide-inject │ ├── store.js │ ├── store.spec.js │ └── users.vue ├── render-functions │ ├── app.vue │ ├── tab-container.js │ ├── tab-content.vue │ ├── tab.vue │ └── tabs.spec.js ├── renderless-password │ ├── App.vue │ ├── AppWithCustomValidator.vue │ ├── renderless-password.js │ └── renderless-password.spec.js └── reusable-date-time │ ├── DateApp.vue │ ├── app.vue │ ├── date-time-serializers.js │ ├── date-time.spec.js │ └── date-time.vue ├── favicon.ico ├── forms-clean.png ├── forms-dirty.png ├── full.sh ├── functional-core-imperative-shell.jpg ├── index.html ├── index.js ├── jest.config.js ├── landing-page.css ├── landing-page.html ├── merge.sh ├── onigiri-big.svg ├── onigiri.svg ├── package.json ├── props-error.png ├── src ├── epub │ ├── ABOUT.md │ ├── API-REQUESTS.md │ ├── CONTENTS.md │ ├── COVER.md │ ├── EVENTS.md │ ├── FORMS.md │ ├── FUNCTIONAL-PROGRAMMING-MUTABLE-VUE.md │ ├── GROUPING-FEATURES-WITH-COMPOSABLES.md │ ├── INTRO.md │ ├── PROPS.md │ ├── PROVIDE-INJECT.md │ ├── RENDER-FUNCTIONS.md │ ├── RENDERLESS-COMPONENTS.md │ └── TRULY-MODULAR-COMPONENTS-WITH-V-MODEL.md └── pdf │ ├── ABOUT.md │ ├── API-REQUESTS.md │ ├── CONTENTS.md │ ├── COVER.md │ ├── EVENTS.md │ ├── FORMS.md │ ├── FUNCTIONAL-PROGRAMMING-MUTABLE-VUE.md │ ├── GROUPING-FEATURES-WITH-COMPOSABLES.md │ ├── INTRO.md │ ├── PROPS.md │ ├── PROVIDE-INJECT.md │ ├── RENDER-FUNCTIONS.md │ ├── RENDERLESS-COMPONENTS.md │ └── TRULY-MODULAR-COMPONENTS-WITH-V-MODEL.md ├── ss-active.png ├── ss-alt.png ├── ss-basic.png ├── ss-complete.png ├── ss-done-clean.png ├── ss-done-dirty.png ├── ss-done.png ├── ss-dt-done.png ├── ss-dt-error.png ├── ss-dt-progress-2.png ├── ss-dt-progress.png ├── ss-provde-inject.png ├── ss-render-default-slots.png ├── ss-render-tabs-basic.png ├── ss-slot-details.png ├── ss-sorted-slots.png ├── ss-tabs-classes.png ├── ss-tabs-done.png ├── ss-tic-tac-toe-done.png ├── ss-ts.png ├── ss1.png ├── ss2.png ├── ss3.png ├── store.js ├── ttt-1.png ├── ttt-2.png └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.pdf 3 | build 4 | 5 | *.aux 6 | *.log 7 | *.toc 8 | *.aux 9 | *.dvi 10 | *.log 11 | *.tex 12 | *.toc 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /another_cover.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 34 |
35 |
Design Patterns for Vue.js.
36 |
A test driven approach to maintainable applications.
37 |
by Lachlan Miller.
38 |
39 | -------------------------------------------------------------------------------- /api-axios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/api-axios.png -------------------------------------------------------------------------------- /api-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/api-map.png -------------------------------------------------------------------------------- /api-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/api-server.png -------------------------------------------------------------------------------- /api-vuex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/api-vuex.png -------------------------------------------------------------------------------- /assets/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/assets/banner.jpg -------------------------------------------------------------------------------- /assets/covers/Document fonts.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/assets/covers/Document fonts.zip -------------------------------------------------------------------------------- /assets/covers/Vue.js_cover.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/assets/covers/Vue.js_cover.ai -------------------------------------------------------------------------------- /assets/covers/Vue.js_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/assets/covers/Vue.js_cover.jpg -------------------------------------------------------------------------------- /assets/covers/Vue.js_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/assets/covers/Vue.js_cover.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', 4 | { 5 | targets: { 6 | node: 'current' 7 | } 8 | } 9 | ] 10 | ] 11 | } -------------------------------------------------------------------------------- /build_: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/build_ -------------------------------------------------------------------------------- /build_epub.sh: -------------------------------------------------------------------------------- 1 | cat \ 2 | src/epub/ABOUT.md \ 3 | src/epub/INTRO.md \ 4 | src/epub/PROPS.md \ 5 | src/epub/EVENTS.md \ 6 | src/epub/FORMS.md \ 7 | src/epub/API-REQUESTS.md \ 8 | src/epub/RENDERLESS-COMPONENTS.md \ 9 | src/epub/RENDER-FUNCTIONS.md \ 10 | src/epub/PROVIDE-INJECT.md \ 11 | src/epub/TRULY-MODULAR-COMPONENTS-WITH-V-MODEL.md \ 12 | src/epub/GROUPING-FEATURES-WITH-COMPOSABLES.md \ 13 | src/epub/FUNCTIONAL-PROGRAMMING-MUTABLE-VUE.md \ 14 | | pandoc \ 15 | --highlight-style tango \ 16 | --pdf-engine pdflatex \ 17 | --number-sections \ 18 | --toc \ 19 | --metadata title="Design Patterns for Vue.js" \ 20 | --metadata author="Lachlan Miller" \ 21 | --epub-cover-image ./assets/covers/Vue.js_cover.png \ 22 | -o build/design_patterns_for_vuejs.epub 23 | -------------------------------------------------------------------------------- /build_pdf.sh: -------------------------------------------------------------------------------- 1 | cat \ 2 | src/pdf/CONTENTS.md \ 3 | src/pdf/ABOUT.md \ 4 | src/pdf/INTRO.md \ 5 | src/pdf/PROPS.md \ 6 | src/pdf/EVENTS.md \ 7 | src/pdf/FORMS.md \ 8 | src/pdf/API-REQUESTS.md \ 9 | src/pdf/RENDERLESS-COMPONENTS.md \ 10 | src/pdf/RENDER-FUNCTIONS.md \ 11 | src/pdf/PROVIDE-INJECT.md \ 12 | src/pdf/TRULY-MODULAR-COMPONENTS-WITH-V-MODEL.md \ 13 | src/pdf/GROUPING-FEATURES-WITH-COMPOSABLES.md \ 14 | src/pdf/FUNCTIONAL-PROGRAMMING-MUTABLE-VUE.md \ 15 | | pandoc \ 16 | --highlight-style tango \ 17 | --pdf-engine pdflatex \ 18 | -o build/design_patterns_for_vuejs.pdf 19 | -------------------------------------------------------------------------------- /cover.css: -------------------------------------------------------------------------------- 1 | h3, html, body, img { 2 | margin: 0; padding: 0; 3 | } 4 | 5 | body { 6 | font-family: system-ui,BlinkMacSystemFont,-apple-system,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif !important; 7 | margin: 50px; 8 | } 9 | 10 | #bg { 11 | filter: brightness(80%); 12 | object-fit: cover; 13 | width: 500px; 14 | height: 80vh; 15 | } 16 | 17 | #book { 18 | height: 80vh; 19 | width: 500px; 20 | position: relative; 21 | } 22 | /* 23 | height: 80vh; 24 | width: 500px; 25 | background-image: url('https://images.unsplash.com/photo-1600132806370-bf17e65e942f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1757&q=80'); 26 | z-index: 0; 27 | } 28 | */ 29 | 30 | #design, #vuejs { 31 | left: 100px; 32 | z-index: 1; 33 | } 34 | 35 | 36 | #author { 37 | left: 190px; 38 | font-size: 1.0rem; 39 | width: 286px; 40 | top: 525px; 41 | position: absolute; 42 | color: white; 43 | } 44 | 45 | 46 | #tdd { 47 | left: 150px; 48 | font-size: 1.0rem; 49 | width: 286px; 50 | top: 450px; 51 | position: absolute; 52 | color: white; 53 | } 54 | 55 | #design { 56 | font-size: 2.0rem; 57 | top: 250px; 58 | position: absolute; 59 | color: white; 60 | } 61 | 62 | #vuejs { 63 | font-size: 6.3rem; 64 | top: 280px; 65 | position: absolute; 66 | color: white; 67 | } 68 | 69 | #vue { 70 | width: 200px; 71 | height: 200px; 72 | left: 150px; 73 | top: 20px; 74 | position: absolute; 75 | } 76 | -------------------------------------------------------------------------------- /cover.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 |

Design Patterns for

14 |

Vue.js

15 |

A test-driven approach to maintainable applications

16 |

Lachlan Miller

17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/cover.png -------------------------------------------------------------------------------- /cover2-small.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | font-family: system-ui,BlinkMacSystemFont,-apple-system,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif !important; 4 | text-align: center; 5 | margin: 50px; 6 | } 7 | 8 | 9 | h1, h3 { 10 | padding: 0; margin: 0; 11 | } 12 | 13 | #cover { 14 | color: white; 15 | background-color: #63a4ff; 16 | background-image: linear-gradient(315deg, #63a4ff 0%, #83eaf1 74%); 17 | 18 | width: 500px; 19 | height: 700px; 20 | border: 1px solid; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | 27 | img { 28 | filter: invert(); 29 | width: 80px; 30 | } 31 | 32 | #design { 33 | font-size: 1rem; 34 | } 35 | 36 | #vuejs { 37 | font-size: 3rem; 38 | } 39 | 40 | #byline { 41 | width: 250px; 42 | display: flex; 43 | justify-content: center; 44 | } 45 | 46 | h3 { 47 | text-align: center; 48 | } 49 | -------------------------------------------------------------------------------- /cover2-small.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 |

Design Patterns for

14 |

Vue.js

15 |
16 | 17 |

Lachlan Miller

18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /cover2.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | font-family: system-ui,BlinkMacSystemFont,-apple-system,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif !important; 4 | text-align: center; 5 | margin: 50px; 6 | } 7 | 8 | 9 | h1 { 10 | padding: 0; margin: 0; 11 | } 12 | 13 | #cover { 14 | color: white; 15 | 16 | background-color: #7fcec5; 17 | background-image: linear-gradient(315deg, #7fcec5 0%, #14557b 74%); 18 | 19 | width: 500px; 20 | height: 700px; 21 | border: 1px solid; 22 | display: flex; 23 | flex-direction: column; 24 | align-items: center; 25 | justify-content: center; 26 | } 27 | 28 | img { 29 | filter: invert(); 30 | width: 200px; 31 | } 32 | 33 | #vuejs { 34 | font-size: 7rem; 35 | } 36 | 37 | #byline { 38 | width: 250px; 39 | display: flex; 40 | justify-content: center; 41 | } 42 | 43 | h3 { 44 | text-align: center; 45 | } 46 | -------------------------------------------------------------------------------- /cover2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 |

Design Patterns for

14 |

Vue.js

15 |
16 |
17 |

A test driven approach to maintainable applications

18 |
19 | 20 |

Lachlan Miller

21 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /dt-ss-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/dt-ss-1.png -------------------------------------------------------------------------------- /dt-ss-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/dt-ss-2.png -------------------------------------------------------------------------------- /examples/App.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 54 | -------------------------------------------------------------------------------- /examples/api-requests/api-requests.spec.js: -------------------------------------------------------------------------------- 1 | import { store } from './store.js' 2 | import { render, fireEvent, screen } from '@testing-library/vue' 3 | import { rest } from 'msw' 4 | import { setupServer } from 'msw/node' 5 | import Login from './login.vue' 6 | 7 | const postedData = [] 8 | const server = setupServer( 9 | rest.post('/login', (req, res, ctx) => { 10 | postedData.push(req.body) 11 | return res( 12 | ctx.json({ 13 | name: 'Lachlan' 14 | }) 15 | ) 16 | }) 17 | ) 18 | 19 | describe('login', () => { 20 | beforeAll(() => server.listen()) 21 | afterAll(() => server.close()) 22 | 23 | it('successfully authenticates', async () => { 24 | render(Login, { store }) 25 | await fireEvent.update( 26 | screen.getByRole('username'), 'Lachlan') 27 | await fireEvent.update( 28 | screen.getByRole('password'), 'secret-password') 29 | await fireEvent.click(screen.getByText('Click here to sign in')) 30 | 31 | await screen.findByText('Hello, Lachlan') 32 | }) 33 | 34 | it('handles incorrect credentials', async () => { 35 | const error = 'Error: please check the details and try again' 36 | server.use( 37 | rest.post('/login', (req, res, ctx) => { 38 | return res( 39 | ctx.status(403), 40 | ctx.json({ error }) 41 | ) 42 | }) 43 | ) 44 | 45 | render(Login, { store }) 46 | await fireEvent.update( 47 | screen.getByRole('username'), 'Lachlan') 48 | await fireEvent.update( 49 | screen.getByRole('password'), 'secret-password') 50 | await fireEvent.click(screen.getByText('Click here to sign in')) 51 | 52 | await screen.findByText(error) 53 | }) 54 | }) 55 | 56 | -------------------------------------------------------------------------------- /examples/api-requests/login.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 47 | -------------------------------------------------------------------------------- /examples/api-requests/services.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/examples/api-requests/services.js -------------------------------------------------------------------------------- /examples/api-requests/store.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { createStore } from 'vuex' 3 | 4 | export const store = { 5 | state() { 6 | return { 7 | user: undefined 8 | } 9 | }, 10 | mutations: { 11 | updateUser(state, user) { 12 | state.user = user 13 | } 14 | }, 15 | actions: { 16 | login: async ({ commit }, { username, password }) => { 17 | const response = await axios.post('/login', { username, password }) 18 | commit('updateUser', response.data) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/composition-functional/tic-tac-toe-app.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 30 | 31 | 42 | -------------------------------------------------------------------------------- /examples/composition-functional/tic-tac-toe.js: -------------------------------------------------------------------------------- 1 | import { ref, readonly, computed } from 'vue' 2 | 3 | /** 4 | * Core Logic 5 | * Framework agnostic 6 | */ 7 | export const initialBoard = [ 8 | ['-', '-', '-'], 9 | ['-', '-', '-'], 10 | ['-', '-', '-'] 11 | ] 12 | 13 | export function createGame(initialState) { 14 | return [...initialState] 15 | } 16 | 17 | export function makeMove(board, { col, row, counter }) { 18 | const newBoard = board.map((theRow, rowIdx) => 19 | theRow.map((cell, colIdx) => 20 | rowIdx === row && colIdx === col 21 | ? counter 22 | : cell 23 | ) 24 | ) 25 | const newCounter = counter === 'o' ? 'x' : 'o' 26 | 27 | return { 28 | newBoard, 29 | newCounter 30 | } 31 | } 32 | 33 | /** 34 | * Vue integration layer 35 | * State is mutable 36 | */ 37 | export function useTicTacToe() { 38 | const boards = ref([initialBoard]) 39 | const counter = ref('o') 40 | const move = ({ col, row }) => { 41 | const { newBoard, newCounter } = makeMove( 42 | currentBoard.value, 43 | { 44 | col, 45 | row, 46 | counter: counter.value 47 | } 48 | ) 49 | boards.value.push(newBoard) 50 | counter.value = newCounter 51 | } 52 | 53 | const currentBoard = computed(() => { 54 | return boards.value[boards.value.length - 1] 55 | }) 56 | 57 | return { 58 | currentBoard, 59 | makeMove: move 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /examples/composition-functional/tic-tac-toe.spec.js: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, screen } from '@testing-library/vue' 2 | import TicTacToeApp from './tic-tac-toe-app.vue' 3 | import { createGame, makeMove, initialBoard } from './tic-tac-toe.js' 4 | 5 | describe('TicTacToeApp', () => { 6 | it('plays a game', async () => { 7 | render(TicTacToeApp) 8 | 9 | await fireEvent.click(screen.getByTestId('row-0-col-0')) 10 | await fireEvent.click(screen.getByTestId('row-0-col-1')) 11 | await fireEvent.click(screen.getByTestId('row-0-col-2')) 12 | 13 | expect(screen.getByTestId('row-0-col-0').textContent).toContain('o') 14 | expect(screen.getByTestId('row-0-col-1').textContent).toContain('x') 15 | expect(screen.getByTestId('row-0-col-2').textContent).toContain('o') 16 | }) 17 | }) 18 | 19 | describe('useTicTacToe', () => { 20 | it('initializes state to an empty board', () => { 21 | const expected = [ 22 | ['-', '-', '-'], 23 | ['-', '-', '-'], 24 | ['-', '-', '-'] 25 | ] 26 | expect(createGame(initialBoard)).toEqual(expected) 27 | }) 28 | }) 29 | 30 | describe('makeMove', () => { 31 | it('returns a new updated board and counter', () => { 32 | const board = createGame(initialBoard) 33 | const { newBoard, newCounter } = makeMove(board, { 34 | row: 0, 35 | col: 0, 36 | counter: 'o' 37 | }) 38 | 39 | expect(newCounter).toBe('x') 40 | expect(newBoard).toEqual([ 41 | ['o', '-', '-'], 42 | ['-', '-', '-'], 43 | ['-', '-', '-'] 44 | ]) 45 | }) 46 | }) 47 | 48 | import { mount } from '@vue/test-utils' 49 | 50 | describe('TicTacToeApp', () => { 51 | it('plays a game', async () => { 52 | const wrapper = mount(TicTacToeApp) 53 | 54 | await wrapper.find('[data-test=row-0-col-0]').trigger('click') 55 | await wrapper.find('[data-test=row-0-col-1]').trigger('click') 56 | await wrapper.find('[data-test=row-0-col-2]').trigger('click') 57 | 58 | expect(wrapper.html()).toContain('data-test="row-0-col-0">o') 59 | expect(wrapper.html()).toContain('data-test="row-0-col-1">x') 60 | expect(wrapper.html()).toContain('data-test="row-0-col-2">o') 61 | }) 62 | }) 63 | 64 | describe('useTicTacToe', () => { 65 | it('initializes state to an empty board', () => { 66 | const expected = [ 67 | ['-', '-', '-'], 68 | ['-', '-', '-'], 69 | ['-', '-', '-'] 70 | ] 71 | expect(createGame(initialBoard)).toEqual(expected) 72 | }) 73 | }) 74 | 75 | describe('makeMove', () => { 76 | it('returns a new updated board and counter', () => { 77 | const board = createGame(initialBoard) 78 | const { newBoard, newCounter } = makeMove(board, { 79 | row: 0, 80 | col: 0, 81 | counter: 'o' 82 | }) 83 | 84 | expect(newCounter).toBe('x') 85 | expect(newBoard).toEqual([ 86 | ['o', '-', '-'], 87 | ['-', '-', '-'], 88 | ['-', '-', '-'] 89 | ]) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /examples/composition/tic-tac-toe-app.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 34 | 35 | 46 | -------------------------------------------------------------------------------- /examples/composition/tic-tac-toe.js: -------------------------------------------------------------------------------- 1 | import { ref, readonly, computed } from 'vue' 2 | 3 | export function useTicTacToe(initialState) { 4 | const initialBoard = [ 5 | ['-', '-', '-'], 6 | ['-', '-', '-'], 7 | ['-', '-', '-'] 8 | ] 9 | 10 | const boards = ref(initialState || [initialBoard]) 11 | const currentPlayer = ref('o') 12 | const currentMove = ref(0) 13 | 14 | function makeMove({ row, col }) { 15 | const newBoard = JSON.parse(JSON.stringify(boards.value))[currentMove.value] 16 | newBoard[row][col] = currentPlayer.value 17 | currentPlayer.value = currentPlayer.value === 'o' ? 'x' : 'o' 18 | boards.value.push(newBoard) 19 | currentMove.value += 1 20 | } 21 | 22 | function undo() { 23 | currentMove.value -= 1 24 | } 25 | 26 | function redo() { 27 | currentMove.value += 1 28 | } 29 | 30 | return { 31 | makeMove, 32 | redo, 33 | undo, 34 | boards: readonly(boards), 35 | currentMove, 36 | currentPlayer: readonly(currentPlayer), 37 | currentBoard: computed(() => boards.value[currentMove.value]) 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /examples/composition/tic-tac-toe.spec.js: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, screen } from '@testing-library/vue' 2 | import TicTacToeApp from './tic-tac-toe-app.vue' 3 | import { useTicTacToe } from './tic-tac-toe.js' 4 | 5 | describe('TicTacToeApp', () => { 6 | it('plays a game', async () => { 7 | render(TicTacToeApp) 8 | 9 | await fireEvent.click(screen.getByTestId('row-0-col-0')) 10 | await fireEvent.click(screen.getByTestId('row-0-col-1')) 11 | await fireEvent.click(screen.getByTestId('row-0-col-2')) 12 | 13 | expect(screen.getByTestId('row-0-col-0').textContent).toContain('o') 14 | expect(screen.getByTestId('row-0-col-1').textContent).toContain('x') 15 | expect(screen.getByTestId('row-0-col-2').textContent).toContain('o') 16 | }) 17 | }) 18 | 19 | describe('useTicTacToe', () => { 20 | it('supports seeding an initial state', () => { 21 | const initialState = [ 22 | ['o', 'o', 'o'], 23 | ['-', '-', '-'], 24 | ['-', '-', '-'] 25 | ] 26 | const { currentBoard } = useTicTacToe([initialState]) 27 | 28 | expect(currentBoard.value).toEqual(initialState) 29 | }) 30 | 31 | it('initializes state to an empty board', () => { 32 | const initialBoard = [ 33 | ['-', '-', '-'], 34 | ['-', '-', '-'], 35 | ['-', '-', '-'] 36 | ] 37 | const { currentBoard } = useTicTacToe() 38 | 39 | expect(currentBoard.value).toEqual(initialBoard) 40 | }) 41 | }) 42 | 43 | describe('makeMove', () => { 44 | it('updates the board and adds the new state', () => { 45 | const { currentBoard, makeMove, boards, currentPlayer } = useTicTacToe() 46 | makeMove({ row: 0, col: 0 }) 47 | 48 | expect(boards.value).toHaveLength(2) 49 | expect(currentPlayer.value).toBe('x') 50 | expect(currentBoard.value).toEqual([ 51 | ['o', '-', '-'], 52 | ['-', '-', '-'], 53 | ['-', '-', '-'] 54 | ]) 55 | }) 56 | }) 57 | 58 | import { mount } from '@vue/test-utils' 59 | 60 | describe('TicTacToeApp', () => { 61 | it('plays a game', async () => { 62 | const wrapper = mount(TicTacToeApp) 63 | 64 | await wrapper.find('[data-test=row-0-col-0]').trigger('click') 65 | await wrapper.find('[data-test=row-0-col-1]').trigger('click') 66 | await wrapper.find('[data-test=row-0-col-2]').trigger('click') 67 | 68 | expect(wrapper.html()).toContain('data-test="row-0-col-0">o') 69 | expect(wrapper.html()).toContain('data-test="row-0-col-1">x') 70 | expect(wrapper.html()).toContain('data-test="row-0-col-2">o') 71 | }) 72 | }) 73 | 74 | describe('useTicTacToe', () => { 75 | it('supports seeding an initial state', () => { 76 | const initialState = [ 77 | ['o', 'o', 'o'], 78 | ['-', '-', '-'], 79 | ['-', '-', '-'] 80 | ] 81 | const { currentBoard } = useTicTacToe([initialState]) 82 | 83 | expect(currentBoard.value).toEqual(initialState) 84 | }) 85 | 86 | it('initializes state to an empty board', () => { 87 | const initialBoard = [ 88 | ['-', '-', '-'], 89 | ['-', '-', '-'], 90 | ['-', '-', '-'] 91 | ] 92 | const { currentBoard } = useTicTacToe() 93 | 94 | expect(currentBoard.value).toEqual(initialBoard) 95 | }) 96 | }) 97 | 98 | describe('makeMove', () => { 99 | it('updates the board and adds the new state', () => { 100 | const { currentBoard, makeMove, boards, currentPlayer } = useTicTacToe() 101 | makeMove({ row: 0, col: 0 }) 102 | 103 | expect(boards.value).toHaveLength(2) 104 | expect(currentPlayer.value).toBe('x') 105 | expect(currentBoard.value).toEqual([ 106 | ['o', '-', '-'], 107 | ['-', '-', '-'], 108 | ['-', '-', '-'] 109 | ]) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /examples/events/counter.spec.js: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/vue' 2 | import Counter, { submitValidator } from './counter.vue' 3 | 4 | describe('Counter', () => { 5 | it('emits an event with the current count', async () => { 6 | const { emitted } = render(Counter) 7 | 8 | await fireEvent.click(screen.getByRole('increment')) 9 | await fireEvent.click(screen.getByRole('submit')) 10 | 11 | expect(emitted().submit[0]).toEqual([1]) 12 | }) 13 | }) 14 | 15 | describe('submitValidator', () => { 16 | it('throws and error when count isNaN', () => { 17 | const actual = () => submitValidator('1') 18 | expect(actual).toThrow() 19 | }) 20 | 21 | it('returns true when count is a number', () => { 22 | const actual = () => submitValidator(1) 23 | expect(actual).not.toThrow() 24 | }) 25 | }) 26 | 27 | import { mount } from '@vue/test-utils' 28 | 29 | describe('Counter', () => { 30 | it('emits an event with the current count', async () => { 31 | const wrapper = mount(Counter) 32 | await wrapper.find('[role="increment"]').trigger('click') 33 | await wrapper.find('[role="submit"]').trigger('click') 34 | expect(wrapper.emitted().submit[0]).toEqual([1]) 35 | }) 36 | }) 37 | 38 | describe('submitValidator', () => { 39 | it('throws and error when count isNaN', () => { 40 | const actual = () => submitValidator('1') 41 | expect(actual).toThrow() 42 | }) 43 | 44 | it('returns true when count is a number', () => { 45 | const actual = () => submitValidator(1) 46 | expect(actual).not.toThrow() 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /examples/events/counter.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 40 | -------------------------------------------------------------------------------- /examples/form-validation/form-validation.spec.js: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/vue' 2 | import FormValidation from './form-validation.vue' 3 | 4 | describe('FormValidation', () => { 5 | it('fills out form correctly', async () => { 6 | render(FormValidation) 7 | 8 | await fireEvent.update(screen.getByLabelText('Name'), 'lachlan') 9 | await fireEvent.update(screen.getByDisplayValue('kg'), 'lb') 10 | await fireEvent.update(screen.getByLabelText('Weight'), '150') 11 | 12 | expect(screen.queryByRole('error')).toBe(null) 13 | }) 14 | 15 | it('shows errors for invalid inputs', async () => { 16 | render(FormValidation) 17 | 18 | await fireEvent.update(screen.getByLabelText('Name'), '') 19 | await fireEvent.update(screen.getByLabelText('Weight'), '5') 20 | await fireEvent.update(screen.getByDisplayValue('kg'), 'lb') 21 | 22 | expect(screen.getAllByRole('error')).toHaveLength(2) 23 | }) 24 | 25 | it('emits a submit event with patientForm when valid form submitted', async () => { 26 | const { emitted } = render(FormValidation) 27 | 28 | await fireEvent.update(screen.getByLabelText('Name'), 'lachlan') 29 | await fireEvent.update(screen.getByLabelText('Weight'), '150') 30 | await fireEvent.update(screen.getByDisplayValue('kg'), 'lb') 31 | await fireEvent.click(screen.getByText('Submit')) 32 | 33 | expect(emitted().submit[0]).toEqual([ 34 | { 35 | patient: { 36 | name: 'lachlan', 37 | weight: { 38 | value: 150, 39 | units: 'lb' 40 | } 41 | } 42 | } 43 | ]) 44 | }) 45 | }) 46 | 47 | import { mount } from '@vue/test-utils' 48 | 49 | describe('FormValidation', () => { 50 | it('fills out form correctly', async () => { 51 | const wrapper = mount(FormValidation) 52 | 53 | await wrapper.find('[role="name"]').setValue('lachlan') 54 | await wrapper.find('[role="weight-units"]').setValue('lb') 55 | await wrapper.find('[role="weight"]').setValue('150') 56 | 57 | expect(wrapper.findAll('[role="error"]')).toHaveLength(0) 58 | }) 59 | 60 | it('shows errors for invalid inputs', async () => { 61 | const wrapper = mount(FormValidation) 62 | 63 | await wrapper.find('[role="name"]').setValue('') 64 | await wrapper.find('[role="weight-units"]').setValue('lb') 65 | await wrapper.find('[role="weight"]').setValue('50') 66 | 67 | expect(wrapper.findAll('[role="error"]')).toHaveLength(2) 68 | }) 69 | 70 | it('emits a submit event with patientForm when valid form submitted', async () => { 71 | const wrapper = mount(FormValidation) 72 | 73 | await wrapper.find('[role="name"]').setValue('lachlan') 74 | await wrapper.find('[role="weight-units"]').setValue('kg') 75 | await wrapper.find('[role="weight"]').setValue('100') 76 | await wrapper.find('[role="submit"]').trigger('submit.prevent') 77 | 78 | expect(wrapper.emitted('submit')[0]).toEqual([ 79 | { 80 | patient: { 81 | name: 'lachlan', 82 | weight: { 83 | value: 100, 84 | units: 'kg' 85 | } 86 | } 87 | } 88 | ]) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /examples/form-validation/form-validation.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 74 | 75 | 99 | -------------------------------------------------------------------------------- /examples/form-validation/form.js: -------------------------------------------------------------------------------- 1 | /** 2 | * name 3 | * weight (imp|metric) 4 | */ 5 | 6 | export function required(value) { 7 | if (!value) { 8 | return { 9 | valid: false, 10 | message: 'Required' 11 | } 12 | } 13 | 14 | return { valid: true } 15 | } 16 | 17 | export function isBetween(value, { min, max }) { 18 | if (value < min || value > max) { 19 | return { 20 | valid: false, 21 | message: `Must be between ${min} and ${max}` 22 | } 23 | } 24 | 25 | return { valid: true } 26 | } 27 | 28 | const limits = { 29 | kg: { min: 30, max: 200 }, 30 | lb: { min: 66, max: 440 }, 31 | } 32 | 33 | export function validateMeasurement(value, { constraints }) { 34 | const result = required(value) 35 | if (!result.valid) { 36 | return result 37 | } 38 | 39 | return isBetween(value, constraints) 40 | } 41 | 42 | export function isFormValid(form) { 43 | return form.name.valid && form.weight.valid 44 | } 45 | 46 | export function patientForm(patient) { 47 | const name = required(patient.name) 48 | 49 | const weight = validateMeasurement(patient.weight.value, { 50 | nullable: false, 51 | constraints: limits[patient.weight.units] 52 | }) 53 | 54 | return { 55 | name, 56 | weight, 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /examples/form-validation/form.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | required, 3 | isBetween, 4 | validateMeasurement, 5 | patientForm, 6 | isFormValid 7 | } from './form.js' 8 | 9 | describe('required', () => { 10 | it('is invalid when undefined', () => { 11 | expect(required(undefined)).toEqual({ valid: false, message: 'Required' }) 12 | }) 13 | 14 | it('is invalid when empty string', () => { 15 | expect(required('')).toEqual({ valid: false, message: 'Required' }) 16 | }) 17 | 18 | it('returns true false value is present', () => { 19 | expect(required('some value')).toEqual({ valid: true }) 20 | }) 21 | }) 22 | 23 | describe('isBetween', () => { 24 | it('returns true when value is equal to min', () => { 25 | expect(isBetween(5, { min: 5, max: 10 })).toEqual({ valid: true }) 26 | }) 27 | 28 | it('returns true when value is between min/max', () => { 29 | expect(isBetween(7, { min: 5, max: 10 })).toEqual({ valid: true }) 30 | }) 31 | 32 | it('returns true when value is equal to max', () => { 33 | expect(isBetween(10, { min: 5, max: 10 })).toEqual({ valid: true }) 34 | }) 35 | 36 | it('returns false when value is less than min', () => { 37 | expect(isBetween(4, { min: 5, max: 10 })).toEqual({ valid: false, message: 'Must be between 5 and 10' }) 38 | }) 39 | 40 | it('returns false when value is greater than max', () => { 41 | expect(isBetween(11, { min: 5, max: 10 })).toEqual({ valid: false, message: 'Must be between 5 and 10' }) 42 | }) 43 | }) 44 | 45 | describe('validateMeasurement', () => { 46 | it('returns invalid for input', () => { 47 | const constraints = { min: 10, max: 30 } 48 | const actual = validateMeasurement(undefined, { constraints, nullable: false }) 49 | expect(actual).toEqual({ valid: false, message: 'Required' }) 50 | }) 51 | 52 | it('returns invalid when outside range', () => { 53 | const constraints = { min: 10, max: 30 } 54 | const actual = validateMeasurement(40, { constraints, nullable: false }) 55 | expect(actual).toEqual({ valid: false, message: 'Must be between 10 and 30' }) 56 | }) 57 | 58 | 59 | it('returns valid when value is in range', () => { 60 | const constraints = { min: 10, max: 30 } 61 | const actual = validateMeasurement(20, { constraints, nullable: false }) 62 | expect(actual).toEqual({ valid: true }) 63 | }) 64 | }) 65 | 66 | describe('isFormValid', () => { 67 | it('returns true when name and weight field are valid', () => { 68 | const form = { 69 | name: { valid: true }, 70 | weight: { valid: true } 71 | } 72 | 73 | expect(isFormValid(form)).toBe(true) 74 | }) 75 | 76 | it('returns false when any field is invalid', () => { 77 | const form = { 78 | name: { valid: false }, 79 | weight: { valid: true } 80 | } 81 | 82 | expect(isFormValid(form)).toBe(false) 83 | }) 84 | }) 85 | 86 | describe('patientForm', () => { 87 | const validPatient = { 88 | name: 'test patient', 89 | weight: { value: 100, units: 'kg' } 90 | } 91 | 92 | it('is valid when form is filled out correctly', () => { 93 | const form = patientForm(validPatient) 94 | expect(form.name).toEqual({ valid: true }) 95 | expect(form.weight).toEqual({ valid: true }) 96 | }) 97 | 98 | it('is invalid when name is null', () => { 99 | const form = patientForm({ ...validPatient, name: '' }) 100 | expect(form.name).toEqual({ valid: false, message: 'Required' }) 101 | }) 102 | 103 | it('validates weight in imperial', () => { 104 | const form = patientForm({ 105 | ...validPatient, 106 | weight: { 107 | value: 65, 108 | units: 'lb' 109 | } 110 | }) 111 | 112 | expect(form.weight).toEqual({ valid: false, message: 'Must be between 66 and 440' }) 113 | }) 114 | 115 | it('validates weight in metric', () => { 116 | const form = patientForm({ 117 | ...validPatient, 118 | weight: { 119 | value: 29, 120 | units: 'kg' 121 | } 122 | }) 123 | 124 | expect(form.weight).toEqual({ valid: false, message: 'Must be between 30 and 200' }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /examples/props/Navbar.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /examples/props/message.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /examples/props/props.spec.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/vue' 2 | import Message, { validateVariant } from './Message.vue' 3 | import Navbar from './Navbar.vue' 4 | 5 | describe('Message', () => { 6 | it('renders variant correctly when passed', () => { 7 | const { container } = render(Message, { 8 | props: { 9 | variant: 'success' 10 | } 11 | }) 12 | 13 | expect(container.firstChild.classList).toContain('success') 14 | }) 15 | 16 | it('validates valid variant prop', () => { 17 | ;['success', 'warning', 'error'].forEach(variant => { 18 | expect(() => validateVariant(variant)).not.toThrow() 19 | }) 20 | }) 21 | 22 | it('throws error for invalid variant prop', () => { 23 | expect(() => validateVariant('invalid')).toThrow() 24 | }) 25 | }) 26 | 27 | describe('Navbar', () => { 28 | function renderNavbar(props) { 29 | render(Navbar, { 30 | props 31 | }) 32 | } 33 | 34 | it('shows login authenticated is true', () => { 35 | renderNavbar({ authenticated: true }) 36 | screen.getByText('Logout') 37 | }) 38 | 39 | it('shows logout by default', () => { 40 | renderNavbar() 41 | screen.getByText('Login') 42 | }) 43 | 44 | it('shows login when authenticated is false', () => { 45 | renderNavbar({ authenticated: false }) 46 | screen.getByText('Login') 47 | }) 48 | }) 49 | 50 | import { mount } from '@vue/test-utils' 51 | 52 | describe('Message', () => { 53 | it('renders variant correctly when passed', () => { 54 | const wrapper = mount(Message, { 55 | props: { 56 | variant: 'success' 57 | } 58 | }) 59 | 60 | expect(wrapper.classes()).toContain('success') 61 | }) 62 | 63 | it('validates valid variant prop', () => { 64 | ;['success', 'warning', 'error'].forEach(variant => { 65 | expect(() => validateVariant(variant)).not.toThrow() 66 | }) 67 | }) 68 | 69 | it('throws error for invalid variant prop', () => { 70 | expect(() => validateVariant('invalid')).toThrow() 71 | }) 72 | }) 73 | 74 | describe('Navbar', () => { 75 | function navbarFactory(props) { 76 | return mount(Navbar, { 77 | props 78 | }) 79 | } 80 | 81 | it('shows login authenticated is true', () => { 82 | const wrapper = navbarFactory({ authenticated: true }) 83 | expect(wrapper.html()).toContain('Logout') 84 | }) 85 | 86 | it('shows logout by default', () => { 87 | const wrapper = navbarFactory() 88 | expect(wrapper.find('a').text()).toBe('Login') 89 | }) 90 | 91 | it('shows login when authenticated is false', () => { 92 | const wrapper = navbarFactory({ authenticated: false }) 93 | expect(wrapper.find('a').text()).toBe('Login') 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /examples/provide-inject/store.js: -------------------------------------------------------------------------------- 1 | import { reactive, readonly, inject } from 'vue' 2 | 3 | export class Store { 4 | #state = {} 5 | 6 | constructor(state) { 7 | this.#state = reactive(state) 8 | } 9 | 10 | getState() { 11 | return readonly(this.#state) 12 | } 13 | 14 | addUser(user) { 15 | this.#state.users.push(user) 16 | } 17 | 18 | removeUser(user) { 19 | this.#state.users = this.#state.users.filter(u => 20 | u.name !== user.name 21 | ) 22 | } 23 | } 24 | 25 | export const store = new Store({ 26 | users: [ 27 | { name: 'Alice' }, 28 | { name: 'Bobby' }, 29 | { name: 'Candice' }, 30 | { name: 'Darren' }, 31 | { name: 'Evelynn' }, 32 | ] 33 | }) 34 | 35 | export function useStore() { 36 | return inject('store') 37 | } 38 | -------------------------------------------------------------------------------- /examples/provide-inject/store.spec.js: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/vue' 2 | import { Store } from './store.js' 3 | import Users from './users.vue' 4 | 5 | describe('store', () => { 6 | it('seeds the initial state', () => { 7 | const store = new Store({ 8 | users: [] 9 | }) 10 | 11 | expect(store.getState()).toEqual({ users: [] }) 12 | }) 13 | 14 | it('adds a user', () => { 15 | const store = new Store({ 16 | users: [] 17 | }) 18 | 19 | store.addUser({ name: 'Alice' }) 20 | 21 | expect(store.getState()).toEqual({ 22 | users: [{ name: 'Alice' }] 23 | }) 24 | }) 25 | 26 | it('removes a user', () => { 27 | const store = new Store({ 28 | users: [{ name: 'Alice' }] 29 | }) 30 | 31 | store.removeUser({ name: 'Alice' }) 32 | 33 | expect(store.getState()).toEqual({ 34 | users: [] 35 | }) 36 | }) 37 | 38 | it('renders a user', async () => { 39 | render(Users, { 40 | global: { 41 | provide: { 42 | store: new Store({ 43 | users: [] 44 | }) 45 | } 46 | } 47 | }) 48 | 49 | await fireEvent.update(screen.getByRole('username'), 'Alice') 50 | await fireEvent.click(screen.getByRole('submit')) 51 | await screen.findByText('Alice') 52 | }) 53 | }) 54 | 55 | 56 | import { mount } from '@vue/test-utils' 57 | 58 | describe('store', () => { 59 | it('renders a user', async () => { 60 | const wrapper = mount(Users, { 61 | global: { 62 | provide: { 63 | store: new Store({ 64 | users: [] 65 | }) 66 | } 67 | } 68 | }) 69 | 70 | await wrapper.find('input').setValue('Alice') 71 | await wrapper.find('button').trigger('submit.prevent') 72 | 73 | expect(wrapper.html()).toContain('Alice') 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /examples/provide-inject/users.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 48 | 49 | 71 | -------------------------------------------------------------------------------- /examples/render-functions/app.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 34 | -------------------------------------------------------------------------------- /examples/render-functions/tab-container.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | 3 | const withTabId = (content) => ({ 4 | props: { 5 | tabId: { 6 | type: String, 7 | required: true 8 | } 9 | }, 10 | ...content 11 | }) 12 | 13 | export const TabContent = withTabId({ 14 | render() { 15 | return h(this.$slots.default) 16 | } 17 | }) 18 | 19 | export const Tab = withTabId({ 20 | render() { 21 | return h('div', h(this.$slots.default)) 22 | } 23 | }) 24 | 25 | export const TabContainer = { 26 | props: { 27 | activeTabId: String 28 | }, 29 | 30 | render() { 31 | const $slots = this.$slots.default() 32 | const tabs = $slots 33 | .filter(slot => slot.type === Tab) 34 | .map(tab => { 35 | return h( 36 | tab, 37 | { 38 | class: { 39 | tab: true, 40 | active: tab.props.tabId === this.activeTabId 41 | }, 42 | onClick: () => { 43 | this.$emit('update:activeTabId', tab.props.tabId) 44 | } 45 | } 46 | ) 47 | }) 48 | 49 | const content = $slots.find(slot => 50 | slot.type === TabContent && 51 | slot.props.tabId === this.activeTabId 52 | ) 53 | 54 | return [ 55 | h(() => h('div', { class: 'tabs' }, tabs)), 56 | h(() => h('div', { class: 'content' }, content)), 57 | ] 58 | } 59 | } 60 | 61 | const style = ` 62 | .tabs { 63 | display: flex; 64 | } 65 | 66 | .tab { 67 | border: 1px solid; 68 | cursor: pointer; 69 | padding: 10px; 70 | width: 100px; 71 | text-align: center; 72 | } 73 | 74 | .tab:first-child { 75 | border-right: none; 76 | } 77 | 78 | .active { 79 | color: blue; 80 | border-bottom: 5px solid blue; 81 | } 82 | 83 | .content { 84 | margin: 10px; 85 | font-size: 1.5rem; 86 | } 87 | ` 88 | -------------------------------------------------------------------------------- /examples/render-functions/tab-content.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /examples/render-functions/tab.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /examples/render-functions/tabs.spec.js: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/vue' 2 | import App from './app.vue' 3 | 4 | test('tabs', async () => { 5 | render(App) 6 | expect(screen.queryByText('Content #2')).toBeFalsy() 7 | 8 | fireEvent.click(screen.getByText('Tab #2')) 9 | await screen.findByText('Content #2') 10 | }) 11 | 12 | import { mount } from '@vue/test-utils' 13 | 14 | test('tabs', async () => { 15 | const wrapper = mount(App) 16 | expect(wrapper.html()).not.toContain('Content #2') 17 | 18 | await wrapper.find('[data-test="2"]').trigger('click') 19 | 20 | expect(wrapper.html()).toContain('Content #2') 21 | }) -------------------------------------------------------------------------------- /examples/renderless-password/App.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 71 | 72 | 133 | -------------------------------------------------------------------------------- /examples/renderless-password/AppWithCustomValidator.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 57 | 58 | 111 | -------------------------------------------------------------------------------- /examples/renderless-password/renderless-password.js: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | 3 | export function isMatching(password, confirmation) { 4 | if (!password || !confirmation) { 5 | return false 6 | } 7 | return password === confirmation 8 | } 9 | 10 | export function calcComplexity(val) { 11 | if (!val) { 12 | return 0 13 | } 14 | 15 | if (val.length > 10) { 16 | return 3 17 | } 18 | if (val.length > 7) { 19 | return 2 20 | } 21 | if (val.length > 5) { 22 | return 1 23 | } 24 | 25 | return 0 26 | } 27 | 28 | export default { 29 | props: { 30 | minComplexity: { 31 | type: Number, 32 | default: 3 33 | }, 34 | 35 | validator: { 36 | type: Function, 37 | }, 38 | 39 | password: { 40 | type: String 41 | }, 42 | 43 | confirmation: { 44 | type: String 45 | } 46 | }, 47 | 48 | setup(props, { slots }) { 49 | const matching = computed(() => isMatching(props.password, props.confirmation)) 50 | const complexity = computed(() => calcComplexity(props.password)) 51 | const valid = computed(() => props.validator 52 | ? props.validator({ 53 | complexity: complexity.value, 54 | password: props.password, 55 | confirmation: props.confirmation, 56 | matching: matching.value, 57 | }) 58 | : complexity.value >= props.minComplexity && matching.value 59 | ) 60 | 61 | return () => slots.default({ 62 | matching: matching.value, 63 | complexity: complexity.value, 64 | valid: valid.value 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/renderless-password/renderless-password.spec.js: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/vue' 2 | import TestComponent from './App.vue' 3 | import AppWithCustomValidator from './AppWithCustomValidator.vue' 4 | import { isMatching, calcComplexity } from './renderless-password.js' 5 | 6 | describe('isMatching', () => { 7 | it('returns true when matching', () => { 8 | expect(isMatching('a', 'b')).toBe(false) 9 | }) 10 | 11 | it('returns true when matching', () => { 12 | expect(isMatching('a', 'a')).toBe(true) 13 | }) 14 | }) 15 | 16 | describe('calcComplexity', () => { 17 | const results = [ 18 | ['a'.repeat(3), 0], 19 | ['a'.repeat(6), 1], 20 | ['a'.repeat(8), 2], 21 | ['a'.repeat(11), 3], 22 | ] 23 | 24 | test.each(results)('return correct complexity based on length', (input, output) => { 25 | expect(calcComplexity(input)).toBe(output) 26 | }) 27 | }) 28 | 29 | describe('component using renderless-password', () => { 30 | it('supports custom validator', async () => { 31 | render(AppWithCustomValidator) 32 | 33 | await fireEvent.update( 34 | screen.getByRole('password'), 'this is a long password') 35 | await fireEvent.update( 36 | screen.getByRole('confirmation'), 'this is a long password') 37 | 38 | expect(screen.getByText('Submit').disabled).toBeTruthy() 39 | }) 40 | 41 | it('meets default requirements', async () => { 42 | render(TestComponent) 43 | 44 | await fireEvent.update( 45 | screen.getByLabelText('Password'), 'this is a long password') 46 | await fireEvent.update( 47 | screen.getByLabelText('Confirmation'), 'this is a long password') 48 | 49 | expect(screen.getByRole('password-complexity').classList).toContain('high') 50 | expect(screen.getByText('Submit').disabled).toBeFalsy() 51 | }) 52 | 53 | it('does not meet complexity requirements', async () => { 54 | render(TestComponent) 55 | 56 | await fireEvent.update( 57 | screen.getByLabelText('Password'), 'shorty') 58 | await fireEvent.update( 59 | screen.getByLabelText('Confirmation'), 'shorty') 60 | 61 | expect(screen.getByRole('password-complexity').classList).toContain('low') 62 | expect(screen.getByText('Submit').disabled).toBeTruthy() 63 | }) 64 | 65 | it('password and confirmation does not match', async () => { 66 | render(TestComponent) 67 | 68 | await fireEvent.update( 69 | screen.getByLabelText('Password'), 'abc') 70 | await fireEvent.update( 71 | screen.getByLabelText('Confirmation'), 'def') 72 | 73 | expect(screen.getByText('Submit').disabled).toBeTruthy() 74 | }) 75 | }) 76 | 77 | import { mount } from '@vue/test-utils' 78 | 79 | describe('component using renderless-password', () => { 80 | it('supports custom validator', async () => { 81 | const wrapper = mount(AppWithCustomValidator) 82 | 83 | await wrapper.find('[role="password"]').setValue('this is a long password') 84 | await wrapper.find('[role="confirmation"]').setValue('this is a long password') 85 | 86 | // customValidator in AppWithCustomValidator return false no matter what 87 | // so button is always disabled. 88 | expect(wrapper.find('button').element.disabled).toBe(true) 89 | }) 90 | 91 | it('meets default requirements', async () => { 92 | const wrapper = mount(TestComponent) 93 | 94 | await wrapper.find('#password').setValue('this is a long password') 95 | await wrapper.find('#confirmation').setValue('this is a long password') 96 | 97 | expect(wrapper.find('.complexity.low').exists()).not.toBe(true) 98 | expect(wrapper.find('.complexity.high').exists()).toBe(true) 99 | expect(wrapper.find('button').element.disabled).toBe(false) 100 | }) 101 | 102 | it('does not meet complexity requirements', async () => { 103 | const wrapper = mount(TestComponent) 104 | 105 | await wrapper.find('[role="password"]').setValue('shorty') 106 | await wrapper.find('[role="confirmation"]').setValue('shorty') 107 | 108 | expect(wrapper.find('button').element.disabled).toBe(true) 109 | expect(wrapper.find('.complexity.high').exists()).not.toBe(true) 110 | expect(wrapper.find('.complexity.low').exists()).toBe(true) 111 | }) 112 | 113 | it('password and confirmation does not match', async () => { 114 | const wrapper = mount(TestComponent) 115 | 116 | await wrapper.find('[role="password"]').setValue('abc') 117 | await wrapper.find('[role="confirmation"]').setValue('def') 118 | 119 | expect(wrapper.find('button').element.disabled).toBe(true) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /examples/reusable-date-time/DateApp.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 54 | -------------------------------------------------------------------------------- /examples/reusable-date-time/app.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 54 | -------------------------------------------------------------------------------- /examples/reusable-date-time/date-time-serializers.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | import moment from 'moment' 3 | 4 | export function serializeMoment(value) { 5 | const toString = `${value.year}-${value.month.padStart(2, '0')}-${value.day.padStart(2, '0')}` 6 | const toObject = moment(toString, 'YYYY-MM-DD', true) 7 | if (toObject.isValid()) { 8 | return toObject 9 | } 10 | return 11 | } 12 | 13 | export function deserializeMoment(value) { 14 | if (!moment.isMoment(value)) { 15 | return value 16 | } 17 | 18 | return { 19 | year: value.year().toString(), 20 | month: (value.month() + 1).toString(), 21 | day: value.date().toString() 22 | } 23 | } 24 | 25 | 26 | export function deserialize(value) { 27 | return { 28 | year: value.get('year'), 29 | month: value.get('month'), 30 | day: value.get('day') 31 | } 32 | } 33 | 34 | export function serialize(value) { 35 | try { 36 | const obj = DateTime.fromObject(value) 37 | if (obj.invalid) { 38 | return 39 | } 40 | } catch { 41 | return 42 | } 43 | 44 | return DateTime.fromObject(value) 45 | } 46 | 47 | -------------------------------------------------------------------------------- /examples/reusable-date-time/date-time.spec.js: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/vue' 2 | import moment from 'moment' 3 | import { DateTime } from 'luxon' 4 | import dateTime from './date-time.vue' 5 | import { 6 | serialize, 7 | deserialize, 8 | serializeMoment, 9 | } from './date-time-serializers.js' 10 | 11 | describe('serializeMoment', () => { 12 | it('serializes valid moment', () => { 13 | const actual = serializeMoment({ year: '2020', month: '1', day: '1' }) 14 | // compare as strings. moment is pain. 15 | expect(actual.toString()).toEqual(moment('2020-01-01').toString()) 16 | }) 17 | 18 | it('returns undefined for invalid moment', () => { 19 | const actual = serializeMoment({ year: '200000020', month: '1xxxxx', day: 'bbbb' }) 20 | expect(actual).toEqual(undefined) 21 | }) 22 | }) 23 | 24 | describe('deserialize', () => { 25 | it('deserializes to Luxon DateTime', () => { 26 | const actual = deserialize(DateTime.fromObject({ year: '2020', month: '1', day: '1' })) 27 | expect(actual).toEqual({ year: 2020, month: 1, day: 1 }) 28 | }) 29 | }) 30 | 31 | 32 | describe('serialize', () => { 33 | it('serializes valid Luxon DateTime', () => { 34 | const actual = serialize({ year: '2020', month: '1', day: '1' }) 35 | expect(actual).toEqual(DateTime.fromObject({ year: 2020, month: 1, day: 1 })) 36 | }) 37 | 38 | it('returns undefined for invalid Luxon DateTime', () => { 39 | const actual = serialize({ year: '200000020', month: '1xxxxx', day: '1' }) 40 | expect(actual).toEqual(undefined) 41 | }) 42 | }) 43 | 44 | describe('deserialize', () => { 45 | it('deserializes to Luxon DateTime', () => { 46 | const actual = deserialize(DateTime.fromObject({ year: '2020', month: '1', day: '1' })) 47 | expect(actual).toEqual({ year: 2020, month: 1, day: 1 }) 48 | }) 49 | }) 50 | 51 | test('DateTime', async () => { 52 | const { emitted } = render(dateTime, { 53 | props: { 54 | modelValue: DateTime.fromObject({ year: '2020', month: '1', day: '1' }), 55 | serialize, 56 | deserialize 57 | } 58 | }) 59 | 60 | await fireEvent.update(screen.getByRole('year') ,'2019') 61 | await fireEvent.update(screen.getByRole('month'), '2') 62 | await fireEvent.update(screen.getByRole('day'), '3') 63 | 64 | // 3 successful updates, 3 emits. 65 | expect(emitted()['update:modelValue']).toHaveLength(3) 66 | 67 | expect(emitted()['update:modelValue'][0][0]).toEqual( 68 | DateTime.fromObject({ year: '2019', month: '1', day: '1' }) 69 | ) 70 | expect(emitted()['update:modelValue'][1][0]).toEqual( 71 | DateTime.fromObject({ year: '2020', month: '2', day: '1' }) 72 | ) 73 | expect(emitted()['update:modelValue'][2][0]).toEqual( 74 | DateTime.fromObject({ year: '2020', month: '1', day: '3' }) 75 | ) 76 | }) 77 | 78 | import { mount } from '@vue/test-utils' 79 | 80 | test('DateTime', async () => { 81 | const wrapper = mount(dateTime, { 82 | props: { 83 | modelValue: DateTime.fromObject({ year: '2020', month: '1', day: '1' }), 84 | serialize, 85 | deserialize 86 | } 87 | }) 88 | 89 | await wrapper.find('[role="year"]').setValue('2019') 90 | await wrapper.find('[role="month"]').setValue('2') 91 | await wrapper.find('[role="day"]').setValue('3') 92 | 93 | // 3 successful updates, 3 emits. 94 | expect(wrapper.emitted('update:modelValue')).toHaveLength(3) 95 | 96 | // update:modelValue will not update the modelValue prop 97 | // in Vue Test Utils, though. 98 | // we could wrap this in another component and do something 99 | // fancy but it's not really worth it. I think this is fine, 100 | // since we know the limitations and understand why we are doing 101 | // what we are doing here. 102 | expect(wrapper.emitted('update:modelValue')[0][0]).toEqual( 103 | DateTime.fromObject({ year: '2019', month: '1', day: '1' }) 104 | ) 105 | expect(wrapper.emitted('update:modelValue')[1][0]).toEqual( 106 | DateTime.fromObject({ year: '2020', month: '2', day: '1' }) 107 | ) 108 | expect(wrapper.emitted('update:modelValue')[2][0]).toEqual( 109 | DateTime.fromObject({ year: '2020', month: '1', day: '3' }) 110 | ) 111 | }) -------------------------------------------------------------------------------- /examples/reusable-date-time/date-time.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 58 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/favicon.ico -------------------------------------------------------------------------------- /forms-clean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/forms-clean.png -------------------------------------------------------------------------------- /forms-dirty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/forms-dirty.png -------------------------------------------------------------------------------- /full.sh: -------------------------------------------------------------------------------- 1 | cat \ 2 | CONTENTS.md \ 3 | INTRO.md \ 4 | RENDERLESS-COMPONENTS.md \ 5 | | pandoc \ 6 | --highlight-style tango \ 7 | --pdf-engine pdflatex \ 8 | -o design_patterns_for_vuejs.pdf 9 | -------------------------------------------------------------------------------- /functional-core-imperative-shell.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/functional-core-imperative-shell.jpg -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Design Patterns for Vue.js 6 | 7 | 8 |
9 | 10 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './examples/renderless-password/App.vue' 3 | 4 | const app = createApp(App) 5 | app.mount('#app') 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | transform: { 4 | "^.+\\.vue$": "vue-jest", 5 | "^.+\\js$": "babel-jest" 6 | }, 7 | moduleFileExtensions: ['vue', 'js', 'json', 'jsx', 'ts', 'tsx', 'node'] 8 | } 9 | -------------------------------------------------------------------------------- /landing-page.css: -------------------------------------------------------------------------------- 1 | h1, h2, h3 { 2 | text-align: center; 3 | } 4 | 5 | h2 { 6 | margin: 0 0 40px 0; 7 | } 8 | 9 | h1, h2, h4 { 10 | font-weight: bold; 11 | } 12 | 13 | body { 14 | font-size: 1.25rem; 15 | line-height: 1.5; 16 | font-family: proxima-nova,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Droid Sans,Helvetica Neue,Fira Sans,sans-serif !important; 17 | } 18 | 19 | .white { 20 | color: white; 21 | } 22 | 23 | .black { 24 | color: black; 25 | } 26 | 27 | .call-to-action { 28 | max-width: 500px; 29 | padding: 35px; 30 | color: black; 31 | background: white; 32 | border-radius: 8px; 33 | box-shadow: 0 15px 35px 0 rgba(18,37,49,.1),0 5px 15px 0 rgba(0,0,0,.05); 34 | } 35 | 36 | .call-to-action__text { 37 | text-align: center; 38 | padding: 0 10px; 39 | } 40 | 41 | .email-form { 42 | display: flex; 43 | flex-direction: column; 44 | border-radius: 8px; 45 | background: white; 46 | margin: 10px 0; 47 | } 48 | 49 | .email-form__email { 50 | background: #b8c2cc57; 51 | } 52 | 53 | .email-form__email, .email-form__button { 54 | box-sizing: border-box; 55 | margin: 5px 0; 56 | font-size: 1.2rem; 57 | border-radius: 5px; 58 | width: 100%; 59 | padding: 15px; 60 | border: none; 61 | } 62 | 63 | .email-form__button:disabled { 64 | opacity: 0.8; 65 | } 66 | 67 | .email-form__button { 68 | background: #30373b; 69 | text-transform: uppercase; 70 | border: none; 71 | color: white; 72 | } 73 | 74 | main { 75 | display: flex; 76 | flex-direction: column; 77 | align-items: center; 78 | } 79 | 80 | section { 81 | padding: 30px 0; 82 | } 83 | 84 | section:nth-child(even) { 85 | background: #1b445d; 86 | color: white; 87 | } 88 | 89 | p { 90 | max-width: 600px; 91 | margin: 20px 0; 92 | font-weight: 400; 93 | } 94 | 95 | .text-center { 96 | text-align: center; 97 | } 98 | 99 | h1 { 100 | font-size: 3em; 101 | } 102 | 103 | h2 { 104 | font-size: 2em; 105 | } 106 | 107 | h3 { 108 | font-size: 1.25em; 109 | } 110 | 111 | section { 112 | width: 100%; 113 | display: flex; 114 | flex-direction: column; 115 | align-items: center; 116 | } 117 | 118 | .check { 119 | height: 30px; 120 | padding-right: 10px; 121 | } 122 | 123 | .features > li { 124 | font-size: 20px; 125 | display: flex; 126 | } 127 | 128 | .cards { 129 | display: flex; 130 | align-items: center; 131 | } 132 | 133 | .cards__outer { 134 | box-shadow: 0 15px 35px 0 rgba(18,37,49,.1),0 5px 15px 0 rgba(0,0,0,.05); 135 | border-radius: 10px; 136 | overflow: hidden; 137 | width: 350px; 138 | } 139 | 140 | .cards__outer:first-child { 141 | margin-left: 10px; 142 | } 143 | 144 | .cards__outer:last-child { 145 | margin-left: -10px; 146 | } 147 | 148 | @media screen and (max-width: 768px) { 149 | .section__content { 150 | margin: 0 20px; 151 | } 152 | 153 | .cards { 154 | flex-direction: column-reverse; 155 | } 156 | 157 | .cards__outer:first-child, .cards__outer:last-child { 158 | margin: 10px 0; 159 | margin-left: 0px; 160 | } 161 | } 162 | 163 | .cards__image-wrapper { 164 | display: flex; 165 | justify-content: center; 166 | } 167 | 168 | .cards__image { 169 | height: 100px; 170 | } 171 | 172 | .cards__title { 173 | background: #f1f5f8; 174 | text-align: center; 175 | color: black; 176 | padding: 15px; 177 | } 178 | 179 | .cards__content { 180 | padding: 30px 40px 25px 40px; 181 | background: white; 182 | color: black; 183 | border-bottom-left-radius: 8px; 184 | } 185 | 186 | .cards__content > ul.features > li { 187 | padding: 5px 0; 188 | } 189 | 190 | a.button { 191 | cursor: pointer; 192 | display: inline-block; 193 | border-radius: 10px; 194 | display: flex; 195 | align-items: center; 196 | justify-content: center; 197 | height: 50px; 198 | text-transform: uppercase; 199 | } 200 | 201 | a.button:hover { 202 | /* filter: brightness(90%); */ 203 | cursor: not-allowed; 204 | } 205 | 206 | .cards__content--button { 207 | margin: 20px 0 0 0; 208 | } 209 | 210 | .bg-silver { 211 | background: #b8c2cc; 212 | color: black; 213 | } 214 | 215 | .bg-green { 216 | background: #40bc91; 217 | color: white; 218 | } 219 | 220 | .strikethrough { 221 | text-decoration: line-through; 222 | } 223 | 224 | .cards__content--divider { 225 | margin: 0 10px; 226 | } 227 | 228 | b { 229 | font-weight: bold; 230 | } 231 | 232 | .who-am-i > a { 233 | color: white; 234 | } 235 | 236 | .profile { 237 | display: flex; 238 | justify-content: center; 239 | margin: 30px 0; 240 | } 241 | 242 | .profile__image { 243 | border-radius: 50%; 244 | height: 200px; 245 | } -------------------------------------------------------------------------------- /landing-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Design Patterns for Vue.js 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 45 |
46 |
47 |

Design Patterns for Vue.js

48 |

A test driven approach to maintainable applications

49 | 50 |
51 |
52 | 53 |
54 |
55 |
56 |
57 | 59 | Sign up to get progress updates, previews and a discounted price on release. 60 |
61 | 62 | 67 | 68 | 73 | 74 |
75 |
76 |
77 | 78 |
79 |
80 | 81 |

82 | Vue.js is a great framework - you find yourself building better applications, 83 | faster than ever. With the Composition API and TypeScript, you're now feeling unstoppable... 84 |

85 | 86 |

87 | ...over time, velocity slows. Business requirements change. The line between business logic 88 | and the your UI components begins to blur. 89 |

90 | 91 |

92 | Design patterns for Vue.js will arm you with the tools, patterns and concepts to 93 | build complex, scalable and testable applications. 94 |

95 | 96 |

97 | We cover: 98 |

99 | 100 |
    101 |
  • 102 | 104 | Separation of concerns 105 |
  • 106 |
  • 107 | 109 | Test-first philosophy 110 |
  • 111 |
  • 112 | 114 | Write tests to help drive your component design 115 |
  • 116 |
  • 117 | 119 | Design Patterns for consistency 120 |
  • 121 |
  • 122 | 124 | Options or Composition? Choosing the right API for the job 125 |
  • 126 |
127 | 128 |
129 |
130 | 131 |
132 |
133 |

What's Included

134 | 135 |

📗 The Book

136 |

137 | Beautifully formatted PDF and epub. 200 pages split into 10 sections that can be read in any order. 138 | Lifetime access (content updated as Vue changes and evolves). 139 |

140 |

141 | 142 |

📺 Screencasts

143 |

144 | A screencast covering each section of the book. See how I think, my code style, and how I approach 145 | writing modular Vue apps with test coverage, as well as little tricks I've learned over the years 146 | from working on many complex Vue applications. 147 |

148 | 149 |

🏋️‍♂️ Exercises (with solutions)

150 |

151 | Each section ends with exercises to make sure you understand everything. The source code is also included, 152 | as well as the solutions to the exercises, so you can check your solutions. 153 |

154 | 155 |

✅ Always up to date

156 |

157 | The very nature of a book about best practices and testing is easy to keep up to date, since the test suite 158 | gives me confidence when updating the content! For this reason, you can be 159 | confident all the code snippets work, and will continue to do so. 160 |

161 | 162 |
163 |
164 | 165 |
166 |
167 |
168 |
169 |
170 | Basic Package 171 |
172 | 173 |
174 |
175 | 176 |
177 | 178 |
    179 |
  • 180 | 182 | Download PDF + epub 183 |
  • 184 |
  • 185 | 187 | Source code + exercise solutions 188 |
  • 189 |
190 | 191 | 192 |
193 | Coming soon! 194 |
195 | 196 | 205 | 206 |
207 | 208 |
209 |
210 | 211 |
212 |
213 | Complete Package 214 |
215 | 216 |
217 |
218 | 219 |
220 | 221 |
    222 |
  • 223 | 225 | Download PDF + epub 226 |
  • 227 |
  • 228 | 230 | Source code + exercise solutions 231 |
  • 232 |
  • 233 | 235 | Video screencasts implementing examples and commentary 236 |
  • 237 |
  • 238 | 240 | Lifetime access to future updates 241 |
  • 242 |
243 | 244 | 245 |
246 | Coming soon! 247 |
248 |
249 | 250 | 251 | 262 | 263 |
264 |
265 | 266 |
267 |
268 |
269 | 270 |
271 |
272 |

Frequently Asked Questions

273 |

How do I get the content?

274 |

275 | When you purchase the content, you'll get a link to a platform I made just for 276 | this course. You'll be able to watch the screencasts online (if you purchased that package) 277 | or download them, as well as download the PDF/epub. 278 |

279 | 280 |

Do you offer purchasing power parity?

281 |

282 | Yep, absolutely. I understand not every country has the same salaries, please 283 | send me an email 284 | and we can work something out! 285 |

286 |
287 |
288 | 289 |
290 |
291 |

Who am I?

292 |
293 | 294 |
295 |

296 | Hi there! I'm Lachlan, Vue.js team member and quality software 297 | enthusiast. 298 |

299 | 300 |

301 | I have taught tens of thousands of developers how to write testable Vue.js applications 302 | through my courses, 303 | books and 304 | YouTube channel. I hope you will be next! 305 |

306 | 307 |

308 | You can find my on GitHub and 309 | Twitter or by just sending me a 310 | good old fashioned email. 311 |

312 | 313 |
314 | 315 |
316 | 317 |
318 |
319 |
320 | Thanks to Linector 321 | and Freepik from 322 | www.flaticon.com 324 | for the cute onigiri icons. 325 |
326 |
327 | 328 |
329 | 330 | 331 |
332 | 333 | 334 | -------------------------------------------------------------------------------- /merge.sh: -------------------------------------------------------------------------------- 1 | gs -dBATCH -dNOPAUSE -q -sDEVICE=pdfwrite -sOutputFile=merged.pdf assets/covers/Vue.js_cover.pdf build/design_patterns_for_vuejs.pdf 2 | -------------------------------------------------------------------------------- /onigiri-big.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /onigiri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@testing-library/vue": "^6.0.0", 4 | "axios": "^0.21.0", 5 | "flush-promises": "^1.0.2", 6 | "luxon": "^1.25.0", 7 | "moment": "^2.29.1", 8 | "msw": "^0.22.1", 9 | "node-request-interceptor": "^0.5.4", 10 | "vite": "^1.0.0-rc.4", 11 | "vue": "^3.0.0", 12 | "vuex": "^4.0.0-rc.1" 13 | }, 14 | "devDependencies": { 15 | "@babel/preset-env": "^7.11.5", 16 | "@vue/compiler-sfc": "^3.0.0", 17 | "babel-jest": "^26.3.0", 18 | "jest": "25.0.0", 19 | "ts-node": "^9.0.0", 20 | "typescript": "^4.0.2", 21 | "vue-jest": "^5.0.0-alpha.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /props-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-pattenrns-for-vuejs/f6fdd2eae0818dd6f86a52f1b3b02ab98bcd4e63/props-error.png -------------------------------------------------------------------------------- /src/epub/ABOUT.md: -------------------------------------------------------------------------------- 1 | # About the Book 2 | 3 | This book is aimed at developers who are comfortable with JavaScript and Vue.js. It focuses on ideas and patterns rather than specific APIs. Separation of concerns, pure functions, writing for testability and re-usability are some of the primary motifs. 4 | 5 | The examples are written with Vue.js 3, the latest version of Vue.js. Both the classic Options API and new Composition API are covered. The tests given as examples throughout the book are written with [Vue Testing Library](https://github.com/testing-library/vue-testing-library). If you prefer Vue Test Utils, no problem - the source code contains the same tests written using both Testing Library and Vue Test Utils. 6 | 7 | The final source code including the solutions to the exercises is available [here](https://github.com/lmiller1990/design-patterns-for-vuejs-source-code): https://github.com/lmiller1990/design-patterns-for-vuejs-source-code. 8 | 9 | ## About the Author 10 | 11 | Lachlan Miller is a full stack software developer based in Brisbane, Australia. He is passionate about open source and mentoring. His primary areas of interest is testing, software quality and design patterns. 12 | 13 | He picked up Vue.js in 2016 and was immediately hooked. He has been contributing to the Vue.js ecosystem since, and is the primary maintainer of several popular libraries, Vue Test Utils as the most notable. 14 | 15 | He also has a YouTube channel where he posts advanced content, similar to that found in this book. He has made two full length courses, enjoyed by over 13000 students. 16 | 17 | [Lachlan's website](https://lachlan-miller.me). 18 | [Lachlan's YouTube channel](https://www.youtube.com/c/LachlanMiller). 19 | [Lachlan's Udemy Profile](https://www.udemy.com/user/lachlan-miller-4/). 20 | 21 | -------------------------------------------------------------------------------- /src/epub/CONTENTS.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Design Patterns for Vue.js 3 | numbersections: true 4 | header-includes: 5 | \usepackage{graphicx} 6 | \usepackage{float} 7 | 8 | output: 9 | epub 10 | --- 11 | -------------------------------------------------------------------------------- /src/epub/COVER.md: -------------------------------------------------------------------------------- 1 | ![](cover.png) 2 | -------------------------------------------------------------------------------- /src/epub/EVENTS.md: -------------------------------------------------------------------------------- 1 | # Emitting Events 2 | 3 | You can find the completed source code in the [GitHub repository under examples/events](https://github.com/lmiller1990/design-patterns-for-vuejs-source-code). 4 | 5 | Vue's primary mechanic for passing data *down* to components is `props`. In contrast, when components needs to communicate with another component higher in the hierarchy, you do so by *emitting events*. This is done by calling `this.$emit()` (Options API) or `ctx.emit()` (Composition API). 6 | 7 | Let's see some examples on how this works, and some guidelines we can set to keep things clean and understandable. 8 | 9 | ## Starting Simple 10 | 11 | Here is a very minimal yet perfectly working `` component. It is not ideal; we will work on improving it during this section. 12 | 13 | This example starts with the Options API; we will eventually refactor it to use the Composition API (using the tests we write to ensure we don't break anything). 14 | 15 | ```html 16 | 20 | 21 | 30 | ``` 31 | \begin{center} 32 | A simple counter component. 33 | \end{center} 34 | 35 | There are two buttons. One increments the `count` value by 1. The other emits a `submit` event with the current count. Let's write a simple test that will let us refactor with the confidence. 36 | 37 | As with the other examples, this one uses Testing Library, but you could really use any testing framework - the important part is that we have a mechanism to let us know if we break something. 38 | 39 | ```js 40 | import { render, screen, fireEvent } from '@testing-library/vue' 41 | import Counter from './counter.vue' 42 | 43 | describe('Counter', () => { 44 | it('emits an event with the current count', async () => { 45 | const { emitted } = render(Counter) 46 | await fireEvent.click(screen.getByRole('increment')) 47 | await fireEvent.click(screen.getByRole('submit')) 48 | console.log(emitted()) 49 | }) 50 | }) 51 | ``` 52 | \begin{center} 53 | Observing the emitted events with emitted(). 54 | \end{center} 55 | 56 | I did a `console.log(emitted())` to illustrate how `emitted` works in Testing Library. If you run the test, the console output is as follows: 57 | 58 | ```json 59 | { 60 | submit: [ 61 | [ 1 ] 62 | ] 63 | } 64 | ``` 65 | \begin{center} 66 | A submit event was emitted with one argument: the number 1. 67 | \end{center} 68 | 69 | `emitted` is an object - each event is a key, and it maps to an array with an entry for each time the event was emitted. `emit` can have any amount of arguments; if I had written `$emit('submit', 1, 2, 3,)` the output would be: 70 | 71 | ```json 72 | { 73 | submit: [ 74 | [ 1, 2, 3 ] 75 | ] 76 | } 77 | ``` 78 | \begin{center} 79 | A submit event was emitted with three arguments, 1, 2, 3. 80 | \end{center} 81 | 82 | Let's add an assertion, before we get onto the main topic: patterns and practices for emitting events. 83 | 84 | ```js 85 | import { render, screen, fireEvent } from '@testing-library/vue' 86 | import Counter from './counter.vue' 87 | 88 | describe('Counter', () => { 89 | it('emits an event with the current count', async () => { 90 | const { emitted } = render(Counter) 91 | 92 | await fireEvent.click(screen.getByRole('increment')) 93 | await fireEvent.click(screen.getByRole('submit')) 94 | 95 | expect(emitted().submit[0]).toEqual([1]) 96 | }) 97 | }) 98 | ``` 99 | \begin{center} 100 | Making an assertion against the emitted events. 101 | \end{center} 102 | 103 | ## Clean Templates 104 | 105 | Templates can often get chaotic among passing props, listening for events and using directives. For this reason, wherever possible, we want to keep our templates simple by moving logic into the ` 130 | ``` 131 | \begin{center} 132 | Moving the emit logic from the template to the script. 133 | \end{center} 134 | 135 | Running the test confirms that everything is still working. This is good. Good tests are resilient to refactors, since they test inputs and outputs, not implementation details. 136 | 137 | I recommend you avoid putting any logic into `