From 1be5f91902ea71dce3b158d9abe01c2d481aad5f Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 31 Oct 2022 06:07:11 +0100 Subject: [PATCH 01/14] feat: implement floating point number field Related to #285 --- package-lock.json | 38 ++++ packages/form-js-editor/package.json | 1 + .../properties-panel/PropertiesPanel.js | 2 + .../src/features/properties-panel/Util.js | 13 ++ .../entries/DefaultValueEntry.js | 41 ++-- .../properties-panel/entries/NumberEntry.js | 123 ++++++++++++ .../entries/NumberSerializationEntry.js | 70 +++++++ .../properties-panel/entries/index.js | 2 + .../properties-panel/groups/GeneralGroup.js | 4 +- .../groups/SerializationGroup.js | 21 ++ .../groups/ValidationGroup.js | 2 +- .../features/properties-panel/groups/index.js | 1 + packages/form-js-viewer/assets/form-js.css | 128 ++++++++++++- packages/form-js-viewer/package.json | 1 + packages/form-js-viewer/src/core/Validator.js | 51 ++++- .../src/render/components/FormComponent.js | 1 + .../render/components/form-fields/Number.js | 180 +++++++++++++++--- .../src/render/components/index.js | 6 +- .../render/components/util/numberFieldUtil.js | 39 ++++ .../src/render/hooks/useEffectDebugger.js | 59 ++++++ .../components/form-fields/Number.spec.js | 14 +- 21 files changed, 738 insertions(+), 59 deletions(-) create mode 100644 packages/form-js-editor/src/features/properties-panel/entries/NumberEntry.js create mode 100644 packages/form-js-editor/src/features/properties-panel/entries/NumberSerializationEntry.js create mode 100644 packages/form-js-editor/src/features/properties-panel/groups/SerializationGroup.js create mode 100644 packages/form-js-viewer/src/render/components/util/numberFieldUtil.js create mode 100644 packages/form-js-viewer/src/render/hooks/useEffectDebugger.js diff --git a/package-lock.json b/package-lock.json index 118248bf9..58ef6f5a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17840,6 +17840,7 @@ "@bpmn-io/form-js-viewer": "^0.10.0-alpha.1", "@bpmn-io/properties-panel": "^0.25.0", "array-move": "^3.0.1", + "big.js": "^6.2.1", "dragula": "^3.7.3", "ids": "^1.0.0", "min-dash": "^4.0.0", @@ -17847,6 +17848,18 @@ "preact": "^10.5.14" } }, + "packages/form-js-editor/node_modules/big.js": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.1.tgz", + "integrity": "sha512-bCtHMwL9LeDIozFn+oNhhFoq+yQ3BNdnsLSASUxLciOb1vgvpHsIO1dsENiGMgbb4SkP5TrzWzRiLddn8ahVOQ==", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, "packages/form-js-editor/node_modules/min-dash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.0.0.tgz", @@ -17918,6 +17931,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bpmn-io/snarkdown": "^2.1.0", + "big.js": "^6.2.1", "classnames": "^2.3.1", "didi": "^9.0.0", "feelin": "^0.41.0", @@ -17927,6 +17941,18 @@ "preact-markup": "^2.1.1" } }, + "packages/form-js-viewer/node_modules/big.js": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.1.tgz", + "integrity": "sha512-bCtHMwL9LeDIozFn+oNhhFoq+yQ3BNdnsLSASUxLciOb1vgvpHsIO1dsENiGMgbb4SkP5TrzWzRiLddn8ahVOQ==", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, "packages/form-js-viewer/node_modules/min-dash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.0.0.tgz", @@ -18397,6 +18423,7 @@ "@bpmn-io/form-js-viewer": "^0.10.0-alpha.1", "@bpmn-io/properties-panel": "^0.25.0", "array-move": "^3.0.1", + "big.js": "*", "dragula": "^3.7.3", "ids": "^1.0.0", "min-dash": "^4.0.0", @@ -18404,6 +18431,11 @@ "preact": "^10.5.14" }, "dependencies": { + "big.js": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.1.tgz", + "integrity": "sha512-bCtHMwL9LeDIozFn+oNhhFoq+yQ3BNdnsLSASUxLciOb1vgvpHsIO1dsENiGMgbb4SkP5TrzWzRiLddn8ahVOQ==" + }, "min-dash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.0.0.tgz", @@ -18472,6 +18504,7 @@ "version": "file:packages/form-js-viewer", "requires": { "@bpmn-io/snarkdown": "^2.1.0", + "big.js": "^6.2.1", "classnames": "^2.3.1", "didi": "^9.0.0", "feelin": "^0.41.0", @@ -18481,6 +18514,11 @@ "preact-markup": "^2.1.1" }, "dependencies": { + "big.js": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.1.tgz", + "integrity": "sha512-bCtHMwL9LeDIozFn+oNhhFoq+yQ3BNdnsLSASUxLciOb1vgvpHsIO1dsENiGMgbb4SkP5TrzWzRiLddn8ahVOQ==" + }, "min-dash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.0.0.tgz", diff --git a/packages/form-js-editor/package.json b/packages/form-js-editor/package.json index 4d26f51ba..860dd0363 100644 --- a/packages/form-js-editor/package.json +++ b/packages/form-js-editor/package.json @@ -45,6 +45,7 @@ "@bpmn-io/form-js-viewer": "^0.10.0-alpha.1", "@bpmn-io/properties-panel": "^0.25.0", "array-move": "^3.0.1", + "big.js": "^6.2.1", "dragula": "^3.7.3", "ids": "^1.0.0", "min-dash": "^4.0.0", diff --git a/packages/form-js-editor/src/features/properties-panel/PropertiesPanel.js b/packages/form-js-editor/src/features/properties-panel/PropertiesPanel.js index 62b4938bb..f4fee363a 100644 --- a/packages/form-js-editor/src/features/properties-panel/PropertiesPanel.js +++ b/packages/form-js-editor/src/features/properties-panel/PropertiesPanel.js @@ -15,6 +15,7 @@ import { ConditionGroup, CustomValuesGroup, GeneralGroup, + SerializationGroup, ValidationGroup, ValuesGroups } from './groups'; @@ -28,6 +29,7 @@ function getGroups(field, editField) { const groups = [ GeneralGroup(field, editField), ConditionGroup(field, editField), + SerializationGroup(field, editField), ...ValuesGroups(field, editField), ValidationGroup(field, editField), CustomValuesGroup(field, editField) diff --git a/packages/form-js-editor/src/features/properties-panel/Util.js b/packages/form-js-editor/src/features/properties-panel/Util.js index 93d13951b..eb4ef052a 100644 --- a/packages/form-js-editor/src/features/properties-panel/Util.js +++ b/packages/form-js-editor/src/features/properties-panel/Util.js @@ -1,3 +1,5 @@ +import Big from 'big.js'; + export function arrayAdd(array, index, item) { const copy = [ ...array ]; @@ -18,6 +20,17 @@ export function prefixId(id) { return `fjs-properties-panel-${ id }`; } + +export function countDecimals(number) { + const num = Big(number); + if (num.toString() === num.toFixed(0)) return 0; + return num.toFixed().split('.')[1].length || 0; +} + +export function isValidNumber(value) { + return (typeof value === 'number' || typeof value === 'string') && value !== '' && !isNaN(Number(value)); +} + export function stopPropagation(listener) { return (event) => { event.stopPropagation(); diff --git a/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js index ddcbe5701..75612b5e4 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js @@ -1,9 +1,7 @@ import { - isNumberFieldEntryEdited, isSelectEntryEdited, isTextFieldEntryEdited, isTextAreaEntryEdited, - NumberFieldEntry, SelectEntry, TextFieldEntry, TextAreaEntry @@ -11,10 +9,11 @@ import { import { get } from 'min-dash'; -import { useService } from '../hooks'; +import Big from 'big.js'; -import { INPUTS, VALUES_INPUTS } from '../Util'; +import { useService } from '../hooks'; +import { countDecimals, INPUTS, isValidNumber, VALUES_INPUTS } from '../Util'; export default function DefaultOptionEntry(props) { const { @@ -52,7 +51,7 @@ export default function DefaultOptionEntry(props) { entries.push({ ...defaultOptions, component: DefaultValueNumber, - isEdited: isNumberFieldEntryEdited + isEdited: isTextFieldEntryEdited }); } @@ -138,25 +137,41 @@ function DefaultValueNumber(props) { label } = props; + const { + decimalDigits, + serializeToString = false + } = field; + const debounce = useService('debounce'); const path = [ 'defaultValue' ]; - const getValue = () => { - return get(field, path, ''); - }; + const getValue = (e) => { - const setValue = (value) => { - return editField(field, path, value); + let value = get(field, path); + + if (!isValidNumber(value)) return null; + + // Enforces decimal notation so that we do not submit defaults in exponent form + return serializeToString ? Big(value).toFixed() : value; }; - return NumberFieldEntry({ + const setValue = (value) => editField(field, path, serializeToString ? value : Number(value)); + + const decimalDigitsSet = decimalDigits || decimalDigits === 0; + + return TextFieldEntry({ debounce, + label, element: field, getValue, id, - label, - setValue + setValue, + validate: (value) => { + if (value === undefined || value === null) return; + if (!isValidNumber(value)) return 'Should be a valid number'; + if (decimalDigitsSet && countDecimals(value) > decimalDigits) return `Should not contain more than ${decimalDigits} decimal digits`; + } }); } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/NumberEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/NumberEntry.js new file mode 100644 index 000000000..0e411940f --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/NumberEntry.js @@ -0,0 +1,123 @@ +import { NumberFieldEntry, isNumberFieldEntryEdited, TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-panel'; + +import Big from 'big.js'; +import { get } from 'min-dash'; +import { useService } from '../hooks'; +import { countDecimals, isValidNumber } from '../Util'; + +export default function NumberEntry(props) { + const { + editField, + field, + id + } = props; + + const { + type + } = field; + + if (type !== 'number') { + return []; + } + + const entries = []; + + entries.push({ + id: id + '-decimalDigits', + component: NumberDecimalDigits, + isEdited: isNumberFieldEntryEdited, + editField, + field + }); + + entries.push({ + id: id + '-step', + component: NumberArrowStep, + isEdited: isTextFieldEntryEdited, + editField, + field + }); + + return entries; +} + +function NumberDecimalDigits(props) { + + const { + editField, + field, + id + } = props; + + const debounce = useService('debounce'); + + const getValue = (e) => get(field, [ 'decimalDigits' ]); + + const setValue = (value) => editField(field, [ 'decimalDigits' ], value); + + return NumberFieldEntry({ + debounce, + label: 'Decimal digits', + element: field, + min: 0, + step: 1, + getValue, + id, + setValue + }); + +} + +function NumberArrowStep(props) { + + const { + editField, + field, + id + } = props; + + const { + decimalDigits + } = field; + + const debounce = useService('debounce'); + + const getValue = (e) => { + + let value = get(field, [ 'increment' ]); + + if (!isValidNumber(value)) return null; + + return value; + }; + + const setValue = (value) => editField(field, [ 'increment' ], value); + + const decimalDigitsSet = decimalDigits || decimalDigits === 0; + + return TextFieldEntry({ + debounce, + label: 'Increment', + element: field, + getValue, + id, + setValue, + validate: (value) => { + + if (value === undefined || value === null) return; + + if (!isValidNumber(value)) return 'Should be a valid number.'; + + if (!decimalDigitsSet && Big(value).cmp(0) <= 0) return 'Should be greater than zero.'; + + if (decimalDigitsSet) { + const minimumValue = Big(`1e-${decimalDigits}`); + + if (Big(value).cmp(minimumValue) < 0) return `Should be at least ${minimumValue.toString()}.`; + if (countDecimals(value) > decimalDigits) return `Should not contain more than ${decimalDigits} decimal digits.`; + + } + } + }); + +} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/entries/NumberSerializationEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/NumberSerializationEntry.js new file mode 100644 index 000000000..7bcc5c2de --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/NumberSerializationEntry.js @@ -0,0 +1,70 @@ +import { CheckboxEntry, isCheckboxEntryEdited } from '@bpmn-io/properties-panel'; + +import { get } from 'min-dash'; + +import Big from 'big.js'; + +export default function NumberSerializationEntry(props) { + const { + editField, + field, + id + } = props; + + const { + type + } = field; + + if (type !== 'number') { + return []; + } + + const entries = []; + + entries.push({ + id: id + '-serialize-to-string', + component: SerializeToString, + isEdited: isCheckboxEntryEdited, + editField, + field + }); + + return entries; +} + +function SerializeToString(props) { + const { + editField, + field, + id + } = props; + + const { + defaultValue + } = field; + + const path = [ 'serializeToString' ]; + + const getValue = () => { + return get(field, path, ''); + }; + + const setValue = (value) => { + + // Whenever changing the formatting, make sure to change the default value type along with it + if (defaultValue || defaultValue === 0) { + editField(field, [ 'defaultValue' ], value ? Big(defaultValue).toFixed() : Number(defaultValue)); + } + + return editField(field, path, value); + }; + + return CheckboxEntry({ + element: field, + getValue, + id, + label: 'Serialize to string', + description: 'Allows arbitrary precision values', + setValue + }); +} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/entries/index.js b/packages/form-js-editor/src/features/properties-panel/entries/index.js index f0de26786..2a2a1254a 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/index.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/index.js @@ -7,6 +7,8 @@ export { default as IdEntry } from './IdEntry'; export { default as KeyEntry } from './KeyEntry'; export { default as LabelEntry } from './LabelEntry'; export { default as TextEntry } from './TextEntry'; +export { default as NumberEntry } from './NumberEntry'; +export { default as NumberSerializationEntry } from './NumberSerializationEntry'; export { default as ValueEntry } from './ValueEntry'; export { default as CustomValueEntry } from './CustomValueEntry'; export { default as ValuesSourceSelectEntry } from './ValuesSourceSelectEntry'; diff --git a/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js index ddd2a9736..4be52243d 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js @@ -7,7 +7,8 @@ import { IdEntry, KeyEntry, LabelEntry, - TextEntry + TextEntry, + NumberEntry, } from '../entries'; @@ -22,6 +23,7 @@ export default function GeneralGroup(field, editField) { ...ActionEntry({ field, editField }), ...ColumnsEntry({ field, editField }), ...TextEntry({ field, editField }), + ...NumberEntry({ field, editField }), ...DisabledEntry({ field, editField }) ]; diff --git a/packages/form-js-editor/src/features/properties-panel/groups/SerializationGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/SerializationGroup.js new file mode 100644 index 000000000..7571490fd --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/groups/SerializationGroup.js @@ -0,0 +1,21 @@ +import { + NumberSerializationEntry +} from '../entries'; + + +export default function SerializationGroup(field, editField) { + + const entries = [ + ...NumberSerializationEntry({ field, editField }) + ]; + + if (!entries.length) { + return null; + } + + return { + id: 'serialization', + label: 'Serialization', + entries + }; +} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/groups/ValidationGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/ValidationGroup.js index e49bd7fe0..adc3e36e4 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/ValidationGroup.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/ValidationGroup.js @@ -283,4 +283,4 @@ function ValidationType(props) { return Object.values(VALIDATION_TYPE_OPTIONS); } }); -} \ No newline at end of file +} diff --git a/packages/form-js-editor/src/features/properties-panel/groups/index.js b/packages/form-js-editor/src/features/properties-panel/groups/index.js index c46435e01..73b2317af 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/index.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/index.js @@ -1,4 +1,5 @@ export { default as GeneralGroup } from './GeneralGroup'; +export { default as SerializationGroup } from './SerializationGroup'; export { default as ValidationGroup } from './ValidationGroup'; export { default as ValuesGroups } from './ValuesGroups'; export { default as CustomValuesGroup } from './CustomValuesGroup'; diff --git a/packages/form-js-viewer/assets/form-js.css b/packages/form-js-viewer/assets/form-js.css index 305af6ecc..6abc1c881 100644 --- a/packages/form-js-viewer/assets/form-js.css +++ b/packages/form-js-viewer/assets/form-js.css @@ -9,6 +9,7 @@ --color-grey-225-10-80: hsl(225, 10%, 80%); --color-grey-225-10-85: hsl(225, 10%, 85%); --color-grey-225-10-90: hsl(225, 10%, 90%); + --color-grey-225-10-93: hsl(225, 10%, 93%); --color-grey-225-10-95: hsl(225, 10%, 95%); --color-grey-225-10-97: hsl(225, 10%, 97%); @@ -23,6 +24,7 @@ --color-red-360-100-40: hsl(360, 100%, 40%); --color-red-360-100-45: hsl(360, 100%, 45%); --color-red-360-100-92: hsl(360, 100%, 92%); + --color-red-360-100-95: hsl(360, 100%, 95%); --color-red-360-100-97: hsl(360, 100%, 97%); --color-white: hsl(0, 0%, 100%); @@ -30,17 +32,24 @@ --color-background: var(--color-white); --color-background-disabled: var(--color-grey-225-10-95); + --color-background-adornment: var(--color-grey-225-10-93); --color-text: var(--color-grey-225-10-15); --color-text-light: var(--color-grey-225-10-35); --color-text-lighter: var(--color-grey-225-10-55); --color-text-inverted: var(--color-white); --color-borders: var(--color-grey-225-10-55); --color-borders-disabled: var(--color-grey-225-10-75); + --color-borders-adornment: var(--color-grey-225-10-85); --color-warning: var(--color-red-360-100-45); --color-accent: var(--color-blue-205-100-40); --font-family: 'IBM Plex Sans', sans-serif; + --border-definition: 1px solid var(--color-borders); + --border-definition-adornment: 1px solid var(--color-borders-adornment); + --outline-definition: 1px solid var(--color-borders); + --border-definition-disabled: 1px solid var(--color-borders-disabled); + height: 100%; } @@ -151,6 +160,62 @@ border-radius: 3px; } +.fjs-container .fjs-input-group { + display: flex; + width: 100%; + margin-top: 8px; + border: var(--border-definition); + border-radius: 3px; +} + +.fjs-container .fjs-input-group.disabled { + background-color: var(--color-background-disabled); +} + +.fjs-container .fjs-input-group.disabled, +.fjs-container .fjs-input-group.disabled .fjs-input, +.fjs-container .fjs-input-group.disabled .fjs-input-adornment { + border-color: var(--color-borders-disabled); +} + +.fjs-container .fjs-taglist .fjs-taglist-input, +.fjs-container .fjs-input-group .fjs-input { + border: none; + border-radius: 0; + margin: 0; + outline: 0; +} + +.fjs-container .fjs-input-group .fjs-input-adornment { + border-width: 0; +} + +.fjs-container .fjs-input-group .fjs-input-adornment.border-left { + border-left-width: 1px; +} + +.fjs-container .fjs-input-group .fjs-input-adornment.border-right { + border-right-width: 1px; +} + +.fjs-container .fjs-input-group .fjs-input-adornment.border-radius-right { + border-radius: 0 3px 3px 0; +} + +.fjs-container .fjs-input-group .fjs-input-adornment.border-radius-left { + border-radius: 3px 0 0 3px; +} + +.fjs-container .fjs-input-group .fjs-input, +.fjs-container .fjs-input-group.disabled .fjs-input { + border-radius: 3px; +} + +.fjs-container .fjs-vertical-group { + display: flex; + width: 100%; +} + .fjs-container .fjs-textarea { resize: none; overflow: hidden; @@ -158,6 +223,39 @@ font-size: 14px; } +.fjs-container .fjs-number-arrow-container { + display: flex; + flex-direction: column; + border-radius: 0 2px 2px 0; + width: 23px; + overflow: clip; + border-left: var(--border-definition-adornment); +} + +.fjs-container .fjs-number-arrow-separator { + height: 1px; + background-color: var(--color-borders-adornment); +} + +.fjs-container .fjs-number-arrow-container button { + border: none; + flex: 1; + color: var(--color-text-light); + background-color: var(--color-grey-225-10-95); + font-weight: bold; + font-size: 10px; +} + +.fjs-container .fjs-number-arrow-container button:hover { + background-color: var(--color-grey-225-10-93); + color: var(--color-text); +} + +.fjs-container .fjs-number-arrow-container.disabled button { + background-color: var(--color-grey-225-10-95); +} + + .fjs-container .fjs-radio { display: flex; flex-direction: column; @@ -190,6 +288,9 @@ font-weight: 600; } +.fjs-container .fjs-taglist:focus-within, +.fjs-container .fjs-input-group:focus-within, +.fjs-container .fjs-textarea:focus, .fjs-container .fjs-input[type='text']:focus, .fjs-container .fjs-input[type='email']:focus, .fjs-container .fjs-input[type='tel']:focus, @@ -201,6 +302,11 @@ outline: var(--color-borders) solid 1px; } +.fjs-container .fjs-input-group .fjs-input, +.fjs-container .fjs-input-group .fjs-input:focus { + outline: none; +} + .fjs-container .fjs-button[type='submit']:focus { border-color: var(--color-accent); } @@ -221,8 +327,26 @@ .fjs-container .fjs-form-field.fjs-has-errors .fjs-input, .fjs-container .fjs-form-field.fjs-has-errors .fjs-select, +.fjs-container .fjs-form-field.fjs-has-errors .fjs-input-group, .fjs-container .fjs-form-field.fjs-has-errors .fjs-textarea { border-color: var(--color-warning); + outline-color: var(--color-warning); +} + +.fjs-container .fjs-form-field.fjs-has-errors .fjs-number-arrow-container { + border-color: var(--color-red-360-100-92); +} + +.fjs-container .fjs-form-field.fjs-has-errors .fjs-number-arrow-separator { + background-color: var(--color-red-360-100-92); +} + +.fjs-container .fjs-form-field.fjs-has-errors .fjs-number-arrow-container button { + background-color: var(--color-red-360-100-97); +} + +.fjs-container .fjs-form-field.fjs-has-errors .fjs-number-arrow-container button:hover { + background-color: var(--color-red-360-100-95); } .fjs-container .fjs-form-field-error { @@ -257,10 +381,6 @@ background-color: var(--color-background); } -.fjs-container .fjs-taglist:focus-within { - outline: var(--color-borders) solid 1px; -} - .fjs-container .fjs-taglist.disabled { border: var(--color-borders-disabled) solid 1px; background-color: var(--color-background-disabled); diff --git a/packages/form-js-viewer/package.json b/packages/form-js-viewer/package.json index 391b75508..231bc477d 100644 --- a/packages/form-js-viewer/package.json +++ b/packages/form-js-viewer/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@bpmn-io/snarkdown": "^2.1.0", + "big.js": "^6.2.1", "classnames": "^2.3.1", "didi": "^9.0.0", "feelin": "^0.41.0", diff --git a/packages/form-js-viewer/src/core/Validator.js b/packages/form-js-viewer/src/core/Validator.js index 5e0d60af4..9a074b1f6 100644 --- a/packages/form-js-viewer/src/core/Validator.js +++ b/packages/form-js-viewer/src/core/Validator.js @@ -1,4 +1,6 @@ import { isNil } from 'min-dash'; +import { countDecimals } from '../render/components/util/numberFieldUtil'; +import Big from 'big.js'; const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ ; @@ -8,10 +10,55 @@ export default class Validator { validateField(field, value) { - const { validate } = field; + const { type, validate } = field; let errors = []; + if (type === 'number') { + + const { decimalDigits, step } = field; + + if (value === 'NaN') { + + errors = [ + ...errors, + 'Value is not a number.' + ]; + + } + else if (value) { + + if (decimalDigits !== undefined && decimalDigits >= 0 && countDecimals(value) > decimalDigits) { + errors = [ + ...errors, + 'Value is expected to ' + + (decimalDigits === 0 + ? 'be an integer' + : `have at most ${decimalDigits} decimal digit${decimalDigits > 1 ? 's' : ''}` + ) + '.' + ]; + } + + if (step) { + + const bigValue = Big(value); + const bigStep = Big(step); + + const offset = bigValue.mod(bigStep); + + if (offset.cmp(0) !== 0) { + const previousValue = bigValue.minus(offset); + const nextValue = previousValue.plus(bigStep); + + errors = [ + ...errors, + `Please select a valid value, the two nearest valid values are ${previousValue} and ${nextValue}.` + ]; + } + } + } + } + if (!validate) { return errors; } @@ -76,4 +123,4 @@ export default class Validator { } } -Validator.$inject = []; +Validator.$inject = []; \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/components/FormComponent.js b/packages/form-js-viewer/src/render/components/FormComponent.js index 93dcd2b6a..d8d4576bc 100644 --- a/packages/form-js-viewer/src/render/components/FormComponent.js +++ b/packages/form-js-viewer/src/render/components/FormComponent.js @@ -34,6 +34,7 @@ export default function FormComponent(props) { class="fjs-form" onSubmit={ handleSubmit } onReset={ handleReset } + noValidate > { - props.onChange({ - field, - value: Number.sanitizeValue({ value: target.value }) - }); + const inputRef = useRef(); + + const [ stringValueCache, setStringValueCache ] = useState(''); + + const valueCacheMismatch = useMemo(() => Numberfield.sanitizeValue({ value, formField: field }) !== Numberfield.sanitizeValue({ value: stringValueCache, formField: field }), [ stringValueCache, value, field ]); + + const displayValue = useMemo(() => { + + if (value === 'NaN') return 'NaN'; + return valueCacheMismatch ? ((value || value === 0) ? Big(value).toFixed() : '') : stringValueCache; + + }, [ stringValueCache, value, valueCacheMismatch ]); + + const arrowIncrement = useMemo(() => { + + if (increment) return Big(increment); + if (decimalDigits) return Big(`1e-${decimalDigits}`); + return Big('1'); + + }, [ decimalDigits, increment ]); + + + const setValue = useCallback((stringValue) => { + + if (isNullEquivalentValue(stringValue)) { + setStringValueCache(''); + onChange({ field, value: null }); + return; + } + + // treat commas as dots + stringValue = stringValue.replaceAll(',', '.'); + + if (isNaN(Number(stringValue))) { + setStringValueCache('NaN'); + onChange({ field, value: 'NaN' }); + return; + } + + setStringValueCache(stringValue); + onChange({ field, value: serializeToString ? stringValue : Number(stringValue) }); + + }, [ field, onChange, serializeToString ]); + + const addIncrement = () => { + const base = isValidNumber(value) ? Big(value) : Big(0); + const stepFlooredValue = base.minus(base.mod(arrowIncrement)); + + // note: toFixed() behaves differently in big.js + setValue(stepFlooredValue.plus(arrowIncrement).toFixed()); + }; + + const decrement = () => { + const base = isValidNumber(value) ? Big(value) : Big(0); + const offset = base.mod(arrowIncrement); + + if (offset.cmp(0) === 0) { + + // if we're already on a valid step, decrement + setValue(base.minus(arrowIncrement).toFixed()); + } + else { + + // otherwise floor to the step + const stepFlooredValue = base.minus(base.mod(arrowIncrement)); + setValue(stepFlooredValue.toFixed()); + } + }; + + const onKeyDown = (e) => { + + // delete the NaN state all at once on backspace or delete + if (value === 'NaN' && (e.code === 'Backspace' || e.code === 'Delete')) { + setValue(null); + e.preventDefault(); + return; + } + + if (e.code === 'ArrowUp') { + addIncrement(); + e.preventDefault(); + return; + } + + if (e.code === 'ArrowDown') { + decrement(); + e.preventDefault(); + return; + } + + }; + + // intercept key presses which would lead to an invalid number + const onKeyPress = (e) => { + const carretIndex = inputRef.current.selectionStart; + const selectionWidth = inputRef.current.selectionStart - inputRef.current.selectionEnd; + const previousValue = inputRef.current.value; + + if (!willKeyProduceValidNumber(e.key, previousValue, carretIndex, selectionWidth, decimalDigits)) { + e.preventDefault(); + } }; const { formId } = useContext(FormContext); @@ -45,30 +153,46 @@ export default function Number(props) { id={ prefixId(id, formId) } label={ label } required={ required } /> - +
+ setValue(e.target.value) } + type="text" + autoComplete="off" + step={ arrowIncrement } + value={ displayValue } /> +
+ +
+ +
+
; } -Number.create = function(options = {}) { - return { - ...options - }; -}; +Numberfield.create = (options = {}) => options; +Numberfield.sanitizeValue = ({ value, formField }) => { + + // null state is allowed + if (isNullEquivalentValue(value)) return null; + + // if data cannot be parsed as a valid number, go into invalid NaN state + if (!isValidNumber(value)) return 'NaN'; -Number.sanitizeValue = ({ value }) => { - const parsedValue = parseInt(value, 10); - return isNaN(parsedValue) ? null : parsedValue; + // otherwise parse to formatting type + return formField.serializeToString ? value.toString() : Number(value); }; -Number.type = type; -Number.keyed = true; -Number.label = 'Number'; -Number.emptyValue = null; \ No newline at end of file +Numberfield.type = type; +Numberfield.keyed = true; +Numberfield.label = 'Number'; +Numberfield.emptyValue = null; \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/components/index.js b/packages/form-js-viewer/src/render/components/index.js index bb483d2d2..d017a58a4 100644 --- a/packages/form-js-viewer/src/render/components/index.js +++ b/packages/form-js-viewer/src/render/components/index.js @@ -3,7 +3,7 @@ import Checkbox from './form-fields/Checkbox'; import Checklist from './form-fields/Checklist'; import Default from './form-fields/Default'; import FormComponent from './FormComponent'; -import Number from './form-fields/Number'; +import Numberfield from './form-fields/Number'; import Radio from './form-fields/Radio'; import Select from './form-fields/Select'; import Taglist from './form-fields/Taglist'; @@ -17,7 +17,7 @@ export { Checklist, Default, FormComponent, - Number, + Numberfield, Radio, Select, Taglist, @@ -31,7 +31,7 @@ export const formFields = [ Checkbox, Checklist, Default, - Number, + Numberfield, Radio, Select, Taglist, diff --git a/packages/form-js-viewer/src/render/components/util/numberFieldUtil.js b/packages/form-js-viewer/src/render/components/util/numberFieldUtil.js new file mode 100644 index 000000000..8ea2e9567 --- /dev/null +++ b/packages/form-js-viewer/src/render/components/util/numberFieldUtil.js @@ -0,0 +1,39 @@ +import Big from 'big.js'; + +export function countDecimals(number) { + const num = Big(number); + if (num.toString() === num.toFixed(0)) return 0; + return num.toFixed().split('.')[1].length || 0; +} + +export function isValidNumber(value) { + return (typeof value === 'number' || typeof value === 'string') && value !== '' && !isNaN(Number(value)); +} + +export function willKeyProduceValidNumber(key, previousValue, carretIndex, selectionWidth, decimalDigits) { + + // Dot and comma are both treated as dot + previousValue = previousValue.replace(',', '.'); + const isFirstDot = !previousValue.includes('.') && (key === '.' || key === ','); + const isFirstMinus = !previousValue.includes('-') && key === '-' && carretIndex === 0; + + const keypressIsNumeric = /^[0-9]$/i.test(key); + const dotIndex = previousValue?.indexOf('.') ?? -1; + + // If the carret is positioned after a dot, and the current decimal digits count is equal or greater to the maximum, disallow the key press + const overflowsDecimalSpace = typeof(decimalDigits) === 'number' + && selectionWidth === 0 + && dotIndex !== -1 + && previousValue.includes('.') + && previousValue.split('.')[1].length >= decimalDigits + && carretIndex > dotIndex; + + const keypressIsAllowedChar = keypressIsNumeric || ((decimalDigits !== 0) && isFirstDot) || isFirstMinus; + + return keypressIsAllowedChar && !overflowsDecimalSpace; + +} + +export function isNullEquivalentValue(value) { + return value === undefined || value === null || value === ''; +} diff --git a/packages/form-js-viewer/src/render/hooks/useEffectDebugger.js b/packages/form-js-viewer/src/render/hooks/useEffectDebugger.js new file mode 100644 index 000000000..821261e74 --- /dev/null +++ b/packages/form-js-viewer/src/render/hooks/useEffectDebugger.js @@ -0,0 +1,59 @@ +const { useEffect, useRef, useCallback } = require('preact/hooks'); + +const usePrevious = (value, initialValue) => { + const ref = useRef(initialValue); + useEffect(() => { + ref.current = value; + }); + return ref.current; +}; + +export function useEffectDebugger(effectHook, dependencies, dependencyNames = [], effectName = 'noname') { + const previousDeps = usePrevious(dependencies, []); + + const changedDeps = dependencies.reduce((accum, dependency, index) => { + if (dependency !== previousDeps[index]) { + const keyName = dependencyNames[index] || index; + return { + ...accum, + [keyName]: { + before: previousDeps[index], + after: dependency + } + }; + } + + return accum; + }, {}); + + if (Object.keys(changedDeps).length) { + console.log('[use-effect-debugger] (' + effectName + ') ', changedDeps); + } + + useEffect(effectHook, dependencies); +} + +export function useCallbackDebugger(callback, dependencies, dependencyNames = [], callbackName = 'noname') { + const previousDeps = usePrevious(dependencies, []); + + const changedDeps = dependencies.reduce((accum, dependency, index) => { + if (dependency !== previousDeps[index]) { + const keyName = dependencyNames[index] || index; + return { + ...accum, + [keyName]: { + before: previousDeps[index], + after: dependency + } + }; + } + + return accum; + }, {}); + + if (Object.keys(changedDeps).length) { + console.log('[use-callback-debugger] (' + callbackName + ') ', changedDeps); + } + + return useCallback(callback, dependencies); +} \ No newline at end of file diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js index f9f579d58..a6d9728e3 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js @@ -39,7 +39,7 @@ describe('Number', function() { expect(formField).to.exist; expect(formField.classList.contains('fjs-form-field-number')).to.be.true; - const input = container.querySelector('input[type="number"]'); + const input = container.querySelector('input[type="text"]'); expect(input).to.exist; expect(input.value).to.equal('123'); @@ -57,7 +57,7 @@ describe('Number', function() { const { container } = createNumberField(); // then - const input = container.querySelector('input[type="number"]'); + const input = container.querySelector('input[type="text"]'); expect(input).to.exist; expect(input.value).to.equal(''); @@ -72,7 +72,7 @@ describe('Number', function() { }); // then - const input = container.querySelector('input[type="number"]'); + const input = container.querySelector('input[type="text"]'); expect(input).to.exist; expect(input.value).to.equal(''); @@ -98,7 +98,7 @@ describe('Number', function() { rerender(, options); // then - const input = container.querySelector('input[type="number"]'); + const input = container.querySelector('input[type="text"]'); expect(input).to.exist; expect(input.value).to.equal(''); @@ -113,7 +113,7 @@ describe('Number', function() { }); // then - const input = container.querySelector('input[type="number"]'); + const input = container.querySelector('input[type="text"]'); expect(input).to.exist; expect(input.disabled).to.be.true; @@ -151,7 +151,7 @@ describe('Number', function() { }); // when - const input = container.querySelector('input[type="number"]'); + const input = container.querySelector('input[type="text"]'); fireEvent.input(input, { target: { value: '124' } }); @@ -174,7 +174,7 @@ describe('Number', function() { }); // when - const input = container.querySelector('input[type="number"]'); + const input = container.querySelector('input[type="text"]'); fireEvent.input(input, { target: { value: '' } }); From 39ebc5c235d8b13216c3871a86c171727d14e66d Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Wed, 23 Nov 2022 09:21:25 +0100 Subject: [PATCH 02/14] chore: test floating point number field Closes #285 --- .../test/spec/core/Validator.spec.js | 111 +++++++++ .../components/form-fields/Number.spec.js | 232 +++++++++++++++++- .../form-fields/util/NumberFieldUtil.spec.js | 75 ++++++ 3 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js diff --git a/packages/form-js-viewer/test/spec/core/Validator.spec.js b/packages/form-js-viewer/test/spec/core/Validator.spec.js index e63f6bd91..9f0c9fb70 100644 --- a/packages/form-js-viewer/test/spec/core/Validator.spec.js +++ b/packages/form-js-viewer/test/spec/core/Validator.spec.js @@ -21,6 +21,117 @@ describe('Validator', function() { }); + describe('', function() { + + it('should disallow NaN', function() { + + // given + const field = { + type: 'number', + }; + + // when + const errors = validator.validateField(field, 'NaN'); + + // then + expect(errors).to.have.length(1); + expect(errors[ 0 ]).to.equal('Value is not a number.'); + + }); + + + it('should restrict decimals', function() { + + // given + const field = { + type: 'number', + decimalDigits: 3 + }; + + // when + const errors = validator.validateField(field, 3.1256); + + // then + expect(errors).to.have.length(1); + expect(errors[0]).to.equal('Value is expected to have at most 3 decimal digits.'); + + }); + + + it('should restrict decimals (0)', function() { + + // given + const field = { + type: 'number', + decimalDigits: 0 + }; + + // when + const errors = validator.validateField(field, 3.1256); + + // then + expect(errors).to.have.length(1); + expect(errors[0]).to.equal('Value is expected to be an integer.'); + + }); + + + it('should restrict decimals', function() { + + // given + const field = { + type: 'number', + decimalDigits: 3 + }; + + // when + const errors = validator.validateField(field, '3.1415'); + + // then + expect(errors).to.have.length(1); + expect(errors[0]).to.equal('Value is expected to have at most 3 decimal digits.'); + + }); + + + it('should restrict step', function() { + + // given + const field = { + type: 'number', + step: 0.05 + }; + + // when + const errors = validator.validateField(field, 3.1689); + + // then + expect(errors).to.have.length(1); + expect(errors[0]).to.equal('Please select a valid value, the two nearest valid values are 3.15 and 3.2.'); + + }); + + + it('should restrict step (string)', function() { + + // given + const field = { + type: 'number', + step: 0.005 + }; + + // when + const errors = validator.validateField(field, '3.1689'); + + // then + expect(errors).to.have.length(1); + expect(errors[0]).to.equal('Please select a valid value, the two nearest valid values are 3.165 and 3.17.'); + + }); + + }); + + describe('pattern', function() { it('should be valid', function() { diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js index a6d9728e3..ee198154b 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js @@ -1,5 +1,6 @@ import { fireEvent, + createEvent, render } from '@testing-library/preact/pure'; @@ -162,7 +163,6 @@ describe('Number', function() { }); }); - it('should clear', function() { // given @@ -188,6 +188,231 @@ describe('Number', function() { }); + describe('formatting', function() { + + it('should handle string inputs as numbers by default', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 123, + }); + + // when + const input = container.querySelector('input[type="text"]'); + + fireEvent.input(input, { target: { value: '124' } }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: defaultField, + value: 124 + }); + + }); + + + it('should handle number inputs as strings if configured', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 123, + field: stringField + }); + + // when + const input = container.querySelector('input[type="text"]'); + + fireEvent.input(input, { target: { value: 124 } }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stringField, + value: '124' + }); + + }); + + + it('should handle string inputs as strings if configured', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 123, + field: stringField + }); + + // when + const input = container.querySelector('input[type="text"]'); + + fireEvent.input(input, { target: { value: '125' } }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stringField, + value: '125' + }); + + }); + + + it('should handle high precision string numbers without trimming', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 123, + field: stringField + }); + + const highPrecisionStringNumber = '125.0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001'; + + // when + const input = container.querySelector('input[type="text"]'); + + fireEvent.input(input, { target: { value: highPrecisionStringNumber } }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stringField, + value: highPrecisionStringNumber + }); + + }); + + + it('should treat invalid string numbers as "NaN"', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 123, + field: stringField + }); + + + // when + const input = container.querySelector('input[type="text"]'); + + fireEvent.input(input, { target: { value: '12.25a' } }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stringField, + value: 'NaN' + }); + + }); + + }); + + + describe('decimals', function() { + }); + + describe('user input', function() { + + it('should prevent key presses generating non-number characters', function() { + + // given + const { container } = createNumberField({ + value: 123 + }); + + const input = container.querySelector('input[type="text"]'); + + expect(input).to.exist; + expect(input.value).to.equal('123'); + + const periodKeyPress = createEvent.keyPress(input, { key: '.', code: 'Period' }); + const commaKeyPress = createEvent.keyPress(input, { key: '.', code: 'Comma' }); + const letterKeyPress = createEvent.keyPress(input, { key: 'a', code: 'KeyA' }); + const digitKeyPress = createEvent.keyPress(input, { key: '2', code: 'Digit2' }); + const minusKeyPress = createEvent.keyPress(input, { key: 'a', code: 'KeyA' }); + + // when + fireEvent.focus(input); + fireEvent(input, periodKeyPress); + fireEvent(input, commaKeyPress); + fireEvent(input, letterKeyPress); + fireEvent(input, digitKeyPress); + fireEvent(input, minusKeyPress); + + // then + expect(periodKeyPress.defaultPrevented).to.be.false; + expect(commaKeyPress.defaultPrevented).to.be.false; + expect(letterKeyPress.defaultPrevented).to.be.true; + expect(digitKeyPress.defaultPrevented).to.be.false; + expect(minusKeyPress.defaultPrevented).to.be.true; + + }); + + + it('should prevent second comma or period', function() { + + // given + const { container } = createNumberField({ + value: 123.5 + }); + + const input = container.querySelector('input[type="text"]'); + + expect(input).to.exist; + expect(input.value).to.equal('123.5'); + + const periodKeyPress = createEvent.keyPress(input, { key: '.', code: 'Period' }); + const commaKeyPress = createEvent.keyPress(input, { key: '.', code: 'Comma' }); + + // when + fireEvent.focus(input); + fireEvent(input, periodKeyPress); + fireEvent(input, commaKeyPress); + + // then + expect(periodKeyPress.defaultPrevented).to.be.true; + expect(commaKeyPress.defaultPrevented).to.be.true; + + }); + + + it('should allow a minus at the start', function() { + + // given + const { container } = createNumberField({ + value: null + }); + + const input = container.querySelector('input[type="text"]'); + + expect(input).to.exist; + expect(input.value).to.equal(''); + + const minusKeyPress = createEvent.keyPress(input, { key: '-', code: 'Minus' }); + + // when + fireEvent.focus(input); + fireEvent(input, minusKeyPress); + + // then + expect(minusKeyPress.defaultPrevented).to.be.false; + + }); + + }); + + it('#create', function() { // assume @@ -241,6 +466,11 @@ const defaultField = { description: 'number' }; +const stringField = { + ...defaultField, + serializeToString: true +}; + function createNumberField(options = {}) { const { disabled, diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js new file mode 100644 index 000000000..01a960c26 --- /dev/null +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js @@ -0,0 +1,75 @@ +import { willKeyProduceValidNumber, countDecimals } from '../../../../../../src/render/components/util/numberFieldUtil.js'; + + + +describe('numberFieldUtil', function() { + + it('#countDecimals', function() { + + // given + const valuesMatrix = [ + [ 1, 0 ], + [ 1.1, 1 ], + [ 1.11, 2 ], + [ 1.111, 3 ], + [ 1.111111, 6 ], + [ 1e-13, 13 ], + [ '1.0000000000000000001', 19 ], + [ '1.1', 1 ], + ]; + + // then + for (const [ value, result ] of valuesMatrix) { + expect(countDecimals(value)).to.equal(result); + } + + }); + + it('#willKeyProduceValidNumber', function() { + + // given + const scenarios = [ + + // entering a number at the start + [ 'a', '123', 0, 0, 0, false ], + + // entering a digit at the start + [ '1', '123', 0, 0, 0, true ], + + // entering a digit at the end + [ '1', '123', 3, 0, 0, true ], + + // entering a period for an integer + [ '.', '123', 3, 0, 0, false ], + + // entering a period for a decimal + [ '.', '123', 3, 0, 3, true ], + + // entering a second period for a decimal + [ '.', '123.', 4, 0, 3, false ], + + // entering a minus at the start of a decimal + [ '-', '123.', 0, 0, 3, true ], + + // entering a second minus at the start of a decimal + [ '-', '-123.', 0, 0, 3, false ], + + // entering minus in the middle of a decimal + [ '-', '123.', 1, 0, 3, false ], + + // entering too many decimals + [ '3', '123.333', 7, 0, 3, false ], + + // entering too many decimals #2 + [ '3', '123.3333', 7, 0, 3, false ], + + ]; + + // then + for (const [ key, previousValue, carretIndex, selectionWidth, decimalDigits, expectedValue ] of scenarios) { + expect(willKeyProduceValidNumber(key, previousValue, carretIndex, selectionWidth, decimalDigits)).to.equal(expectedValue); + } + + }); + +}); \ No newline at end of file From 2bcb35d05b905d4ffd7c1a9b563e8a9e697ce23d Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 28 Nov 2022 14:25:17 +0100 Subject: [PATCH 03/14] chore: define big,js as an external dependency Related to #285 --- packages/form-js-editor/rollup.config.js | 1 + packages/form-js-viewer/rollup.config.js | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/form-js-editor/rollup.config.js b/packages/form-js-editor/rollup.config.js index 869b02fd2..b2f2c2628 100644 --- a/packages/form-js-editor/rollup.config.js +++ b/packages/form-js-editor/rollup.config.js @@ -57,6 +57,7 @@ export default [ 'ids', 'min-dash', 'array-move', + 'big.js', 'preact', 'preact/jsx-runtime', 'preact/hooks', diff --git a/packages/form-js-viewer/rollup.config.js b/packages/form-js-viewer/rollup.config.js index fee53ef20..9c6ec2f82 100644 --- a/packages/form-js-viewer/rollup.config.js +++ b/packages/form-js-viewer/rollup.config.js @@ -52,6 +52,7 @@ export default [ ], external: [ 'min-dash', + 'big.js', 'preact', 'preact/jsx-runtime', 'preact/hooks', From b1bac93332fee535136f7e180c894ad0f11b64d4 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 28 Nov 2022 14:30:32 +0100 Subject: [PATCH 04/14] chore: decimal number field clarity and cleanups Related to #285 --- .../{NumberEntry.js => NumberEntries.js} | 2 +- .../properties-panel/entries/index.js | 2 +- .../properties-panel/groups/GeneralGroup.js | 4 +- packages/form-js-viewer/src/core/Validator.js | 4 +- .../render/components/form-fields/Number.js | 37 ++++++++++--------- .../components/form-fields/Number.spec.js | 3 -- .../form-fields/util/NumberFieldUtil.spec.js | 1 + 7 files changed, 27 insertions(+), 26 deletions(-) rename packages/form-js-editor/src/features/properties-panel/entries/{NumberEntry.js => NumberEntries.js} (98%) diff --git a/packages/form-js-editor/src/features/properties-panel/entries/NumberEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js similarity index 98% rename from packages/form-js-editor/src/features/properties-panel/entries/NumberEntry.js rename to packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js index 0e411940f..2b4b7a5f1 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/NumberEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js @@ -5,7 +5,7 @@ import { get } from 'min-dash'; import { useService } from '../hooks'; import { countDecimals, isValidNumber } from '../Util'; -export default function NumberEntry(props) { +export default function NumberEntries(props) { const { editField, field, diff --git a/packages/form-js-editor/src/features/properties-panel/entries/index.js b/packages/form-js-editor/src/features/properties-panel/entries/index.js index 2a2a1254a..ac6f8bb8c 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/index.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/index.js @@ -7,7 +7,7 @@ export { default as IdEntry } from './IdEntry'; export { default as KeyEntry } from './KeyEntry'; export { default as LabelEntry } from './LabelEntry'; export { default as TextEntry } from './TextEntry'; -export { default as NumberEntry } from './NumberEntry'; +export { default as NumberEntries } from './NumberEntries'; export { default as NumberSerializationEntry } from './NumberSerializationEntry'; export { default as ValueEntry } from './ValueEntry'; export { default as CustomValueEntry } from './CustomValueEntry'; diff --git a/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js index 4be52243d..bb2f9a814 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js @@ -8,7 +8,7 @@ import { KeyEntry, LabelEntry, TextEntry, - NumberEntry, + NumberEntries, } from '../entries'; @@ -23,7 +23,7 @@ export default function GeneralGroup(field, editField) { ...ActionEntry({ field, editField }), ...ColumnsEntry({ field, editField }), ...TextEntry({ field, editField }), - ...NumberEntry({ field, editField }), + ...NumberEntries({ field, editField }), ...DisabledEntry({ field, editField }) ]; diff --git a/packages/form-js-viewer/src/core/Validator.js b/packages/form-js-viewer/src/core/Validator.js index 9a074b1f6..ce5b44224 100644 --- a/packages/form-js-viewer/src/core/Validator.js +++ b/packages/form-js-viewer/src/core/Validator.js @@ -3,7 +3,7 @@ import { countDecimals } from '../render/components/util/numberFieldUtil'; import Big from 'big.js'; const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ -; + ; const PHONE_PATTERN = /(\+|00)(297|93|244|1264|358|355|376|971|54|374|1684|1268|61|43|994|257|32|229|226|880|359|973|1242|387|590|375|501|1441|591|55|1246|673|975|267|236|1|61|41|56|86|225|237|243|242|682|57|269|238|506|53|5999|61|1345|357|420|49|253|1767|45|1809|1829|1849|213|593|20|291|212|34|372|251|358|679|500|33|298|691|241|44|995|44|233|350|224|590|220|245|240|30|1473|299|502|594|1671|592|852|504|385|509|36|62|44|91|246|353|98|964|354|972|39|1876|44|962|81|76|77|254|996|855|686|1869|82|383|965|856|961|231|218|1758|423|94|266|370|352|371|853|590|212|377|373|261|960|52|692|389|223|356|95|382|976|1670|258|222|1664|596|230|265|60|262|264|687|227|672|234|505|683|31|47|977|674|64|968|92|507|64|51|63|680|675|48|1787|1939|850|351|595|970|689|974|262|40|7|250|966|249|221|65|500|4779|677|232|503|378|252|508|381|211|239|597|421|386|46|268|1721|248|963|1649|235|228|66|992|690|993|670|676|1868|216|90|688|886|255|256|380|598|1|998|3906698|379|1784|58|1284|1340|84|678|681|685|967|27|260|263)(9[976]\d|8[987530]\d|6[987]\d|5[90]\d|42\d|3[875]\d|2[98654321]\d|9[8543210]|8[6421]|6[6543210]|5[87654321]|4[987654310]|3[9643210]|2[70]|7|1)\d{4,20}$/; export default class Validator { @@ -28,7 +28,7 @@ export default class Validator { } else if (value) { - if (decimalDigits !== undefined && decimalDigits >= 0 && countDecimals(value) > decimalDigits) { + if (decimalDigits >= 0 && countDecimals(value) > decimalDigits) { errors = [ ...errors, 'Value is expected to ' + 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 13ff94f61..cda66f4f3 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 @@ -38,7 +38,7 @@ export default function Numberfield(props) { validate = {}, decimalDigits, serializeToString = false, - increment + increment: incrementValue } = field; const { required } = validate; @@ -47,22 +47,24 @@ export default function Numberfield(props) { const [ stringValueCache, setStringValueCache ] = useState(''); - const valueCacheMismatch = useMemo(() => Numberfield.sanitizeValue({ value, formField: field }) !== Numberfield.sanitizeValue({ value: stringValueCache, formField: field }), [ stringValueCache, value, field ]); + // 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.sanitizeValue({ value, formField: field }) === Numberfield.sanitizeValue({ value: stringValueCache, formField: field }), [ stringValueCache, value, field ]); const displayValue = useMemo(() => { if (value === 'NaN') return 'NaN'; - return valueCacheMismatch ? ((value || value === 0) ? Big(value).toFixed() : '') : stringValueCache; + return cacheValueMatchesState ? stringValueCache : ((value || value === 0) ? Big(value).toFixed() : ''); - }, [ stringValueCache, value, valueCacheMismatch ]); + }, [ stringValueCache, value, cacheValueMatchesState ]); - const arrowIncrement = useMemo(() => { + const arrowIncrementValue = useMemo(() => { - if (increment) return Big(increment); + if (incrementValue) return Big(incrementValue); if (decimalDigits) return Big(`1e-${decimalDigits}`); return Big('1'); - }, [ decimalDigits, increment ]); + }, [ decimalDigits, incrementValue ]); const setValue = useCallback((stringValue) => { @@ -87,27 +89,27 @@ export default function Numberfield(props) { }, [ field, onChange, serializeToString ]); - const addIncrement = () => { + const increment = () => { const base = isValidNumber(value) ? Big(value) : Big(0); - const stepFlooredValue = base.minus(base.mod(arrowIncrement)); + const stepFlooredValue = base.minus(base.mod(arrowIncrementValue)); // note: toFixed() behaves differently in big.js - setValue(stepFlooredValue.plus(arrowIncrement).toFixed()); + setValue(stepFlooredValue.plus(arrowIncrementValue).toFixed()); }; const decrement = () => { const base = isValidNumber(value) ? Big(value) : Big(0); - const offset = base.mod(arrowIncrement); + const offset = base.mod(arrowIncrementValue); if (offset.cmp(0) === 0) { // if we're already on a valid step, decrement - setValue(base.minus(arrowIncrement).toFixed()); + setValue(base.minus(arrowIncrementValue).toFixed()); } else { // otherwise floor to the step - const stepFlooredValue = base.minus(base.mod(arrowIncrement)); + const stepFlooredValue = base.minus(base.mod(arrowIncrementValue)); setValue(stepFlooredValue.toFixed()); } }; @@ -122,7 +124,7 @@ export default function Numberfield(props) { } if (e.code === 'ArrowUp') { - addIncrement(); + increment(); e.preventDefault(); return; } @@ -166,12 +168,13 @@ export default function Numberfield(props) { onInput={ (e) => setValue(e.target.value) } type="text" autoComplete="off" - step={ arrowIncrement } + step={ arrowIncrementValue } value={ displayValue } />
- + { /* we're disabling tab navigation on both buttons to imitate the native browser behavior of input[type='number'] increment arrows */ } +
- +
diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js index ee198154b..222f7c165 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js @@ -319,9 +319,6 @@ describe('Number', function() { }); - describe('decimals', function() { - }); - describe('user input', function() { it('should prevent key presses generating non-number characters', function() { diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js index 01a960c26..4db632b75 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js @@ -25,6 +25,7 @@ describe('numberFieldUtil', function() { }); + it('#willKeyProduceValidNumber', function() { // given From 1588be7ac28d2667df99975ec430e99d18eec2ec Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 08:14:33 +0100 Subject: [PATCH 05/14] chore: added stepper button test coverage Related to #285 --- .../components/form-fields/Number.spec.js | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js index 222f7c165..d51ede4a8 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js @@ -185,6 +185,245 @@ describe('Number', function() { }); }); + + }); + + + describe('interaction', function() { + + describe('increment button', function() { + + it('should increment', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 123 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-up'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: defaultField, + value: 124 + }); + }); + + + it('should increment according to `decimalDigits`', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + field: decimalField, + onChange: onChangeSpy, + value: 123 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-up'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: decimalField, + value: 123.001 + }); + }); + + + it('should increment according to `step`', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + field: stepField, + onChange: onChangeSpy, + value: 123 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-up'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stepField, + value: 123.25 + }); + }); + + + it('should increment to exact step when not aligned', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + field: stepField, + onChange: onChangeSpy, + value: 122.99 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-up'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stepField, + value: 123 + }); + }); + + + it('should increment properly when negative', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + field: stepField, + onChange: onChangeSpy, + value: -1 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-up'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stepField, + value: -0.75 + }); + }); + }); + + + describe('decrement button', function() { + + it('should decrement', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 123 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-down'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: defaultField, + value: 122 + }); + }); + + + it('should decrement according to `decimalDigits`', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + field: decimalField, + onChange: onChangeSpy, + value: 123 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-down'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: decimalField, + value: 122.999 + }); + }); + + + it('should decrement according to `step`', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + field: stepField, + onChange: onChangeSpy, + value: 123 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-down'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stepField, + value: 122.75 + }); + }); + + + it('should decrement to exact step when not aligned', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + field: stepField, + onChange: onChangeSpy, + value: 122.76 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-down'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stepField, + value: 122.75 + }); + }); + + + it('should decrement properly when negative', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + field: stepField, + onChange: onChangeSpy, + value: -1 + }); + + // when + const incrementButton = container.querySelector('.fjs-number-arrow-down'); + fireEvent.click(incrementButton); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: stepField, + value: -1.25 + }); + }); + }); + }); @@ -468,6 +707,17 @@ const stringField = { serializeToString: true }; +const decimalField = { + ...defaultField, + decimalDigits: 3 +}; + +const stepField = { + ...defaultField, + decimalDigits: 3, + increment: 0.25 +}; + function createNumberField(options = {}) { const { disabled, From 4067996b653818c9804f495eeeae00ca280a9d30 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 13:24:19 +0100 Subject: [PATCH 06/14] fix: clear default value to null Related to #285 --- .../properties-panel/entries/DefaultValueEntry.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js index 75612b5e4..263c364a4 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js @@ -150,13 +150,22 @@ function DefaultValueNumber(props) { let value = get(field, path); - if (!isValidNumber(value)) return null; + if (!isValidNumber(value)) return; // Enforces decimal notation so that we do not submit defaults in exponent form return serializeToString ? Big(value).toFixed() : value; }; - const setValue = (value) => editField(field, path, serializeToString ? value : Number(value)); + const setValue = (value) => { + + let newValue; + + if (isValidNumber(value)) { + newValue = serializeToString ? value : Number(value); + } + + return editField(field, path, newValue); + }; const decimalDigitsSet = decimalDigits || decimalDigits === 0; From 3225d684d42426de4be23cab85d30365f5b06929 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 13:24:42 +0100 Subject: [PATCH 07/14] chore: number properties panel test cov Related to #285 --- .../properties-panel/PropertiesPanel.spec.js | 472 ++++++++++++++++++ 1 file changed, 472 insertions(+) diff --git a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js index 1d2a7a9b4..ec45c6b1e 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js @@ -1986,6 +1986,478 @@ describe('properties panel', function() { }); + + describe('number', function() { + + it('entries', function() { + + // given + const field = schema.components.find(({ key }) => key === 'amount'); + + const result = createPropertiesPanel({ + container, + field + }); + + // then + expectGroups(result.container, [ + 'General', + 'Serialization', + 'Validation' + ]); + + expectGroupEntries(result.container, 'General', [ + 'Field label', + 'Field description', + 'Key', + 'Default value', + 'Decimal digits', + 'Increment', + 'Disabled' + ]); + + expectGroupEntries(result.container, 'Serialization', [ + 'Serialize to string' + ]); + + expectGroupEntries(result.container, 'Validation', [ + 'Required', + 'Minimum', + 'Maximum' + ]); + }); + + + describe('default value', function() { + + it('should add default value', function() { + + // given + const editFieldSpy = spy(); + + const field = schema.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Default value'); + + expect(input.value).to.equal(''); + + // when + fireEvent.input(input, { target: { value: 250 } }); + + // then + expect(editFieldSpy).to.have.been.calledOnce; + expect(editFieldSpy).to.have.been.calledWith(field, [ 'defaultValue' ], 250); + }); + + + it('should remove default value', function() { + + // given + const editFieldSpy = spy(); + + const field = defaultValues.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field: { + ...field, + defaultValue: 0 + } + }); + + // assume + const input = screen.getByLabelText('Default value'); + + expect(input.value).to.equal('0'); + + // when + fireEvent.input(input, { target: { value: '' } }); + + // then + expect(editFieldSpy).to.have.been.calledOnce; + expect(editFieldSpy).to.have.been.calledWith(field, [ 'defaultValue' ], undefined); + + }); + + }); + + describe('decimal digits', function() { + + it('should add positive integer values', function() { + + // given + const editFieldSpy = spy(); + + const field = schema.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Decimal digits'); + + expect(input.value).to.equal(''); + + // when + fireEvent.input(input, { target: { value: 100 } }); + + // then + expect(editFieldSpy).to.have.been.calledOnce; + expect(editFieldSpy).to.have.been.calledWith(field, [ 'decimalDigits' ], 100); + }); + + + it('should add zero', function() { + + // given + const editFieldSpy = spy(); + + const field = schema.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Decimal digits'); + + expect(input.value).to.equal(''); + + // when + fireEvent.input(input, { target: { value: 0 } }); + + // then + expect(editFieldSpy).to.have.been.calledOnce; + expect(editFieldSpy).to.have.been.calledWith(field, [ 'decimalDigits' ], 0); + }); + + + it('should reject negative values', function() { + + // given + const editFieldSpy = spy(); + + const field = schema.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Decimal digits'); + + expect(input.value).to.equal(''); + + // when + fireEvent.input(input, { target: { value: -1 } }); + + // then + expect(editFieldSpy).to.not.have.been.called; + }); + + + it('should reject decimal values', function() { + + // given + const editFieldSpy = spy(); + + const field = schema.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Decimal digits'); + + expect(input.value).to.equal(''); + + // when + fireEvent.input(input, { target: { value: -1 } }); + + // then + expect(editFieldSpy).to.not.have.been.called; + }); + + }); + + + describe('validation', function() { + + describe('default value', function() { + + it('should refuse non numeric values', function() { + + // given + const editFieldSpy = spy(); + + const field = defaultValues.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Default value'); + + // when + fireEvent.input(input, { target: { value: 'Joe' } }); + + // then + expect(editFieldSpy).to.not.have.been.called; + + const error = screen.getByText('Should be a valid number'); + expect(error).to.exist; + + }); + + + it('should refuse values not conforming to decimal digits', function() { + + // given + const editFieldSpy = spy(); + + const field = defaultValues.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field: { + ...field, + decimalDigits: 4, + } + }); + + // assume + const input = screen.getByLabelText('Default value'); + + // when + fireEvent.input(input, { target: { value: '0.00001' } }); + + // then + expect(editFieldSpy).to.not.have.been.called; + + const error = screen.getByText('Should not contain more than 4 decimal digits'); + expect(error).to.exist; + + }); + + + }); + + describe('increment', function() { + + it('should reject non-numeric values', function() { + + // given + const editFieldSpy = spy(); + + const field = defaultValues.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Increment'); + + // when + fireEvent.input(input, { target: { value: 'Joe' } }); + + // then + expect(editFieldSpy).to.not.have.been.called; + + const error = screen.getByText('Should be a valid number.'); + expect(error).to.exist; + + }); + + + it('should reject values smaller than the unit of the smallest digit', function() { + + // given + const editFieldSpy = spy(); + + const field = defaultValues.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field: { + ...field, + decimalDigits: 4, + } + }); + + // assume + const input = screen.getByLabelText('Increment'); + + // when + fireEvent.input(input, { target: { value: '0.00001' } }); + + // then + expect(editFieldSpy).to.not.have.been.called; + + const error = screen.getByText('Should be at least 0.0001.'); + expect(error).to.exist; + + }); + + + it('should be greater than zero', function() { + + // given + const editFieldSpy = spy(); + + const field = defaultValues.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Increment'); + + // when + fireEvent.input(input, { target: { value: '-0.00001' } }); + + // then + expect(editFieldSpy).to.not.have.been.called; + + const error = screen.getByText('Should be greater than zero.'); + expect(error).to.exist; + + }); + + }); + + describe('key', function() { + + it('should not be empty', function() { + + // given + const editFieldSpy = spy(); + + const field = schema.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Key', { selector: '#bio-properties-panel-key' }); + + expect(input.value).to.equal('amount'); + + // when + fireEvent.input(input, { target: { value: '' } }); + + // then + expect(editFieldSpy).not.to.have.been.called; + + const error = screen.getByText('Must not be empty.'); + + expect(error).to.exist; + }); + + + it('should not contain spaces', function() { + + // given + const editFieldSpy = spy(); + + const field = schema.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field + }); + + // assume + const input = screen.getByLabelText('Key', { selector: '#bio-properties-panel-key' }); + + expect(input.value).to.equal('amount'); + + // when + fireEvent.input(input, { target: { value: 'amou nt' } }); + + // then + expect(editFieldSpy).not.to.have.been.called; + + const error = screen.getByText('Must not contain spaces.'); + + expect(error).to.exist; + }); + + + it('should be unique', function() { + + // given + const editFieldSpy = spy(); + + const field = schema.components.find(({ key }) => key === 'amount'); + + createPropertiesPanel({ + container, + editField: editFieldSpy, + field, + formFieldRegistry: { + _keys: { + assigned(key) { + return schema.components.find((component) => component.key === key); + } + } + } + }); + + // assume + const input = screen.getByLabelText('Key', { selector: '#bio-properties-panel-key' }); + + expect(input.value).to.equal('amount'); + + // when + fireEvent.input(input, { target: { value: 'creditor' } }); + + // then + expect(editFieldSpy).not.to.have.been.called; + + const error = screen.getByText('Must be unique.'); + + expect(error).to.exist; + }); + + }); + + }); + + }); + }); From e905388bac586422a78fc8a11227917ac28b6926 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 13:34:20 +0100 Subject: [PATCH 08/14] chore: decapitalized numberFieldUtil spec Related to #285 --- .../util/{NumberFieldUtil.spec.js => numberFieldUtil.spec.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/form-js-viewer/test/spec/render/components/form-fields/util/{NumberFieldUtil.spec.js => numberFieldUtil.spec.js} (100%) diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/util/numberFieldUtil.spec.js similarity index 100% rename from packages/form-js-viewer/test/spec/render/components/form-fields/util/NumberFieldUtil.spec.js rename to packages/form-js-viewer/test/spec/render/components/form-fields/util/numberFieldUtil.spec.js From 4753f8569364653b9a88889abc9bb8c0fd7e94c6 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 18:58:39 +0100 Subject: [PATCH 09/14] fix: changed 'increment' to always validate >= 0 Related to #285 --- .../src/features/properties-panel/entries/NumberEntries.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js b/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js index 2b4b7a5f1..bc216ab1c 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js @@ -108,7 +108,7 @@ function NumberArrowStep(props) { if (!isValidNumber(value)) return 'Should be a valid number.'; - if (!decimalDigitsSet && Big(value).cmp(0) <= 0) return 'Should be greater than zero.'; + if (Big(value).cmp(0) <= 0) return 'Should be greater than zero.'; if (decimalDigitsSet) { const minimumValue = Big(`1e-${decimalDigits}`); From c33e16ff7bbe72647ea75fb8f626ba9e50fd3223 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 19:16:09 +0100 Subject: [PATCH 10/14] fix: moved preactDebuggers in test folder Related to #285 --- .../hooks/useEffectDebugger.js => test/helper/preactDebuggers.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/form-js-viewer/{src/render/hooks/useEffectDebugger.js => test/helper/preactDebuggers.js} (100%) diff --git a/packages/form-js-viewer/src/render/hooks/useEffectDebugger.js b/packages/form-js-viewer/test/helper/preactDebuggers.js similarity index 100% rename from packages/form-js-viewer/src/render/hooks/useEffectDebugger.js rename to packages/form-js-viewer/test/helper/preactDebuggers.js From 9474ddfa9a35b453b932014b43b2b235cc35b539 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 19:28:48 +0100 Subject: [PATCH 11/14] chore: added a11y tests to number field variants Related to #285 --- .../components/form-fields/Number.spec.js | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js index d51ede4a8..b154fdd57 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js @@ -689,6 +689,52 @@ describe('Number', function() { await expectNoViolations(container); }); + + it('should have no violations (decimal field)', async function() { + + // given + this.timeout(5000); + + const { container } = createNumberField({ + field: decimalField, + value: 123.23 + }); + + // then + await expectNoViolations(container); + }); + + + it('should have no violations (string parsed field)', async function() { + + // given + this.timeout(5000); + + const { container } = createNumberField({ + field: stringField, + value: '123.233333333333333333333' + }); + + // then + await expectNoViolations(container); + }); + + + it('should have no violations (step field)', async function() { + + // given + this.timeout(5000); + + const { container } = createNumberField({ + field: stringField, + value: 123.25 + }); + + // then + await expectNoViolations(container); + }); + + }); }); From eba7d241356e8f332a268b8c68637114c8537b20 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 19:42:32 +0100 Subject: [PATCH 12/14] chore: added test coverage to special input keys Closes #285 --- .../components/form-fields/Number.spec.js | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js index b154fdd57..48c25c265 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/Number.spec.js @@ -646,6 +646,110 @@ describe('Number', function() { }); + + it('should clear NaN state on backspace', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 'NaN' + }); + + // when + const input = container.querySelector('input[type="text"]'); + + expect(input).to.exist; + expect(input.value).to.equal('NaN'); + + fireEvent.keyDown(input, { key: 'Backspace', code: 'Backspace' }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: defaultField, + value: null + }); + }); + + + it('should clear NaN state on delete', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 'NaN' + }); + + // when + const input = container.querySelector('input[type="text"]'); + + expect(input).to.exist; + expect(input.value).to.equal('NaN'); + + fireEvent.keyDown(input, { key: 'Delete', code: 'Delete' }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: defaultField, + value: null + }); + }); + + + it('should increment on arrow up', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 0 + }); + + // when + const input = container.querySelector('input[type="text"]'); + + expect(input).to.exist; + expect(input.value).to.equal('0'); + + fireEvent.keyDown(input, { key: 'ArrowUp', code: 'ArrowUp' }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: defaultField, + value: 1 + }); + }); + + + it('should decrement on arrow down', function() { + + // given + const onChangeSpy = spy(); + + const { container } = createNumberField({ + onChange: onChangeSpy, + value: 0 + }); + + // when + const input = container.querySelector('input[type="text"]'); + + expect(input).to.exist; + expect(input.value).to.equal('0'); + + fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' }); + + // then + expect(onChangeSpy).to.have.been.calledWith({ + field: defaultField, + value: -1 + }); + }); + }); From 476758ae906c8a65e055fd071418b737a526ed47 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 29 Nov 2022 22:51:45 +0100 Subject: [PATCH 13/14] chore: updated package lock Related to #285 --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 58ef6f5a9..6f76444b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18423,7 +18423,7 @@ "@bpmn-io/form-js-viewer": "^0.10.0-alpha.1", "@bpmn-io/properties-panel": "^0.25.0", "array-move": "^3.0.1", - "big.js": "*", + "big.js": "^6.2.1", "dragula": "^3.7.3", "ids": "^1.0.0", "min-dash": "^4.0.0", From 0a318e683c6286ede4c5f28db65ec93b7b1ac6d0 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Thu, 1 Dec 2022 10:46:58 +0100 Subject: [PATCH 14/14] chore: minor formatting fix Closes #285 --- packages/form-js-viewer/src/core/Validator.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/form-js-viewer/src/core/Validator.js b/packages/form-js-viewer/src/core/Validator.js index ce5b44224..d72dbccbd 100644 --- a/packages/form-js-viewer/src/core/Validator.js +++ b/packages/form-js-viewer/src/core/Validator.js @@ -2,8 +2,7 @@ import { isNil } from 'min-dash'; import { countDecimals } from '../render/components/util/numberFieldUtil'; import Big from 'big.js'; -const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ - ; +const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; const PHONE_PATTERN = /(\+|00)(297|93|244|1264|358|355|376|971|54|374|1684|1268|61|43|994|257|32|229|226|880|359|973|1242|387|590|375|501|1441|591|55|1246|673|975|267|236|1|61|41|56|86|225|237|243|242|682|57|269|238|506|53|5999|61|1345|357|420|49|253|1767|45|1809|1829|1849|213|593|20|291|212|34|372|251|358|679|500|33|298|691|241|44|995|44|233|350|224|590|220|245|240|30|1473|299|502|594|1671|592|852|504|385|509|36|62|44|91|246|353|98|964|354|972|39|1876|44|962|81|76|77|254|996|855|686|1869|82|383|965|856|961|231|218|1758|423|94|266|370|352|371|853|590|212|377|373|261|960|52|692|389|223|356|95|382|976|1670|258|222|1664|596|230|265|60|262|264|687|227|672|234|505|683|31|47|977|674|64|968|92|507|64|51|63|680|675|48|1787|1939|850|351|595|970|689|974|262|40|7|250|966|249|221|65|500|4779|677|232|503|378|252|508|381|211|239|597|421|386|46|268|1721|248|963|1649|235|228|66|992|690|993|670|676|1868|216|90|688|886|255|256|380|598|1|998|3906698|379|1784|58|1284|1340|84|678|681|685|967|27|260|263)(9[976]\d|8[987530]\d|6[987]\d|5[90]\d|42\d|3[875]\d|2[98654321]\d|9[8543210]|8[6421]|6[6543210]|5[87654321]|4[987654310]|3[9643210]|2[70]|7|1)\d{4,20}$/; export default class Validator {