├── .gitignore ├── icon.png ├── banner.jpg ├── examples ├── 08-render-functions │ ├── tabs.ts │ ├── Tab.vue │ ├── TabContent.vue │ ├── HExample.ts │ ├── cypress │ │ └── RenderFunctionsApp.cy.ts │ ├── Tabs.spec.js │ ├── RenderFunctionsApp.vue │ ├── vitest │ │ └── RenderFunctionsApp.spec.ts │ └── TabContainer.ts ├── 05-events │ ├── patient.ts │ ├── Counter.vue │ └── PatientForm.vue ├── 03-props │ ├── Sum.vue │ ├── SumApp.vue │ └── SumWithProps.vue ├── index.ts ├── 10-datetime-strategy-pattern │ ├── cypress │ │ └── DateTimeApp.cy.ts │ ├── DateTimeApp.vue │ ├── App.vue │ ├── serializers.ts │ ├── DateTime.vue │ └── vitest │ │ └── DateTime.spec.ts ├── 06-http-and-api-requests │ ├── with-pinia │ │ ├── store.ts │ │ ├── Login.vue │ │ └── Login.spec.ts │ ├── starting-simple │ │ ├── Login.spec.ts │ │ └── Login.vue │ ├── cypress │ │ └── Login.cy.ts │ └── mock-service-worker │ │ └── Login.spec.ts ├── 07-renderless-components │ ├── cypress │ │ └── RenderlessPasswordApp.cy.ts │ ├── RenderlessPassword.vue │ ├── RenderlessPasswordApp.vue │ └── vitest │ │ └── RenderlessPassword.spec.ts ├── 12-functional-core-imperative-shell │ ├── TicTacToe.vue │ ├── vitest │ │ └── TicTacToe.spec.ts │ ├── TicTacToe.ts │ └── cypress │ │ └── TicTacToe.cy.ts ├── 11-grouping-with-composables │ ├── TicTacToe.vue │ ├── TicTacToe.ts │ └── TicTacToe.cy.ts ├── 04-forms │ ├── cypress │ │ └── PatientForm.cy.ts │ ├── vitest │ │ └── PatientForm.spec.ts │ ├── form.ts │ ├── PatientForm.vue │ └── form.spec.ts └── 09-provide-inject │ ├── store.ts │ ├── users.vue │ ├── store.cy.ts │ └── store.spec.ts ├── cypress ├── videos │ ├── 09-provide-inject │ │ └── store.cy.ts.mp4 │ ├── 04-forms │ │ └── cypress │ │ │ └── PatientForm.cy.ts.mp4 │ ├── 06-http-and-api-requests │ │ └── cypress │ │ │ └── Login.cy.ts.mp4 │ ├── 11-grouping-with-composables │ │ └── TicTacToe.cy.ts.mp4 │ ├── 08-render-functions │ │ └── cypress │ │ │ └── RenderFunctionsApp.cy.ts.mp4 │ ├── 10-datetime-strategy-pattern │ │ └── cypress │ │ │ └── DateTimeApp.cy.ts.mp4 │ ├── 07-renderless-components │ │ └── cypress │ │ │ └── RenderlessPasswordApp.cy.ts.mp4 │ └── 12-functional-core-imperative-shell │ │ └── cypress │ │ └── TicTacToe.cy.ts.mp4 ├── fixtures │ └── example.json └── support │ ├── component-index.html │ ├── component.ts │ └── commands.ts ├── cypress.config.ts ├── vite.config.ts ├── index.html ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/icon.png -------------------------------------------------------------------------------- /banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/banner.jpg -------------------------------------------------------------------------------- /examples/08-render-functions/tabs.ts: -------------------------------------------------------------------------------- 1 | export { TabContainer, Tab, TabContent } from "./TabContainer.js"; 2 | -------------------------------------------------------------------------------- /examples/05-events/patient.ts: -------------------------------------------------------------------------------- 1 | export interface Patient { 2 | firstName: string; 3 | familyName: string; 4 | } 5 | -------------------------------------------------------------------------------- /examples/08-render-functions/Tab.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /cypress/videos/09-provide-inject/store.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/cypress/videos/09-provide-inject/store.cy.ts.mp4 -------------------------------------------------------------------------------- /cypress/videos/04-forms/cypress/PatientForm.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/cypress/videos/04-forms/cypress/PatientForm.cy.ts.mp4 -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /examples/08-render-functions/TabContent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /cypress/videos/06-http-and-api-requests/cypress/Login.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/cypress/videos/06-http-and-api-requests/cypress/Login.cy.ts.mp4 -------------------------------------------------------------------------------- /cypress/videos/11-grouping-with-composables/TicTacToe.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/cypress/videos/11-grouping-with-composables/TicTacToe.cy.ts.mp4 -------------------------------------------------------------------------------- /cypress/videos/08-render-functions/cypress/RenderFunctionsApp.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/cypress/videos/08-render-functions/cypress/RenderFunctionsApp.cy.ts.mp4 -------------------------------------------------------------------------------- /cypress/videos/10-datetime-strategy-pattern/cypress/DateTimeApp.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/cypress/videos/10-datetime-strategy-pattern/cypress/DateTimeApp.cy.ts.mp4 -------------------------------------------------------------------------------- /cypress/videos/07-renderless-components/cypress/RenderlessPasswordApp.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/cypress/videos/07-renderless-components/cypress/RenderlessPasswordApp.cy.ts.mp4 -------------------------------------------------------------------------------- /cypress/videos/12-functional-core-imperative-shell/cypress/TicTacToe.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/design-patterns-for-vuejs-source-code/HEAD/cypress/videos/12-functional-core-imperative-shell/cypress/TicTacToe.cy.ts.mp4 -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | component: { 5 | experimentalSingleTabRunMode: true, 6 | devServer: { 7 | framework: "vue", 8 | bundler: "vite", 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import vue from "@vitejs/plugin-vue"; 2 | import vueJsx from '@vitejs/plugin-vue-jsx' 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | plugins: [vue(), vueJsx()], 7 | test: { 8 | environment: 'jsdom', 9 | globals: true 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /examples/08-render-functions/HExample.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from "vue"; 2 | 3 | export const Comp = defineComponent({ 4 | setup() { 5 | const e1 = h("div"); 6 | const e2 = h("span"); 7 | const e3 = h({ 8 | setup() { 9 | return () => h("p", {}, ["Some Content"]); 10 | }, 11 | }); 12 | 13 | return () => [h(() => e1), h(() => e2), h(() => e3)]; 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /examples/03-props/Sum.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Design Patterns for Vue.js 6 | 7 | 8 |
9 | 10 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | // Chapter 3 - Props 4 | import Sum from "./03-props/Sum.vue" 5 | import SumApp from "./03-props/SumApp.vue" 6 | 7 | // Chapter 4 - Forms 8 | import PatientForm from "./04-forms/PatientForm.vue" 9 | 10 | // Chapter 7 - Rendless Components 11 | import RenderlessPasswordApp from "./07-renderless-components/RenderlessPasswordApp.vue" 12 | 13 | 14 | const app = createApp(RenderlessPasswordApp); 15 | app.mount("#app"); 16 | -------------------------------------------------------------------------------- /examples/03-props/SumApp.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 17 | -------------------------------------------------------------------------------- /examples/10-datetime-strategy-pattern/cypress/DateTimeApp.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import DateTimeApp from "../DateTimeApp.vue"; 3 | 4 | describe("", () => { 5 | it("renders", () => { 6 | cy.mount(DateTimeApp); 7 | 8 | function fillDate() { 9 | cy.get('[name="year"]').clear().type("2020"); 10 | cy.get('[name="month"]').clear().type("2"); 11 | cy.get('[name="day"]').clear().type("28"); 12 | } 13 | 14 | fillDate(); 15 | cy.contains("2020-02-28T00:00:00.000"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](./banner.jpg) 2 | 3 | Source code and solutions for exercises in my book, [Design Patterns for Vue.js: A Test Driven Approach to Maintainable Applications](https://lachlan-miller.me/design-patterns-for-vuejs). 4 | 5 | If you want to run the tests locally, you can install the dependencies with `npm install` and run them with `"npm run cypress:run"` for Cypress or `"npm run vitest"` for Vitest. 6 | 7 | Some of the section have a UI associated with them, you can import the relevant example into `examples/.index.ts` and start the dev server with `npx vite dev`. 8 | -------------------------------------------------------------------------------- /examples/08-render-functions/cypress/RenderFunctionsApp.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import RenderFunctionsApp from '../RenderFunctionsApp.vue' 3 | 4 | describe('', () => { 5 | it('renders', () => { 6 | cy.mount(RenderFunctionsApp) 7 | cy.get('.active').contains('Tab #1') 8 | cy.contains('Content #1').should('exist') 9 | cy.contains('Content #2').should('not.exist') 10 | 11 | cy.contains('Tab #2').click() 12 | 13 | cy.contains('Content #1').should('not.exist') 14 | cy.contains('Content #2').should('exist') 15 | }) 16 | }) -------------------------------------------------------------------------------- /examples/10-datetime-strategy-pattern/DateTimeApp.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /examples/03-props/SumWithProps.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /examples/05-events/Counter.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /examples/08-render-functions/Tabs.spec.js: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/vue' 2 | import App from './RenderFunctionsApp.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-testid="tab-2"]').trigger('click') 19 | 20 | expect(wrapper.html()).toContain('Content #2') 21 | }) -------------------------------------------------------------------------------- /examples/05-events/PatientForm.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /examples/06-http-and-api-requests/with-pinia/store.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { defineStore } from "pinia"; 3 | 4 | export interface User { 5 | username: string; 6 | } 7 | 8 | interface UsersState { 9 | user?: User; 10 | } 11 | 12 | export const useUsers = defineStore("users", { 13 | state(): UsersState { 14 | return { 15 | user: undefined, 16 | }; 17 | }, 18 | 19 | actions: { 20 | updateUser(user: User) { 21 | this.user = user; 22 | }, 23 | async login(username: string, password: string) { 24 | const response = await axios.post("/login", { 25 | username, 26 | password, 27 | }); 28 | this.user = response.data; 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /examples/07-renderless-components/cypress/RenderlessPasswordApp.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import RenderlessPasswordApp from '../RenderlessPasswordApp.vue' 3 | 4 | describe('', () => { 5 | it('renders and validates', () => { 6 | cy.mount(RenderlessPasswordApp) 7 | 8 | cy.get('.complexity').should('have.class', 'low') 9 | cy.get('#password').type('password') 10 | cy.get('.complexity').should('have.class', 'mid') 11 | cy.get('#password').clear().type('password123') 12 | cy.get('.complexity').should('have.class', 'high') 13 | 14 | cy.get('button').contains('Submit').should('be.disabled') 15 | cy.get('#confirmation').type('password123') 16 | cy.get('button').contains('Submit').should('not.be.disabled') 17 | }) 18 | }) -------------------------------------------------------------------------------- /examples/12-functional-core-imperative-shell/TicTacToe.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 25 | 26 | 37 | -------------------------------------------------------------------------------- /examples/12-functional-core-imperative-shell/vitest/TicTacToe.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { render, fireEvent, screen } from "@testing-library/vue"; 3 | import TicTacToe from "../TicTacToe.vue"; 4 | 5 | describe("TicTacToeApp", () => { 6 | it("plays a game", async () => { 7 | render(TicTacToe); 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( 14 | "o" 15 | ); 16 | expect(screen.getByTestId("row-0-col-1").textContent).toContain( 17 | "x" 18 | ); 19 | expect(screen.getByTestId("row-0-col-2").textContent).toContain( 20 | "o" 21 | ); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /examples/11-grouping-with-composables/TicTacToe.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 26 | 27 | 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@testing-library/cypress": "^9.0.0", 4 | "@testing-library/vue": "6.6.1", 5 | "@types/luxon": "^3.2.0", 6 | "@vitejs/plugin-vue": "^4.0.0", 7 | "@vitejs/plugin-vue-jsx": "^3.0.1", 8 | "@vue/babel-plugin-jsx": "^1.1.1", 9 | "axios": "1.3.4", 10 | "cypress": "^12.17.1", 11 | "flush-promises": "1.0.2", 12 | "jsdom": "^21.1.1", 13 | "luxon": "^3.3.0", 14 | "moment": "2.29.4", 15 | "msw": "^1.2.5", 16 | "node-request-interceptor": "0.6.3", 17 | "pinia": "^2.0.33", 18 | "vite": "4.1.4", 19 | "vue": "^3.3.4" 20 | }, 21 | "devDependencies": { 22 | "typescript": "^5.1.6", 23 | "vitest": "0.29.2" 24 | }, 25 | "scripts": { 26 | "cypress:run": "cypress run --component --spec 'examples/**/*.cy.ts'", 27 | "cypress:open": "cypress open --component -b chrome --spec 'examples/**/*.cy.ts'", 28 | "vitest": "vitest --run" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/10-datetime-strategy-pattern/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 44 | -------------------------------------------------------------------------------- /examples/04-forms/cypress/PatientForm.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import PatientForm from "../PatientForm.vue"; 3 | 4 | describe("Form", () => { 5 | it("fills out form", () => { 6 | cy.mount(PatientForm); 7 | // disabled due to errors 8 | cy.get('[role="error"]').should("have.length", 2); 9 | cy.get("button[type='submit']").should("be.disabled"); 10 | 11 | cy.get("input[name='name']").type("lachlan"); 12 | cy.get('[role="error"]').should("have.length", 1); 13 | cy.get("input[name='weight']").type("30"); 14 | cy.get('[role="error"]').should("have.length", 0); 15 | 16 | cy.get("#weight-units") 17 | .select("lb") 18 | // 30 lb is not valid! Error shown 19 | cy.get('[role="error"]') 20 | .should("have.length", 1) 21 | .should("have.text", "Must be between 66 and 440"); 22 | 23 | cy.get("input[name='weight']").clear().type("100"); 24 | cy.get("button[type='submit']").should("be.enabled"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /examples/04-forms/vitest/PatientForm.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { render, screen, fireEvent } from '@testing-library/vue' 3 | import PatientForm from "../PatientForm.vue"; 4 | 5 | describe("PatientForm.vue", () => { 6 | it('fills out form correctly', async () => { 7 | render(PatientForm) 8 | 9 | await fireEvent.update(screen.getByLabelText('Name'), 'lachlan') 10 | await fireEvent.update(screen.getByDisplayValue('kg'), 'lb') 11 | await fireEvent.update(screen.getByLabelText('Weight'), '150') 12 | 13 | expect(screen.queryByRole('error')).toBe(null) 14 | }) 15 | 16 | it('shows errors for invalid inputs', async () => { 17 | render(PatientForm) 18 | 19 | await fireEvent.update(screen.getByLabelText('Name'), '') 20 | await fireEvent.update(screen.getByLabelText('Weight'), '5') 21 | await fireEvent.update(screen.getByDisplayValue('kg'), 'lb') 22 | 23 | expect(screen.getAllByRole('error')).toHaveLength(2) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /examples/08-render-functions/RenderFunctionsApp.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | 23 | -------------------------------------------------------------------------------- /examples/06-http-and-api-requests/with-pinia/Login.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 35 | -------------------------------------------------------------------------------- /examples/06-http-and-api-requests/starting-simple/Login.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach, vi } from "vitest"; 2 | import { render, fireEvent, screen } from "@testing-library/vue"; 3 | import Login from "./Login.vue"; 4 | 5 | vi.mock("axios", () => { 6 | return { 7 | default: { 8 | post: () => 9 | Promise.resolve({ 10 | data: { 11 | username: "Lachlan", 12 | password: "this-is-the-password", 13 | }, 14 | }), 15 | }, 16 | }; 17 | }); 18 | 19 | describe("login", () => { 20 | it("successfully authenticates", async () => { 21 | const { container } = render(Login); 22 | 23 | await fireEvent.update( 24 | container.querySelector("#username")!, 25 | "Lachlan" 26 | ); 27 | 28 | await fireEvent.update( 29 | container.querySelector("#password")!, 30 | "secret-password" 31 | ); 32 | 33 | await fireEvent.click(screen.getByText("Click here to sign in")); 34 | await screen.findByText("Hello, Lachlan"); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /examples/06-http-and-api-requests/starting-simple/Login.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 40 | -------------------------------------------------------------------------------- /examples/09-provide-inject/store.ts: -------------------------------------------------------------------------------- 1 | import { reactive, readonly, inject } from "vue"; 2 | 3 | interface User { 4 | id: number 5 | name: string; 6 | } 7 | 8 | interface State { 9 | users: User[]; 10 | } 11 | 12 | export class Store { 13 | #state: State = { users: [] }; 14 | 15 | constructor(state: State) { 16 | this.#state = reactive(state); 17 | } 18 | 19 | getState() { 20 | return readonly(this.#state); 21 | } 22 | 23 | addUser(user: Omit) { 24 | const id = this.#state.users.length === 0 ? 1 : Math.max(...this.#state.users.map(user => user.id)) + 1 25 | this.#state.users.push({ id, ...user }); 26 | } 27 | 28 | removeUser(user: User) { 29 | this.#state.users = this.#state.users.filter( 30 | (u) => u.name !== user.name 31 | ); 32 | } 33 | } 34 | 35 | export function useStore(): Store { 36 | return inject("store") as Store; 37 | } 38 | 39 | export const store = new Store({ 40 | users: [ 41 | { id: 1, name: "Alice" }, 42 | { id: 2, name: "Bobby" }, 43 | { id: 3, name: "Candice" }, 44 | { id: 4, name: "Darren" }, 45 | { id: 5, name: "Evelynn" }, 46 | ], 47 | }); 48 | -------------------------------------------------------------------------------- /examples/09-provide-inject/users.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 33 | 34 | 56 | -------------------------------------------------------------------------------- /examples/11-grouping-with-composables/TicTacToe.ts: -------------------------------------------------------------------------------- 1 | import { ref, readonly, computed } from "vue"; 2 | 3 | type Marker = "x" | "o" | "-"; 4 | 5 | export type Board = Array; 6 | 7 | export function useTicTacToe(initialState?: Board[]) { 8 | const initialBoard: Board = [ 9 | ["-", "-", "-"], 10 | ["-", "-", "-"], 11 | ["-", "-", "-"], 12 | ]; 13 | 14 | const boards = ref(initialState || [initialBoard]); 15 | const currentPlayer = ref("o"); 16 | const currentMove = ref(0); 17 | 18 | function makeMove(move: { row: number; col: number }) { 19 | const newBoard = JSON.parse(JSON.stringify(boards.value))[ 20 | currentMove.value 21 | ] as Board; 22 | newBoard[move.row][move.col] = currentPlayer.value; 23 | currentPlayer.value = currentPlayer.value === "o" ? "x" : "o"; 24 | boards.value.push(newBoard); 25 | currentMove.value += 1; 26 | } 27 | 28 | function undo() { 29 | currentMove.value -= 1; 30 | } 31 | 32 | function redo() { 33 | currentMove.value += 1; 34 | } 35 | 36 | return { 37 | makeMove, 38 | redo, 39 | undo, 40 | boards: readonly(boards), 41 | currentMove, 42 | currentPlayer: readonly(currentPlayer), 43 | currentBoard: computed(() => boards.value[currentMove.value]), 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /examples/06-http-and-api-requests/with-pinia/Login.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach, vi } from "vitest"; 2 | import { render, fireEvent, screen } from "@testing-library/vue"; 3 | import Login from "./Login.vue"; 4 | import { createPinia, Pinia, setActivePinia } from "pinia"; 5 | 6 | vi.mock("axios", () => { 7 | return { 8 | default: { 9 | post: () => 10 | Promise.resolve({ 11 | data: { 12 | username: "Lachlan", 13 | password: "this-is-the-password", 14 | }, 15 | }), 16 | }, 17 | }; 18 | }); 19 | 20 | describe("login", () => { 21 | let pinia: Pinia; 22 | 23 | beforeEach(() => { 24 | pinia = createPinia(); 25 | setActivePinia(pinia); 26 | }); 27 | 28 | it("successfully authenticates", async () => { 29 | const { container } = render(Login, { 30 | global: { 31 | plugins: [pinia] 32 | } 33 | }); 34 | 35 | await fireEvent.update( 36 | container.querySelector("#username")!, 37 | "Lachlan" 38 | ); 39 | 40 | await fireEvent.update( 41 | container.querySelector("#password")!, 42 | "secret-password" 43 | ); 44 | 45 | await fireEvent.click(screen.getByText("Click here to sign in")); 46 | await screen.findByText("Hello, Lachlan"); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/vue' 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add('mount', mount) 37 | 38 | import '@testing-library/cypress/add-commands' 39 | 40 | // Example use: 41 | // cy.mount(MyComponent) -------------------------------------------------------------------------------- /examples/10-datetime-strategy-pattern/serializers.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import moment, { Moment } from "moment"; 3 | 4 | export interface InternalDateTime { 5 | year: number; 6 | month: number; 7 | day: number; 8 | } 9 | 10 | export function serializeMoment(value: InternalDateTime) { 11 | const toString = `${value.year}-${value.month 12 | .toString() 13 | .padStart(2, "0")}-${value.day.toString().padStart(2, "0")}`; 14 | const toObject = moment(toString, "YYYY-MM-DD", true); 15 | if (toObject.isValid()) { 16 | return toObject; 17 | } 18 | return; 19 | } 20 | 21 | export function deserializeMoment(value: Moment): InternalDateTime { 22 | if (!moment.isMoment(value)) { 23 | return value; 24 | } 25 | 26 | return { 27 | year: value.year(), 28 | month: value.month() + 1, 29 | day: value.date(), 30 | }; 31 | } 32 | 33 | export function deserialize(value: DateTime): InternalDateTime { 34 | return { 35 | year: value.get("year"), 36 | month: value.get("month"), 37 | day: value.get("day"), 38 | }; 39 | } 40 | 41 | export function serialize(value: InternalDateTime) { 42 | try { 43 | const obj = DateTime.fromObject(value); 44 | if (!obj.isValid) { 45 | return; 46 | } 47 | } catch { 48 | return; 49 | } 50 | 51 | return DateTime.fromObject(value); 52 | } 53 | -------------------------------------------------------------------------------- /examples/09-provide-inject/store.cy.ts: -------------------------------------------------------------------------------- 1 | import { Store } from './store.js' 2 | import Users from './users.vue' 3 | 4 | describe('store', () => { 5 | it('seeds the initial state', () => { 6 | const store = new Store({ 7 | users: [] 8 | }) 9 | 10 | expect(store.getState()).to.eql({ users: [] }) 11 | }) 12 | 13 | it('adds a user', () => { 14 | const store = new Store({ 15 | users: [] 16 | }) 17 | 18 | store.addUser({ name: 'Alice' }) 19 | 20 | expect(store.getState()).to.eql({ 21 | users: [{ id: 1, name: 'Alice' }] 22 | }) 23 | }) 24 | 25 | it('removes a user', () => { 26 | const store = new Store({ 27 | users: [{ id: 1, name: 'Alice' }] 28 | }) 29 | 30 | store.removeUser({ id: 1, name: 'Alice' }) 31 | 32 | expect(store.getState()).to.eql({ 33 | users: [] 34 | }) 35 | }) 36 | 37 | it('renders a user', () => { 38 | cy.mount(Users, { 39 | global: { 40 | provide: { 41 | store: new Store({ 42 | users: [] 43 | }) 44 | } 45 | } 46 | }) 47 | 48 | cy.get('#username').type('Alice') 49 | cy.get('button').contains('Add User').click() 50 | cy.contains('ID: 1. Name: Alice') 51 | 52 | cy.get('#username').type('Bob') 53 | cy.get('button').contains('Add User').click() 54 | cy.get('div').contains('ID: 2. Name: Bob') 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /examples/07-renderless-components/RenderlessPassword.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 61 | -------------------------------------------------------------------------------- /examples/08-render-functions/vitest/RenderFunctionsApp.spec.ts: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from "@testing-library/vue"; 2 | import RenderFunctionsApp from "../RenderFunctionsApp.vue"; 3 | import { mount } from "@vue/test-utils"; 4 | import { expect } from "vitest"; 5 | 6 | describe("", () => { 7 | it("with testing-library", async () => { 8 | const { container } = render(RenderFunctionsApp); 9 | expect( 10 | container.querySelector('[data-testid="tab-1"]') 11 | ).toBeTruthy(); 12 | expect( 13 | container.querySelector('[data-testid="tab-2"]') 14 | ).toBeTruthy(); 15 | 16 | expect(container.outerHTML).toContain("Content #1"); 17 | expect(container.outerHTML).not.toContain("Content #2"); 18 | 19 | await fireEvent.click( 20 | container.querySelector('[data-testid="tab-2"]')! 21 | ); 22 | 23 | expect(container.outerHTML).not.toContain("Content #1"); 24 | expect(container.outerHTML).toContain("Content #2"); 25 | }); 26 | 27 | it("with test utils", async () => { 28 | const wrapper = mount(RenderFunctionsApp); 29 | expect(wrapper.html()).toContain("Content #1"); 30 | expect(wrapper.html()).not.toContain("Content #2"); 31 | 32 | await wrapper.find('[data-testid="tab-2"]').trigger("click"); 33 | 34 | expect(wrapper.html()).not.toContain("Content #1"); 35 | expect(wrapper.html()).toContain("Content #2"); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /examples/12-functional-core-imperative-shell/TicTacToe.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed } from "vue"; 2 | 3 | /** 4 | * Core Logic 5 | * Framework agnostic 6 | */ 7 | export const initialBoard: Board = [ 8 | ["-", "-", "-"], 9 | ["-", "-", "-"], 10 | ["-", "-", "-"], 11 | ]; 12 | 13 | type Marker = "x" | "o" | "-"; 14 | 15 | export type Board = Marker[][]; 16 | 17 | export function createGame(initialState: Board[]) { 18 | return [...initialState]; 19 | } 20 | 21 | export function makeMove( 22 | board: Board, 23 | { col, row, counter }: { col: number; row: number; counter: Marker } 24 | ) { 25 | const newBoard = board.map((theRow, rowIdx) => { 26 | return theRow.map((cell, colIdx) => 27 | rowIdx === row && colIdx === col ? counter : cell 28 | ); 29 | }); 30 | 31 | const newCounter: Marker = counter === "o" ? "x" : "o"; 32 | 33 | return { 34 | newBoard, 35 | newCounter, 36 | }; 37 | } 38 | 39 | /** 40 | * Vue integration layer 41 | * State is mutable 42 | */ 43 | export function useTicTacToe() { 44 | const boards = ref([initialBoard]); 45 | const counter = ref("o"); 46 | 47 | const move = ({ col, row }: { col: number; row: number }) => { 48 | const { newBoard, newCounter } = makeMove(currentBoard.value, { 49 | col, 50 | row, 51 | counter: counter.value, 52 | }); 53 | boards.value.push(newBoard); 54 | counter.value = newCounter; 55 | }; 56 | 57 | const currentBoard = computed(() => { 58 | return boards.value[boards.value.length - 1]; 59 | }); 60 | 61 | return { 62 | currentBoard, 63 | makeMove: move, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /examples/10-datetime-strategy-pattern/DateTime.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 68 | -------------------------------------------------------------------------------- /examples/06-http-and-api-requests/cypress/Login.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import axios from "axios"; 3 | import Login from "../with-pinia/Login.vue" 4 | import { createPinia, Pinia, setActivePinia } from "pinia"; 5 | 6 | describe("login", () => { 7 | let pinia: Pinia; 8 | 9 | beforeEach(() => { 10 | pinia = createPinia(); 11 | setActivePinia(pinia); 12 | }); 13 | 14 | it("successfully authenticates", () => { 15 | cy.intercept("/login", (req) => { 16 | req.reply({ 17 | username: "Lachlan", 18 | }); 19 | }).as("login"); 20 | 21 | cy.mount(Login, { global: { plugins: [pinia] } }); 22 | 23 | cy.get("#username").type("Lachlan"); 24 | cy.get("#password").type("secret-password"); 25 | cy.get("button").contains("Click here to sign in").click(); 26 | 27 | cy.get("@login") 28 | .its("request.body") 29 | .should("eql", { 30 | username: "Lachlan", 31 | password: "secret-password", 32 | }); 33 | cy.contains("Hello, Lachlan"); 34 | }); 35 | 36 | it("handles incorrect credentials", () => { 37 | const error = "Error: please check the details and try again"; 38 | cy.intercept("/login", { 39 | statusCode: 403, 40 | body: { 41 | error, 42 | }, 43 | }); 44 | 45 | cy.mount(Login, { global: { plugins: [pinia] } }); 46 | 47 | cy.get("#username").type("Lachlan"); 48 | cy.get("#password").type("secret-password"); 49 | cy.get("button").contains("Click here to sign in").click(); 50 | 51 | cy.contains(error); 52 | }); 53 | 54 | it("works by stubbing axios", () => { 55 | cy.stub(axios, "post").resolves({ 56 | data: { 57 | username: "Lachlan", 58 | }, 59 | }); 60 | 61 | cy.mount(Login, { global: { plugins: [pinia] } }); 62 | 63 | cy.get("#username").type("Lachlan"); 64 | cy.get("#password").type("secret-password"); 65 | cy.get("button").contains("Click here to sign in").click(); 66 | 67 | cy.contains("Hello, Lachlan"); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /examples/09-provide-inject/store.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { render, screen, fireEvent } from "@testing-library/vue"; 3 | import { Store } from "./store.js"; 4 | import Users from "./users.vue"; 5 | 6 | describe("store", () => { 7 | it("seeds the initial state", () => { 8 | const store = new Store({ 9 | users: [], 10 | }); 11 | 12 | expect(store.getState()).toEqual({ users: [] }); 13 | }); 14 | 15 | it("adds a user", () => { 16 | const store = new Store({ 17 | users: [], 18 | }); 19 | 20 | store.addUser({ name: "Alice" }); 21 | 22 | expect(store.getState()).toEqual({ 23 | users: [{ id: 1, name: "Alice" }], 24 | }); 25 | }); 26 | 27 | it("removes a user", () => { 28 | const store = new Store({ 29 | users: [{ id: 1, name: "Alice" }], 30 | }); 31 | 32 | store.removeUser({ id: 1, name: "Alice" }); 33 | 34 | expect(store.getState()).toEqual({ 35 | users: [], 36 | }); 37 | }); 38 | 39 | it.skip("renders a user", () => { 40 | cy.mount(Users, { 41 | global: { 42 | provide: { 43 | store: new Store({ 44 | users: [], 45 | }), 46 | }, 47 | }, 48 | }); 49 | 50 | cy.get('[role="username"]').type("Alice"); 51 | cy.get("button").contains("Add User").click(); 52 | cy.contains("ID: 1. Name: Alice"); 53 | 54 | cy.get('[role="username"]').type("Bob"); 55 | cy.get("button").contains("Add User").click(); 56 | cy.get("div").contains("ID: 2. Name: Bob"); 57 | }); 58 | }); 59 | 60 | describe("store", () => { 61 | it("renders a user", async () => { 62 | const { container } = render(Users, { 63 | global: { 64 | provide: { 65 | store: new Store({ users: [] }), 66 | }, 67 | }, 68 | }); 69 | await fireEvent.update(container.querySelector("#username")!, "Alice"); 70 | await fireEvent.click(container.querySelector("#add-user")!); 71 | await screen.findByText("ID: 1. Name: Alice"); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /examples/04-forms/form.ts: -------------------------------------------------------------------------------- 1 | interface ValidationResult { 2 | valid: boolean; 3 | message?: string; 4 | } 5 | 6 | export type Validator = (...args: any[]) => ValidationResult; 7 | 8 | export const required: Validator = (value: any): ValidationResult => { 9 | if (!value) { 10 | return { 11 | valid: false, 12 | message: `Required`, 13 | }; 14 | } 15 | return { valid: true }; 16 | }; 17 | 18 | interface RangeRule { 19 | min: number; 20 | max: number; 21 | } 22 | 23 | export const validateRange: Validator = ( 24 | value: number, 25 | { min, max }: RangeRule 26 | ): ValidationResult => { 27 | if (value < min || value > max) { 28 | return { 29 | valid: false, 30 | message: `Must be between ${min} and ${max}`, 31 | }; 32 | } 33 | return { valid: true }; 34 | }; 35 | 36 | export function applyRules( 37 | ...results: ValidationResult[] 38 | ): ValidationResult { 39 | return results.find((result) => !result.valid) ?? { valid: true }; 40 | } 41 | 42 | // definition 43 | export interface Patient { 44 | name: string; 45 | weight: { 46 | value: number; 47 | units: "kg" | "lb"; 48 | }; 49 | } 50 | 51 | interface ValidationResult { 52 | valid: boolean; 53 | messsage?: string; 54 | } 55 | 56 | interface PatientFormValidity { 57 | name: ValidationResult; 58 | weight: ValidationResult; 59 | } 60 | 61 | export function isFormValid< 62 | T extends Record 63 | >(form: T): boolean { 64 | const invalidField = Object.values(form).find((res) => !res.valid); 65 | return invalidField ? false : true; 66 | } 67 | 68 | const limits = { 69 | kg: { min: 30, max: 200 }, 70 | lb: { min: 66, max: 440 }, 71 | }; 72 | 73 | type PatientForm = { 74 | [k in keyof Patient]: ValidationResult; 75 | }; 76 | 77 | export function patientForm(patient: Patient): PatientForm { 78 | return { 79 | name: required(patient.name), 80 | weight: applyRules( 81 | required(patient.weight.value), 82 | validateRange( 83 | patient.weight.value, 84 | limits[patient.weight.units] 85 | ) 86 | ), 87 | }; 88 | } -------------------------------------------------------------------------------- /examples/12-functional-core-imperative-shell/cypress/TicTacToe.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import TicTacToe from "../TicTacToe.vue" 3 | import { createGame, makeMove, initialBoard, Board } from "../TicTacToe.js" 4 | 5 | /** 6 | * UI Tests. 7 | * 8 | * Same as the previous chapter - so they should be, no change 9 | * in behavior. 10 | */ 11 | describe("TicTacToe", () => { 12 | it("plays a game", () => { 13 | cy.mount(TicTacToe); 14 | 15 | cy.findByTestId("row-0-col-0").click(); 16 | cy.findByTestId("row-0-col-1").click(); 17 | cy.findByTestId("row-0-col-2").click(); 18 | 19 | cy.findByTestId("row-0-col-0").contains("o"); 20 | cy.findByTestId("row-0-col-1").contains("x"); 21 | cy.findByTestId("row-0-col-2").contains("o"); 22 | }); 23 | 24 | // TODO: Try implementing undo/redo in the business layer 25 | // And wire it up! 26 | it.skip('undo and redo', () => { 27 | cy.mount(TicTacToe); 28 | cy.findByTestId("row-0-col-0").click(); 29 | cy.findByTestId("row-0-col-0").contains("o"); 30 | cy.get('button').contains('Undo').click() 31 | cy.findByTestId("row-0-col-0").contains("-"); 32 | cy.get('button').contains('Redo').click() 33 | cy.findByTestId("row-0-col-0").contains("o"); 34 | }) 35 | }); 36 | 37 | /** 38 | * Can test / iterate on business logic in isolation. 39 | */ 40 | describe('useTicTacToe', () => { 41 | it('initializes state to an empty board', () => { 42 | const expected: Board = [ 43 | ['-', '-', '-'], 44 | ['-', '-', '-'], 45 | ['-', '-', '-'] 46 | ] 47 | expect(createGame([initialBoard])).to.eql([expected]) 48 | }) 49 | }) 50 | 51 | describe('makeMove', () => { 52 | it('returns a new updated board and counter', () => { 53 | const board = createGame([initialBoard]) 54 | const { newBoard, newCounter } = makeMove(board[0], { 55 | row: 0, 56 | col: 0, 57 | counter: 'o' 58 | }) 59 | 60 | expect(newCounter).to.eql('x') 61 | expect(newBoard).to.eql([ 62 | ['o', '-', '-'], 63 | ['-', '-', '-'], 64 | ['-', '-', '-'] 65 | ]) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /examples/08-render-functions/TabContainer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useSlots, 3 | defineComponent, 4 | h, 5 | computed, 6 | watchEffect, 7 | } from "vue"; 8 | 9 | function withTabId() { 10 | return defineComponent({ 11 | props: { 12 | tabId: { 13 | type: String, 14 | required: true, 15 | }, 16 | }, 17 | setup() { 18 | const slots = useSlots() as any; 19 | return () => h("div", slots.default?.()); 20 | }, 21 | }); 22 | } 23 | 24 | export const Tab = withTabId(); 25 | export const TabContent = withTabId(); 26 | 27 | export const TabContainer = defineComponent({ 28 | props: { 29 | modelValue: { 30 | type: String, 31 | required: true, 32 | }, 33 | }, 34 | emits: { 35 | "update:modelValue": (activeTabId: string) => true, 36 | }, 37 | setup(props, { emit }) { 38 | const slots = useSlots() as any; 39 | 40 | const content: Array = 41 | slots.default?.() ?? []; 42 | 43 | const tabFilter = (component: any): component is typeof Tab => 44 | component.type === Tab; 45 | 46 | const tabs = computed(() => { 47 | return content.filter(tabFilter).map((tab) => { 48 | return h(tab, { 49 | ...tab.props, 50 | class: { 51 | key: tab.props.tabId, 52 | tab: true, 53 | active: tab.props.tabId === props.modelValue, 54 | }, 55 | onClick: () => { 56 | emit("update:modelValue", tab.props.tabId); 57 | }, 58 | }); 59 | }); 60 | }); 61 | 62 | const contentFilter = ( 63 | component: any 64 | ): component is typeof TabContent => { 65 | return ( 66 | component.type === TabContent && 67 | component.props.tabId === props.modelValue 68 | ) 69 | }; 70 | 71 | const tabContent = computed(() => { 72 | const slot = content.find(contentFilter)!; 73 | return h(slot, { ...slots.props, key: slot.props.tabId }); 74 | }); 75 | 76 | return () => [ 77 | h("div", { class: "tabs" }, tabs.value), 78 | h("div", { class: "content" }, tabContent.value), 79 | ]; 80 | }, 81 | }); 82 | -------------------------------------------------------------------------------- /examples/11-grouping-with-composables/TicTacToe.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import TicTacToe from "./TicTacToe.vue"; 3 | import { useTicTacToe, Board } from "./TicTacToe.js"; 4 | 5 | describe("TicTacToe", () => { 6 | it("plays a game", () => { 7 | cy.mount(TicTacToe); 8 | 9 | cy.findByTestId("row-0-col-0").click(); 10 | cy.findByTestId("row-0-col-1").click(); 11 | cy.findByTestId("row-0-col-2").click(); 12 | 13 | cy.findByTestId("row-0-col-0").contains("o"); 14 | cy.findByTestId("row-0-col-1").contains("x"); 15 | cy.findByTestId("row-0-col-2").contains("o"); 16 | }); 17 | 18 | it("undo and redo", () => { 19 | cy.mount(TicTacToe); 20 | cy.findByTestId("row-0-col-0").click(); 21 | cy.findByTestId("row-0-col-0").contains("o"); 22 | cy.get("button").contains("Undo").click(); 23 | cy.findByTestId("row-0-col-0").contains("-"); 24 | cy.get("button").contains("Redo").click(); 25 | cy.findByTestId("row-0-col-0").contains("o"); 26 | }); 27 | }); 28 | 29 | describe("useTicTacToe", () => { 30 | it("supports seeding an initial state", () => { 31 | const initialState: Board = [ 32 | ["o", "o", "o"], 33 | ["-", "-", "-"], 34 | ["-", "-", "-"], 35 | ]; 36 | const { currentBoard } = useTicTacToe([initialState]); 37 | 38 | expect(currentBoard.value).to.eql(initialState); 39 | }); 40 | 41 | it("initializes state to an empty board", () => { 42 | const initialBoard: Board = [ 43 | ["-", "-", "-"], 44 | ["-", "-", "-"], 45 | ["-", "-", "-"], 46 | ]; 47 | const { currentBoard } = useTicTacToe(); 48 | 49 | expect(currentBoard.value).to.eql(initialBoard); 50 | }); 51 | }); 52 | 53 | describe("makeMove", () => { 54 | it("updates the board and adds the new state", () => { 55 | const { currentBoard, makeMove, boards, currentPlayer } = 56 | useTicTacToe(); 57 | makeMove({ row: 0, col: 0 }); 58 | 59 | expect(boards.value).to.have.length(2); 60 | expect(currentPlayer.value).to.eql("x"); 61 | expect(currentBoard.value).to.eql([ 62 | ["o", "-", "-"], 63 | ["-", "-", "-"], 64 | ["-", "-", "-"], 65 | ]); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /examples/06-http-and-api-requests/mock-service-worker/Login.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach, afterEach } from "vitest"; 2 | import { render, fireEvent, screen } from "@testing-library/vue"; 3 | import { rest } from "msw"; 4 | import { SetupServer, setupServer } from "msw/node"; 5 | import Login from "../with-pinia/Login.vue" 6 | import { createPinia, Pinia, setActivePinia } from "pinia"; 7 | 8 | describe("login", () => { 9 | let pinia: Pinia; 10 | 11 | beforeEach(() => { 12 | pinia = createPinia(); 13 | setActivePinia(pinia); 14 | }); 15 | 16 | let server: SetupServer; 17 | 18 | afterEach(() => { 19 | server.close(); 20 | }); 21 | 22 | it("successfully authenticates", async () => { 23 | server = setupServer( 24 | rest.post("/login", (req, res, ctx) => { 25 | return res( 26 | ctx.json({ 27 | username: "Lachlan", 28 | }) 29 | ); 30 | }) 31 | ); 32 | server.listen(); 33 | 34 | const { container } = render(Login, { 35 | global: { plugins: [pinia] }, 36 | }); 37 | 38 | await fireEvent.update( 39 | container.querySelector("#username")!, 40 | "Lachlan" 41 | ); 42 | await fireEvent.update( 43 | container.querySelector("#password")!, 44 | "secret-password" 45 | ); 46 | await fireEvent.click(screen.getByText("Click here to sign in")); 47 | 48 | await screen.findByText("Hello, Lachlan"); 49 | }); 50 | 51 | it("handles incorrect credentials", async () => { 52 | const error = "Error: please check the details and try again"; 53 | server = setupServer( 54 | rest.post("/login", (req, res, ctx) => { 55 | return res( 56 | ctx.json({ 57 | username: "Lachlan", 58 | }) 59 | ); 60 | }) 61 | ); 62 | server.use( 63 | rest.post("/login", (req, res, ctx) => { 64 | return res(ctx.status(403), ctx.json({ error })); 65 | }) 66 | ); 67 | server.listen(); 68 | 69 | const { container } = render(Login, { 70 | global: { plugins: [pinia] }, 71 | }); 72 | 73 | await fireEvent.update( 74 | container.querySelector("#username")!, 75 | "Lachlan" 76 | ); 77 | await fireEvent.update( 78 | container.querySelector("#password")!, 79 | "secret-password" 80 | ); 81 | await fireEvent.click(screen.getByText("Click here to sign in")); 82 | 83 | await screen.findByText(error); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /examples/04-forms/PatientForm.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 75 | 76 | -------------------------------------------------------------------------------- /examples/07-renderless-components/RenderlessPasswordApp.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 49 | 50 | 111 | -------------------------------------------------------------------------------- /examples/07-renderless-components/vitest/RenderlessPassword.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { render, screen, fireEvent } from "@testing-library/vue"; 3 | import RenderlessPasswordApp from "../RenderlessPasswordApp.vue"; 4 | 5 | describe("component using RenderlessPassword", () => { 6 | it("meets default requirements", async () => { 7 | render(RenderlessPasswordApp); 8 | 9 | await fireEvent.update( 10 | screen.getByLabelText("Password"), 11 | "this is a long password" 12 | ); 13 | await fireEvent.update( 14 | screen.getByLabelText("Confirmation"), 15 | "this is a long password" 16 | ); 17 | 18 | expect(screen.getByTestId("complexity").textContent).toContain( 19 | "Complexity: 3" 20 | ); 21 | expect( 22 | screen.getByText("Submit").disabled 23 | ).toBeFalsy(); 24 | }); 25 | 26 | it("does not meet complexity requirements", async () => { 27 | render(RenderlessPasswordApp); 28 | 29 | await fireEvent.update( 30 | screen.getByLabelText("Password"), 31 | "shorty" 32 | ); 33 | await fireEvent.update( 34 | screen.getByLabelText("Confirmation"), 35 | "shorty" 36 | ); 37 | 38 | expect(screen.getByTestId("complexity").textContent).toContain( 39 | "Complexity: 1" 40 | ); 41 | expect( 42 | screen.getByText("Submit").disabled 43 | ).toBeTruthy(); 44 | }); 45 | 46 | it("password and confirmation does not match", async () => { 47 | render(RenderlessPasswordApp); 48 | 49 | await fireEvent.update(screen.getByLabelText("Password"), "abc"); 50 | await fireEvent.update( 51 | screen.getByLabelText("Confirmation"), 52 | "def" 53 | ); 54 | 55 | expect( 56 | screen.getByText("Submit").disabled 57 | ).toBeTruthy(); 58 | }); 59 | }); 60 | 61 | import { mount } from "@vue/test-utils"; 62 | 63 | describe("component using RenderlessPassword", () => { 64 | it("meets default requirements", async () => { 65 | const wrapper = mount(RenderlessPasswordApp); 66 | 67 | await wrapper 68 | .find("#password") 69 | .setValue("this is a long password"); 70 | await wrapper 71 | .find("#confirmation") 72 | .setValue("this is a long password"); 73 | 74 | expect(wrapper.find(".complexity.low").exists()).not.toBe(true); 75 | expect(wrapper.find(".complexity.high").exists()).toBe(true); 76 | expect(wrapper.find("button").element.disabled).toBe(false); 77 | }); 78 | 79 | it("does not meet complexity requirements", async () => { 80 | const wrapper = mount(RenderlessPasswordApp); 81 | 82 | await wrapper.find("#password").setValue("shorty"); 83 | await wrapper.find("#confirmation").setValue("shorty"); 84 | 85 | expect(wrapper.find("button").element.disabled).toBe(true); 86 | expect(wrapper.find(".complexity.high").exists()).not.toBe(true); 87 | expect(wrapper.find(".complexity.low").exists()).toBe(true); 88 | }); 89 | 90 | it("password and confirmation does not match", async () => { 91 | const wrapper = mount(RenderlessPasswordApp); 92 | 93 | await wrapper.find("#password").setValue("abc"); 94 | await wrapper.find("#confirmation").setValue("def"); 95 | 96 | expect(wrapper.find("button").element.disabled).toBe(true); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /examples/04-forms/form.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { 3 | Validator, 4 | required, 5 | validateRange, 6 | applyRules, 7 | isFormValid, 8 | patientForm, 9 | } from "./form.js"; 10 | 11 | describe("required", () => { 12 | it("is invalid when undefined", () => { 13 | expect(required(undefined)).toEqual({ 14 | valid: false, 15 | message: "Required", 16 | }); 17 | }); 18 | it("is invalid when empty string", () => { 19 | expect(required("")).toEqual({ 20 | valid: false, 21 | message: "Required", 22 | }); 23 | }); 24 | it("returns true false value is present", () => { 25 | expect(required("some value")).toEqual({ valid: true }); 26 | }); 27 | }); 28 | 29 | describe("validateRange", () => { 30 | it("returns true when value is equal to min", () => { 31 | expect(validateRange(5, { min: 5, max: 10 })).toEqual({ 32 | valid: true, 33 | }); 34 | }); 35 | 36 | it("returns true when value is between min/max", () => { 37 | expect(validateRange(7, { min: 5, max: 10 })).toEqual({ 38 | valid: true, 39 | }); 40 | }); 41 | 42 | it("returns true when value is equal to max", () => { 43 | expect(validateRange(10, { min: 5, max: 10 })).toEqual({ 44 | valid: true, 45 | }); 46 | }); 47 | 48 | it("returns false when value is less than min", () => { 49 | expect(validateRange(4, { min: 5, max: 10 })).toEqual({ 50 | valid: false, 51 | message: "Must be between 5 and 10", 52 | }); 53 | }); 54 | 55 | it("returns false when value is greater than max", () => { 56 | expect(validateRange(11, { min: 5, max: 10 })).toEqual({ 57 | valid: false, 58 | message: "Must be between 5 and 10", 59 | }); 60 | }); 61 | }); 62 | 63 | describe("applyRules", () => { 64 | it("returns invalid for missing required input", () => { 65 | const actual = applyRules(required("")); 66 | expect(actual).toEqual({ valid: false, message: "Required" }); 67 | }); 68 | 69 | it("returns invalid when outside range", () => { 70 | const constraints = { min: 10, max: 30 }; 71 | const actual = applyRules(validateRange(9, constraints)); 72 | expect(actual).toEqual({ 73 | valid: false, 74 | message: "Must be between 10 and 30", 75 | }); 76 | }); 77 | it("returns invalid when at least one validator is invalid", () => { 78 | const alwaysValid: Validator = () => ({ valid: true }); 79 | const neverValid: Validator = () => ({ 80 | valid: false, 81 | message: "Invalid!", 82 | }); 83 | const actual = applyRules(alwaysValid(), neverValid()); 84 | expect(actual).toEqual({ valid: false, message: "Invalid!" }); 85 | }); 86 | it("returns true when all validators return true", () => { 87 | const alwaysValid: Validator = () => ({ valid: true }); 88 | const actual = applyRules(alwaysValid()); 89 | expect(actual).toEqual({ valid: true }); 90 | }); 91 | }); 92 | 93 | describe("isFormValid", () => { 94 | it("returns true when all fields are valid", () => { 95 | const form = { 96 | name: { valid: true }, 97 | weight: { valid: true }, 98 | }; 99 | expect(isFormValid(form)).toBe(true); 100 | }); 101 | it("returns false when any field is invalid", () => { 102 | const form = { 103 | name: { valid: false }, 104 | weight: { valid: true }, 105 | }; 106 | expect(isFormValid(form)).toBe(false); 107 | }); 108 | }); 109 | 110 | describe("patientForm", () => { 111 | const validPatient: Patient = { 112 | name: "test patient", 113 | weight: { value: 100, units: "kg" }, 114 | }; 115 | it("is valid when form is filled out correctly", () => { 116 | const form = patientForm(validPatient); 117 | expect(form.name).toEqual({ valid: true }); 118 | expect(form.weight).toEqual({ valid: true }); 119 | }); 120 | it("is invalid when name is null", () => { 121 | const form = patientForm({ ...validPatient, name: "" }); 122 | expect(form.name).toEqual({ valid: false, message: "Required" }); 123 | }); 124 | it("validates weight in imperial", () => { 125 | const form = patientForm({ 126 | ...validPatient, 127 | weight: { 128 | value: 65, 129 | units: "lb", 130 | }, 131 | }); 132 | expect(form.weight).toEqual({ 133 | valid: false, 134 | message: "Must be between 66 and 440", 135 | }); 136 | }); 137 | it("validates weight in metric", () => { 138 | const form = patientForm({ 139 | ...validPatient, 140 | weight: { 141 | value: 29, 142 | units: "kg", 143 | }, 144 | }); 145 | expect(form.weight).toEqual({ 146 | valid: false, 147 | message: "Must be between 30 and 200", 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /examples/10-datetime-strategy-pattern/vitest/DateTime.spec.ts: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from "@testing-library/vue"; 2 | import { describe, it, expect } from "vitest"; 3 | import moment from "moment"; 4 | import * as Luxon from "luxon"; 5 | import DateTime from "../DateTime.vue"; 6 | import { 7 | serialize, 8 | deserialize, 9 | serializeMoment, 10 | } from "../serializers.js"; 11 | 12 | describe("serializeMoment", () => { 13 | it("serializes valid moment", () => { 14 | const actual = serializeMoment({ year: 2020, month: 1, day: 1 }); 15 | // compare as strings. moment is pain. 16 | expect(actual?.toString()).toEqual( 17 | moment("2020-01-01").toString() 18 | ); 19 | }); 20 | 21 | it("returns undefined for invalid moment", () => { 22 | const actual = serializeMoment({ 23 | // @ts-expect-error 24 | year: "200000020", 25 | // @ts-expect-error 26 | month: "1xxxxx", 27 | // @ts-expect-error 28 | day: "bbbb", 29 | }); 30 | expect(actual).toEqual(undefined); 31 | }); 32 | }); 33 | 34 | describe("deserialize", () => { 35 | it("deserializes to Luxon DateTime", () => { 36 | const actual = deserialize( 37 | Luxon.DateTime.fromObject({ year: 2020, month: 1, day: 1 }) 38 | ); 39 | expect(actual).toEqual({ year: 2020, month: 1, day: 1 }); 40 | }); 41 | }); 42 | 43 | describe("serialize", () => { 44 | it("serializes valid Luxon DateTime", () => { 45 | const actual = serialize({ year: 2020, month: 1, day: 1 }); 46 | expect(actual).toEqual( 47 | Luxon.DateTime.fromObject({ year: 2020, month: 1, day: 1 }) 48 | ); 49 | }); 50 | 51 | it("returns undefined for invalid Luxon DateTime", () => { 52 | const actual = serialize({ 53 | // @ts-expect-error 54 | year: "200000020", 55 | // @ts-expect-error 56 | month: "1xxxxx", 57 | // @ts-expect-error 58 | day: "1", 59 | }); 60 | expect(actual).toEqual(undefined); 61 | }); 62 | }); 63 | 64 | describe("deserialize", () => { 65 | it("deserializes to Luxon DateTime", () => { 66 | const actual = deserialize( 67 | Luxon.DateTime.fromObject({ year: 2020, month: 1, day: 1 }) 68 | ); 69 | expect(actual).toEqual({ year: 2020, month: 1, day: 1 }); 70 | }); 71 | }); 72 | 73 | test("DateTime", async () => { 74 | const { emitted, container } = render(DateTime, { 75 | props: { 76 | modelValue: Luxon.DateTime.fromObject({ 77 | year: 2020, 78 | month: 1, 79 | day: 1, 80 | }), 81 | serialize, 82 | deserialize, 83 | }, 84 | }); 85 | 86 | await fireEvent.update( 87 | container.querySelector("[name='year']")!, 88 | "2019" 89 | ); 90 | await fireEvent.update( 91 | container.querySelector("[name='month']")!, 92 | "2" 93 | ); 94 | await fireEvent.update( 95 | container.querySelector("[name='day']")!, 96 | "3" 97 | ); 98 | 99 | // 3 successful updates, 3 emits. 100 | expect(emitted()["update:modelValue"]).toHaveLength(3); 101 | 102 | expect((emitted()["update:modelValue"] as any)[0][0]).toEqual( 103 | Luxon.DateTime.fromObject({ year: 2019, month: 1, day: 1 }) 104 | ); 105 | expect((emitted()["update:modelValue"] as any)[1][0]).toEqual( 106 | Luxon.DateTime.fromObject({ year: 2020, month: 2, day: 1 }) 107 | ); 108 | expect((emitted()["update:modelValue"] as any)[2][0]).toEqual( 109 | Luxon.DateTime.fromObject({ year: 2020, month: 1, day: 3 }) 110 | ); 111 | }); 112 | 113 | import { mount } from "@vue/test-utils"; 114 | 115 | test("DateTime", async () => { 116 | const wrapper = mount(DateTime, { 117 | props: { 118 | modelValue: Luxon.DateTime.fromObject({ 119 | year: 2020, 120 | month: 1, 121 | day: 1, 122 | }), 123 | serialize, 124 | deserialize, 125 | }, 126 | }); 127 | 128 | await wrapper.find('[name="year"]').setValue("2019"); 129 | await wrapper.find('[name="month"]').setValue("2"); 130 | await wrapper.find('[name="day"]').setValue("3"); 131 | 132 | // 3 successful updates, 3 emits. 133 | expect(wrapper.emitted("update:modelValue")).toHaveLength(3); 134 | 135 | // update:modelValue will not update the modelValue prop 136 | // in Vue Test Utils, though. 137 | // we could wrap this in another component and do something 138 | // fancy but it's not really worth it. I think this is fine, 139 | // since we know the limitations and understand why we are doing 140 | // what we are doing here. 141 | expect((wrapper.emitted("update:modelValue") as any)[0][0]).toEqual( 142 | Luxon.DateTime.fromObject({ year: 2019, month: 1, day: 1 }) 143 | ); 144 | expect((wrapper.emitted("update:modelValue") as any)[1][0]).toEqual( 145 | Luxon.DateTime.fromObject({ year: 2020, month: 2, day: 1 }) 146 | ); 147 | expect((wrapper.emitted("update:modelValue") as any)[2][0]).toEqual( 148 | Luxon.DateTime.fromObject({ year: 2020, month: 1, day: 3 }) 149 | ); 150 | }); 151 | --------------------------------------------------------------------------------