From 2383f4288c6115669cd8d06367cb65a196e38ace Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Sat, 13 Apr 2019 20:58:07 +0300 Subject: [PATCH] implements #4 --- frontend/jest.config.js | 11 ++- frontend/package-lock.json | 32 +++--- frontend/package.json | 10 +- frontend/src/Documents/DocumentEdit.tsx | 45 ++++++++- frontend/src/Documents/DocumentView.tsx | 2 +- .../src/Documents/tests/DocumentEdit.test.tsx | 99 +++++++++++++++++++ frontend/src/fileMock.ts | 2 + frontend/src/styleMock.ts | 1 + package-lock.json | 59 +++++++---- package.json | 8 +- 10 files changed, 219 insertions(+), 50 deletions(-) create mode 100644 frontend/src/Documents/tests/DocumentEdit.test.tsx create mode 100644 frontend/src/fileMock.ts create mode 100644 frontend/src/styleMock.ts diff --git a/frontend/jest.config.js b/frontend/jest.config.js index d4f62d1..653ebd6 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -3,8 +3,13 @@ const { compilerOptions } = require("./tsconfig"); module.exports = { preset: "ts-jest", - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { - prefix: "/", - }), + moduleNameMapper: { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/src/fileMock.ts", + "\\.(css|less|scss)$": "/src/styleMock.ts", + ...pathsToModuleNameMapper(compilerOptions.paths, { + prefix: "/", + }), + }, setupFilesAfterEnv: ["/src/setupTests.ts"], }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11933a4..e83244b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -829,11 +829,11 @@ } }, "@blueprintjs/core": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.15.0.tgz", - "integrity": "sha512-znXO0UaWBJO7Nm2qEvZzURCDuogsJ9DJlIw3N2XnIi+k/c8mQ1xlvtg0TT2pS88lMG9HUEKky3b0UQaSXBw4LA==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.15.1.tgz", + "integrity": "sha512-M8ltbqqlMZuZ6SEuqo/3Fr59ZcUfd8Er7ocbm7EACVfRW7dRhOCd/TKkf2kfICNtCDwznwXk0iAePLXZhUGtQg==", "requires": { - "@blueprintjs/icons": "^3.7.0", + "@blueprintjs/icons": "^3.8.0", "@types/dom4": "^2.0.1", "classnames": "^2.2", "dom4": "^2.0.1", @@ -846,9 +846,9 @@ } }, "@blueprintjs/icons": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.7.0.tgz", - "integrity": "sha512-608lQx1okOo85LNp7LyvsXWlgJjoQAiu29wULcktGuAuMmVXgueaGbMEDpuEMbu+uRlg9yqDhmk1eWm7W7R5ag==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.8.0.tgz", + "integrity": "sha512-yHaRQ3vfV9Gf3foZ4ONtxddz+u5ufkHqHj8Ia5VhPbFgG4el+cPdmsGGIIM72rgKS1KQa5Ay+ggjpByUlXvrKg==", "requires": { "classnames": "^2.2", "tslib": "^1.9.0" @@ -1347,9 +1347,9 @@ } }, "@types/react-dom": { - "version": "16.8.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.8.3.tgz", - "integrity": "sha512-HF5hD5YR3z9Mn6kXcW1VKe4AQ04ZlZj1EdLBae61hzQ3eEWWxMgNLUbIxeZp40BnSxqY1eAYLsH9QopQcxzScA==", + "version": "16.8.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.8.4.tgz", + "integrity": "sha512-eIRpEW73DCzPIMaNBDP5pPIpK1KXyZwNgfxiVagb5iGiz6da+9A5hslSX6GAQKdO7SayVCS/Fr2kjqprgAvkfA==", "dev": true, "requires": { "@types/react": "*" @@ -1377,9 +1377,9 @@ } }, "@types/react-router-dom": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-4.3.1.tgz", - "integrity": "sha512-GbztJAScOmQ/7RsQfO4cd55RuH1W4g6V1gDW3j4riLlt+8yxYLqqsiMzmyuXBLzdFmDtX/uU2Bpcm0cmudv44A==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-4.3.2.tgz", + "integrity": "sha512-biesHodFxPgDxku2m08XwPeAfUYBcxAnrQG7pwFikuA3L2e3u2OKAb+Sb16bJuU3L5CTHd+Ivap+ke4mmGsHqQ==", "dev": true, "requires": { "@types/history": "*", @@ -9125,9 +9125,9 @@ } }, "react-redux": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.0.1.tgz", - "integrity": "sha512-orSiI/QXtGiiJmf8lN/zVTx4hysFo/kGOsce28IUu/mu98AGemBwPTDzf64P4Vf/miRmevO8/w2RSw2awDd21w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.0.2.tgz", + "integrity": "sha512-uKRuMgQt8dWbcz0U75oFK5tDo3boyAKrqvf/j94vpqRFFZfyDDy4kofUgloFIGyuKTq2Zz51zgK9RzOTFXk5ew==", "requires": { "@babel/runtime": "^7.4.3", "hoist-non-react-statics": "^3.3.0", diff --git a/frontend/package.json b/frontend/package.json index 103cdee..2888bae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,10 +13,10 @@ "@types/node-sass": "^4.11.0", "@types/parcel-bundler": "^1.12.0", "@types/react": "^16.8.13", - "@types/react-dom": "^16.8.3", + "@types/react-dom": "^16.8.4", "@types/react-redux": "^7.0.6", "@types/react-router": "^4.4.5", - "@types/react-router-dom": "^4.3.1", + "@types/react-router-dom": "^4.3.2", "autoprefixer": "^9.5.1", "enzyme": "^3.9.0", "enzyme-adapter-react-16": "^1.12.1", @@ -28,14 +28,14 @@ "ts-jest": "^24.0.2" }, "dependencies": { - "@blueprintjs/core": "^3.15.0", - "@blueprintjs/icons": "^3.7.0", + "@blueprintjs/core": "^3.15.1", + "@blueprintjs/icons": "^3.8.0", "http2": "^3.3.7", "rea": "0.0.1", "react": "^16.8.6", "react-dom": "^16.8.6", "react-markdown": "^4.0.6", - "react-redux": "^7.0.1", + "react-redux": "^7.0.2", "react-router": "^5.0.0", "react-router-dom": "^5.0.0", "react-spring": "^8.0.19", diff --git a/frontend/src/Documents/DocumentEdit.tsx b/frontend/src/Documents/DocumentEdit.tsx index 6478e0d..7874811 100644 --- a/frontend/src/Documents/DocumentEdit.tsx +++ b/frontend/src/Documents/DocumentEdit.tsx @@ -17,7 +17,7 @@ import { import { IAppState } from "~redux/reducers"; export interface IDocumentEditComponentProps extends RouteComponentProps { - allDocs: IDocumentJSON[]; + allDocs: { [key: number]: IDocumentJSON }; fetching: boolean; spinner: boolean; @@ -34,6 +34,11 @@ export interface IDocumentEditComponentState { id: number | null; name: string | null; content: string | null; + + savedName: string | null; + savedContent: string | null; + + dirty: boolean; } const defaultDocumentEditComponentState: IDocumentEditComponentState = { @@ -42,6 +47,11 @@ const defaultDocumentEditComponentState: IDocumentEditComponentState = { id: null, name: null, content: null, + + savedName: null, + savedContent: null, + + dirty: false, }; export class DocumentEditComponent extends React.PureComponent< @@ -53,6 +63,7 @@ export class DocumentEditComponent extends React.PureComponent< this.state = defaultDocumentEditComponentState; this.handleInputChange = this.handleInputChange.bind(this); + this.onUnload = this.onUnload.bind(this); } public render() { @@ -117,6 +128,11 @@ export class DocumentEditComponent extends React.PureComponent< this.state.name, this.state.content, ); + this.setState({ + savedName: this.state.name, + savedContent: this.state.content, + dirty: false, + } as any); } public handleInputChange( @@ -128,8 +144,21 @@ export class DocumentEditComponent extends React.PureComponent< const value = target.value; const name = target.name; + const { savedName, savedContent } = this.state; + + const updDoc: { [key: string]: string } = { + name: this.state.name, + content: this.state.content, + }; + + updDoc[name] = value; + + const dirty = + savedName !== updDoc.name || savedContent !== updDoc.content; + this.setState({ [name]: value, + dirty, } as any); } @@ -139,6 +168,18 @@ export class DocumentEditComponent extends React.PureComponent< public componentDidMount() { this.tryLoad(); + window.addEventListener("beforeunload", this.onUnload); + } + + public componentWillUnmount() { + window.removeEventListener("beforeunload", this.onUnload); + } + + public onUnload(e: BeforeUnloadEvent) { + if (this.state.dirty) { + e.preventDefault(); + e.returnValue = ""; + } } private tryLoad() { @@ -159,6 +200,8 @@ export class DocumentEditComponent extends React.PureComponent< id: doc.id, name: doc.name, content: doc.content, + savedContent: doc.content, + savedName: doc.name, }); } } diff --git a/frontend/src/Documents/DocumentView.tsx b/frontend/src/Documents/DocumentView.tsx index b9b7331..5d18079 100644 --- a/frontend/src/Documents/DocumentView.tsx +++ b/frontend/src/Documents/DocumentView.tsx @@ -12,7 +12,7 @@ import { fetchDocsStart } from "~redux/docs/actions"; import { IAppState } from "~redux/reducers"; export interface IDocumentViewComponentProps extends RouteComponentProps { - allDocs: IDocumentJSON[]; + allDocs: { [key: number]: IDocumentJSON }; fetching: boolean; spinner: boolean; diff --git a/frontend/src/Documents/tests/DocumentEdit.test.tsx b/frontend/src/Documents/tests/DocumentEdit.test.tsx new file mode 100644 index 0000000..a14dae5 --- /dev/null +++ b/frontend/src/Documents/tests/DocumentEdit.test.tsx @@ -0,0 +1,99 @@ +import { mount } from "enzyme"; +import * as React from "react"; + +import { DocumentEditComponent } from "../DocumentEdit"; + +const mock: any = jest.fn(); + +const onUnloadSpy = jest.spyOn(DocumentEditComponent.prototype, "onUnload"); + +const testDoc = { + 1: { + name: "not changed", + content: "not changed", + id: 1, + user: 1, + createdAt: 0, + editedAt: 0, + }, +}; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("", () => { + it("should warn before exiting with unsaved changes", () => { + // https://medium.com/@DavideRama/testing-global-event-listener-within-a-react-component-b9d661e59953 + const map: { [key: string]: any } = {}; + window.addEventListener = jest.fn((event, cb) => { + map[event] = cb; + }); + + const wrapper = mount( + , + ); + + const content = wrapper.find("textarea"); + expect(content).toHaveLength(1); + (content.instance() as any).value = "changed"; + + content.simulate("change"); + + const preventDefault = jest.fn(); + const event = { preventDefault }; + map.beforeunload(event); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(onUnloadSpy).toHaveBeenCalledTimes(1); + }); + + it("shouldn't warn before exiting with no changes", () => { + const map: { [key: string]: any } = {}; + window.addEventListener = jest.fn((event, cb) => { + map[event] = cb; + }); + + const wrapper = mount( + , + ); + + const content = wrapper.find("textarea"); + expect(content).toHaveLength(1); + + (content.instance() as any).value = "changed"; + content.simulate("change"); + + (content.instance() as any).value = "not changed"; + content.simulate("change"); + + const preventDefault = jest.fn(); + const event = { preventDefault }; + map.beforeunload(event); + + expect(onUnloadSpy).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(0); + }); +}); diff --git a/frontend/src/fileMock.ts b/frontend/src/fileMock.ts new file mode 100644 index 0000000..57a4949 --- /dev/null +++ b/frontend/src/fileMock.ts @@ -0,0 +1,2 @@ +module.exports = "test-file-stub"; + diff --git a/frontend/src/styleMock.ts b/frontend/src/styleMock.ts new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/frontend/src/styleMock.ts @@ -0,0 +1 @@ +module.exports = {}; diff --git a/package-lock.json b/package-lock.json index 21674de..3f7338f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1569,11 +1569,12 @@ } }, "eslint-plugin-import": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.16.0.tgz", - "integrity": "sha512-z6oqWlf1x5GkHIFgrSvtmudnqM6Q60KM4KvpWi5ubonMjycLjndvd5+8VAZIsTlHC03djdgJuyKG6XO577px6A==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.17.0.tgz", + "integrity": "sha512-JCsOtNwPYUoeZPlSr8t0+uCU5OVlHh+dIBn8Rw7FiOPjCECG+QzDIKDqshbyJE6CYoj9wpcstEl8vUY7rXkqVA==", "dev": true, "requires": { + "array-includes": "^3.0.3", "contains-path": "^0.1.0", "debug": "^2.6.9", "doctrine": "1.5.0", @@ -1583,7 +1584,7 @@ "lodash": "^4.17.11", "minimatch": "^3.0.4", "read-pkg-up": "^2.0.0", - "resolve": "^1.9.0" + "resolve": "^1.10.0" }, "dependencies": { "debug": { @@ -1597,7 +1598,7 @@ }, "doctrine": { "version": "1.5.0", - "resolved": "http://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", "integrity": "sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=", "dev": true, "requires": { @@ -1616,7 +1617,7 @@ }, "load-json-file": { "version": "2.0.0", - "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", "dev": true, "requires": { @@ -1662,6 +1663,15 @@ "read-pkg": "^2.0.0" } }, + "resolve": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.10.0.tgz", + "integrity": "sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, "strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -3104,9 +3114,9 @@ } }, "mocha": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.1.2.tgz", - "integrity": "sha512-BgD2/RozoSC3uQK5R0isDcxjqaWw2n5HWdk8njYUyZf2NC79ErO5FtYVX52+rfqGoEgMfJf4fuG0IWh2TMzFoA==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.1.3.tgz", + "integrity": "sha512-QdE/w//EPHrqgT5PNRUjRVHy6IJAzAf1R8n2O8W8K2RZ+NbPfOD5cBDp+PGa2Gptep37C/TdBiaNwakppEzEbg==", "dev": true, "requires": { "ansi-colors": "3.2.3", @@ -3123,7 +3133,7 @@ "minimatch": "3.0.4", "mkdirp": "0.5.1", "ms": "2.1.1", - "node-environment-flags": "1.0.4", + "node-environment-flags": "1.0.5", "object.assign": "4.1.0", "strip-json-comments": "2.0.1", "supports-color": "6.0.0", @@ -3308,12 +3318,21 @@ } }, "node-environment-flags": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.4.tgz", - "integrity": "sha512-M9rwCnWVLW7PX+NUWe3ejEdiLYinRpsEre9hMkU/6NS4h+EEulYaDH1gCEZ2gyXsmw+RXYDaV2JkkTNcsPDJ0Q==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.5.tgz", + "integrity": "sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ==", "dev": true, "requires": { - "object.getownpropertydescriptors": "^2.0.3" + "object.getownpropertydescriptors": "^2.0.3", + "semver": "^5.7.0" + }, + "dependencies": { + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } } }, "node-notifier": { @@ -3739,9 +3758,9 @@ "dev": true }, "prettier": { - "version": "1.16.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.16.4.tgz", - "integrity": "sha512-ZzWuos7TI5CKUeQAtFd6Zhm2s6EpAD/ZLApIhsF9pRvRtM1RFo61dM/4MSRUA0SuLugA/zgrZD8m0BaY46Og7g==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.17.0.tgz", + "integrity": "sha512-sXe5lSt2WQlCbydGETgfm1YBShgOX4HxQkFPvbxkcwgDvGDeqVau8h+12+lmSVlP3rHPz0oavfddSZg/q+Szjw==", "dev": true }, "prettier-linter-helpers": { @@ -4565,9 +4584,9 @@ } }, "typescript": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.2.tgz", - "integrity": "sha512-Og2Vn6Mk7JAuWA1hQdDQN/Ekm/SchX80VzLhjKN9ETYrIepBFAd8PkOdOTK2nKt0FCkmMZKBJvQ1dV1gIxPu/A==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.3.tgz", + "integrity": "sha512-FFgHdPt4T/duxx6Ndf7hwgMZZjZpB+U0nMNGVCYPq0rEzWKjEDobm4J6yb3CS7naZ0yURFqdw9Gwc7UOh/P9oQ==", "dev": true }, "unpipe": { diff --git a/package.json b/package.json index a73eb3d..dd7a99a 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,12 @@ "eslint": "^5.16.0", "eslint-config-airbnb": "^17.1.0", "eslint-config-prettier": "^4.1.0", - "eslint-plugin-import": "^2.16.0", + "eslint-plugin-import": "^2.17.0", "eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-prettier": "^3.0.1", "eslint-plugin-react": "^7.12.4", - "mocha": "^6.1.2", - "prettier": "^1.16.4", + "mocha": "^6.1.3", + "prettier": "^1.17.0", "supertest": "^4.0.2", "ts-node": "8.0.3", "ts-node-dev": "^1.0.0-pre.32", @@ -37,7 +37,7 @@ "tslint-config-prettier": "^1.18.0", "tslint-no-unused-expression-chai": "^0.1.4", "tslint-plugin-prettier": "^2.0.1", - "typescript": "3.4.2" + "typescript": "3.4.3" }, "dependencies": { "@koa/cors": "^3.0.0",