diff --git a/packages/form-js-editor/src/FormEditor.js b/packages/form-js-editor/src/FormEditor.js index 41b64910b..c0ebafb91 100644 --- a/packages/form-js-editor/src/FormEditor.js +++ b/packages/form-js-editor/src/FormEditor.js @@ -248,13 +248,14 @@ export default class FormEditor { */ _createInjector(options, container) { const { - additionalModules = [], modules = this._getModules(), - renderer = {} + additionalModules = [], + renderer = {}, + ...config } = options; - const config = { - ...options, + const enrichedConfig = { + ...config, renderer: { ...renderer, container @@ -262,7 +263,7 @@ export default class FormEditor { }; return createInjector([ - { config: [ 'value', config ] }, + { config: [ 'value', enrichedConfig ] }, { formEditor: [ 'value', this ] }, core, ...modules, diff --git a/packages/form-js-viewer/src/Form.js b/packages/form-js-viewer/src/Form.js index c493c0389..144c0d1d4 100644 --- a/packages/form-js-viewer/src/Form.js +++ b/packages/form-js-viewer/src/Form.js @@ -335,18 +335,20 @@ export default class Form { */ _createInjector(options, container) { const { + modules = this._getModules(), additionalModules = [], - modules = this._getModules() + ...config } = options; - const config = { + const enrichedConfig = { + ...config, renderer: { container } }; return createInjector([ - { config: [ 'value', config ] }, + { config: [ 'value', enrichedConfig ] }, { form: [ 'value', this ] }, core, ...modules, diff --git a/packages/form-js-viewer/src/index.js b/packages/form-js-viewer/src/index.js index 1be1568db..97732687e 100644 --- a/packages/form-js-viewer/src/index.js +++ b/packages/form-js-viewer/src/index.js @@ -27,10 +27,10 @@ export function createForm(options) { const { data, schema, - ...rest + ...formOptions } = options; - const form = new Form(rest); + const form = new Form(formOptions); return form.importSchema(schema, data).then(function() { return form; diff --git a/packages/form-js-viewer/src/render/components/form-fields/Number.js b/packages/form-js-viewer/src/render/components/form-fields/Number.js index ad51e6601..e5d656095 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/Number.js +++ b/packages/form-js-viewer/src/render/components/form-fields/Number.js @@ -1,6 +1,8 @@ import Big from 'big.js'; import classNames from 'classnames'; + import { useCallback, useMemo, useRef, useState } from 'preact/hooks'; +import useFlushDebounce from '../../hooks/useFlushDebounce'; import Description from '../Description'; import Errors from '../Errors'; @@ -32,8 +34,7 @@ export default function Numberfield(props) { onFocus, field, value, - readonly, - onChange + readonly } = props; const { @@ -57,6 +58,19 @@ export default function Numberfield(props) { const [ stringValueCache, setStringValueCache ] = useState(''); + const [ onChangeDebounced, flushOnChange ] = useFlushDebounce((params) => { + props.onChange(params); + }, [ props.onChange ]); + + const onInputBlur = () => { + flushOnChange && flushOnChange(); + onBlur && onBlur(); + }; + + const onInputFocus = () => { + onFocus && onFocus(); + }; + // checks whether the value currently in the form data is practically different from the one in the input field cache // this allows us to guarantee the field always displays valid form data, but without auto-simplifying values like 1.000 to 1 const cacheValueMatchesState = useMemo(() => Numberfield.config.sanitizeValue({ value, formField: field }) === Numberfield.config.sanitizeValue({ value: stringValueCache, formField: field }), [ stringValueCache, value, field ]); @@ -82,7 +96,7 @@ export default function Numberfield(props) { if (isNullEquivalentValue(stringValue)) { setStringValueCache(''); - onChange({ field, value: null }); + onChangeDebounced({ field, value: null }); return; } @@ -96,14 +110,14 @@ export default function Numberfield(props) { if (isNaN(Number(stringValue))) { setStringValueCache('NaN'); - onChange({ field, value: 'NaN' }); + onChangeDebounced({ field, value: 'NaN' }); return; } setStringValueCache(stringValue); - onChange({ field, value: serializeToString ? stringValue : Number(stringValue) }); + onChangeDebounced({ field, value: serializeToString ? stringValue : Number(stringValue) }); - }, [ field, onChange, serializeToString ]); + }, [ field, onChangeDebounced, serializeToString ]); const increment = () => { if (readonly) { @@ -187,8 +201,8 @@ export default function Numberfield(props) { id={ domId } onKeyDown={ onKeyDown } onKeyPress={ onKeyPress } - onBlur={ () => onBlur && onBlur() } - onFocus={ () => onFocus && onFocus() } + onBlur={ onInputBlur } + onFocus={ onInputFocus } // @ts-ignore onInput={ (e) => setValue(e.target.value) } diff --git a/packages/form-js-viewer/src/render/components/form-fields/Textarea.js b/packages/form-js-viewer/src/render/components/form-fields/Textarea.js index 3fb396f51..3b2fdf5a0 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/Textarea.js +++ b/packages/form-js-viewer/src/render/components/form-fields/Textarea.js @@ -1,5 +1,8 @@ import { isArray, isObject, isNil } from 'min-dash'; + import { useEffect, useLayoutEffect, useRef } from 'preact/hooks'; +import useFlushDebounce from '../../hooks/useFlushDebounce'; + import { formFieldClasses } from '../Util'; import Description from '../Description'; @@ -28,14 +31,22 @@ export default function Textarea(props) { } = field; const { required } = validate; - const textareaRef = useRef(); - const onInput = ({ target }) => { + const [ onInputChange, flushOnChange ] = useFlushDebounce(({ target }) => { props.onChange({ field, value: target.value }); + }, [ props.onChange ]); + + const onInputBlur = () => { + flushOnChange && flushOnChange(); + onBlur && onBlur(); + }; + + const onInputFocus = () => { + onFocus && onFocus(); }; useLayoutEffect(() => { @@ -55,9 +66,9 @@ export default function Textarea(props) { disabled={ disabled } readonly={ readonly } id={ domId } - onInput={ onInput } - onBlur={ () => onBlur && onBlur() } - onFocus={ () => onFocus && onFocus() } + onInput={ onInputChange } + onBlur={ onInputBlur } + onFocus={ onInputFocus } value={ value } ref={ textareaRef } aria-describedby={ errorMessageId } /> diff --git a/packages/form-js-viewer/src/render/components/form-fields/Textfield.js b/packages/form-js-viewer/src/render/components/form-fields/Textfield.js index 734cb5cba..0ad91ed4b 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/Textfield.js +++ b/packages/form-js-viewer/src/render/components/form-fields/Textfield.js @@ -6,6 +6,8 @@ import Errors from '../Errors'; import Label from '../Label'; import InputAdorner from './parts/TemplatedInputAdorner'; +import useFlushDebounce from '../../hooks/useFlushDebounce'; + const type = 'textfield'; export default function Textfield(props) { @@ -35,11 +37,20 @@ export default function Textfield(props) { const { required } = validate; - const onChange = ({ target }) => { + const [ onInputChange, flushOnChange ] = useFlushDebounce(({ target }) => { props.onChange({ field, value: target.value }); + }, [ props.onChange ]); + + const onInputBlur = () => { + flushOnChange && flushOnChange(); + onBlur && onBlur(); + }; + + const onInputFocus = () => { + onFocus && onFocus(); }; return
@@ -53,9 +64,9 @@ export default function Textfield(props) { disabled={ disabled } readOnly={ readonly } id={ domId } - onInput={ onChange } - onBlur={ () => onBlur && onBlur() } - onFocus={ () => onFocus && onFocus() } + onInput={ onInputChange } + onBlur={ onInputBlur } + onFocus={ onInputFocus } type="text" value={ value } aria-describedby={ errorMessageId } /> diff --git a/packages/form-js-viewer/src/render/hooks/useFlushDebounce.js b/packages/form-js-viewer/src/render/hooks/useFlushDebounce.js new file mode 100644 index 000000000..cccaafafa --- /dev/null +++ b/packages/form-js-viewer/src/render/hooks/useFlushDebounce.js @@ -0,0 +1,50 @@ +import { useCallback, useRef } from 'preact/hooks'; +import useService from './useService'; + +function useFlushDebounce(func, additionalDeps = []) { + + const timeoutRef = useRef(null); + const lastArgsRef = useRef(null); + + const config = useService('config', false); + const debounce = config && config.debounce; + const shouldDebounce = debounce !== false && debounce !== 0; + const delay = typeof debounce === 'number' ? debounce : 300; + + const debounceFunc = useCallback((...args) => { + + if (!shouldDebounce) { + func(...args); + return; + } + + lastArgsRef.current = args; + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + func(...lastArgsRef.current); + lastArgsRef.current = null; + }, delay); + + }, [ func, delay, shouldDebounce, ...additionalDeps ]); + + const flushFunc = useCallback(() => { + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + if (lastArgsRef.current !== null) { + func(...lastArgsRef.current); + lastArgsRef.current = null; + } + timeoutRef.current = null; + } + + }, [ func, ...additionalDeps ]); + + return [ debounceFunc, flushFunc ]; +} + +export default useFlushDebounce; diff --git a/packages/form-js-viewer/test/spec/Form.spec.js b/packages/form-js-viewer/test/spec/Form.spec.js index f07c7dad8..af093e719 100644 --- a/packages/form-js-viewer/test/spec/Form.spec.js +++ b/packages/form-js-viewer/test/spec/Form.spec.js @@ -63,7 +63,7 @@ describe('Form', function() { const bootstrapForm = ({ bootstrapExecute = () => {}, ...options }) => { return act(async () => { - form = await createForm(options); + form = await createForm({ debounce: false, ...options }); bootstrapExecute(form); }); }; diff --git a/packages/form-js-viewer/test/spec/render/components/helper/mocks/index.js b/packages/form-js-viewer/test/spec/render/components/helper/mocks/index.js index 7cbea5a97..ffcb7f4ca 100644 --- a/packages/form-js-viewer/test/spec/render/components/helper/mocks/index.js +++ b/packages/form-js-viewer/test/spec/render/components/helper/mocks/index.js @@ -10,6 +10,10 @@ export function createMockInjector(services = {}, options = {}) { return injector; } +const VIEWER_CONFIG = { + debounce: false +}; + function _createMockModule(services, options) { return { @@ -21,6 +25,8 @@ function _createMockModule(services, options) { formFieldRegistry: [ 'value', services.formFieldRegistry || new FormFieldRegistryMock(options) ], pathRegistry: [ 'value', services.pathRegistry || new PathRegistryMock(options) ], eventBus: [ 'value', services.eventBus || new EventBusMock(options) ], + debounce: [ 'value', services.debounce || (fn => fn) ], + config: [ 'value', services.config || VIEWER_CONFIG ], // using actual implementations in testing formFields: services.formFields ? [ 'value', services.formFields ] : [ 'type', FormFields ],