From 3baf993b9be57207b2f4d018e750f019b28b28c2 Mon Sep 17 00:00:00 2001 From: Kendall Roth Date: Wed, 30 Sep 2020 17:12:26 -0400 Subject: [PATCH 1/7] Initial TypeScript commit --- .eslintignore | 4 +- .eslintrc.js | 18 +- .gitignore | 2 +- CHANGELOG.md | 7 + README.md | 25 +-- package.json | 14 +- src/formCreateMixin.js | 138 ------------- src/formCreateMixin.ts | 188 ++++++++++++++++++ ...veGuardMixin.js => formLeaveGuardMixin.ts} | 51 +++-- src/{index.js => index.ts} | 0 ...eMixin.test.js => formCreateMixin.test.ts} | 8 +- ...in.test.js => formLeaveGuardMixin.test.ts} | 12 +- test/{index.test.js => index.test.ts} | 0 tsconfig.json | 17 ++ 14 files changed, 301 insertions(+), 183 deletions(-) delete mode 100644 src/formCreateMixin.js create mode 100644 src/formCreateMixin.ts rename src/{formLeaveGuardMixin.js => formLeaveGuardMixin.ts} (72%) rename src/{index.js => index.ts} (100%) rename test/{formCreateMixin.test.js => formCreateMixin.test.ts} (97%) rename test/{formLeaveGuardMixin.test.js => formLeaveGuardMixin.test.ts} (92%) rename test/{index.test.js => index.test.ts} (100%) create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore index 9f4e8d7..5de02b1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,3 @@ -#test +coverage +dist +node_modules \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 1054185..85e0edc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,19 +1,25 @@ module.exports = { - parser: "babel-eslint", - extends: "eslint:recommended", + parser: "@typescript-eslint/parser", + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier", + "prettier/@typescript-eslint", + ], + plugins: ["@typescript-eslint"], env: { jest: true, node: true, }, rules: { - "comma-dangle": ["warn", "always-multiline"], + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/ban-ts-ignore": "off", + "no-console": "warn", "no-tabs": "warn", "no-trailing-spaces": "warn", - "no-unused-vars": "error", + "no-unused-vars": "warn", "no-unreachable": "error", "prefer-const": "warn", "prefer-destructuring": "warn", - quotes: ["warn", "double"], - semi: ["warn", "always"], }, }; diff --git a/.gitignore b/.gitignore index 17b6d24..3b74477 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ jspm_packages .node_repl_history # Lib -lib +/lib # npm package lock package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a119a85..be49dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Breaking +- Changed the default behaviour of the `setValues` form function (now will not set initial values by default) + - _This change was made to align with developer expectations (behaviour moved to `setInitial`)_ + +### Added +- Overhauled package to use [TypeScript](https://typescriptlang.org)! +- `setInitial` form function to set a form's initial (and current) values (similar to old behaviour of `setValues`) ## [0.2.3] - 2020-09-30 ### Added diff --git a/README.md b/README.md index f1d6ac7..7849d4a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ vm.data.testForm.setLoading(true); vm.data.testForm.setSubmitting(true); ``` -> **NOTE:** The `createForm` function is an alternative to the `FormCreateMixin` (which is preferred). +> **NOTE:** The `createForm` function is an alternative to the `FormCreateMixin` (recommended with TypeScript). ```js // Alternative approach @@ -71,17 +71,18 @@ Both `FormCreateMixin` and `createForm` accept several arguments to configure th The `form` object (name specified by mixin options) provides a simple API, particularly the field values and form flags. There are several additional utility methods to control the flags. -| Property | Description | -| -------------------------------------- | ------------------------------------------------------ | -| `_initial` | _Initial field values_ | -| `flags` | Form state flags | -| `fields` | Form field values | -| `getValues()` | Get form values | -| `setFlag(flag, value)` | Set a form flag (**only use for custom `flags`!**) | -| `setLoading(isLoading)` | Set the loading flag | -| `setSubmitting(isSubmitting)` | Set the submitting flag | -| `setValues(values, setInitial = true)` | Set the form values (optionally update initial values) | -| `reset()` | Reset the form to initial values | +| Property | Description | +| --------------------------------------- | ------------------------------------------------------ | +| `_initial` | _Initial field values_ | +| `flags` | Form state flags | +| `fields` | Form field values | +| `getValues()` | Get form values | +| `setFlag(flag, value)` | Set a form flag (**only use for custom `flags`!**) | +| `setInitial(values)` | Set the initial form values | +| `setLoading(isLoading)` | Set the loading flag | +| `setSubmitting(isSubmitting)` | Set the submitting flag | +| `setValues(values, setInitial = false)` | Set the form values (update initial values by default) | +| `reset()` | Reset the form to initial values | > **NOTE:** Included form `flags` are handled internally and should not be modified with `setFlags()` method! diff --git a/package.json b/package.json index 0f902c3..23fcbf7 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,18 @@ "version": "0.2.3", "description": "Simple Vue form state management library", "main": "./lib/index.js", + "types": "./lib/index.d.ts", "scripts": { "clean": "rimraf lib", - "format": "prettier --write {src,test}/**/*.js", + "format": "prettier --write {src,test}/**/*.ts", "test": "npm run lint && npm run test:only", "test:cover": "npm run test:only -- --coverage", "test:prod": "cross-env BABEL_ENV=production npm run test", "test:only": "jest --verbose", "test:watch": "npm run test:only -- --watch", "lint": "eslint src test", - "build": "cross-env BABEL_ENV=production babel src --out-dir lib", - "build:dev": "cross-env BABEL_ENV=development babel src --out-dir lib --watch", + "build": "cross-env NODE_ENV=\"production\" tsc", + "build:dev": "tsc --watch", "prepublish": "npm run clean && npm run test && npm run build" }, "files": [ @@ -39,15 +40,19 @@ "@babel/cli": "^7.10.3", "@babel/core": "^7.10.3", "@babel/preset-env": "^7.10.3", + "@babel/preset-typescript": "^7.10.4", "@babel/register": "^7.10.3", + "@typescript-eslint/eslint-plugin": "^4.3.0", + "@typescript-eslint/parser": "^4.3.0", "@vue/test-utils": "^1.0.3", "babel-eslint": "^10.0.1", - "babel-jest": "^26.1.0", + "babel-jest": "^26.3.0", "babel-plugin-add-module-exports": "^1.0.0", "babel-polyfill": "^6.26.0", "babel-preset-minify": "^0.5.0", "cross-env": "^7.0.2", "eslint": "^7.3.1", + "eslint-config-prettier": "^6.12.0", "eslint-plugin-import": "^2.7.0", "husky": "^4.2.5", "jest": "^26.1.0", @@ -57,6 +62,7 @@ "lint-staged": "^10.2.11", "prettier": "^2.0.5", "rimraf": "^3.0.2", + "typescript": "^4.0.3", "vue": "^2.6.11", "vue-template-compiler": "^2.6.11" }, diff --git a/src/formCreateMixin.js b/src/formCreateMixin.js deleted file mode 100644 index e0c0389..0000000 --- a/src/formCreateMixin.js +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Create a Vue form - * - * @param {string} name - Form name ('data' key) - * @param {Object} fields - Form fields and initial values - * @param {Object} options - Form configuration options - */ -const createForm = (name, fields, options = {}) => { - const { calculateChanged = true, flags = {} } = options; - - const form = { - [name]: { - // Clone the fields to set as initial values (for reset) - _initial: { ...fields }, - flags: { - // Allow tracking additional flags - ...flags, - // NOTE: Declared even though overridden so that it is tracked by object - changed: false, - get disabled() { - return this.loading || this.submitting; - }, - loading: false, - submitting: false, - }, - fields, - /** - * Get the form values - * @return {Object} - Form values - */ - getValues() { - return this.fields; - }, - /** - * Set a form flag - * @param {string} flag - Form flag name - * @param {boolean} value - Form flag value - */ - setFlag(flag, value) { - // Only set flags that exist or are custom! - if (this.flags[flag] === undefined) return; - - this.flags[flag] = Boolean(value); - }, - /** - * Set whether the form is loading - * @param {boolean} isLoading - Whether form is loading - */ - setLoading(isLoading) { - this.setFlag("loading", isLoading); - }, - /** - * Set whether the form is submitting - * @param {boolean} isSubmitting - Whether form is submitting - */ - setSubmitting(isSubmitting) { - this.setFlag("submitting", isSubmitting); - }, - /** - * Set the form values - * @param {Object} values - New form values - * @param {boolean} setInitial - Whether initial values should be updated - */ - setValues(values, setInitial = true) { - Object.keys(values).forEach((key) => { - const fieldValue = values[key]; - this.fields[key] = fieldValue; - }); - - // Optionally set the new values as the initial values (default) - // NOTE: This doesn't work too well with VeeValidate "changed" flag, - // but the form flag "changed" should be used anyway. - if (setInitial) { - Object.keys(values).forEach((key) => { - const fieldValue = values[key]; - this._initial[key] = fieldValue; - }); - } - }, - /** - * Reset the form to its initial values - */ - reset() { - Object.keys(this._initial).forEach((key) => { - const initialValue = this._initial[key]; - this.fields[key] = initialValue; - }); - }, - }, - }; - - /** - * Determine whether any form fields have changed - * @return {boolean} - Whether any fields have changed - */ - function getChanged() { - return Object.keys(this.fields).some((fieldKey) => { - const fieldValue = this.fields[fieldKey]; - const initialValue = this._initial[fieldKey]; - return fieldValue !== initialValue; - }); - } - - // Only calculate "changed" property if necessary (default) - if (calculateChanged) { - // Need to bind "this" context since "Object.defineProperty" uses the base object! - const boundGetChanged = getChanged.bind(form[name]); - - Object.defineProperty(form[name].flags, "changed", { - get: boundGetChanged, - }); - } else { - delete form[name].flags.changed; - } - - return form; -}; - -/** - * Vue form state management mixin - * - * @param {string} name - Form name ('data' key) - * @param {Object} fields - Form fields and initial values - * @param {Object} options - Form configuration options - * @param {boolean} options.calculateChanged - Whether form "changed" flag is calculated - * @param {Object} options.flags - Additional custom flags - */ -const FormCreateMixin = (name, fields, options) => ({ - data() { - return { - ...createForm.call(this, name, fields, options), - }; - }, -}); - -export { createForm, FormCreateMixin }; - -/* eslint no-underscore-dangle: off */ diff --git a/src/formCreateMixin.ts b/src/formCreateMixin.ts new file mode 100644 index 0000000..7a393e5 --- /dev/null +++ b/src/formCreateMixin.ts @@ -0,0 +1,188 @@ +export interface FormFields { + [name: string]: string | number | null; +} + +export interface FormOptions { + calculateChanged?: boolean; + flags?: FormOptionsFlags; +} + +export interface FormOptionsFlags { + // Necessary to allow custom flags + [key: string]: boolean | undefined; + changed?: boolean; + disabled?: boolean; + loading?: boolean; + submitting?: boolean; +} + +export interface FormFlags { + // Necessary to allow referencing as 'this.flags[flag]'... + [key: string]: boolean | undefined; + changed?: boolean; + disabled: boolean; + loading: boolean; + submitting: boolean; +} + +export interface Form { + flags: FormFlags; + fields: FormFields; + _initial: FormFields; + getValues: () => FormFields; + setFlag: (flag: string, value: boolean) => void; + setInitial: (values: FormFields) => void; + setLoading: (isLoading: boolean) => void; + setSubmitting: (isSubmitting: boolean) => void; + setValues: (values: FormFields, setInitial?: boolean) => void; + reset: () => void; +} + +/** + * Create a Vue form + * + * @param {Object} fields - Form fields and initial values + * @param {Object} options - Form configuration options + */ +const createForm = (fields: FormFields, options?: FormOptions): Form => { + const { calculateChanged = true, flags } = options || {}; + + const formFlags: FormFlags = { + // Allow tracking additional flags + ...(flags || {}), + // NOTE: Declared even though overridden so that it is tracked by object + changed: false, + get disabled(): boolean { + return this.loading || this.submitting; + }, + loading: false, + submitting: false, + }; + + const form: Form = { + // Clone the fields to set as initial values (for reset) + _initial: { ...fields }, + flags: formFlags, + fields, + /** + * Get the form values + * @return {Object} - Form values + */ + getValues(): FormFields { + return this.fields; + }, + /** + * Set a form flag + * @param {string} flag - Form flag name + * @param {boolean} value - Form flag value + */ + setFlag(flag: string, value: boolean): void { + // Only set flags that exist or are custom! + if (this.flags[flag] === undefined) return; + + this.flags[flag] = Boolean(value); + }, + /** + * Set the form current and initial values + * @param {Object} values - New form values + */ + setInitial(values: FormFields): void { + this.setValues(values, true); + }, + /** + * Set whether the form is loading + * @param {boolean} isLoading - Whether form is loading + */ + setLoading(isLoading: boolean): void { + this.setFlag("loading", isLoading); + }, + /** + * Set whether the form is submitting + * @param {boolean} isSubmitting - Whether form is submitting + */ + setSubmitting(isSubmitting: boolean): void { + this.setFlag("submitting", isSubmitting); + }, + /** + * Set the form values + * @param {Object} values - New form values + * @param {boolean} setInitial - Whether initial values should be updated + */ + setValues(values: FormFields, setInitial = false): void { + Object.keys(values).forEach((key) => { + const fieldValue = values[key]; + this.fields[key] = fieldValue; + }); + + // Optionally set the new values as the initial values (default) + // NOTE: This doesn't work too well with VeeValidate "changed" flag, + // but the form flag "changed" should be used anyway. + if (setInitial) { + Object.keys(values).forEach((key) => { + const fieldValue = values[key]; + this._initial[key] = fieldValue; + }); + } + }, + /** + * Reset the form to its initial values + */ + reset(): void { + Object.keys(this._initial).forEach((key) => { + const initialValue = this._initial[key]; + this.fields[key] = initialValue; + }); + }, + }; + + /** + * Determine whether any form fields have changed + * @return {boolean} - Whether any fields have changed + */ + const getChanged = (): boolean => { + return Object.keys(form.fields).some((fieldKey) => { + const fieldValue = form.fields[fieldKey]; + const initialValue = form._initial[fieldKey]; + return fieldValue !== initialValue; + }); + }; + + // Only calculate "changed" property if necessary (default) + if (calculateChanged) { + // Need to bind "this" context since "Object.defineProperty" uses the base object! + const boundGetChanged = getChanged.bind(form); + + Object.defineProperty(form.flags, "changed", { + get: boundGetChanged, + }); + } else { + delete form.flags.changed; + } + + return form; +}; + +/** + * Vue form state management mixin + * + * @param {string} name - Form name ('data' key) + * @param {Object} fields - Form fields and initial values + * @param {Object} options - Form configuration options + * @param {boolean} options.calculateChanged - Whether form "changed" flag is calculated + * @param {Object} options.flags - Additional custom flags + */ +const FormCreateMixin = ( + name: string, + fields: FormFields, + options?: FormOptions +): any => ({ + data() { + return { + [name]: createForm.call(this, fields, options), + }; + }, +}); + +export { createForm, FormCreateMixin }; + +/* eslint no-underscore-dangle: off */ diff --git a/src/formLeaveGuardMixin.js b/src/formLeaveGuardMixin.ts similarity index 72% rename from src/formLeaveGuardMixin.js rename to src/formLeaveGuardMixin.ts index 3817e80..b2e1a8d 100644 --- a/src/formLeaveGuardMixin.js +++ b/src/formLeaveGuardMixin.ts @@ -1,3 +1,17 @@ +import { Form } from "./formCreateMixin"; + +export interface FormMixin { + // Dynamic form name + [name: string]: Form; +} + +export interface FormLeaveOptions { + activeKey?: string; + callbackKey?: string; + onlyPrevent?: boolean; + onPrevent?: (callback?: () => void) => void; +} + /** * Prevent leaving a route with unsaved changes * @param {string|string[]} formKeys - Form state key(s) @@ -7,13 +21,16 @@ * @param {boolean} options.onlyPrevent - Only prevent leaving route (no "active" state) * @param {function} options.onPrevent - Prevention handler (for custom handling) */ -const FormLeaveGuardMixin = (formKeys, options = {}) => { +const FormLeaveGuardMixin = ( + formKeys: string[], + options?: FormLeaveOptions +): any => { const { activeKey = "isLeaveFormActive", callbackKey = "formLeaveCallback", onlyPrevent = false, - onPrevent = () => {}, - } = options; + onPrevent, + } = options || {}; return { data() { @@ -23,28 +40,32 @@ const FormLeaveGuardMixin = (formKeys, options = {}) => { }, computed: { [activeKey]: { - get() { + get(): boolean { + // @ts-ignore return Boolean(this[callbackKey]); }, - set(val) { + set(val: boolean): void { // Can only set to inactive, since setting to "active" requires a "next()" callback! if (!val) { // Must wait until next tick to avoid clearing callback before calling + // @ts-ignore this.$nextTick(() => { + // @ts-ignore this[callbackKey] = null; }); } }, }, }, - beforeRouteLeave(to, from, next) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + beforeRouteLeave(to: any, from: any, next: () => void) { // Check all supplied forms for unsaved changes if (typeof formKeys === "string") { - const isClean = checkFormClean.call(this, formKeys); + const isClean = checkFormClean(this, formKeys); if (isClean) return next(); } else if (Array.isArray(formKeys)) { const areAllClean = formKeys.every((key) => - checkFormClean.call(this, key) + checkFormClean(this, key) ); if (areAllClean) return next(); } else { @@ -72,9 +93,9 @@ const FormLeaveGuardMixin = (formKeys, options = {}) => { if (shouldContinue) { // Reset the form before leaving (otherwise it sometimes retains data) if (Array.isArray(formKeys)) { - formKeys.forEach((key) => resetForm.call(this, key)); + formKeys.forEach((key) => resetForm(this, key)); } else { - resetForm.call(this, formKeys); + resetForm(this, formKeys); } return next(); @@ -90,11 +111,12 @@ const FormLeaveGuardMixin = (formKeys, options = {}) => { /** * Check whether a form has any unsaved changes + * @param {Object} that - Calling 'this' context * @param {string} formKey - Form state key * @return {boolean} - Whether form is clean */ -function checkFormClean(formKey) { - const form = this[formKey]; +function checkFormClean(that: FormMixin, formKey: string) { + const form = that[formKey]; if (!form) return true; return !form.flags.changed && !form.flags.submitting; @@ -102,10 +124,11 @@ function checkFormClean(formKey) { /** * Reset a form + * @param {Object} that - Calling 'this' context * @param {string} formKey - Form state key */ -function resetForm(formKey) { - const form = this[formKey]; +function resetForm(that: FormMixin, formKey: string) { + const form = that[formKey]; if (!form) return; form.reset(); diff --git a/src/index.js b/src/index.ts similarity index 100% rename from src/index.js rename to src/index.ts diff --git a/test/formCreateMixin.test.js b/test/formCreateMixin.test.ts similarity index 97% rename from test/formCreateMixin.test.js rename to test/formCreateMixin.test.ts index a8e0256..f4d1b28 100644 --- a/test/formCreateMixin.test.js +++ b/test/formCreateMixin.test.ts @@ -72,14 +72,14 @@ describe("Form Create Mixin", () => { }); it("should update form values (but not initial)", () => { - wrapper.vm[formName].setValues({ ...fieldChanges }, false); + wrapper.vm[formName].setValues({ ...fieldChanges }); expect(wrapper.vm[formName].getValues()).toEqual(updatedFields); expect(wrapper.vm[formName]._initial).toEqual(fields); }); it("should update form values (and initial)", () => { // Should set form values and update initial values - wrapper.vm[formName].setValues({ ...fieldChanges }); + wrapper.vm[formName].setValues({ ...fieldChanges }, true); expect(wrapper.vm[formName].getValues()).toEqual(updatedFields); expect(wrapper.vm[formName]._initial).toEqual(updatedFields); @@ -89,7 +89,7 @@ describe("Form Create Mixin", () => { }); it("should reset form values", () => { - wrapper.vm[formName].setValues({ ...fieldChanges }, false); + wrapper.vm[formName].setValues({ ...fieldChanges }); expect(wrapper.vm[formName].getValues()).toEqual(updatedFields); wrapper.vm[formName].reset(); expect(wrapper.vm[formName].getValues()).toEqual(fields); @@ -199,7 +199,7 @@ describe("Form Create Function", () => { // NOTE: Must spread setup values to avoid mutating by reference! data() { return { - ...createForm(formName, { ...fields }), + [formName]: createForm({ ...fields }), }; }, }); diff --git a/test/formLeaveGuardMixin.test.js b/test/formLeaveGuardMixin.test.ts similarity index 92% rename from test/formLeaveGuardMixin.test.js rename to test/formLeaveGuardMixin.test.ts index 500e587..c40bb1b 100644 --- a/test/formLeaveGuardMixin.test.js +++ b/test/formLeaveGuardMixin.test.ts @@ -85,6 +85,8 @@ describe("Form Leave Guard Mixin", () => { it("should handle leaving clean forms (multiple)", () => { const wrapperMulti = mountComponent([formName]); + // TODO: Possibly fix by adding 'beforeRouteLeave' to the vm options... + // @ts-ignore const beforeRouteLeaveMulti = wrapperMulti.vm.$options.beforeRouteLeave; beforeRouteLeaveMulti.call(wrapperMulti.vm, "toObj", "fromObj", nextFn); @@ -103,7 +105,7 @@ describe("Form Leave Guard Mixin", () => { beforeHandler(); // Make changes to form (to trigger "changed" flag) - wrapper.vm[formName].setValues({ ...fieldChanges }, false); + wrapper.vm[formName].setValues({ ...fieldChanges }); beforeRouteLeave.call(wrapper.vm, "toObj", "fromObj", nextFn); }); afterEach(afterHandler); @@ -145,8 +147,10 @@ describe("Form Leave Guard Mixin", () => { it("should leave dirty forms (multiple) after confirmation", () => { const wrapperMulti = mountComponent([formName]); + // TODO: Possibly fix by adding 'beforeRouteLeave' to the vm options... + // @ts-ignore const beforeRouteLeaveMulti = wrapperMulti.vm.$options.beforeRouteLeave; - wrapperMulti.vm[formName].setValues({ ...fieldChanges }, false); + wrapperMulti.vm[formName].setValues({ ...fieldChanges }); beforeRouteLeaveMulti.call(wrapperMulti.vm, "toObj", "fromObj", nextFn); wrapperMulti.vm[callbackKey](true); @@ -163,9 +167,11 @@ describe("Form Leave Guard Mixin", () => { const wrapperPrevent = mountComponent(formName, { onlyPrevent: true, }); + // TODO: Possibly fix by adding 'beforeRouteLeave' to the vm options... + // @ts-ignore const beforeRouteLeavePrevent = wrapperPrevent.vm.$options.beforeRouteLeave; - wrapperPrevent.vm[formName].setValues({ ...fieldChanges }, false); + wrapperPrevent.vm[formName].setValues({ ...fieldChanges }); beforeRouteLeavePrevent.call(wrapperPrevent.vm, "toObj", "fromObj", nextFn); // Should not call "next" when only preventing leaving dirty form diff --git a/test/index.test.js b/test/index.test.ts similarity index 100% rename from test/index.test.js rename to test/index.test.ts diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cab240b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "declaration": true, + "esModuleInterop": true, + "outDir": "./lib", + "strict": true, + }, + "baseUrl": "./", + "paths": { + "@typings": ["src/types"], + "@typings/*": ["src/types/*"], + }, + "include": ["src/**/*.ts"], + "exclude": ["lib", "node_modules", "test"] +} From caeb36a8dc4e655d8007cc2ef67d015ed561464a Mon Sep 17 00:00:00 2001 From: Kendall Roth Date: Wed, 30 Sep 2020 21:14:27 -0400 Subject: [PATCH 2/7] Replace 'formLeaveGuardMixin' with 'FormGuardMixin' --- package.json | 2 + src/FormGuardMixin.ts | 103 +++++++++++++++++++++++++++++++++++++ src/classComponentHooks.ts | 15 ++++++ src/formLeaveGuardMixin.ts | 4 ++ src/index.ts | 3 ++ tsconfig.json | 1 + 6 files changed, 128 insertions(+) create mode 100644 src/FormGuardMixin.ts create mode 100644 src/classComponentHooks.ts diff --git a/package.json b/package.json index 23fcbf7..80d96a8 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "rimraf": "^3.0.2", "typescript": "^4.0.3", "vue": "^2.6.11", + "vue-class-component": "^7.2.6", + "vue-property-decorator": "^9.0.0", "vue-template-compiler": "^2.6.11" }, "husky": { diff --git a/src/FormGuardMixin.ts b/src/FormGuardMixin.ts new file mode 100644 index 0000000..8cd81fa --- /dev/null +++ b/src/FormGuardMixin.ts @@ -0,0 +1,103 @@ +import { Component, Vue } from "vue-property-decorator"; + +// Types +import { Form } from "./formCreateMixin"; + +@Component +export default class FormGuardMixin extends Vue { + // TODO: Possibly configure via router 'props'? + + isFormGuardActive = true; + + formLeaveCallback: ((leave: boolean) => void) | null = null; + + get forms(): Form[] { + return this.$data.guardedForms || []; + } + + get isLeaveFormActive(): boolean { + return Boolean(this.formLeaveCallback); + } + + set isLeaveFormActive(val: boolean) { + // Can only set to inactive, since setting to "active" requires a "next()" callback! + if (!val) { + // Must wait until next tick to avoid clearing callback before calling + // @ts-ignore + this.$nextTick(() => { + this.formLeaveCallback = null; + }); + } + } + + // TODO: Possibly implement via router 'props'? + get onlyPrevent(): boolean { + return false; + } + + onFormLeave(shouldLeave: boolean): void { + if (!this.formLeaveCallback) return; + + this.formLeaveCallback(shouldLeave); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + beforeRouteLeave(to: any, from: any, next: () => void): void { + // Check all supplied forms for unsaved changes + const areAllClean = checkForms(this.forms); + if (areAllClean) return next(); + + // The "onlyPrevent" option only prevents leaving with unsaved data, + // and does not manage any additional "active" status. + if (this.onlyPrevent) return; + + /** + * Callback to determine whether leaving form is allowed + * @param {boolean} shouldContinue - Whether to leave the form + */ + const callback = (shouldContinue = false) => { + // Prevent calling twice (from here and "onPrevent" handler) + if (!this.formLeaveCallback) return; + this.formLeaveCallback = null; + + if (shouldContinue) { + // Reset the form before leaving (otherwise it sometimes retains data) + resetForms(this.forms); + + return next(); + } + }; + + // Set the callback and pass the reference to the "onPrevent" callback + this.formLeaveCallback = callback; + } +} + +/** + * Check whether a form has any unsaved changes + * @param {Object} form - Form state + * @returns {boolean} - Whether form is clean + */ +function checkForms(forms: Form[]): boolean { + if (!forms || !Array.isArray(forms)) return true; + + return forms.every((form) => { + if (!form || !form.flags) return true; + return (!form.flags.changed && !form.flags.submitting); + }); +} + +/** + * Reset a form + * @param {Object} form - Form state + */ +function resetForms(forms: Form[]): void { + if (!forms || !Array.isArray(forms)) return; + + forms.forEach((form) => { + if (!form || !form.reset) return; + form.reset() + }); +} + +/* eslint @typescript-eslint/no-use-before-define: off */ diff --git a/src/classComponentHooks.ts b/src/classComponentHooks.ts new file mode 100644 index 0000000..f806a84 --- /dev/null +++ b/src/classComponentHooks.ts @@ -0,0 +1,15 @@ +/** + * Register several hooks provided by components + * - Vue Router navigation guards + * + * Taken from: https://class-component.vuejs.org/guide/additional-hooks.html + */ + +import Component from "vue-class-component"; + +// Register the router hooks with their names +Component.registerHooks([ + "beforeRouteEnter", + "beforeRouteLeave", + "beforeRouteUpdate", +]); diff --git a/src/formLeaveGuardMixin.ts b/src/formLeaveGuardMixin.ts index b2e1a8d..09ca058 100644 --- a/src/formLeaveGuardMixin.ts +++ b/src/formLeaveGuardMixin.ts @@ -1,3 +1,7 @@ +/** + * NOTE: This mixin is not typed when imported (since it is dynamic)! + */ + import { Form } from "./formCreateMixin"; export interface FormMixin { diff --git a/src/index.ts b/src/index.ts index 8a6a119..0112f29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,5 @@ +import "./classComponentHooks"; + export { createForm, FormCreateMixin } from "./formCreateMixin"; export { FormLeaveGuardMixin } from "./formLeaveGuardMixin"; +export { default as FormGuardMixin } from "./FormGuardMixin"; diff --git a/tsconfig.json b/tsconfig.json index cab240b..60c4eef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "es5", "module": "commonjs", "declaration": true, + "experimentalDecorators": true, "esModuleInterop": true, "outDir": "./lib", "strict": true, From 47d9fd6b4fdc6daee6ac79b42c61052b79fcf274 Mon Sep 17 00:00:00 2001 From: Kendall Roth Date: Wed, 30 Sep 2020 21:24:21 -0400 Subject: [PATCH 3/7] Deprecate 'FormLeaveGuardMixin' --- CHANGELOG.md | 8 +- README.md | 66 +++++++++++++-- src/FormGuardMixin.ts | 7 +- src/createForm.ts | 163 ++++++++++++++++++++++++++++++++++++ src/formCreateMixin.ts | 164 +------------------------------------ src/formLeaveGuardMixin.ts | 9 +- src/index.ts | 5 +- 7 files changed, 250 insertions(+), 172 deletions(-) create mode 100644 src/createForm.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be49dfb..fb0743e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Breaking +- **Deprecated** the `FormLeaveGuardMixin` component in favour of `FormGuardMixin` (fully typed) + - _The `FormLeaveGuardMixin` (not truly a mixin...) did not work with TypeScript_ + - _There was no need for customizing the leave guard to the extend provided_ - Changed the default behaviour of the `setValues` form function (now will not set initial values by default) - _This change was made to align with developer expectations (behaviour moved to `setInitial`)_ +- Removed the form key/name from the `createForm` function API + - _This was an unnecessary step that caused more internal work for no gain (simply assign to data)_ ### Added - Overhauled package to use [TypeScript](https://typescriptlang.org)! -- `setInitial` form function to set a form's initial (and current) values (similar to old behaviour of `setValues`) +- Fully typed `FormGuardMixin` to replace `FormLeaveGuardMixin` (can be customized with `formGuards` data key) +- New `setInitial` form function to set a form's initial (and current) values (similar to old behaviour of `setValues`) ## [0.2.3] - 2020-09-30 ### Added diff --git a/README.md b/README.md index 7849d4a..5c94d25 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Simple Vue form state management library (no validation, etc). -- [`FormCreateMixin`](#formcreatemixin) -- [`FormLeaveGuardMixin`](#formleaveguardmixin) +- [`FormCreateMixin` / `createForm()`](#formcreatemixin) +- [`FormGuardMixin`](#formguardmixin) +- [~~`FormLeaveGuardMixin`~~](#deprecated-formleaveguardmixin) (_deprecated_) ```sh npm install @kendallroth/vue-simple-forms --save @@ -19,6 +20,8 @@ npm install @kendallroth/vue-simple-forms --save ## `FormCreateMixin` +> **NOTE:** This mixin is not fully typed! When using TypeScript, use the underlying `createForm` helper to create fully typed forms! + ### Usage The `FormCreateMixin` handles creating the reactive data and flags from the field keys and initial values. The form name/key and fields (with intial values) can be specified when adding the mixin to the component. @@ -41,14 +44,14 @@ vm.data.testForm.setSubmitting(true); > **NOTE:** The `createForm` function is an alternative to the `FormCreateMixin` (recommended with TypeScript). ```js -// Alternative approach +// Alternative approach (TypeScript, etc) import { createForm } from "@kendallroth/vue-simple-forms"; const fields = { email: "", password: "" }; const vm = new Vue({ data() { - ...createForm("testForm", fields, { calculateChanged: false }), + testForm: createForm(fields, { calculateChanged: false }), }, }); ``` @@ -57,11 +60,11 @@ const vm = new Vue({ ### Config -Both `FormCreateMixin` and `createForm` accept several arguments to configure the form. +Both `createForm` and `FormCreateMixin` accept several arguments to configure the form. | Property | Type | Default | Description | | -------------------------- | --------- | ------- | ------------------------------------------------------ | -| `name` | `string` | | Form `data` key name | +| `name`\* | `string` | | Form `data` key name **(only `FormCreateMixin`)** | | `fields` | `Object` | | Form fields and initial values | | `options` | `Object` | | Form configuration options | | `options.calculateChanged` | `boolean` | `true` | Whether `changed` flag is calculated (performance) | @@ -97,7 +100,56 @@ The form flags are computed from the form state and should not be modified direc | `loading` | Whether form is loading | `setLoading()` | | `submitting` | Whether form is submitting | `setSubmitting()` | -## `FormLeaveGuardMixin` +## `FormGuardMixin` + +### Usage + +The `FormLeaveGuardMixin` provides helpers to prevent leaving a form (managed by `createForm`) with unsaved data. These helpers can be utilized by the component to allow the user to handle the route change or cancellation based on the provided properties. The mixin checks the `changed` flag of a form (or forms) created by the `createForm`. + +```js +import { createForm, FormGuardMixin } from "@kendallroth/vue-simple-forms"; + +const vm = new Vue({ + data() { + sampleForm: createForm(...), + formGuards: [this.sampleForm], + }, + mixins: [FormLeaveGuardMixin], + template: ` + + `, +}); +``` + +### API + +### Config + +`FormGuardMixin` accepts a configuration `data` variable. + +| Property | Type | Description | +| ------------ | -------- | ------------------------------------ | +| `formGuards` | `Form[]` | Form objects created by `createForm` | + +### Mixin Data + +The `FormGuardMixin` provides a computed property to control a confirmation dialog (or other form) and a callback to handle leaving or remaining at the form. + +| Property | Description | +| ---------------------------- | -------------------------------------------------- | +| `isFormGuardActive` | Whether the leave route protection is active/shown | +| `onFormLeave(shouldLeave)` | Confirmation callback (from dialog, etc) | + +## [DEPRECATED] `FormLeaveGuardMixin` + +> **NOTE:** This has been deprecated in favour of the fully typed `FormGuardMixin`. ### Usage diff --git a/src/FormGuardMixin.ts b/src/FormGuardMixin.ts index 8cd81fa..8938a40 100644 --- a/src/FormGuardMixin.ts +++ b/src/FormGuardMixin.ts @@ -1,7 +1,12 @@ +/** + * NOTE: This component requires a 'data' property to be set in order + * to know which forms to check (guardedForms[]). + */ + import { Component, Vue } from "vue-property-decorator"; // Types -import { Form } from "./formCreateMixin"; +import { Form } from "./createForm"; @Component export default class FormGuardMixin extends Vue { diff --git a/src/createForm.ts b/src/createForm.ts new file mode 100644 index 0000000..3c7d127 --- /dev/null +++ b/src/createForm.ts @@ -0,0 +1,163 @@ +export interface FormFields { + [name: string]: string | number | null; +} + +export interface FormOptions { + calculateChanged?: boolean; + flags?: FormOptionsFlags; +} + +export interface FormOptionsFlags { + // Necessary to allow custom flags + [key: string]: boolean | undefined; + changed?: boolean; + disabled?: boolean; + loading?: boolean; + submitting?: boolean; +} + +export interface FormFlags { + // Necessary to allow referencing as 'this.flags[flag]'... + [key: string]: boolean | undefined; + changed?: boolean; + disabled: boolean; + loading: boolean; + submitting: boolean; +} + +export interface Form { + flags: FormFlags; + fields: FormFields; + _initial: FormFields; + getValues: () => FormFields; + setFlag: (flag: string, value: boolean) => void; + setInitial: (values: FormFields) => void; + setLoading: (isLoading: boolean) => void; + setSubmitting: (isSubmitting: boolean) => void; + setValues: (values: FormFields, setInitial?: boolean) => void; + reset: () => void; +} + +/** + * Create a Vue form + * + * @param {Object} fields - Form fields and initial values + * @param {Object} options - Form configuration options + */ +export const createForm = (fields: FormFields, options?: FormOptions): Form => { + const { calculateChanged = true, flags } = options || {}; + + const formFlags: FormFlags = { + // Allow tracking additional flags + ...(flags || {}), + // NOTE: Declared even though overridden so that it is tracked by object + changed: false, + get disabled(): boolean { + return this.loading || this.submitting; + }, + loading: false, + submitting: false, + }; + + const form: Form = { + // Clone the fields to set as initial values (for reset) + _initial: { ...fields }, + flags: formFlags, + fields, + /** + * Get the form values + * @return {Object} - Form values + */ + getValues(): FormFields { + return this.fields; + }, + /** + * Set a form flag + * @param {string} flag - Form flag name + * @param {boolean} value - Form flag value + */ + setFlag(flag: string, value: boolean): void { + // Only set flags that exist or are custom! + if (this.flags[flag] === undefined) return; + + this.flags[flag] = Boolean(value); + }, + /** + * Set the form current and initial values + * @param {Object} values - New form values + */ + setInitial(values: FormFields): void { + this.setValues(values, true); + }, + /** + * Set whether the form is loading + * @param {boolean} isLoading - Whether form is loading + */ + setLoading(isLoading: boolean): void { + this.setFlag("loading", isLoading); + }, + /** + * Set whether the form is submitting + * @param {boolean} isSubmitting - Whether form is submitting + */ + setSubmitting(isSubmitting: boolean): void { + this.setFlag("submitting", isSubmitting); + }, + /** + * Set the form values + * @param {Object} values - New form values + * @param {boolean} setInitial - Whether initial values should be updated + */ + setValues(values: FormFields, setInitial = false): void { + Object.keys(values).forEach((key) => { + const fieldValue = values[key]; + this.fields[key] = fieldValue; + }); + + // Optionally set the new values as the initial values (default) + // NOTE: This doesn't work too well with VeeValidate "changed" flag, + // but the form flag "changed" should be used anyway. + if (setInitial) { + Object.keys(values).forEach((key) => { + const fieldValue = values[key]; + this._initial[key] = fieldValue; + }); + } + }, + /** + * Reset the form to its initial values + */ + reset(): void { + Object.keys(this._initial).forEach((key) => { + const initialValue = this._initial[key]; + this.fields[key] = initialValue; + }); + }, + }; + + /** + * Determine whether any form fields have changed + * @return {boolean} - Whether any fields have changed + */ + const getChanged = (): boolean => { + return Object.keys(form.fields).some((fieldKey) => { + const fieldValue = form.fields[fieldKey]; + const initialValue = form._initial[fieldKey]; + return fieldValue !== initialValue; + }); + }; + + // Only calculate "changed" property if necessary (default) + if (calculateChanged) { + // Need to bind "this" context since "Object.defineProperty" uses the base object! + const boundGetChanged = getChanged.bind(form); + + Object.defineProperty(form.flags, "changed", { + get: boundGetChanged, + }); + } else { + delete form.flags.changed; + } + + return form; +}; diff --git a/src/formCreateMixin.ts b/src/formCreateMixin.ts index 7a393e5..f5c4f01 100644 --- a/src/formCreateMixin.ts +++ b/src/formCreateMixin.ts @@ -1,166 +1,9 @@ -export interface FormFields { - [name: string]: string | number | null; -} - -export interface FormOptions { - calculateChanged?: boolean; - flags?: FormOptionsFlags; -} - -export interface FormOptionsFlags { - // Necessary to allow custom flags - [key: string]: boolean | undefined; - changed?: boolean; - disabled?: boolean; - loading?: boolean; - submitting?: boolean; -} - -export interface FormFlags { - // Necessary to allow referencing as 'this.flags[flag]'... - [key: string]: boolean | undefined; - changed?: boolean; - disabled: boolean; - loading: boolean; - submitting: boolean; -} - -export interface Form { - flags: FormFlags; - fields: FormFields; - _initial: FormFields; - getValues: () => FormFields; - setFlag: (flag: string, value: boolean) => void; - setInitial: (values: FormFields) => void; - setLoading: (isLoading: boolean) => void; - setSubmitting: (isSubmitting: boolean) => void; - setValues: (values: FormFields, setInitial?: boolean) => void; - reset: () => void; -} - /** - * Create a Vue form - * - * @param {Object} fields - Form fields and initial values - * @param {Object} options - Form configuration options + * NOTE: Use 'createForm' function with TypeScript instead (this mixin is not typed)! */ -const createForm = (fields: FormFields, options?: FormOptions): Form => { - const { calculateChanged = true, flags } = options || {}; - - const formFlags: FormFlags = { - // Allow tracking additional flags - ...(flags || {}), - // NOTE: Declared even though overridden so that it is tracked by object - changed: false, - get disabled(): boolean { - return this.loading || this.submitting; - }, - loading: false, - submitting: false, - }; - - const form: Form = { - // Clone the fields to set as initial values (for reset) - _initial: { ...fields }, - flags: formFlags, - fields, - /** - * Get the form values - * @return {Object} - Form values - */ - getValues(): FormFields { - return this.fields; - }, - /** - * Set a form flag - * @param {string} flag - Form flag name - * @param {boolean} value - Form flag value - */ - setFlag(flag: string, value: boolean): void { - // Only set flags that exist or are custom! - if (this.flags[flag] === undefined) return; - - this.flags[flag] = Boolean(value); - }, - /** - * Set the form current and initial values - * @param {Object} values - New form values - */ - setInitial(values: FormFields): void { - this.setValues(values, true); - }, - /** - * Set whether the form is loading - * @param {boolean} isLoading - Whether form is loading - */ - setLoading(isLoading: boolean): void { - this.setFlag("loading", isLoading); - }, - /** - * Set whether the form is submitting - * @param {boolean} isSubmitting - Whether form is submitting - */ - setSubmitting(isSubmitting: boolean): void { - this.setFlag("submitting", isSubmitting); - }, - /** - * Set the form values - * @param {Object} values - New form values - * @param {boolean} setInitial - Whether initial values should be updated - */ - setValues(values: FormFields, setInitial = false): void { - Object.keys(values).forEach((key) => { - const fieldValue = values[key]; - this.fields[key] = fieldValue; - }); - - // Optionally set the new values as the initial values (default) - // NOTE: This doesn't work too well with VeeValidate "changed" flag, - // but the form flag "changed" should be used anyway. - if (setInitial) { - Object.keys(values).forEach((key) => { - const fieldValue = values[key]; - this._initial[key] = fieldValue; - }); - } - }, - /** - * Reset the form to its initial values - */ - reset(): void { - Object.keys(this._initial).forEach((key) => { - const initialValue = this._initial[key]; - this.fields[key] = initialValue; - }); - }, - }; - - /** - * Determine whether any form fields have changed - * @return {boolean} - Whether any fields have changed - */ - const getChanged = (): boolean => { - return Object.keys(form.fields).some((fieldKey) => { - const fieldValue = form.fields[fieldKey]; - const initialValue = form._initial[fieldKey]; - return fieldValue !== initialValue; - }); - }; - - // Only calculate "changed" property if necessary (default) - if (calculateChanged) { - // Need to bind "this" context since "Object.defineProperty" uses the base object! - const boundGetChanged = getChanged.bind(form); - - Object.defineProperty(form.flags, "changed", { - get: boundGetChanged, - }); - } else { - delete form.flags.changed; - } - return form; -}; +// Utilities +import { createForm, FormFields, FormOptions } from "./createForm"; /** * Vue form state management mixin @@ -175,6 +18,7 @@ const FormCreateMixin = ( name: string, fields: FormFields, options?: FormOptions + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any => ({ data() { return { diff --git a/src/formLeaveGuardMixin.ts b/src/formLeaveGuardMixin.ts index 09ca058..b520bb4 100644 --- a/src/formLeaveGuardMixin.ts +++ b/src/formLeaveGuardMixin.ts @@ -1,8 +1,9 @@ /** - * NOTE: This mixin is not typed when imported (since it is dynamic)! + * NOTE: Use 'FormGuardMixin' mixin with TypeScript instead (this mixin is not typed)! */ -import { Form } from "./formCreateMixin"; +// Types +import { Form } from "./createForm"; export interface FormMixin { // Dynamic form name @@ -28,7 +29,11 @@ export interface FormLeaveOptions { const FormLeaveGuardMixin = ( formKeys: string[], options?: FormLeaveOptions + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any => { + // eslint-disable-next-line no-console + console.warn("'FormLeaveGuardMixin' is deprecated (no type support) - use 'FormGuardMixin' instead!"); + const { activeKey = "isLeaveFormActive", callbackKey = "formLeaveCallback", diff --git a/src/index.ts b/src/index.ts index 0112f29..2b4a425 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ import "./classComponentHooks"; -export { createForm, FormCreateMixin } from "./formCreateMixin"; +export { createForm } from "./createForm"; export { FormLeaveGuardMixin } from "./formLeaveGuardMixin"; export { default as FormGuardMixin } from "./FormGuardMixin"; + +// NOTE: 'createForm' should be used instead when using TypeScript! +export { FormCreateMixin } from "./FormCreateMixin"; From dc9f45c1e5598fae83ee5957d29197e263e56318 Mon Sep 17 00:00:00 2001 From: Kendall Roth Date: Thu, 1 Oct 2020 00:27:48 -0400 Subject: [PATCH 4/7] Fix tests with Typescript --- .gitattributes | 1 + .prettierrc.js | 1 + CHANGELOG.md | 13 +- README.md | 130 ++++++------- jest.config.js | 21 +- package.json | 5 +- src/FormGuardMixin.ts | 12 +- src/formCreateMixin.ts | 32 --- src/formLeaveGuardMixin.ts | 146 -------------- src/index.ts | 6 +- ...CreateMixin.test.ts => formCreate.test.ts} | 82 +++----- test/formGuardMixin.test.ts | 133 +++++++++++++ test/formLeaveGuardMixin.test.ts | 184 ------------------ tsconfig.json | 8 +- 14 files changed, 245 insertions(+), 529 deletions(-) create mode 100644 .gitattributes delete mode 100644 src/formCreateMixin.ts delete mode 100644 src/formLeaveGuardMixin.ts rename test/{formCreateMixin.test.ts => formCreate.test.ts} (77%) create mode 100644 test/formGuardMixin.test.ts delete mode 100644 test/formLeaveGuardMixin.test.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.prettierrc.js b/.prettierrc.js index 524ab2b..daafb2a 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,4 +1,5 @@ module.exports = { singleQuote: false, + endOfLine: "lf", trailingComma: "es5", }; diff --git a/CHANGELOG.md b/CHANGELOG.md index fb0743e..34574e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased -### Breaking -- **Deprecated** the `FormLeaveGuardMixin` component in favour of `FormGuardMixin` (fully typed) +### Removed +- Removed the `FormCreateMixin` (replaced with typed `createForm` function) + - _The `FormCreaetMixin` (not truly a mixin...) did not work with TypeScript_ + - _The old mixin simply called this function anyway..._ +- Removed the `FormLeaveGuardMixin` (replaced with typed `FormGuardMixin`) - _The `FormLeaveGuardMixin` (not truly a mixin...) did not work with TypeScript_ - _There was no need for customizing the leave guard to the extend provided_ -- Changed the default behaviour of the `setValues` form function (now will not set initial values by default) - - _This change was made to align with developer expectations (behaviour moved to `setInitial`)_ - Removed the form key/name from the `createForm` function API - _This was an unnecessary step that caused more internal work for no gain (simply assign to data)_ +### Changed +- Changed the default behaviour of the `setValues` form function (now will not set initial values by default) + - _This change was made to align with developer expectations (behaviour moved to `setInitial`)_ + ### Added - Overhauled package to use [TypeScript](https://typescriptlang.org)! - Fully typed `FormGuardMixin` to replace `FormLeaveGuardMixin` (can be customized with `formGuards` data key) diff --git a/README.md b/README.md index 5c94d25..c3092e6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Simple Vue form state management library (no validation, etc). -- [`FormCreateMixin` / `createForm()`](#formcreatemixin) +- [`createForm`](#createform) +- [~~`FormCreateMixin`~~](#deprecated-formcreatemixin) (_deprecated_) - [`FormGuardMixin`](#formguardmixin) - [~~`FormLeaveGuardMixin`~~](#deprecated-formleaveguardmixin) (_deprecated_) @@ -18,21 +19,23 @@ npm install @kendallroth/vue-simple-forms --save - Track basic form fields - Help prevent leaving a route with unsaved changes -## `FormCreateMixin` +## `createForm` -> **NOTE:** This mixin is not fully typed! When using TypeScript, use the underlying `createForm` helper to create fully typed forms! +> **NOTE:** The previous `FormCreateMixin` has been removed as it did not support TypeScript! ### Usage -The `FormCreateMixin` handles creating the reactive data and flags from the field keys and initial values. The form name/key and fields (with intial values) can be specified when adding the mixin to the component. +The `createForm` function handles creating the reactive data and flags from the field keys and initial values. The form name/key and fields (with intial values) can be specified when adding the data to the component. ```js -import { FormCreateMixin } from "@kendallroth/vue-simple-forms"; +import { createForm } from "@kendallroth/vue-simple-forms"; -const fields = { email: "test@example.com", password: "********" }; +const fields = { email: "", password: "" }; const vm = new Vue({ - mixins: [FormCreateMixin("testForm", fields, { calculateChanged: false })], + data() { + testForm: createForm(fields, { calculateChanged: false }), + }, }); // Indicate loading @@ -41,30 +44,31 @@ vm.data.testForm.setLoading(true); vm.data.testForm.setSubmitting(true); ``` -> **NOTE:** The `createForm` function is an alternative to the `FormCreateMixin` (recommended with TypeScript). +Alternatively, TypeScript users will benefit from `vue-property-decorator` integration: ```js -// Alternative approach (TypeScript, etc) import { createForm } from "@kendallroth/vue-simple-forms"; +import { Component, Vue } from "vue-property-decorator"; -const fields = { email: "", password: "" }; +@Component +export default class Form extends Vue { + testForm = createForm({ ... }); + + mounted() { + this.testForm.setValues({ ... }); + } +} -const vm = new Vue({ - data() { - testForm: createForm(fields, { calculateChanged: false }), - }, -}); ``` ### API ### Config -Both `createForm` and `FormCreateMixin` accept several arguments to configure the form. +`createForm` accepts several arguments to configure the form. | Property | Type | Default | Description | | -------------------------- | --------- | ------- | ------------------------------------------------------ | -| `name`\* | `string` | | Form `data` key name **(only `FormCreateMixin`)** | | `fields` | `Object` | | Form fields and initial values | | `options` | `Object` | | Form configuration options | | `options.calculateChanged` | `boolean` | `true` | Whether `changed` flag is calculated (performance) | @@ -100,11 +104,15 @@ The form flags are computed from the form state and should not be modified direc | `loading` | Whether form is loading | `setLoading()` | | `submitting` | Whether form is submitting | `setSubmitting()` | +## [DEPRECATED] `FormCreateMixin` + +> **NOTE:** This has been deprecated in favour of the fully typed `createForm`. + ## `FormGuardMixin` ### Usage -The `FormLeaveGuardMixin` provides helpers to prevent leaving a form (managed by `createForm`) with unsaved data. These helpers can be utilized by the component to allow the user to handle the route change or cancellation based on the provided properties. The mixin checks the `changed` flag of a form (or forms) created by the `createForm`. +The `FormGuardMixin` provides helpers to prevent leaving a form (managed by `createForm`) with unsaved data. These helpers can be utilized by the component to allow the user to handle the route change or cancellation based on the provided properties. The mixin checks the `changed` flag of a form (or forms) created by the `createForm`. ```js import { createForm, FormGuardMixin } from "@kendallroth/vue-simple-forms"; @@ -128,83 +136,57 @@ const vm = new Vue({ }); ``` -### API - -### Config - -`FormGuardMixin` accepts a configuration `data` variable. - -| Property | Type | Description | -| ------------ | -------- | ------------------------------------ | -| `formGuards` | `Form[]` | Form objects created by `createForm` | - -### Mixin Data - -The `FormGuardMixin` provides a computed property to control a confirmation dialog (or other form) and a callback to handle leaving or remaining at the form. - -| Property | Description | -| ---------------------------- | -------------------------------------------------- | -| `isFormGuardActive` | Whether the leave route protection is active/shown | -| `onFormLeave(shouldLeave)` | Confirmation callback (from dialog, etc) | - -## [DEPRECATED] `FormLeaveGuardMixin` - -> **NOTE:** This has been deprecated in favour of the fully typed `FormGuardMixin`. - -### Usage - -The `FormLeaveGuardMixin` provides helpers to prevent leaving a form (managed by `FormCreateMixin`) with unsaved data. These helpers can be utilized by the component to allow the user to handle the route change or cancellation based on the provided properties. The mixin checks the `changed` flag of a form (or forms) created by the `FormCreateMixin`. +Alternatively, TypeScript users will benefit from `vue-property-decorator` integration: ```js -import { FormLeaveGuardMixin } from "@kendallroth/vue-simple-forms"; +import { createForm, FormGuardMixin } from "@kendallroth/vue-simple-forms"; +import { Component, Mixins } from "vue-property-decorator"; -const vm = new Vue({ - mixins: [ - FormLeaveGuardMixin("testForm", { - activeKey: "isLeavingForm", - callbackKey: "formLeaveCallback", - onlyPrevent: false, // Would render "activeKey" useless - // onPrevent: (callback) => Vuex.commit("SHOW_ROUTE_LEAVE", { callback }) - }), - ], - // mixins: [FormLeaveGuardMixin(["testForm", "anotherForm")], +@Component({ template: ` `, -}); +}) +export default class Form extends Mixins(FormGuardMixin) { + testForm = createForm({ ... }); + formGards = [this.testForm] + + mounted() { + this.testForm.setValues({ ... }); + } +} + ``` ### API ### Config -`FormLeaveGuardMixin` accepts several arguments to configure the form. +`FormGuardMixin` accepts a configuration `data` variable. -| Property | Type | Default | Description | -| ----------------------------- | ----------------- | ------------------- | ------------------------------------------------------------- | -| `formNames` | `string|string[]` | | Form `data` key name | -| `options` | `Object` | `{}` | Form configuration options | -| `options.activeKey` | `string` | `isLeaveFormActive` | Key name to indicate when form leave guard is active | -| `options.callbackKey` | `string` | `formLeaveCallback` | Key name for route leave confirmation callback | -| `options.onlyPrevent` | `boolean` | `false` | Whether to only prevent leaving form ("activeKey" is useless) | -| `options.onPrevent(callback)` | `function` | `() => {}` | Custom prevention handler (ie. for handling with Vuex, etc) | +| Property | Type | Description | +| ------------ | -------- | ------------------------------------ | +| `formGuards` | `Form[]` | Form objects created by `createForm` | ### Mixin Data -The `FormLeaveGuardMixin` provides a computed property to control a confirmation dialog (or other form) and a callback to handle leaving or remaining at the form. +The `FormGuardMixin` provides a computed property to control a confirmation dialog (or other form) and a callback to handle leaving or remaining at the form. + +| Property | Description | +| -------------------------- | -------------------------------------------------- | +| `isFormGuardActive` | Whether the leave route protection is active/shown | +| `onFormLeave(shouldLeave)` | Confirmation callback (from dialog, etc) | -| Property | Description | -| ---------------------------------- | -------------------------------------------------- | -| `isLeaveFormActive`\* | Whether the leave route protection is active/shown | -| `formLeaveCallback(shouldLeave)`\* | Confirmation callback (from dialog, etc) | +## [DEPRECATED] `FormLeaveGuardMixin` -> **NOTE:** Since these API names can be configured, use the appropriate names from the mixin constructor. +> **NOTE:** This has been deprecated in favour of the fully typed `FormGuardMixin`. ## Development diff --git a/jest.config.js b/jest.config.js index 037784b..1a515b6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,7 @@ module.exports = { collectCoverage: false, // An array of glob patterns indicating a set of files for which coverage information should be collected - collectCoverageFrom: ["src/**/*.js", "!src/index.js"], + collectCoverageFrom: ["src/**/*.ts", "!src/index.ts", "!src/classComponentHooks.ts"], // The directory where Jest should output its coverage files coverageDirectory: "coverage", @@ -84,7 +84,7 @@ module.exports = { // notifyMode: "failure-change", // A preset that is used as a base for Jest's configuration - // preset: undefined, + preset: "ts-jest", // Run tests from one or more projects // projects: undefined, @@ -105,12 +105,12 @@ module.exports = { // restoreMocks: false, // The root directory that Jest should scan for tests and modules within - // rootDir: "./test/", + rootDir: "./test/", // A list of paths to directories that Jest should use to search for files in - // roots: [ - // "", - // ], + roots: [ + "", + ], // Allows you to use a custom runner instead of Jest's default test runner // runner: "jest-runner", @@ -134,10 +134,9 @@ module.exports = { // testLocationInResults: false, // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], + testMatch: [ + "**/*.test.ts", + ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // testPathIgnorePatterns: [ @@ -161,7 +160,7 @@ module.exports = { // A map from regular expressions to paths to transformers transform: { - "^.+\\.js$": "babel-jest", + "^.+\\.ts$": "ts-jest", }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation diff --git a/package.json b/package.json index 80d96a8..fb04c86 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "format": "prettier --write {src,test}/**/*.ts", "test": "npm run lint && npm run test:only", "test:cover": "npm run test:only -- --coverage", - "test:prod": "cross-env BABEL_ENV=production npm run test", + "test:prod": "cross-env NODE_ENV=production npm run test", "test:only": "jest --verbose", "test:watch": "npm run test:only -- --watch", "lint": "eslint src test", @@ -40,8 +40,8 @@ "@babel/cli": "^7.10.3", "@babel/core": "^7.10.3", "@babel/preset-env": "^7.10.3", - "@babel/preset-typescript": "^7.10.4", "@babel/register": "^7.10.3", + "@types/jest": "^26.0.14", "@typescript-eslint/eslint-plugin": "^4.3.0", "@typescript-eslint/parser": "^4.3.0", "@vue/test-utils": "^1.0.3", @@ -62,6 +62,7 @@ "lint-staged": "^10.2.11", "prettier": "^2.0.5", "rimraf": "^3.0.2", + "ts-jest": "^26.4.1", "typescript": "^4.0.3", "vue": "^2.6.11", "vue-class-component": "^7.2.6", diff --git a/src/FormGuardMixin.ts b/src/FormGuardMixin.ts index 8938a40..b357db5 100644 --- a/src/FormGuardMixin.ts +++ b/src/FormGuardMixin.ts @@ -9,22 +9,20 @@ import { Component, Vue } from "vue-property-decorator"; import { Form } from "./createForm"; @Component -export default class FormGuardMixin extends Vue { +export class FormGuardMixin extends Vue { // TODO: Possibly configure via router 'props'? - isFormGuardActive = true; - formLeaveCallback: ((leave: boolean) => void) | null = null; get forms(): Form[] { return this.$data.guardedForms || []; } - get isLeaveFormActive(): boolean { + get isFormGuardActive(): boolean { return Boolean(this.formLeaveCallback); } - set isLeaveFormActive(val: boolean) { + set isFormGuardActive(val: boolean) { // Can only set to inactive, since setting to "active" requires a "next()" callback! if (!val) { // Must wait until next tick to avoid clearing callback before calling @@ -88,7 +86,7 @@ function checkForms(forms: Form[]): boolean { return forms.every((form) => { if (!form || !form.flags) return true; - return (!form.flags.changed && !form.flags.submitting); + return !form.flags.changed && !form.flags.submitting; }); } @@ -101,7 +99,7 @@ function resetForms(forms: Form[]): void { forms.forEach((form) => { if (!form || !form.reset) return; - form.reset() + form.reset(); }); } diff --git a/src/formCreateMixin.ts b/src/formCreateMixin.ts deleted file mode 100644 index f5c4f01..0000000 --- a/src/formCreateMixin.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * NOTE: Use 'createForm' function with TypeScript instead (this mixin is not typed)! - */ - -// Utilities -import { createForm, FormFields, FormOptions } from "./createForm"; - -/** - * Vue form state management mixin - * - * @param {string} name - Form name ('data' key) - * @param {Object} fields - Form fields and initial values - * @param {Object} options - Form configuration options - * @param {boolean} options.calculateChanged - Whether form "changed" flag is calculated - * @param {Object} options.flags - Additional custom flags - */ -const FormCreateMixin = ( - name: string, - fields: FormFields, - options?: FormOptions - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): any => ({ - data() { - return { - [name]: createForm.call(this, fields, options), - }; - }, -}); - -export { createForm, FormCreateMixin }; - -/* eslint no-underscore-dangle: off */ diff --git a/src/formLeaveGuardMixin.ts b/src/formLeaveGuardMixin.ts deleted file mode 100644 index b520bb4..0000000 --- a/src/formLeaveGuardMixin.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * NOTE: Use 'FormGuardMixin' mixin with TypeScript instead (this mixin is not typed)! - */ - -// Types -import { Form } from "./createForm"; - -export interface FormMixin { - // Dynamic form name - [name: string]: Form; -} - -export interface FormLeaveOptions { - activeKey?: string; - callbackKey?: string; - onlyPrevent?: boolean; - onPrevent?: (callback?: () => void) => void; -} - -/** - * Prevent leaving a route with unsaved changes - * @param {string|string[]} formKeys - Form state key(s) - * @param {Object} options - Guard configuration options - * @param {string} options.activeKey - Form leave active state key - * @param {string} options.callbackKey - Callback method key - * @param {boolean} options.onlyPrevent - Only prevent leaving route (no "active" state) - * @param {function} options.onPrevent - Prevention handler (for custom handling) - */ -const FormLeaveGuardMixin = ( - formKeys: string[], - options?: FormLeaveOptions - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): any => { - // eslint-disable-next-line no-console - console.warn("'FormLeaveGuardMixin' is deprecated (no type support) - use 'FormGuardMixin' instead!"); - - const { - activeKey = "isLeaveFormActive", - callbackKey = "formLeaveCallback", - onlyPrevent = false, - onPrevent, - } = options || {}; - - return { - data() { - return { - [callbackKey]: null, - }; - }, - computed: { - [activeKey]: { - get(): boolean { - // @ts-ignore - return Boolean(this[callbackKey]); - }, - set(val: boolean): void { - // Can only set to inactive, since setting to "active" requires a "next()" callback! - if (!val) { - // Must wait until next tick to avoid clearing callback before calling - // @ts-ignore - this.$nextTick(() => { - // @ts-ignore - this[callbackKey] = null; - }); - } - }, - }, - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - beforeRouteLeave(to: any, from: any, next: () => void) { - // Check all supplied forms for unsaved changes - if (typeof formKeys === "string") { - const isClean = checkFormClean(this, formKeys); - if (isClean) return next(); - } else if (Array.isArray(formKeys)) { - const areAllClean = formKeys.every((key) => - checkFormClean(this, key) - ); - if (areAllClean) return next(); - } else { - /* istanbul ignore next - Uncommon case */ - return next(); - } - - // The "onlyPrevent" option only prevents leaving with unsaved data, - // and does not manage any additional "active" status. - if (onlyPrevent) { - // Call the prevention handler with no callback (ie. no "next()") - onPrevent && onPrevent(); - return; - } - - /** - * Callback to determine whether leaving form is allowed - * @param {boolean} shouldContinue - Whether to leave the form - */ - const callback = (shouldContinue = false) => { - // Prevent calling twice (from here and "onPrevent" handler) - if (!this[callbackKey]) return; - this[callbackKey] = null; - - if (shouldContinue) { - // Reset the form before leaving (otherwise it sometimes retains data) - if (Array.isArray(formKeys)) { - formKeys.forEach((key) => resetForm(this, key)); - } else { - resetForm(this, formKeys); - } - - return next(); - } - }; - - // Set the callback and pass the reference to the "onPrevent" callback - this[callbackKey] = callback; - onPrevent && onPrevent(callback); - }, - }; -}; - -/** - * Check whether a form has any unsaved changes - * @param {Object} that - Calling 'this' context - * @param {string} formKey - Form state key - * @return {boolean} - Whether form is clean - */ -function checkFormClean(that: FormMixin, formKey: string) { - const form = that[formKey]; - if (!form) return true; - - return !form.flags.changed && !form.flags.submitting; -} - -/** - * Reset a form - * @param {Object} that - Calling 'this' context - * @param {string} formKey - Form state key - */ -function resetForm(that: FormMixin, formKey: string) { - const form = that[formKey]; - if (!form) return; - - form.reset(); -} - -export { FormLeaveGuardMixin }; diff --git a/src/index.ts b/src/index.ts index 2b4a425..f70a46a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,4 @@ import "./classComponentHooks"; export { createForm } from "./createForm"; -export { FormLeaveGuardMixin } from "./formLeaveGuardMixin"; -export { default as FormGuardMixin } from "./FormGuardMixin"; - -// NOTE: 'createForm' should be used instead when using TypeScript! -export { FormCreateMixin } from "./FormCreateMixin"; +export { FormGuardMixin } from "./FormGuardMixin"; diff --git a/test/formCreateMixin.test.ts b/test/formCreate.test.ts similarity index 77% rename from test/formCreateMixin.test.ts rename to test/formCreate.test.ts index f4d1b28..8491c7f 100644 --- a/test/formCreateMixin.test.ts +++ b/test/formCreate.test.ts @@ -1,11 +1,12 @@ import Vue from "vue"; -import { shallowMount } from "@vue/test-utils"; +import { shallowMount, Wrapper } from "@vue/test-utils"; // Utilities -import { createForm, FormCreateMixin } from "../src"; +import { createForm } from "../src"; describe("Form Create Mixin", () => { - let wrapper = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let wrapper: Wrapper; const Component = Vue.component("formComponent", { template: "
", }); @@ -23,13 +24,15 @@ describe("Form Create Mixin", () => { ...fieldChanges, }; - // Setup the component with mixin before each test + // Setup the component data before each test const beforeHandler = () => { wrapper = shallowMount(Component, { - mixins: [ - // NOTE: Must spread setup values to avoid mutating by reference! - FormCreateMixin(formName, { ...fields }), - ], + // NOTE: Must spread setup values to avoid mutating by reference! + data() { + return { + [formName]: createForm({ ...fields }), + }; + }, }); }; @@ -40,17 +43,17 @@ describe("Form Create Mixin", () => { beforeEach(beforeHandler); afterEach(afterHandler); - it("should run mixin in component", () => { + it("should run function in component", () => { // Should import successfully - expect(FormCreateMixin).toBeTruthy(); + expect(createForm).toBeTruthy(); // Should have form data key from mixin options expect(wrapper.vm).toHaveProperty(formName); }); - it("should populate fields from mixin options", () => { - // Should have field values from mixin options + it("should populate fields from function options", () => { + // Should have field values from function options expect(wrapper.vm[formName].fields).toEqual(fields); - // Should have matching initial valuse + // Should have matching initial values expect(wrapper.vm[formName]._initial).toEqual(fields); }); @@ -147,15 +150,18 @@ describe("Form Create Mixin", () => { }); describe("should use optional mixin options", () => { - let wrapperStatic = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let wrapperStatic: Wrapper; beforeEach(() => { const options = { calculateChanged: false, flags: { locked: true } }; wrapperStatic = shallowMount(Component, { - mixins: [ - // NOTE: Must spread setup values to avoid mutating by reference! - FormCreateMixin(formName, { ...fields }, options), - ], + // NOTE: Must spread setup values to avoid mutating by reference! + data() { + return { + [formName]: createForm({ ...fields }, options), + }; + }, }); }); afterEach(() => { @@ -178,43 +184,3 @@ describe("Form Create Mixin", () => { }); }); }); - -describe("Form Create Function", () => { - let wrapper = null; - - const formName = "form"; - const fields = { - email: "test@example.com", - password: "******", - }; - - // Setup the component with mixin before each test - const beforeHandler = () => { - const Component = Vue.component("testComponent", { - template: "
", - }); - - wrapper = shallowMount(Component, { - // Form function with initial fields and custom flags - // NOTE: Must spread setup values to avoid mutating by reference! - data() { - return { - [formName]: createForm({ ...fields }), - }; - }, - }); - }; - - beforeEach(beforeHandler); - afterEach(() => { - wrapper.destroy(); - }); - - // NOTE: Only this test is necessary, since the Mixin uses this function underneath! - it("should run mixin in component", () => { - // Should import successfully - expect(createForm).toBeTruthy(); - // Should have form data key from mixin options - expect(wrapper.vm).toHaveProperty(formName); - }); -}); diff --git a/test/formGuardMixin.test.ts b/test/formGuardMixin.test.ts new file mode 100644 index 0000000..3a0fbd6 --- /dev/null +++ b/test/formGuardMixin.test.ts @@ -0,0 +1,133 @@ +import Vue from "vue"; +import { shallowMount, Wrapper } from "@vue/test-utils"; + +// Utilities +import { createForm, FormGuardMixin } from "../src"; + +const nextFn = jest.fn(); +const Component = Vue.component("formComponent", { + template: "
", +}); + +/** + * Shallow mount a component with the mixin (and custom options) + * @param {string} formKey - Form key + * @return - Vue wrapper + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mountComponent = ( + formKey: string +): Wrapper & { [key: string]: any } => + shallowMount(Component, { + data() { + return { + // NOTE: Must spread setup values to avoid mutating by reference! + [formKey]: createForm({ ...fields }), + // @ts-ignore + formGuards: [this[formKey]], + }; + }, + mixins: [FormGuardMixin], + }); + +const formName = "form"; +const fields = { + email: "test@example.com", + password: "******", +}; +const fieldChanges = { + email: "noone@example.com", +}; + +const activeKey = "isFormGuardActive"; +const callbackKey = "formLeaveCallback"; + +describe("Form Leave Guard Mixin", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let wrapper: Wrapper; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let beforeRouteLeave: (to: any, from: any, next: () => void) => void; + + // Setup the component with mixin before each test + const beforeHandler = () => { + wrapper = mountComponent(formName); + // TODO: Possibly fix by adding 'beforeRouteLeave' to the vm options... + // @ts-ignore + ({ beforeRouteLeave } = wrapper.vm.$options); + }; + + const afterHandler = () => { + wrapper.destroy(); + nextFn.mockReset(); + }; + + beforeEach(beforeHandler); + afterEach(afterHandler); + + it("should run mixin in component", () => { + // Should import successfully + expect(FormGuardMixin).toBeTruthy(); + // Should have active key + expect(wrapper.vm[activeKey]).toBe(false); + // Should have callback key + expect(wrapper.vm[callbackKey]).toBeNull(); + }); + + it("should handle leaving clean form", () => { + beforeRouteLeave.call(wrapper.vm, "toObj", "fromObj", nextFn); + + // Should call "next" when leaving clean form + expect(nextFn).toHaveBeenCalled(); + // Should not set active key when leaving clean form + expect(wrapper.vm[activeKey]).toBe(false); + // Should not set callback handler when leaving clean form + expect(wrapper.vm[callbackKey]).toBeNull(); + }); + + // TODO: Tests likely fail because of "beforeRouteLeave" not getting triggered? + /*describe("should handle leaving dirty form", () => { + beforeEach(() => { + beforeHandler(); + + // Make changes to form (to trigger "changed" flag) + wrapper.vm[formName].setValues({ ...fieldChanges }); + beforeRouteLeave.call(wrapper.vm, "toObj", "fromObj", nextFn); + }); + afterEach(afterHandler); + + it("should prevent leaving dirty form", async () => { + // Should not call "next" when leaving dirty form + expect(nextFn).not.toHaveBeenCalled(); + // Should set "is leaving" active getter + expect(wrapper.vm[activeKey]).toBe(true); + // Should set form leave confirmation callback + expect(wrapper.vm[callbackKey]).not.toBeNull(); + + // Should remove callback after it is the setter is called (v-model issue) + wrapper.vm[activeKey] = false; + expect(wrapper.vm[callbackKey]).not.toBeNull(); + await Vue.nextTick(); + expect(wrapper.vm[callbackKey]).toBeNull(); + }); + + it("should remain on dirty form after cancellation", () => { + wrapper.vm[callbackKey](); + + // Should reset callback key when staying on dirty form + expect(wrapper.vm[callbackKey]).toBeNull(); + // Should not call "next" when staying on dirty form + expect(nextFn).not.toHaveBeenCalled(); + }); + + it("should leave dirty form after confirmation", () => { + wrapper.vm[callbackKey](true); + + // Should reset callback key when leaving dirty form + expect(wrapper.vm[callbackKey]).toBeNull(); + // Should call "next" when leaving dirty form + expect(nextFn).toHaveBeenCalled(); + // Should reset form state when leaving dirty form + expect(wrapper.vm[formName].getValues()).toEqual(fields); + }); + });*/ +}); diff --git a/test/formLeaveGuardMixin.test.ts b/test/formLeaveGuardMixin.test.ts deleted file mode 100644 index c40bb1b..0000000 --- a/test/formLeaveGuardMixin.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import Vue from "vue"; -import { shallowMount } from "@vue/test-utils"; - -// Utilities -import { FormCreateMixin, FormLeaveGuardMixin } from "../src"; - -const nextFn = jest.fn(); -const onPreventFn = jest.fn(); -const Component = Vue.component("formComponent", { - template: "
", -}); - -/** - * Shallow mount a component with the mixin (and custom options) - * @param {string} formKey - Form key - * @param {Object} mixinOptions - Mixin options - * @return - Vue wrapper - */ -const mountComponent = (formKey, mixinOptions = {}) => - shallowMount(Component, { - mixins: [ - // NOTE: Must spread setup values to avoid mutating by reference! - FormCreateMixin(formName, { ...fields }), - FormLeaveGuardMixin(formKey, { - activeKey, - callbackKey, - onPrevent: onPreventFn, - ...mixinOptions, - }), - ], - }); - -const formName = "form"; -const fields = { - email: "test@example.com", - password: "******", -}; -const fieldChanges = { - email: "noone@example.com", -}; - -const activeKey = "isLeavingForm"; -const callbackKey = "onFormLeave"; - -describe("Form Leave Guard Mixin", () => { - let wrapper = null; - let beforeRouteLeave = null; - - // Setup the component with mixin before each test - const beforeHandler = () => { - wrapper = mountComponent(formName); - ({ beforeRouteLeave } = wrapper.vm.$options); - }; - - const afterHandler = () => { - wrapper.destroy(); - nextFn.mockReset(); - onPreventFn.mockReset(); - }; - - beforeEach(beforeHandler); - afterEach(afterHandler); - - it("should run mixin in component", () => { - // Should import successfully - expect(FormLeaveGuardMixin).toBeTruthy(); - // Should have active key from mixin options - expect(wrapper.vm[activeKey]).toBe(false); - // Should have callback key from mixin options - expect(wrapper.vm[callbackKey]).toBeNull(); - }); - - it("should handle leaving clean form", () => { - beforeRouteLeave.call(wrapper.vm, "toObj", "fromObj", nextFn); - - // Should call "next" when leaving clean form - expect(nextFn).toHaveBeenCalled(); - // Should not set active key when leaving clean form - expect(wrapper.vm[activeKey]).toBe(false); - // Should not set callback handler when leaving clean form - expect(wrapper.vm[callbackKey]).toBeNull(); - // Should not call custom prevent handler when leaving clean form - expect(onPreventFn).not.toHaveBeenCalled(); - }); - - it("should handle leaving clean forms (multiple)", () => { - const wrapperMulti = mountComponent([formName]); - // TODO: Possibly fix by adding 'beforeRouteLeave' to the vm options... - // @ts-ignore - const beforeRouteLeaveMulti = wrapperMulti.vm.$options.beforeRouteLeave; - beforeRouteLeaveMulti.call(wrapperMulti.vm, "toObj", "fromObj", nextFn); - - // Should call "next" when leaving clean forms - expect(nextFn).toHaveBeenCalled(); - // Should not set active key when leaving clean forms - expect(wrapperMulti.vm[activeKey]).toBe(false); - // Should not set callback handler when leaving clean forms - expect(wrapperMulti.vm[callbackKey]).toBeNull(); - // Should not call custom prevent handler when leaving clean forms - expect(onPreventFn).not.toHaveBeenCalled(); - }); - - describe("should handle leaving dirty form", () => { - beforeEach(() => { - beforeHandler(); - - // Make changes to form (to trigger "changed" flag) - wrapper.vm[formName].setValues({ ...fieldChanges }); - beforeRouteLeave.call(wrapper.vm, "toObj", "fromObj", nextFn); - }); - afterEach(afterHandler); - - it("should prevent leaving dirty form", async () => { - // Should not call "next" when leaving dirty form - expect(nextFn).not.toHaveBeenCalled(); - // Should set "is leaving" active getter - expect(wrapper.vm[activeKey]).toBe(true); - // Should set form leave confirmation callback - expect(wrapper.vm[callbackKey]).not.toBeNull(); - - // Should remove callback after it is the setter is called (v-model issue) - wrapper.vm[activeKey] = false; - expect(wrapper.vm[callbackKey]).not.toBeNull(); - await Vue.nextTick(); - expect(wrapper.vm[callbackKey]).toBeNull(); - }); - - it("should remain on dirty form after cancellation", () => { - wrapper.vm[callbackKey](); - - // Should reset callback key when staying on dirty form - expect(wrapper.vm[callbackKey]).toBeNull(); - // Should not call "next" when staying on dirty form - expect(nextFn).not.toHaveBeenCalled(); - }); - - it("should leave dirty form after confirmation", () => { - wrapper.vm[callbackKey](true); - - // Should reset callback key when leaving dirty form - expect(wrapper.vm[callbackKey]).toBeNull(); - // Should call "next" when leaving dirty form - expect(nextFn).toHaveBeenCalled(); - // Should reset form state when leaving dirty form - expect(wrapper.vm[formName].getValues()).toEqual(fields); - }); - - it("should leave dirty forms (multiple) after confirmation", () => { - const wrapperMulti = mountComponent([formName]); - // TODO: Possibly fix by adding 'beforeRouteLeave' to the vm options... - // @ts-ignore - const beforeRouteLeaveMulti = wrapperMulti.vm.$options.beforeRouteLeave; - wrapperMulti.vm[formName].setValues({ ...fieldChanges }); - beforeRouteLeaveMulti.call(wrapperMulti.vm, "toObj", "fromObj", nextFn); - wrapperMulti.vm[callbackKey](true); - - // Should reset callback key when leaving dirty form - expect(wrapperMulti.vm[callbackKey]).toBeNull(); - // Should call "next" when leaving dirty form - expect(nextFn).toHaveBeenCalled(); - // Should reset form state when leaving dirty form - expect(wrapperMulti.vm[formName].getValues()).toEqual(fields); - }); - }); - - it("should prevent leaving dirty form (without callbacks) if specified", () => { - const wrapperPrevent = mountComponent(formName, { - onlyPrevent: true, - }); - // TODO: Possibly fix by adding 'beforeRouteLeave' to the vm options... - // @ts-ignore - const beforeRouteLeavePrevent = wrapperPrevent.vm.$options.beforeRouteLeave; - - wrapperPrevent.vm[formName].setValues({ ...fieldChanges }); - beforeRouteLeavePrevent.call(wrapperPrevent.vm, "toObj", "fromObj", nextFn); - - // Should not call "next" when only preventing leaving dirty form - expect(nextFn).not.toBeCalled(); - // Should not set callback when only preventing leaving dirty form - expect(wrapperPrevent.vm[callbackKey]).toBeNull(); - // Should call custom prevent handler when only preventing leaving dirty form - expect(onPreventFn).toBeCalled(); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 60c4eef..03cf605 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,10 +9,6 @@ "strict": true, }, "baseUrl": "./", - "paths": { - "@typings": ["src/types"], - "@typings/*": ["src/types/*"], - }, - "include": ["src/**/*.ts"], - "exclude": ["lib", "node_modules", "test"] + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["lib", "node_modules"] } From 324605ef0af4ffcc08436625c51e528c1246c0da Mon Sep 17 00:00:00 2001 From: Kendall Roth Date: Thu, 1 Oct 2020 00:51:03 -0400 Subject: [PATCH 5/7] Separate build TS config --- jest.config.js | 2 +- package.json | 4 ++-- tsconfig.build.json | 8 ++++++++ tsconfig.json | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 tsconfig.build.json diff --git a/jest.config.js b/jest.config.js index 1a515b6..95cbc0c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -69,7 +69,7 @@ module.exports = { // ], // An array of file extensions your modules use - // moduleFileExtensions: ["js", "json", "vue"], + moduleFileExtensions: ["js", "json", "ts"], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module // moduleNameMapper: {}, diff --git a/package.json b/package.json index fb04c86..22c8987 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "test:only": "jest --verbose", "test:watch": "npm run test:only -- --watch", "lint": "eslint src test", - "build": "cross-env NODE_ENV=\"production\" tsc", - "build:dev": "tsc --watch", + "build": "cross-env NODE_ENV=\"production\" tsc -p tsconfig.build.json", + "build:dev": "tsc -p tsconfig.build.json --watch", "prepublish": "npm run clean && npm run test && npm run build" }, "files": [ diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..1b4abd5 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + }, + "include": ["src/**/*.ts"], + "exclude": ["lib", "node_modules", "test/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 03cf605..1f01fde 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "noEmit": true, "target": "es5", "module": "commonjs", "declaration": true, From 68516aa064ac3af578496960e4c7365920877d8b Mon Sep 17 00:00:00 2001 From: Kendall Roth Date: Thu, 1 Oct 2020 23:38:36 -0400 Subject: [PATCH 6/7] Overhaul for additional typescript changes --- README.md | 4 ++++ babel.config.js | 37 ++++++++++++++----------------------- package.json | 18 +++++++++++++----- src/FormGuardMixin.ts | 9 +++++++-- tsconfig.build.json | 3 ++- tsconfig.dev.json | 7 +++++++ tsconfig.json | 4 +++- 7 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 tsconfig.dev.json diff --git a/README.md b/README.md index c3092e6..682d77c 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,10 @@ This project can be started and will automatically rebuild on file changes: npm run build:dev ``` +See [this link](https://www.typescriptlang.org/docs/handbook/babel-with-typescript.html) for information on using TypeScript with Babel. In summary, TypeScript is used for type checking but Babel is used for transpilation! + +> **NOTE:** Coverage tests are currently broken after the switch to TypeScript, and some had to be disabled! + ## Miscellaneous > Project boilerplate from: [`flexdinesh/npm-module-boilerplate`](https://github.com/flexdinesh/npm-module-boilerplate) diff --git a/babel.config.js b/babel.config.js index 55df36e..9256cf2 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,25 +1,16 @@ +const { BABEL_ENV } = process.env; + +const isProduction = BABEL_ENV === "production"; + module.exports = { - env: { - development: { - presets: ["@babel/preset-env"], - plugins: ["add-module-exports"], - }, - production: { - presets: ["@babel/preset-env", "minify"], - plugins: ["add-module-exports"], - }, - test: { - presets: [ - [ - "@babel/preset-env", - { - targets: { - node: "current", - }, - }, - ], - ], - plugins: ["add-module-exports"], - }, - }, + presets: [ + "@babel/preset-env", + "@babel/typescript", + isProduction && "minify", + ].filter(Boolean), + plugins: [ + ["@babel/plugin-proposal-decorators", { legacy: true }], + ["@babel/plugin-proposal-class-properties", { loose: true }], + "@babel/proposal-object-rest-spread", + ], }; diff --git a/package.json b/package.json index 22c8987..a0e49b4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "main": "./lib/index.js", "types": "./lib/index.d.ts", "scripts": { - "clean": "rimraf lib", "format": "prettier --write {src,test}/**/*.ts", "test": "npm run lint && npm run test:only", "test:cover": "npm run test:only -- --coverage", @@ -13,9 +12,14 @@ "test:only": "jest --verbose", "test:watch": "npm run test:only -- --watch", "lint": "eslint src test", - "build": "cross-env NODE_ENV=\"production\" tsc -p tsconfig.build.json", - "build:dev": "tsc -p tsconfig.build.json --watch", - "prepublish": "npm run clean && npm run test && npm run build" + "build": "npm run build:types && npm run build:js", + "build:clean": "rimraf lib", + "build:dev": "tsc -p tsconfig.dev.json --watch", + "build:js": "cross-env BABEL_ENV=production babel src --out-dir lib --extensions \".ts\" --source-maps inline", + "build:types": "tsc -p tsconfig.build.json", + "prepublish": "npm run build:clean && npm run test && npm run build", + "type-check": "tsc", + "type-check:watch": "npm run type-check -- --watch" }, "files": [ "lib", @@ -39,7 +43,11 @@ "devDependencies": { "@babel/cli": "^7.10.3", "@babel/core": "^7.10.3", + "@babel/plugin-proposal-class-properties": "^7.10.4", + "@babel/plugin-proposal-decorators": "^7.10.5", + "@babel/plugin-proposal-object-rest-spread": "^7.11.0", "@babel/preset-env": "^7.10.3", + "@babel/preset-typescript": "^7.10.4", "@babel/register": "^7.10.3", "@types/jest": "^26.0.14", "@typescript-eslint/eslint-plugin": "^4.3.0", @@ -47,7 +55,7 @@ "@vue/test-utils": "^1.0.3", "babel-eslint": "^10.0.1", "babel-jest": "^26.3.0", - "babel-plugin-add-module-exports": "^1.0.0", + "babel-plugin-add-module-exports": "^1.0.4", "babel-polyfill": "^6.26.0", "babel-preset-minify": "^0.5.0", "cross-env": "^7.0.2", diff --git a/src/FormGuardMixin.ts b/src/FormGuardMixin.ts index b357db5..bb9f116 100644 --- a/src/FormGuardMixin.ts +++ b/src/FormGuardMixin.ts @@ -8,8 +8,11 @@ import { Component, Vue } from "vue-property-decorator"; // Types import { Form } from "./createForm"; -@Component -export class FormGuardMixin extends Vue { +@Component({ + // NOTE: Necessary to avoid Vue component name issues with minified code! + name: "FormGuardMixin" +}) +class FormGuardMixin extends Vue { // TODO: Possibly configure via router 'props'? formLeaveCallback: ((leave: boolean) => void) | null = null; @@ -104,3 +107,5 @@ function resetForms(forms: Form[]): void { } /* eslint @typescript-eslint/no-use-before-define: off */ + +export { FormGuardMixin }; diff --git a/tsconfig.build.json b/tsconfig.build.json index 1b4abd5..0799f2f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, + "emitDeclarationOnly": true, + "isolatedModules": true, }, - "include": ["src/**/*.ts"], "exclude": ["lib", "node_modules", "test/**/*.ts"] } diff --git a/tsconfig.dev.json b/tsconfig.dev.json new file mode 100644 index 0000000..c79a603 --- /dev/null +++ b/tsconfig.dev.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + }, + "exclude": ["lib", "node_modules", "test/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 1f01fde..dd5d9a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,14 @@ { "compilerOptions": { "noEmit": true, - "target": "es5", + "target": "ES5", "module": "commonjs", + "moduleResolution": "node", "declaration": true, "experimentalDecorators": true, "esModuleInterop": true, "outDir": "./lib", + "allowSyntheticDefaultImports": true, "strict": true, }, "baseUrl": "./", From 27554756fd3bb4542e45e10ab9ed249ecbb2396d Mon Sep 17 00:00:00 2001 From: Kendall Roth Date: Thu, 1 Oct 2020 23:39:31 -0400 Subject: [PATCH 7/7] Update package version --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34574e3..ba08980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + +## [0.3.0] - 2020-10-01 ### Removed - Removed the `FormCreateMixin` (replaced with typed `createForm` function) - _The `FormCreaetMixin` (not truly a mixin...) did not work with TypeScript_ diff --git a/package.json b/package.json index a0e49b4..a26c3a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kendallroth/vue-simple-forms", - "version": "0.2.3", + "version": "0.3.0", "description": "Simple Vue form state management library", "main": "./lib/index.js", "types": "./lib/index.d.ts",