diff --git a/packages/dnb-design-system-portal/src/docs/uilib/helpers/functions.mdx b/packages/dnb-design-system-portal/src/docs/uilib/helpers/functions.mdx index a77d70bf739..5f3312de3ce 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/helpers/functions.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/helpers/functions.mdx @@ -246,7 +246,6 @@ const debounced = debounce( wait = 250, // milliseconds { immediate = false, // execute the debounceFunc on the leading end - instance = null // function or class instance for "this" } = {}, ) @@ -255,6 +254,35 @@ debounced({ foo: 'bar'}) // delay the execution – again debounced.cancel() // optional, cancel the execution ``` +Async example: + +```js +import { debounceAsync } from '@dnb/eufemia/shared/helpers' + +async function debounceFunc({ foo }) { + // optionally, add a cancel event (wasCanceled is a "function" to check later if it was canceled) + const wasCanceled = this.addCancelEvent(myCancelMethod) + + await wait(1000) // do something async +} + +const myCancelMethod = () => { + console.log('canceled') +} + +const debounced = debounceAsync( + debounceFunc, + (wait = 250), // milliseconds +) + +debounceAsync({ foo: 'bar' }) // delay the execution – again + +debounced.cancel() // optional, cancel the execution +debounced.addCancelEvent(myCancelMethod) // alternatively, you can add the cancel event like so +``` + +In order to use `this.addCancelEvent` you need to use a `function()`, and not an arrow function. + ### copyToClipboard Copies the given string to the device's clipboard. diff --git a/packages/dnb-eufemia/src/shared/__tests__/helpers.test.js b/packages/dnb-eufemia/src/shared/__tests__/helpers.test.js index 1e3bdc82fa1..9954204022d 100644 --- a/packages/dnb-eufemia/src/shared/__tests__/helpers.test.js +++ b/packages/dnb-eufemia/src/shared/__tests__/helpers.test.js @@ -15,7 +15,6 @@ import { hasSelectedText, getSelectedText, insertElementBeforeSelection, - debounce, isEdge, isiOS, isSafari, @@ -25,7 +24,7 @@ import { warn, } from '../helpers' -import { mockGetSelection, wait } from '../../core/jest/jestSetup' +import { mockGetSelection } from '../../core/jest/jestSetup' // make it possible to change the navigator lang // because "navigator.language" defaults to en-GB @@ -274,105 +273,6 @@ describe('selection related methods', () => { }) }) -describe('"debounce" should', () => { - it('delay execution', async () => { - let outside = 'one' - - const debounced = debounce(({ inside }) => { - outside = inside - expect(outside).toBe('two') - - return 'not accessible' - }, 1) - - await wait(2) - - const result = debounced({ inside: 'two' }) - - expect(typeof debounced).toBe('function') - expect(typeof debounced.cancel).toBe('function') - - expect(outside).toBe('one') - expect(result).toBe(undefined) - }) - - it('use given instance', async () => { - const instance = () => {} - instance.property = 'hello' - - const debounced = debounce( - // Needs to be a function (so we can use "this") - function () { - expect(this).toBe(instance) - expect(this.property).toBe(instance.property) - }, - 1, - { instance } - ) - - debounced() - }) - - it('execution immediate', async () => { - let outside = 'one' - - const debounced = debounce( - ({ inside }) => { - expect(outside).toBe('one') - outside = inside - expect(outside).toBe('two') - }, - 1, - { immediate: true } - ) - - debounced({ inside: 'two' }) - - await wait(2) - - expect(outside).toBe('two') - }) - - it('execution immediate and return result', async () => { - let outside = 'one' - - const debounced = debounce( - ({ inside }) => { - expect(outside).toBe('one') - outside = inside - expect(outside).toBe('two') - - return inside - }, - 1, - { immediate: true } - ) - - const immediateResult = debounced({ inside: 'two' }) - - await wait(2) - - expect(outside).toBe('two') - expect(immediateResult).toBe('two') - }) - - it('should not run debounced function when cancelled', async () => { - let outside = 'one' - - const debounced = debounce(({ inside }) => { - expect(outside).toBe('one') - outside = inside - expect(outside).toBe('two') - }, 1) - debounced({ inside: 'two' }) - debounced.cancel() - - await wait(2) - - expect(outside).toBe('one') - }) -}) - describe('"warn" should', () => { const log = global.console.log diff --git a/packages/dnb-eufemia/src/shared/helpers.js b/packages/dnb-eufemia/src/shared/helpers.js index 8e6b4268a90..1727133b8c2 100644 --- a/packages/dnb-eufemia/src/shared/helpers.js +++ b/packages/dnb-eufemia/src/shared/helpers.js @@ -1,10 +1,11 @@ /** * Global helpers * - * NB: Do not import other deps in this file. - * Just to have things clean and one directional. */ +// For backward compatibility +export { debounce, debounceAsync } from './helpers/debounce' + export const PLATFORM_MAC = 'Mac|iPad|iPhone|iPod' export const PLATFORM_WIN = 'Win' export const PLATFORM_ANDROID = 'Android' @@ -235,84 +236,6 @@ export function scrollToLocationHashId({ } } -/** - * More or less classical debounce function - * - * Calling this function will return a new function, that, as long as it continues to be invoked, will not - * be triggered. The function will be called after it stops being called for - * N milliseconds. If `immediate` is passed, trigger the function on the - * leading edge, instead of the trailing. - * - * @param {function} func The function to execute - * @param {number} wait The time (milliseconds) before the first given function executes, if the returned one, not got called - * @param {object} options The options object - * @property {boolean} immediate If true, the function will execute immediately. Defaults to false - * @property {instance} instance Defines the instance "this" to use on the executed function - * @returns The function to invalidate the execution - */ -export function debounce( - func, - wait = 250, - { immediate = false, instance = null } = {} -) { - let timeout - let recall - - const cancel = () => clearTimeout(timeout) - - // This is the function that is actually executed when - // the DOM event is triggered. - function executedFunction(...args) { - // Store the instance of this and any - // parameters passed to executedFunction - let inst = this - - // console.log('instance', instance) - - if (typeof recall === 'function') { - recall() - } - - // The function to be called after - // the debounce time has elapsed - const later = () => { - // null timeout to indicate the debounce ended - timeout = null - - // Call function now if you did not on the leading end - if (!immediate) { - recall = func.apply(instance || inst, args) - } - } - - // Determine if you should call the function - // on the leading or trail end - const callNow = immediate && !timeout - - // This will reset the waiting every function execution. - // This is the step that prevents the function from - // being executed because it will never reach the - // inside of the previous setTimeout - clearTimeout(timeout) - - // Restart the debounce waiting period. - // setTimeout returns a truthy value (it differs in web vs node) - timeout = setTimeout(later, wait) - - // Call immediately if you're dong a leading - // end execution - if (callNow) { - recall = func.apply(instance || inst, args) - } - - return recall - } - - executedFunction.cancel = cancel - - return executedFunction -} - export function insertElementBeforeSelection(elem) { try { const selection = window.getSelection() diff --git a/packages/dnb-eufemia/src/shared/helpers/__tests__/debounce.test.ts b/packages/dnb-eufemia/src/shared/helpers/__tests__/debounce.test.ts new file mode 100644 index 00000000000..7bcaae1bfa8 --- /dev/null +++ b/packages/dnb-eufemia/src/shared/helpers/__tests__/debounce.test.ts @@ -0,0 +1,255 @@ +import { wait } from '../../../core/jest/jestSetup' +import { debounce, debounceAsync } from '../debounce' + +const delay = 2 + +describe('debounce', () => { + it('delay execution', async () => { + let outside = 'one' + + const debounced = debounce(({ inside }) => { + outside = inside + }, delay) + + debounced({ inside: 'two' }) + + expect(outside).toBe('one') + + debounced({ inside: 'three' }) + + expect(outside).toBe('one') + + debounced({ inside: 'four' }) + + expect(outside).toBe('one') + + await wait(delay + 1) + + expect(outside).toBe('four') + }) + + it('handle return value', () => { + let outside = 'one' + + const debounced = debounce(({ inside }) => { + outside = inside + expect(outside).toBe('two') + + return 'not accessible' + }, delay) + + const result = debounced({ inside: 'two' }) + + expect(typeof debounced).toBe('function') + expect(typeof debounced.cancel).toBe('function') + + expect(outside).toBe('one') + expect(result).toBe(undefined) + }) + + it('use given instance', () => { + const instance = () => null + instance.property = 'hello' + + const debounced = debounce( + // Needs to be a function (so we can use "this") + function () { + expect(this).toBe(instance) + expect(this.property).toBe(instance.property) + }, + delay, + { instance } + ) + + debounced() + }) + + it('execution immediate', () => { + let outside = 'one' + + const debounced = debounce( + ({ inside }) => { + expect(outside).toBe('one') + outside = inside + expect(outside).toBe('two') + }, + delay, + { immediate: true } + ) + + debounced({ inside: 'two' }) + + expect(outside).toBe('two') + }) + + it('execution immediate and return result', () => { + let outside = 'one' + + const debounced = debounce( + ({ inside }) => { + expect(outside).toBe('one') + outside = inside + expect(outside).toBe('two') + + return inside + }, + delay, + { immediate: true } + ) + + const immediateResult = debounced({ inside: 'two' }) + + expect(outside).toBe('two') + expect(immediateResult).toBe('two') + }) + + it('should not run debounced function when cancelled', () => { + let outside = 'one' + + const debounced = debounce(({ inside }) => { + expect(outside).toBe('one') + outside = inside + expect(outside).toBe('two') + }, delay) + debounced({ inside: 'two' }) + debounced.cancel() + + expect(outside).toBe('one') + }) +}) + +describe('debounceAsync', () => { + it('delay async execution', async () => { + let outside = 'one' + + const debounced = debounceAsync(({ inside }) => { + outside = inside + }, delay) + + debounced({ inside: 'two' }) + + expect(outside).toBe('one') + + debounced({ inside: 'three' }) + + expect(outside).toBe('one') + + await debounced({ inside: 'four' }) + + expect(outside).toBe('four') + }) + + it('delay async execution with additional async debouncedFunction', async () => { + let outside = 'one' + + const debounced = debounceAsync(async ({ inside }) => { + await wait(delay + 1) + outside = inside + }, delay) + + debounced({ inside: 'two' }) + + expect(outside).toBe('one') + + debounced({ inside: 'three' }) + + expect(outside).toBe('one') + + await debounced({ inside: 'four' }) + + expect(outside).toBe('four') + }) + + it('execute async method once', async () => { + let count = 0 + + const debounced = debounceAsync(async () => { + count++ + }, delay) + + debounced() + debounced() + await debounced() + debounced() + + expect(count).toBe(1) + }) + + it('cancel async execution', async () => { + let outside = 'one' + + const debounced = debounceAsync(async ({ inside }) => { + outside = inside + }, delay) + + debounced({ inside: 'two' }) + + await wait(delay + 1) + + expect(outside).toBe('two') + + debounced({ inside: 'three' }) + + // If we don't cancel, we get "three" instead of "two" + debounced.cancel() + + await wait(delay + 1) + + expect(outside).toBe('two') + }) + + it('call "addCancelEvent" method on cancel', async () => { + const onCancel = jest.fn() + let wasCanceled = undefined + + const debounced = debounceAsync(async function () { + wasCanceled = this.addCancelEvent(onCancel) + }, delay) + + debounced() + + await wait(delay + 1) + + debounced() + + expect(onCancel).toHaveBeenCalledTimes(0) + expect(wasCanceled()).toBe(false) + + debounced.cancel() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(wasCanceled()).toBe(true) + + debounced() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(wasCanceled()).toBe(false) + }) + + it('call "addCancelEvent" method on cancel – from the return', async () => { + const onCancel = jest.fn() + + const debounced = debounceAsync(async () => null, delay) + + const wasCanceled = debounced.addCancelEvent(onCancel) + + debounced() + + await wait(delay + 1) + + debounced() + + expect(onCancel).toHaveBeenCalledTimes(0) + expect(wasCanceled()).toBe(false) + + debounced.cancel() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(wasCanceled()).toBe(true) + + debounced() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(wasCanceled()).toBe(false) + }) +}) diff --git a/packages/dnb-eufemia/src/shared/helpers/debounce.ts b/packages/dnb-eufemia/src/shared/helpers/debounce.ts new file mode 100644 index 00000000000..21eb0dc67d7 --- /dev/null +++ b/packages/dnb-eufemia/src/shared/helpers/debounce.ts @@ -0,0 +1,107 @@ +/** + * Debounces a function to be executed after a specified wait time. + * + * @param {Function} debouncedFunction - The function to be debounced. + * @param {number} [wait=250] - The wait time in milliseconds before executing the debounced function. + * @param {Object} [options] - Additional options for the debounced function. + * @param {boolean} [options.immediate=false] - Whether to execute the debounced function immediately. + * @param {Object} [options.instance=null] - The instance to bind the debounced function to. + * @param {boolean} [options.async=false] - Whether to return a promise that resolves with the result of the debounced function. + * @returns {Function|Promise} - The debounced function or a promise that resolves with the result of the debounced function. + * @memberof helpers + */ +export function debounce( + debouncedFunction, + wait = 250, + { immediate = false, instance = null, async = false } = {} +) { + let timeout + let recall + let resolvePromise + let rejectPromise + let canceled = false + const customCancels = [] + + const cancel = () => { + canceled = true + + clearTimeout(timeout) + resolvePromise?.() + + customCancels.forEach((fn) => { + fn() + }) + } + + const addCancelEvent = (fn) => { + if (!customCancels.includes(fn)) { + customCancels.push(fn) + } + + return () => { + return canceled + } + } + + function executedFunction(...args) { + if (typeof recall === 'function') { + recall() + } + + canceled = false + + const inst = instance || this || {} + inst.cancel = cancel + inst.addCancelEvent = addCancelEvent + + const later = (callNow) => { + timeout = null + if (callNow || !immediate) { + try { + recall = debouncedFunction.apply(inst, args) + resolvePromise?.(recall) + } catch (error) { + rejectPromise?.(error) + } + } + } + + const callNow = immediate && !timeout + + clearTimeout(timeout) + timeout = setTimeout(later, wait) + + if (callNow) { + later(true) + } + + if (async) { + return new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + } + + return recall + } + + executedFunction.cancel = cancel + executedFunction.addCancelEvent = addCancelEvent + + return executedFunction +} + +/** + * Debounces a function in async to be executed after a specified wait time. + * + * @param {Function} debouncedFunction - The function to be debounced. + * @param {number} [wait=250] - The wait time in milliseconds before executing the debounced function. + * @param {Object} [options] - Additional options for the debounced function. + * @param {boolean} [options.immediate=false] - Whether to execute the debounced function immediately. + * @param {Object} [options.instance=null] - The instance to bind the debounced function to. + * @returns {Promise} - The debounced promise that resolves with the result of the debounced function. + * @memberof helpers + */ +export function debounceAsync(debouncedFunction, wait = 250, opts = null) { + return debounce(debouncedFunction, wait, { ...opts, async: true }) +}