├── .browserslistrc ├── .gitignore ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── screenshots ├── landingpage.png ├── order-button-page.png └── userdetailpage.png ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── Header.vue │ ├── UserDetail.vue │ └── steps │ │ ├── FormOne.vue │ │ ├── FormTwo.vue │ │ └── Intro.vue ├── main.js ├── router │ └── index.js ├── services │ └── GitHubApi.js ├── store │ └── index.js └── views │ └── Home.vue └── tests └── unit ├── Home.spec.js └── UserDetail.spec.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multi-step form app 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/7259fdf9-60a1-4b7e-9100-77c6d95325e7/deploy-status)](https://app.netlify.com/sites/vue-multi-steps/deploys) 4 | 5 | DEMO: [https://vue-multi-steps.netlify.app/](https://vue-multi-steps.netlify.app/) 6 | 7 | This is a simple app that guides a user through a multi-step process to register into a community group. 8 | 9 | By filling up the form step-by-step, you will get more information from Github APIs at the end. 10 | 11 | ![demo1 - landing](./screenshots/landingpage.png) 12 | ![demo2 - userpage](./screenshots/userdetailpage.png) 13 | 14 | ## Technologies 15 | 16 | - Vue.js 3 17 | - Vuex 4 18 | - [vee-validate 4](https://vee-validate.logaretm.com/v4/) with [yup](https://github.com/jquense/yup) 19 | - Axios 20 | - Jest 21 | 22 | ## Project setup 23 | 24 | To run this project, use the following commands in your terminal: 25 | 26 | ``` 27 | # clone the repository 28 | git clone git@github.com:peterchencc/vue-multi-steps.git 29 | 30 | # change the folder 31 | cd vue-multi-steps 32 | 33 | # install dependencies 34 | npm install 35 | 36 | # Compiles and hot-reloads for development 37 | npm run serve 38 | 39 | # Compiles and minifies for production 40 | npm run build 41 | 42 | # Run unit tests 43 | npm run test:unit 44 | ``` 45 | 46 | ## Project structure 47 | 48 | The home page contain two part, `
` wrapper and `` component. vee-validate offers many helpers to handle form validation. Using `` wrapper with `handleSubmit` slot to handle form submissions manually by clicking "next" button and trigger a callback to next step. 49 | 50 | Switch between each steps by dynamic components. Every step with required input field ` ` to handle errors and basic validation. The method `validateCurrentStep` will check the values and errors on current step. If the value on the current step is invalid, the "Next" button will be disabled. 51 | 52 | The `` component shows up only when user finish the steps and submit the from. It will fetch the GitHub API with username to get response data and show the avatar image. 53 | 54 | ## Gotchas 55 | 56 | ### Add HTML `autofocus` attribute on the first text field 57 | 58 | Let the first input field automatically get focus when the component loads 59 | 60 | ### Use CSS `order` property to specify the order of "Back" "Next" button position 61 | 62 | Placing the "Next" button follows the last input field in the HTML. User can press "Tab" after filling each of the field. It will then focus on "Next" button after last field. 63 | 64 | ```html 65 | 66 | 67 | 68 | 69 | 77 | ``` 78 | 79 | On the app. 80 | ![On the app.](./screenshots/order-button-page.png) 81 | 82 | Small change on the HTML structure and keep the UI with more intuitive UX. 83 | 84 | ### Use CSS Grid in `` component to display responsive layout 85 | 86 | ### Bonus: workflow can be browsed using the native browser's "back" / "next" buttons 87 | 88 | Approach: Using Vue Router. By declaring every step path, so that we can use Web API History `back()` / `forward()` to trigger native browser. 89 | 90 | ```js 91 | { 92 | path: '/steps', 93 | component: Steps, 94 | children: [ 95 | { path: '1', component: StepOne, name: 'StepOne' }, 96 | { path: '2', component: StepTwo, name: 'StepTwo' }, 97 | { path: '3', component: StepThree, name: 'StepThree' } 98 | ], 99 | }, 100 | ``` 101 | 102 | I tried this way at the beginning and found out we need to add some complex logic on navigation guards and redirection on `$router`. Too much criteria, and I'm new for the Vue Router. So I decided to build a simple one that can finish our goal first. 103 | 104 | ### Bonus: Animate workflow transitions 105 | 106 | I tried to add this kind of transition on the dynamic components. So it could have animation when switching between each workflow. 107 | 108 | ```html 109 | 110 | 111 | 112 | ``` 113 | 114 | But it didn't work out at the end. Not sure what happened here. I've done the same way under Vue.js 2 before and it worked. 115 | 116 | ### What's next? 117 | 118 | - Refactor the `` ``, and wrap a reusable `` component for them. 119 | - Improve the error handling of fetching GitHub API. 120 | - With or without Vuex for this project? 121 | - More testing! 122 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest', 3 | transform: { 4 | '^.+\\.vue$': 'vue-jest' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-multi-steps", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.21.1", 12 | "core-js": "^3.6.5", 13 | "vee-validate": "^4.2.4", 14 | "vue": "^3.0.0", 15 | "vue-router": "^4.0.0-0", 16 | "vuex": "^4.0.0-0", 17 | "yup": "^0.32.9" 18 | }, 19 | "devDependencies": { 20 | "@vue/cli-plugin-babel": "~4.5.0", 21 | "@vue/cli-plugin-router": "~4.5.0", 22 | "@vue/cli-plugin-unit-jest": "~4.5.0", 23 | "@vue/cli-plugin-vuex": "~4.5.0", 24 | "@vue/cli-service": "~4.5.0", 25 | "@vue/compiler-sfc": "^3.0.0", 26 | "@vue/test-utils": "^2.0.0-0", 27 | "typescript": "~3.9.3", 28 | "vue-jest": "^5.0.0-0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterchencc/vue-multi-steps/5c22d2058f4747ce8692590eec19e311dfada720/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /screenshots/landingpage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterchencc/vue-multi-steps/5c22d2058f4747ce8692590eec19e311dfada720/screenshots/landingpage.png -------------------------------------------------------------------------------- /screenshots/order-button-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterchencc/vue-multi-steps/5c22d2058f4747ce8692590eec19e311dfada720/screenshots/order-button-page.png -------------------------------------------------------------------------------- /screenshots/userdetailpage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterchencc/vue-multi-steps/5c22d2058f4747ce8692590eec19e311dfada720/screenshots/userdetailpage.png -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | 20 | 73 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterchencc/vue-multi-steps/5c22d2058f4747ce8692590eec19e311dfada720/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /src/components/UserDetail.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 79 | 80 | 125 | -------------------------------------------------------------------------------- /src/components/steps/FormOne.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 112 | -------------------------------------------------------------------------------- /src/components/steps/FormTwo.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/components/steps/Intro.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 29 | 30 | 37 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store' 5 | 6 | createApp(App) 7 | .use(store) 8 | .use(router) 9 | .mount('#app') 10 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import Home from '../views/Home.vue' 3 | 4 | const routes = [ 5 | { 6 | path: '/', 7 | component: Home, 8 | }, 9 | ] 10 | 11 | const router = createRouter({ 12 | history: createWebHistory(process.env.BASE_URL), 13 | routes, 14 | }) 15 | 16 | export default router 17 | -------------------------------------------------------------------------------- /src/services/GitHubApi.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export default () => { 4 | return axios.create({ 5 | baseURL: `https://api.github.com`, 6 | headers: { 7 | Accept: 'application/vnd.github.v3+json', 8 | }, 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'vuex' 2 | 3 | const getDefaultUser = () => { 4 | return { 5 | firstName: '', 6 | lastName: '', 7 | username: '', 8 | email: '', 9 | isAgreeToTerms: false, 10 | } 11 | } 12 | 13 | export default createStore({ 14 | strict: process.env.NODE_ENV !== 'production', 15 | state: { 16 | user: getDefaultUser(), 17 | }, 18 | mutations: { 19 | resetUserState(state) { 20 | Object.assign(state.user, getDefaultUser()) 21 | }, 22 | updateFirstName(state, payload) { 23 | state.user.firstName = payload 24 | }, 25 | updateLastName(state, payload) { 26 | state.user.lastName = payload 27 | }, 28 | updateUsername(state, payload) { 29 | state.user.username = payload 30 | }, 31 | updateEmail(state, payload) { 32 | state.user.email = payload 33 | }, 34 | updateIsAgreeToTerms(state, payload) { 35 | state.user.isAgreeToTerms = payload 36 | }, 37 | }, 38 | actions: { 39 | resetUserState({ commit }) { 40 | commit('resetUserState') 41 | }, 42 | }, 43 | modules: {}, 44 | }) 45 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 109 | 110 | 227 | -------------------------------------------------------------------------------- /tests/unit/Home.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { createStore } from 'vuex' 3 | import Home from '@/views/Home.vue' 4 | 5 | const store = createStore({ 6 | state() { 7 | return { 8 | user: { 9 | firstName: '', 10 | lastName: '', 11 | username: '', 12 | email: '', 13 | isAgreeToTerms: false, 14 | }, 15 | } 16 | }, 17 | mutations: {}, 18 | }) 19 | 20 | const wrapper = mount(Home, { 21 | global: { 22 | plugins: [store], 23 | }, 24 | data() { 25 | return { 26 | currentStep: 0, 27 | } 28 | }, 29 | }) 30 | 31 | describe('Home component', () => { 32 | test('renders first step', () => { 33 | expect(wrapper.vm.currentStep).toBe(0) 34 | expect(wrapper.text()).toContain('Register Now!') 35 | }) 36 | 37 | test('renders first step with next button', () => { 38 | const button = wrapper.find('button.btn-next') 39 | expect(button.text()).toContain('Next') 40 | }) 41 | 42 | test('renders second step', () => { 43 | const wrapper = mount(Home, { 44 | global: { 45 | plugins: [store], 46 | }, 47 | data() { 48 | return { 49 | currentStep: 1, 50 | } 51 | }, 52 | }) 53 | expect(wrapper.text()).toContain('Personal Info') 54 | }) 55 | 56 | test('renders third step', () => { 57 | const wrapper = mount(Home, { 58 | global: { 59 | plugins: [store], 60 | }, 61 | data() { 62 | return { 63 | currentStep: 2, 64 | } 65 | }, 66 | }) 67 | expect(wrapper.text()).toContain('Almost there') 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tests/unit/UserDetail.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import UserDetail from '@/components/UserDetail.vue' 3 | 4 | const $store = { 5 | state: { 6 | user: { 7 | firstName: 'Peter', 8 | lastName: 'Chen', 9 | username: 'peterchencc', 10 | email: 'peterchencc@gmail.com', 11 | isAgreeToTerms: true, 12 | }, 13 | }, 14 | commit: jest.fn(), 15 | } 16 | 17 | const wrapper = mount(UserDetail, { 18 | global: { 19 | mocks: { 20 | $store, 21 | }, 22 | }, 23 | data() { 24 | return { 25 | responseData: {}, 26 | } 27 | }, 28 | }) 29 | 30 | describe('UserDetail component', () => { 31 | test('renders user info', () => { 32 | expect(wrapper.find('.user-info').html()).toContain('Peter') 33 | expect(wrapper.find('.user-info').html()).toContain('Chen') 34 | expect(wrapper.find('.user-info').html()).toContain('peterchencc@gmail.com') 35 | }) 36 | }) 37 | --------------------------------------------------------------------------------