├── .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 |
2 |
3 |
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 |
2 |
3 |
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 |
14 |
15 |
16 | {{ n1 }} + {{ n2 }} is {{ result }}.
17 |
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 |
15 |
16 |
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 | 
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 |
2 |
7 | {{ dateLuxon }}
8 |
9 |
10 |
24 |
--------------------------------------------------------------------------------
/examples/03-props/SumWithProps.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 | {{ n1 }} + {{ n2 }} is {{ result }}.
23 |
24 |
--------------------------------------------------------------------------------
/examples/05-events/Counter.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
2 | Create Patient
3 |
10 |
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 |
2 |
7 |
15 | {{ col }}
16 |
17 |
18 |
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 |
2 |
7 |
14 | {{ col }}
15 |
16 |
17 |
18 |
19 |
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 |
2 |
7 | {{ dateLuxon.toISODate() }}
8 |
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 |
2 |
3 | Tab #1
4 | Tab #2
5 |
6 | Content #1
7 | Content #2
8 |
9 |
10 |
11 |
22 |
23 |
--------------------------------------------------------------------------------
/examples/06-http-and-api-requests/with-pinia/Login.vue:
--------------------------------------------------------------------------------
1 |
2 | Hello, {{ store.user.username }}
3 |
12 | {{ error }}
13 |
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 |
2 | Hello, {{ user.username }}
3 |
8 | {{ error }}
9 |
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 |
2 |
3 |
7 |
8 |
9 | -
10 | ID: {{ user.id }}. Name: {{ user.name }}
11 |
12 |
13 |
14 |
15 |
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 |
2 |
7 |
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 |
2 | update($event, 'year')"
6 | />
7 | update($event, 'month')"
11 | />
12 | update($event, 'day')"
16 | />
17 |
18 | Internal date is:
19 | {{ modelValue }}
20 |
22 |
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 |
2 |
40 |
41 |
42 | Patient Data {{ form }}
43 |
44 |
45 | Form State
46 | {{ validatedForm }}
47 |
48 |
49 |
50 |
75 |
76 |
--------------------------------------------------------------------------------
/examples/07-renderless-components/RenderlessPasswordApp.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
24 |
25 | Matches: {{ matching }}
26 | Complexity: {{ complexity }}
27 |
28 |
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 |
--------------------------------------------------------------------------------