diff --git a/packages/form-js-viewer/src/render/components/form-fields/Checklist.js b/packages/form-js-viewer/src/render/components/form-fields/Checklist.js index 5ecd28a71..efd0306c7 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/Checklist.js +++ b/packages/form-js-viewer/src/render/components/form-fields/Checklist.js @@ -1,5 +1,5 @@ -import { useContext, useRef } from 'preact/hooks'; -import useValuesAsync, { LOAD_STATES } from '../../hooks/useValuesAsync'; +import { useContext, useEffect, useRef } from 'preact/hooks'; +import useOptionsAsync, { LOAD_STATES } from '../../hooks/useOptionsAsync'; import classNames from 'classnames'; import { FormContext } from '../../context'; @@ -9,7 +9,7 @@ import Label from '../Label'; import { sanitizeMultiSelectValue } from '../util/sanitizerUtil'; -import { createEmptyOptions } from '../util/valuesUtil'; +import { createEmptyOptions } from '../util/optionsUtil'; import { formFieldClasses, @@ -27,7 +27,7 @@ export default function Checklist(props) { onFocus, field, readonly, - value = [], + value: values = [], } = props; const { @@ -43,7 +43,7 @@ export default function Checklist(props) { const toggleCheckbox = (v) => { - let newValue = [ ...value ]; + let newValue = [ ...values ]; if (!newValue.includes(v)) { newValue.push(v); @@ -76,9 +76,9 @@ export default function Checklist(props) { }; const { - state: loadState, - values: options - } = useValuesAsync(field); + loadState, + options + } = useOptionsAsync(field); const { formId } = useContext(FormContext); const errorMessageId = errors.length === 0 ? undefined : `${prefixId(id, formId)}-error-message`; @@ -95,11 +95,11 @@ export default function Checklist(props) { key={ `${id}-${index}` } label={ v.label } class={ classNames({ - 'fjs-checked': value.includes(v.value) + 'fjs-checked': values.includes(v.value) }) } required={ false }> Object.assign({}, ...options.map((o, x) => ({ [o.value]: options[x] }))), [ options ]); + const hasOptionsLeft = useMemo(() => options.length > values.length, [ options.length, values.length ]); + // Usage of stringify is necessary here because we want this effect to only trigger when there is a value change to the array - useEffect(() => { - if (loadState === LOAD_STATES.LOADED) { - setFilteredOptions(options.filter((o) => o.label && o.value && (o.label.toLowerCase().includes(filter.toLowerCase())) && !values.includes(o.value))); - } - else { - setFilteredOptions([]); + const filteredOptions = useMemo(() => { + if (loadState !== LOAD_STATES.LOADED) { + return []; } - }, [ filter, JSON.stringify(values), options, loadState ]); + return options.filter((o) => o.label && o.value && (o.label.toLowerCase().includes(filter.toLowerCase())) && !values.includes(o.value)); + }, [ filter, options, JSON.stringify(values), loadState ]); - useEffect(() => { - setHasOptionsLeft(options.length > values.length); - }, [ options.length, values.length ]); const selectValue = (value) => { if (filter) { diff --git a/packages/form-js-viewer/src/render/components/form-fields/parts/SearchableSelect.js b/packages/form-js-viewer/src/render/components/form-fields/parts/SearchableSelect.js index 24da67741..7f0f8b3f4 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/parts/SearchableSelect.js +++ b/packages/form-js-viewer/src/render/components/form-fields/parts/SearchableSelect.js @@ -1,5 +1,5 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks'; -import useValuesAsync, { LOAD_STATES } from '../../../hooks/useValuesAsync'; +import useOptionsAsync, { LOAD_STATES } from '../../../hooks/useOptionsAsync'; import { useService } from '../../../hooks'; import { FormContext } from '../../../context'; @@ -37,9 +37,9 @@ export default function SearchableSelect(props) { const eventBus = useService('eventBus'); const { - state: loadState, - values: options - } = useValuesAsync(field); + loadState, + options + } = useOptionsAsync(field); // We cache a map of option values to their index so that we don't need to search the whole options array every time to correlate the label const valueToOptionMap = useMemo(() => Object.assign({}, ...options.map((o, x) => ({ [o.value]: options[x] }))), [ options ]); diff --git a/packages/form-js-viewer/src/render/components/form-fields/parts/SimpleSelect.js b/packages/form-js-viewer/src/render/components/form-fields/parts/SimpleSelect.js index 1eae5a284..463498123 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/parts/SimpleSelect.js +++ b/packages/form-js-viewer/src/render/components/form-fields/parts/SimpleSelect.js @@ -1,5 +1,5 @@ import { useCallback, useContext, useMemo, useRef, useState } from 'preact/hooks'; -import useValuesAsync, { LOAD_STATES } from '../../../hooks/useValuesAsync'; +import useOptionsAsync, { LOAD_STATES } from '../../../hooks/useOptionsAsync'; import { FormContext } from '../../../context'; @@ -34,9 +34,9 @@ export default function SimpleSelect(props) { const inputRef = useRef(); const { - state: loadState, - values: options - } = useValuesAsync(field); + loadState, + options + } = useOptionsAsync(field); // We cache a map of option values to their index so that we don't need to search the whole options array every time to correlate the label const valueToOptionMap = useMemo(() => Object.assign({}, ...options.map((o, x) => ({ [o.value]: options[x] }))), [ options ]); diff --git a/packages/form-js-viewer/src/render/components/util/optionsUtil.js b/packages/form-js-viewer/src/render/components/util/optionsUtil.js new file mode 100644 index 000000000..a40b33445 --- /dev/null +++ b/packages/form-js-viewer/src/render/components/util/optionsUtil.js @@ -0,0 +1,69 @@ +import { get } from 'min-dash'; + +// parses the options data from the provided form field and form data +export function getOptionsData(formField, formData) { + const { valuesKey: optionsKey, values: staticOptions } = formField; + return optionsKey ? get(formData, [ optionsKey ]) : staticOptions; +} + +// transforms the provided options into a normalized format, trimming invalid options +export function normalizeOptionsData(optionsData) { + return optionsData.filter(_isOptionSomething).map(v => _normalizeOptionsData(v)).filter(v => v); +} + +function _normalizeOptionsData(optionData) { + + if (_isAllowedOption(optionData)) { + + // if a primitive is provided, use it as label and value + return { value: optionData, label: `${ optionData }` }; + } + + if (typeof (optionData) === 'object') { + if (!optionData.label && _isAllowedOption(optionData.value)) { + + // if no label is provided, use the value as label + return { value: optionData.value, label: `${ optionData.value }` }; + } + + if (_isOptionSomething(optionData.value) && _isAllowedOption(optionData.label)) { + + // if both value and label are provided, use them as is, in this scenario, the value may also be an object + return optionData; + } + } + + return null; +} + +function _isAllowedOption(option) { + return _isReadableType(option) && _isOptionSomething(option); +} + +function _isReadableType(option) { + return [ 'number', 'string', 'boolean' ].includes(typeof(option)); +} + +function _isOptionSomething(option) { + return option || option === 0 || option === false; +} + +export function createEmptyOptions(options = {}) { + + const defaults = {}; + + // provide default options if valuesKey and valuesExpression are not set + if (!options.valuesKey && !options.valuesExpression) { + defaults.values = [ + { + label: 'Value', + value: 'value' + } + ]; + } + + return { + ...defaults, + ...options + }; +} \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/components/util/sanitizerUtil.js b/packages/form-js-viewer/src/render/components/util/sanitizerUtil.js index beba0ac39..eec8db134 100644 --- a/packages/form-js-viewer/src/render/components/util/sanitizerUtil.js +++ b/packages/form-js-viewer/src/render/components/util/sanitizerUtil.js @@ -1,6 +1,6 @@ import { DATETIME_SUBTYPES } from '../../../util/constants/DatetimeConstants'; import { isDateInputInformationMatching, isDateTimeInputInformationSufficient, isInvalidDateString, parseIsoTime } from './dateTimeUtil'; -import { getValuesData, normalizeValuesData } from './valuesUtil'; +import { getOptionsData, normalizeOptionsData } from './optionsUtil'; export function sanitizeDateTimePickerValue(options) { const { @@ -27,7 +27,7 @@ export function sanitizeSingleSelectValue(options) { } = options; try { - const validValues = normalizeValuesData(getValuesData(formField, data)).map(v => v.value); + const validValues = normalizeOptionsData(getOptionsData(formField, data)).map(v => v.value); return validValues.includes(value) ? value : null; } catch (error) { @@ -45,7 +45,7 @@ export function sanitizeMultiSelectValue(options) { } = options; try { - const validValues = normalizeValuesData(getValuesData(formField, data)).map(v => v.value); + const validValues = normalizeOptionsData(getOptionsData(formField, data)).map(v => v.value); return value.filter(v => validValues.includes(v)); } catch (error) { diff --git a/packages/form-js-viewer/src/render/components/util/valuesUtil.js b/packages/form-js-viewer/src/render/components/util/valuesUtil.js deleted file mode 100644 index fb24e4c72..000000000 --- a/packages/form-js-viewer/src/render/components/util/valuesUtil.js +++ /dev/null @@ -1,69 +0,0 @@ -import { get } from 'min-dash'; - -// parses the options data from the provided form field and form data -export function getValuesData(formField, formData) { - const { valuesKey, values } = formField; - return valuesKey ? get(formData, [ valuesKey ]) : values; -} - -// transforms the provided options into a normalized format, trimming invalid options -export function normalizeValuesData(valuesData) { - return valuesData.filter(_isValueSomething).map(v => _normalizeValueData(v)).filter(v => v); -} - -function _normalizeValueData(valueData) { - - if (_isAllowedValue(valueData)) { - - // if a primitive is provided, use it as label and value - return { value: valueData, label: `${ valueData }` }; - } - - if (typeof (valueData) === 'object') { - if (!valueData.label && _isAllowedValue(valueData.value)) { - - // if no label is provided, use the value as label - return { value: valueData.value, label: `${ valueData.value }` }; - } - - if (_isValueSomething(valueData.value) && _isAllowedValue(valueData.label)) { - - // if both value and label are provided, use them as is, in this scenario, the value may also be an object - return valueData; - } - } - - return null; -} - -function _isAllowedValue(value) { - return _isReadableType(value) && _isValueSomething(value); -} - -function _isReadableType(value) { - return [ 'number', 'string', 'boolean' ].includes(typeof(value)); -} - -function _isValueSomething(value) { - return value || value === 0 || value === false; -} - -export function createEmptyOptions(options = {}) { - - const defaults = {}; - - // provide default values if valuesKey and valuesExpression are not set - if (!options.valuesKey && !options.valuesExpression) { - defaults.values = [ - { - label: 'Value', - value: 'value' - } - ]; - } - - return { - ...defaults, - ...options - }; -} \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/hooks/useOptionsAsync.js b/packages/form-js-viewer/src/render/hooks/useOptionsAsync.js new file mode 100644 index 000000000..a0acfa157 --- /dev/null +++ b/packages/form-js-viewer/src/render/hooks/useOptionsAsync.js @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'preact/hooks'; +import { normalizeOptionsData } from '../components/util/optionsUtil'; +import { useExpressionEvaluation, useDeepCompareState, useService } from './index'; + +/** + * @enum { String } + */ +export const LOAD_STATES = { + LOADING: 'loading', + LOADED: 'loaded', + ERROR: 'error' +}; + +/** + * @typedef {Object} OptionsGetter + * @property {Object[]} options - The options data + * @property {(LOAD_STATES)} loadState - The options data's loading state, to use for conditional rendering + */ + +/** + * A hook to load options for single and multiselect components. + * + * @param {Object} field - The form field to handle options for + * @return {OptionsGetter} optionsGetter - A options getter object providing loading state and options + */ +export default function(field) { + const { + valuesExpression: optionsExpression, + valuesKey: optionsKey, + values: staticOptions + } = field; + + const [ optionsGetter, setOptionsGetter ] = useState({ options: [], error: undefined, loadState: LOAD_STATES.LOADING }); + const initialData = useService('form')._getState().initialData; + + const expressionEvaluation = useExpressionEvaluation(optionsExpression); + const evaluatedOptions = useDeepCompareState(expressionEvaluation || [], []); + + useEffect(() => { + + let options = []; + + // dynamic options + if (optionsKey !== undefined) { + const keyedOptions = (initialData || {})[ optionsKey ]; + + if (keyedOptions && Array.isArray(keyedOptions)) { + options = keyedOptions; + } + + // static options + } else if (staticOptions !== undefined) { + options = Array.isArray(staticOptions) ? staticOptions : []; + + // expression + } else if (optionsExpression) { + + if (evaluatedOptions && Array.isArray(evaluatedOptions)) { + options = evaluatedOptions; + } + } else { + setOptionsGetter(buildErrorState('No options source defined in the form definition')); + return; + } + + // normalize data to support primitives and partially defined objects + options = normalizeOptionsData(options); + + setOptionsGetter(buildLoadedState(options)); + + }, [ optionsKey, staticOptions, initialData, optionsExpression, evaluatedOptions ]); + + return optionsGetter; +} + +const buildErrorState = (error) => ({ options: [], error, loadState: LOAD_STATES.ERROR }); + +const buildLoadedState = (options) => ({ options, error: undefined, loadState: LOAD_STATES.LOADED }); diff --git a/packages/form-js-viewer/src/render/hooks/useValuesAsync.js b/packages/form-js-viewer/src/render/hooks/useValuesAsync.js deleted file mode 100644 index 0f97f3b83..000000000 --- a/packages/form-js-viewer/src/render/hooks/useValuesAsync.js +++ /dev/null @@ -1,78 +0,0 @@ -import { useEffect, useState } from 'preact/hooks'; -import { normalizeValuesData } from '../components/util/valuesUtil'; -import { useExpressionEvaluation, useDeepCompareState, useService } from './index'; - -/** - * @enum { String } - */ -export const LOAD_STATES = { - LOADING: 'loading', - LOADED: 'loaded', - ERROR: 'error' -}; - -/** - * @typedef {Object} ValuesGetter - * @property {Object[]} values - The values data - * @property {(LOAD_STATES)} state - The values data's loading state, to use for conditional rendering - */ - -/** - * A hook to load values for single and multiselect components. - * - * @param {Object} field - The form field to handle values for - * @return {ValuesGetter} valuesGetter - A values getter object providing loading state and values - */ -export default function(field) { - const { - valuesExpression, - valuesKey, - values: staticValues - } = field; - - const [ valuesGetter, setValuesGetter ] = useState({ values: [], error: undefined, state: LOAD_STATES.LOADING }); - const initialData = useService('form')._getState().initialData; - - const expressionEvaluation = useExpressionEvaluation(valuesExpression); - const evaluatedValues = useDeepCompareState(expressionEvaluation || [], []); - - useEffect(() => { - - let values = []; - - // dynamic values - if (valuesKey !== undefined) { - const keyedValues = (initialData || {})[ valuesKey ]; - - if (keyedValues && Array.isArray(keyedValues)) { - values = keyedValues; - } - - // static values - } else if (staticValues !== undefined) { - values = Array.isArray(staticValues) ? staticValues : []; - - // expression - } else if (valuesExpression) { - - if (evaluatedValues && Array.isArray(evaluatedValues)) { - values = evaluatedValues; - } - } else { - setValuesGetter(buildErrorState('No values source defined in the form definition')); - return; - } - - // normalize data to support primitives and partially defined objects - values = normalizeValuesData(values); - - setValuesGetter(buildLoadedState(values)); - - }, [ valuesKey, staticValues, initialData, valuesExpression, evaluatedValues ]); - - return valuesGetter; -} - -const buildErrorState = (error) => ({ values: [], error, state: LOAD_STATES.ERROR }); - -const buildLoadedState = (values) => ({ values, error: undefined, state: LOAD_STATES.LOADED }); diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/util/valuesUtil.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/util/valuesUtil.spec.js index 082004079..426c7bd20 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/util/valuesUtil.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/util/valuesUtil.spec.js @@ -1,8 +1,8 @@ -import { normalizeValuesData } from '../../../../../../src/render/components/util/valuesUtil.js'; +import { normalizeOptionsData } from '../../../../../../src/render/components/util/optionsUtil.js'; -describe('valuesUtil', function() { +describe('optionsUtil', function() { - describe('#normalizeValuesData', function() { + describe('#normalizeOptionsData', function() { it('should not alter fully defined value', function() { @@ -10,7 +10,7 @@ describe('valuesUtil', function() { const values = [ { value: 'john', label: 'John' }, { value: 'jessica', label: 'Jessica' } ]; // when - const result = normalizeValuesData(values); + const result = normalizeOptionsData(values); // then expect(result).to.eql([ { value: 'john', label: 'John' }, { value: 'jessica', label: 'Jessica' } ]); @@ -23,7 +23,7 @@ describe('valuesUtil', function() { const values = [ { value: 'john', label: 'John' }, { value: 'jessica', label: 'Jessica' }, null ]; // when - const result = normalizeValuesData(values); + const result = normalizeOptionsData(values); // then expect(result).to.eql([ { value: 'john', label: 'John' }, { value: 'jessica', label: 'Jessica' } ]); @@ -36,7 +36,7 @@ describe('valuesUtil', function() { const values = [ { value: 'john', label: 'John' }, { value: 'jessica', label: 'Jessica' }, undefined ]; // when - const result = normalizeValuesData(values); + const result = normalizeOptionsData(values); // then expect(result).to.eql([ { value: 'john', label: 'John' }, { value: 'jessica', label: 'Jessica' } ]); @@ -49,7 +49,7 @@ describe('valuesUtil', function() { const values = [ { value: 'john' }, { value: 'jessica' } ]; // when - const result = normalizeValuesData(values); + const result = normalizeOptionsData(values); // then expect(result).to.eql([ { value: 'john', label: 'john' }, { value: 'jessica', label: 'jessica' } ]); @@ -62,7 +62,7 @@ describe('valuesUtil', function() { const valuesData = [ { label: 'John' }, { label: 'Jessica' } ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([]); @@ -75,7 +75,7 @@ describe('valuesUtil', function() { const valuesData = [ 'john', 'jessica' ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([ { value: 'john', label: 'john' }, { value: 'jessica', label: 'jessica' } ]); @@ -88,7 +88,7 @@ describe('valuesUtil', function() { const valuesData = [ { foo: 'bar' }, { value: 'john', label: 'John' } ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([ { value: 'john', label: 'John' } ]); @@ -102,7 +102,7 @@ describe('valuesUtil', function() { const valuesData = [ { value: { foo: 'bar', bar: 'foo' }, label: 'myObject' }, { value: 'john', label: 'John' } ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([ { value: { foo: 'bar', bar: 'foo' }, label: 'myObject' }, { value: 'john', label: 'John' } ]); @@ -116,7 +116,7 @@ describe('valuesUtil', function() { const valuesData = [ { value: { foo: 'bar', bar: 'foo' } }, { value: 'john', label: 'John' } ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([ { value: 'john', label: 'John' } ]); @@ -130,7 +130,7 @@ describe('valuesUtil', function() { const valuesData = [ 1, 2 ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([ { value: 1, label: '1' }, { value: 2, label: '2' } ]); @@ -143,7 +143,7 @@ describe('valuesUtil', function() { const valuesData = [ 0 ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([ { value: 0, label: '0' } ]); @@ -156,7 +156,7 @@ describe('valuesUtil', function() { const valuesData = [ true, false ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([ { value: true, label: 'true' }, { value: false, label: 'false' } ]); @@ -169,7 +169,7 @@ describe('valuesUtil', function() { const valuesData = [ { value: 'john', label: 'John' }, 'jessica', 1, true, false, null, undefined ]; // when - const result = normalizeValuesData(valuesData); + const result = normalizeOptionsData(valuesData); // then expect(result).to.eql([ { value: 'john', label: 'John' }, { value: 'jessica', label: 'jessica' }, { value: 1, label: '1' }, { value: true, label: 'true' }, { value: false, label: 'false' } ]);