diff --git a/README.md b/README.md index 1148e256..045277fd 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,31 @@ hamus.js is a collection of React hooks for sharing reusable functionality used when dealing with Qlik Associative Engine projects. +- [`useModel`](./docs/useModel.md) — creates a session object from a definition. +- [`useLayout`](./docs/useLayout.md) — fetches the layout from a model, and updates the layout on model changes. +- [`usePicasso`](./docs/usePicasso.md) — renders a [picasso.js](https://github.com/qlik-oss/picasso.js) visualization to an element. + # Usage -You need to have React 16.8.1 or later installed to use React Hooks API. Some of the hooks requires other packages as well, -see each hook individually below. +You need to have React 16.8.1 or later installed to use the React Hooks API. Some of the hooks requires other packages as well, +see each hook individually above. This module uses ES6 import syntax, which means that you need Babel to transpile the code. -You can import each hook individually, e.g `import { useModel } from 'hamus.js'`. +You can import each hook individually, e.g `import useModel from 'hamus.js/src/use-model'`, -- [`useModel`](./docs/useModel.md) — creates a session object from a definition. -- [`useLayout`](./docs/useLayout.md) — fetches the layout from a model, and updates the layout on model changes. -- [`usePicasso`](./docs/usePicasso.md) — renders a [picasso.js](https://github.com/qlik-oss/picasso.js) visualization to an element. +or you can use ES6 named imports, e.g. `import { useModel } from 'hamus.js'`. + +When using ES6 named imports you might run into missing dependency errors from hooks that you don't actually use in your project. +To resolve these errors, you either have to install the dependencies, or you can tranform the named import statements to individual +import statements with `[babel-plugin-import](https://github.com/ant-design/babel-plugin-import)`. The `.babelrc` file will look something +like this: + +``` + "plugins": [ + ["import", { "libraryName": "hamus.js", "libraryDirectory": "src"}] + ] + ``` # Example diff --git a/package-lock.json b/package-lock.json index 28eb0584..8594a7c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8649,27 +8649,39 @@ "dev": true }, "react": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", - "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", + "version": "16.9.0-alpha.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.9.0-alpha.0.tgz", + "integrity": "sha512-y4bu7rJvtnPPsIwOj7sp5Y2SqlOb0jFupfkdjWxxn8ZeqzUARgpR9wJBUVwW1/QosVdOblmApjo/j6iiAXnebA==", "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.13.6" + "scheduler": "^0.14.0-alpha.0" } }, "react-dom": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", - "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", + "version": "16.9.0-alpha.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.9.0-alpha.0.tgz", + "integrity": "sha512-BQ5gN42yIPuTnBvE6K9vSjNfDRpSNcYCs2sUx9XR5VaWKwlHTt3G6qIWK6zdXy8TYKb1+IxpsAI0RtbRdXQZ2A==", "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.13.6" + "scheduler": "^0.14.0-alpha.0" + }, + "dependencies": { + "scheduler": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.14.0.tgz", + "integrity": "sha512-9CgbS06Kki2f4R9FjLSITjZo5BZxPsryiRNyL3LpvrM9WxcVmhlqAOc9E+KQbeI2nqej4JIIbOsfdL51cNb4Iw==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + } } }, "react-hooks-testing-library": { @@ -8705,15 +8717,33 @@ "dev": true }, "react-test-renderer": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.6.tgz", - "integrity": "sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw==", + "version": "16.9.0-alpha.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.9.0-alpha.0.tgz", + "integrity": "sha512-eDl0oVFo6PGY1wpYFs0ezBpZhOgVce5TSta9UPLanshTi4z8NhlM6IgO8KBdioQ5H5/pmyGxOVtpUxJOt19NAQ==", "dev": true, "requires": { "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "react-is": "^16.8.6", - "scheduler": "^0.13.6" + "react-is": "^16.9.0-alpha.0", + "scheduler": "^0.14.0-alpha.0" + }, + "dependencies": { + "react-is": { + "version": "16.9.0-alpha.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0-alpha.0.tgz", + "integrity": "sha512-psl0ePLTFliYfwcbwvimLgTNN156ZdeWB4zvP7dV/6lTAqWMHFfidg/mSZ2fFgE1LMNN8ZJOLl2DfZ8yg+3ETA==", + "dev": true + }, + "scheduler": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.14.0.tgz", + "integrity": "sha512-9CgbS06Kki2f4R9FjLSITjZo5BZxPsryiRNyL3LpvrM9WxcVmhlqAOc9E+KQbeI2nqej4JIIbOsfdL51cNb4Iw==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + } } }, "react-testing-library": { @@ -9150,9 +9180,9 @@ "dev": true }, "scheduler": { - "version": "0.13.6", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", - "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.14.0.tgz", + "integrity": "sha512-9CgbS06Kki2f4R9FjLSITjZo5BZxPsryiRNyL3LpvrM9WxcVmhlqAOc9E+KQbeI2nqej4JIIbOsfdL51cNb4Iw==", "dev": true, "requires": { "loose-envify": "^1.1.0", diff --git a/package.json b/package.json index 5d1e0c9b..464a9612 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,11 @@ "react-use-promise": "0.2.0" }, "peerDependencies": { - "picasso.js": "0.25.1", - "picasso-plugin-q": "0.25.1", - "react": "16.8.6", - "react-dom": "16.8.6", - "enigma.js": "2.4.0" + "picasso.js": ">=0.14.0", + "picasso-plugin-q": ">=0.14.0", + "react": "^16.8.0", + "react-dom": "^16.8.0", + "enigma.js": "^2.0.0" }, "devDependencies": { "@babel/core": "7.4.4", @@ -53,19 +53,20 @@ "parcel-bundler": "1.12.3", "picasso.js": "0.25.1", "picasso-plugin-q": "0.25.1", - "react": "16.8.6", - "react-dom": "16.8.6", + "react": "16.9.0-alpha.0", + "react-dom": "16.9.0-alpha.0", "jest-puppeteer": "4.1.1", "coveralls": "3.0.3", "react-testing-library": "7.0.0", - "react-test-renderer": "16.8.6" + "react-test-renderer": "16.9.0-alpha.0" }, "jest": { "collectCoverage": true, "collectCoverageOnlyFrom": { "src/use-layout.js": true, "src/use-model.js": true, - "src/use-picasso.js": true + "src/use-picasso.js": true, + "src/render-debouncer.js": true } } } diff --git a/src/render-debouncer.js b/src/render-debouncer.js new file mode 100644 index 00000000..ba166242 --- /dev/null +++ b/src/render-debouncer.js @@ -0,0 +1,31 @@ +import ReactDOM from 'react-dom'; + +let timer = null; +let pendingStateMutators = []; +let startTime = null; +function fireAllPendingMutators() { + ReactDOM.unstable_batchedUpdates(() => { + pendingStateMutators.forEach(mutator => mutator()); + pendingStateMutators = []; + }); +} + +function debounce(stateMutator) { + pendingStateMutators.push(stateMutator); + if (timer != null) { + // Timer already pending + clearTimeout(timer); + } else { + // No timer pending, set start time + startTime = new Date().getTime(); + } + const now = new Date().getTime(); + const averageInterMutateInterval = (now - startTime) / pendingStateMutators.length; + const timerInterval = (averageInterMutateInterval < 32) ? averageInterMutateInterval * 3 + 4 : 100; // Never wait more than 100 ms + timer = setTimeout(() => { + fireAllPendingMutators(); + timer = null; + }, timerInterval); +} + +export default debounce; diff --git a/src/use-layout.js b/src/use-layout.js index 3700cd66..97d05a9c 100644 --- a/src/use-layout.js +++ b/src/use-layout.js @@ -1,28 +1,31 @@ import { useState, useEffect } from 'react'; -import usePromise from 'react-use-promise'; +import debounce from './render-debouncer'; export default function useLayout(model) { - const [changed, setChanged] = useState(null); let canceled = false; - let layout = null; - let error = null; - - [layout, error] = usePromise(() => { - if (!model || canceled) return null; - return model.getAppLayout ? model.getAppLayout() : model.getLayout(); - }, [model, changed]); + const [error, setError] = useState(null); + const [layout, setLayout] = useState(null); useEffect(() => { - if (!model) { - return undefined; - } - const modelChanged = () => { - setChanged(new Date()); + if (!model) return undefined; + const fetchModel = async () => { + try { + const newLayout = model.getAppLayout ? await model.getAppLayout() : await model.getLayout(); + if (!canceled) { + debounce(() => { + setLayout(newLayout); + }); + } + } catch (err) { + setError(err); + } }; - model.on('changed', modelChanged); + model.on('changed', fetchModel); + fetchModel(); + return () => { canceled = true; - model.removeListener('changed', modelChanged); + model.removeListener('changed', fetchModel); }; }, [model && model.id]); diff --git a/test/unit/use-layout.test.js b/test/unit/use-layout.test.js index 14e4ee54..8477e5b5 100644 --- a/test/unit/use-layout.test.js +++ b/test/unit/use-layout.test.js @@ -1,9 +1,7 @@ import { renderHook, act } from 'react-hooks-testing-library'; import { useLayout } from '../../src/index'; -import TestPromise from './test-promise'; describe('useLayout', () => { - let promise; let model; const mockLayout = { @@ -11,65 +9,62 @@ describe('useLayout', () => { }; beforeEach(() => { - promise = new TestPromise(); model = { id: 'myModel', on: jest.fn(), removeListener: jest.fn(), - getLayout: jest.fn(() => promise), + getLayout: () => jest.fn().mockReturnValue(mockLayout), }; }); - test('should return undefined when no model is present', () => { + test('should return null when no model is present', () => { const { result } = renderHook(() => useLayout(null)); const [layout, error] = result.current; - expect(layout).toBeUndefined(); - expect(error).toBeUndefined(); + expect(layout).toBeNull(); + expect(error).toBeNull(); }); - test('should return undefined on pending promise', () => { + test('should catch and return error', async () => { + const rejectedError = new Error('Error occurred'); + model.getLayout = () => { throw rejectedError; }; const { result } = renderHook(() => useLayout(model)); const [layout, error] = result.current; - expect(layout).toBeUndefined(); - expect(error).toBeUndefined(); - }); - - test('should throw error on rejected promise', () => { - const { result } = renderHook(() => useLayout(model)); - const rejectedError = 'Error occurred'; - act(() => promise.reject(rejectedError)); - const [layout, error] = result.current; - expect(layout).toBeUndefined(); + expect(layout).toBeNull(); expect(error).toEqual(rejectedError); }); - test('should return layout object', () => { - const { result } = renderHook(() => useLayout(model)); - act(() => promise.resolve(mockLayout)); + test('should return layout object', async () => { + const { result, waitForNextUpdate } = renderHook(() => useLayout(model)); + await act(async () => waitForNextUpdate()); const [layout, error] = result.current; expect(layout).toEqual(mockLayout); - expect(error).toBeUndefined(); + expect(error).toBeNull(); }); - test('should add listener for model changes', () => { + test('should add listener for model changes', async () => { + model.getLayout = jest.fn(); renderHook(() => useLayout(model)); - act(() => promise.resolve(mockLayout)); + expect(model.getLayout).toHaveBeenCalledTimes(1); expect(model.on).toHaveBeenCalledTimes(1); expect(model.on).toHaveBeenCalledWith('changed', expect.any(Function)); + // fake a changed event + const modelChangedHandler = model.on.mock.calls[0][1]; + modelChangedHandler(); + expect(model.getLayout).toHaveBeenCalledTimes(2); expect(model.removeListener).toHaveBeenCalledTimes(0); }); - test('should remove event listener for model changes on unmount', () => { - const { unmount } = renderHook(() => useLayout(model)); - act(() => promise.resolve(mockLayout)); + test('should remove event listener for model changes on unmount', async () => { + const { waitForNextUpdate, unmount } = renderHook(() => useLayout(model)); + await act(async () => waitForNextUpdate()); unmount(); expect(model.removeListener).toHaveBeenCalledTimes(1); expect(model.removeListener).toHaveBeenCalledWith('changed', expect.any(Function)); }); - test('side effect should run when model is updated', () => { - const { rerender } = renderHook(() => useLayout(model)); - act(() => promise.resolve(mockLayout)); + test('side effect should run when model is updated', async () => { + const { rerender, waitForNextUpdate } = renderHook(() => useLayout(model)); + await act(async () => waitForNextUpdate()); rerender(); expect(model.on).toHaveBeenCalledTimes(1); model.id = 'myNewId'; @@ -77,27 +72,27 @@ describe('useLayout', () => { expect(model.on).toHaveBeenCalledTimes(2); }); - test('if model object is a Doc, getAppLayout should be called', () => { + test('if model object is a Doc, getAppLayout should be called', async () => { model = { id: 'myApp', on: jest.fn(), removeListener: jest.fn(), - getAppLayout: jest.fn(() => promise), + getAppLayout: jest.fn().mockReturnValue(mockLayout), }; - const { result } = renderHook(() => useLayout(model)); - act(() => promise.resolve(mockLayout)); + const { result, waitForNextUpdate } = renderHook(() => useLayout(model)); + await act(async () => waitForNextUpdate()); const [layout, error] = result.current; expect(model.getAppLayout).toBeCalledTimes(1); expect(layout).toEqual(mockLayout); - expect(error).toBeUndefined(); + expect(error).toBeNull(); }); - test('layout should not be updated if component has been unmounted when promise resolves', () => { + test('layout should not be updated if component has been unmounted when promise resolves', async () => { + model.getLayout = jest.fn(); const { result, unmount } = renderHook(() => useLayout(model)); unmount(); - act(() => promise.resolve(mockLayout)); const [layout, error] = result.current; - expect(layout).toBeUndefined(); - expect(error).toBeUndefined(); + expect(layout).toBeNull(); + expect(error).toBeNull(); }); });