diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab5d616..a6648770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Added + +- Add [`onFocus`](https://www.draftail.org/docs/next/api#managing-focus) and [`onBlur`](https://www.draftail.org/docs/next/api#managing-focus) props to use callbacks on those events. This can be useful for [form validation](https://www.draftail.org/docs/next/form-validation). [#170](https://github.com/springload/draftail/issues/170), [#174](https://github.com/springload/draftail/pull/174), thanks to [@TheSpicyMeatball](https://github.com/TheSpicyMeatball). + ### Fixed - Stop unnecessarily calling `onSave` in the editor’s `onBlur` ([#173](https://github.com/springload/draftail/issues/173)). diff --git a/examples/docs.story.js b/examples/docs.story.js index 245fcb32..0a06c4d2 100644 --- a/examples/docs.story.js +++ b/examples/docs.story.js @@ -3,8 +3,9 @@ import React from "react"; import { injectIntl } from "react-intl"; import { convertFromHTML, convertToHTML } from "draft-convert"; import { convertToRaw, convertFromRaw } from "draft-js"; +import { Formik } from "formik"; -import { INLINE_STYLE, ENTITY_TYPE, BLOCK_TYPE } from "../lib"; +import { DraftailEditor, INLINE_STYLE, ENTITY_TYPE, BLOCK_TYPE } from "../lib"; import { INLINE_CONTROL, @@ -368,4 +369,46 @@ storiesOf("Docs", module) entityTypes={[ENTITY_CONTROL.LINK, ENTITY_CONTROL.IMAGE]} /> ); - }); + }) + .add("Form validation", () => ( + { + const errors = {}; + + if (!values.content) { + errors.content = "Please enter at least two paragraphs"; + } else { + const { blocks, entityMap } = values.content; + if (Object.keys(entityMap).length === 0) { + errors.content = "Please use at least one link"; + } + + // Check there are at least two blocks with non-whitespace text + if (blocks.filter((b) => b.text.trim().length > 0).length < 2) { + errors.content = "Please enter at least two paragraphs"; + } + } + + return errors; + }} + > + {({ errors, touched, handleSubmit, setFieldTouched, setFieldValue }) => ( +
+
+ +
+ {errors.content && touched.content && errors.content} +
+
+ +
+ )} +
+ )); diff --git a/examples/utils/_forms.scss b/examples/utils/_forms.scss index fd025e0a..0af44f4d 100644 --- a/examples/utils/_forms.scss +++ b/examples/utils/_forms.scss @@ -1,3 +1,5 @@ +$color-error: #dc143c; + fieldset { padding: 0; margin: 0; @@ -60,3 +62,8 @@ fieldset { display: block; margin-bottom: 1rem; } + +.form-field [role="alert"] { + color: $color-error; + min-height: 18px; +} diff --git a/lib/components/DraftailEditor.js b/lib/components/DraftailEditor.js index 347daaca..e8e06bcd 100644 --- a/lib/components/DraftailEditor.js +++ b/lib/components/DraftailEditor.js @@ -108,12 +108,24 @@ class DraftailEditor extends Component { this.setState({ hasFocus: true, }); + + const { onFocus } = this.props; + + if (onFocus) { + onFocus(); + } } onBlur() { this.setState({ hasFocus: false, }); + + const { onBlur } = this.props; + + if (onBlur) { + onBlur(); + } } onTab(event) { @@ -647,6 +659,10 @@ DraftailEditor.defaultProps = { rawContentState: null, // Called when changes occured. Use this to persist editor content. onSave: () => {}, + // Called when the editor receives focus. + onFocus: null, + // Called when the editor loses focus. + onBlur: null, // Displayed when the editor is empty. Hidden if the user changes styling. placeholder: null, // Enable the use of horizontal rules in the editor. @@ -698,6 +714,8 @@ DraftailEditor.defaultProps = { DraftailEditor.propTypes = { rawContentState: PropTypes.object, onSave: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, placeholder: PropTypes.string, enableHorizontalRule: PropTypes.oneOfType([ PropTypes.bool, diff --git a/lib/components/DraftailEditor.test.js b/lib/components/DraftailEditor.test.js index 68d8cff9..7069b69c 100644 --- a/lib/components/DraftailEditor.test.js +++ b/lib/components/DraftailEditor.test.js @@ -438,6 +438,24 @@ describe("DraftailEditor", () => { expect(onSave).not.toHaveBeenCalled(); }); + it("#onFocus", () => { + const onFocus = jest.fn(); + const wrapper = shallowNoLifecycle(); + + wrapper.instance().onFocus(); + + expect(onFocus).toHaveBeenCalled(); + }); + + it("#onBlur", () => { + const onBlur = jest.fn(); + const wrapper = shallowNoLifecycle(); + + wrapper.instance().onBlur(); + + expect(onBlur).toHaveBeenCalled(); + }); + it("onTab", () => { jest.spyOn(RichUtils, "onTab"); diff --git a/package-lock.json b/package-lock.json index c01c9a52..3e22087c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4985,6 +4985,16 @@ "object-assign": "^4.1.1" } }, + "create-react-context": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.3.tgz", + "integrity": "sha512-CQBmD0+QGgTaxDL3OX1IDXYqjkp2It4RIbcb99jS6AEg27Ga+a9G3JtK6SIu0HBwPLZlmwt9F7UwWA4Bn92Rag==", + "dev": true, + "requires": { + "fbjs": "^0.8.0", + "gud": "^1.0.0" + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -5511,6 +5521,12 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "dev": true + }, "default-require-extensions": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-1.0.0.tgz", @@ -7915,6 +7931,41 @@ "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", "dev": true }, + "formik": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/formik/-/formik-1.4.1.tgz", + "integrity": "sha512-1pjcg65Pn4fuOgQv4cQOn9wDjCQ6f2J1QONDQaP4GfaiRYN/pQx2xtoyGo9ibNr/zR/cmayr1ew7EFaeAPLvsA==", + "dev": true, + "requires": { + "create-react-context": "^0.2.2", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^2.5.5", + "lodash": "^4.17.11", + "lodash-es": "^4.17.11", + "prop-types": "^15.6.1", + "react-fast-compare": "^2.0.1", + "tslib": "^1.9.3", + "warning": "^3.0.0" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==", + "dev": true + }, + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "dev": true, + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } + } + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -8759,6 +8810,12 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, + "gud": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", + "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==", + "dev": true + }, "gzip-size": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.0.0.tgz", @@ -11477,6 +11534,12 @@ "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", "dev": true }, + "lodash-es": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.11.tgz", + "integrity": "sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q==", + "dev": true + }, "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -15270,6 +15333,12 @@ "integrity": "sha512-akMy/BQT5m1J3iJIHkSb4qycq2wzllWsmmolaaFVnb+LPV9cIJ/nTud40ZsiiT0H3P+/wXIdbjx2fzF61OaeOQ==", "dev": true }, + "react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", + "dev": true + }, "react-fuzzy": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/react-fuzzy/-/react-fuzzy-0.5.2.tgz", diff --git a/package.json b/package.json index d100b71e..0b011af4 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "eslint": "^5.6.0", "eslint-plugin-compat": "^2.5.1", "express": "^4.16.3", + "formik": "^1.4.1", "immutable": "~3.7.4", "jest": "^23.6.0", "jest-axe": "^3.1.0", diff --git a/tests/integration/performance.test.js b/tests/integration/performance.test.js index e531f9e8..f6ad6cde 100644 --- a/tests/integration/performance.test.js +++ b/tests/integration/performance.test.js @@ -1,3 +1,5 @@ +const PERFORMANCE_BUFFER = 1.5; + describe("performance", () => { let page; beforeEach(async () => { @@ -33,7 +35,7 @@ describe("performance", () => { () => window.performance.memory.usedJSHeapSize, ); const heapSizeMB = heapSize / 10 ** 6; - expect(heapSizeMB).toBeLessThanOrEqual(19); + expect(heapSizeMB).toBeLessThanOrEqual(19 * PERFORMANCE_BUFFER); }); it("markov_draftjs 41 speed", async () => { @@ -46,6 +48,6 @@ describe("performance", () => { const mean = await page.$eval('[data-benchmark="mean"]', (elt) => parseFloat(elt.innerHTML), ); - expect(mean).toBeLessThanOrEqual(47 * 1.5); + expect(mean).toBeLessThanOrEqual(47 * PERFORMANCE_BUFFER); }); });