diff --git a/spiffworkflow-frontend/src/components/CustomForm.tsx b/spiffworkflow-frontend/src/components/CustomForm.tsx index f30539f3..60f34de0 100644 --- a/spiffworkflow-frontend/src/components/CustomForm.tsx +++ b/spiffworkflow-frontend/src/components/CustomForm.tsx @@ -9,6 +9,7 @@ import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWid import MarkDownFieldWidget from '../rjsf/custom_widgets/MarkDownFieldWidget/MarkDownFieldWidget'; import NumericRangeField from '../rjsf/custom_widgets/NumericRangeField/NumericRangeField'; import ObjectFieldRestrictedGridTemplate from '../rjsf/custom_templates/ObjectFieldRestrictGridTemplate'; +import CharacterCounterField from '../rjsf/custom_widgets/CharacterCounterField/CharacterCounterField'; enum DateCheckType { minimum = 'minimum', @@ -53,6 +54,7 @@ export default function CustomForm({ // set in uiSchema using the "ui:field" key for a property const rjsfFields: RegistryFieldsType = { 'numeric-range': NumericRangeField, + 'character-counter': CharacterCounterField, }; const rjsfTemplates: any = {}; @@ -250,12 +252,71 @@ export default function CustomForm({ formDataToCheck: any, propertyKey: string, errors: any, - _jsonSchema: any, + jsonSchema: any, _uiSchemaPassedIn?: any ) => { - if (formDataToCheck[propertyKey].min > formDataToCheck[propertyKey].max) { + if ( + jsonSchema.required && + jsonSchema.required.includes(propertyKey) && + (formDataToCheck[propertyKey].min === undefined || + formDataToCheck[propertyKey].max === undefined) + ) { errors[propertyKey].addError( - `must have min less than max on numeric range` + `must have valid Minimum and Maximum on ${propertyKey}` + ); + } + if ( + formDataToCheck[propertyKey].min < + jsonSchema.properties[propertyKey].minimum + ) { + errors[propertyKey].addError( + `must have min greater than or equal to ${jsonSchema.properties[propertyKey].minimum}` + ); + } + if ( + formDataToCheck[propertyKey].min > + jsonSchema.properties[propertyKey].maximum + ) { + errors[propertyKey].addError( + `must have min less than or equal to ${jsonSchema.properties[propertyKey].maximum}` + ); + } + if ( + formDataToCheck[propertyKey].max < + jsonSchema.properties[propertyKey].minimum + ) { + errors[propertyKey].addError( + `must have max greater than or equal to ${jsonSchema.properties[propertyKey].minimum}` + ); + } + if ( + formDataToCheck[propertyKey].max > + jsonSchema.properties[propertyKey].maximum + ) { + errors[propertyKey].addError( + `must have max less than or equal to ${jsonSchema.properties[propertyKey].maximum}` + ); + } + if (formDataToCheck[propertyKey].min > formDataToCheck[propertyKey].max) { + errors[propertyKey].addError(`must have min less than or equal to max`); + } + }; + + const checkCharacterCounter = ( + formDataToCheck: any, + propertyKey: string, + errors: any, + jsonSchema: any, + _uiSchemaPassedIn?: any + ) => { + if ( + jsonSchema.required && + jsonSchema.required.includes(propertyKey) && + (formDataToCheck[propertyKey] === undefined || + formDataToCheck[propertyKey] === '') + ) { + errors[propertyKey].addError( + `must have required property '${propertyKey}'` ); } }; @@ -332,6 +393,20 @@ export default function CustomForm({ ); } + if ( + currentUiSchema && + 'ui:field' in currentUiSchema && + currentUiSchema['ui:field'] === 'character-counter' + ) { + checkCharacterCounter( + formDataToCheck, + propertyKey, + errors, + jsonSchemaToUse, + currentUiSchema + ); + } + // recurse through all nested properties as well let formDataToSend = formDataToCheck[propertyKey]; if (formDataToSend) { diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index eac89731..de201f52 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -966,6 +966,23 @@ div.onboarding { line-height: 48px; } +.radio-button-group-column { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-inline-end: 0; +} + +.radio-button-group-column > * { + margin-bottom: 0.5rem; +} + +.radio-button-group-row { + display: flex; + flex-direction: row; + align-items: flex-start; +} + /* Utility classes to create horizontally centered stacks (to align icons etc) */ .flex-align-horizontal-center { display: flex; diff --git a/spiffworkflow-frontend/src/rjsf/carbon_theme/RadioWidget/RadioWidget.tsx b/spiffworkflow-frontend/src/rjsf/carbon_theme/RadioWidget/RadioWidget.tsx index fb773395..7573fcdc 100644 --- a/spiffworkflow-frontend/src/rjsf/carbon_theme/RadioWidget/RadioWidget.tsx +++ b/spiffworkflow-frontend/src/rjsf/carbon_theme/RadioWidget/RadioWidget.tsx @@ -28,6 +28,8 @@ function RadioWidget({ onChange(newValue); } }; + + const column = uiSchema?.['ui:layout']?.toString().toLowerCase() === 'column'; const _onBlur = ({ target: { value } }: React.FocusEvent) => onBlur(id, value); const _onFocus = ({ @@ -57,16 +59,18 @@ function RadioWidget({ onBlur={_onBlur} onFocus={_onFocus} > - {Array.isArray(enumOptions) && - enumOptions.map((option) => { - return ( - - ); - })} +
+ {Array.isArray(enumOptions) && + enumOptions.map((option) => { + return ( + + ); + })} +
); } diff --git a/spiffworkflow-frontend/src/rjsf/custom_widgets/CharacterCounterField/CharacterCounterField.tsx b/spiffworkflow-frontend/src/rjsf/custom_widgets/CharacterCounterField/CharacterCounterField.tsx new file mode 100644 index 00000000..b82a91d3 --- /dev/null +++ b/spiffworkflow-frontend/src/rjsf/custom_widgets/CharacterCounterField/CharacterCounterField.tsx @@ -0,0 +1,114 @@ +import { + descriptionId, + FieldProps, + getTemplate, + getUiOptions, +} from '@rjsf/utils'; +import { TextInput } from '@carbon/react'; +import React from 'react'; +import { getCommonAttributes } from '../../helpers'; + +// Example jsonSchema - NOTE: the "min" and "max" properties are special names and must be used: +// "name":{ +// "title": "Name", +// "type": "string", +// "maxLength": 999999999999, +// } +// +// Example uiSchema: +// "name": { +// "ui:field": "character-counter", +// } + +// eslint-disable-next-line sonarjs/cognitive-complexity +export default function CharacterCounterField({ + id, + schema, + uiSchema, + idSchema, + disabled, + readonly, + onChange, + autofocus, + label, + rawErrors = [], + formData, + registry, + required, +}: FieldProps) { + const commonAttributes = getCommonAttributes( + label, + schema, + uiSchema, + rawErrors + ); + + const description = schema?.description || uiSchema?.['ui:description']; + + const uiOptions = getUiOptions(uiSchema || {}); + const DescriptionFieldTemplate = getTemplate( + 'DescriptionFieldTemplate', + registry, + uiOptions + ); + + if (schema.maxLength === undefined) { + throw new Error( + 'CharacterCounterTextField requires a "maxLength" property to be specified in the schema' + ); + } + + const text = formData || ''; + + const onChangeLocal = (event: any) => { + event.preventDefault(); + if (!disabled && !readonly) { + onChange(event.target.value); + } + }; + + return ( +
+
+
+ {required ? `${commonAttributes.label} *` : commonAttributes.label} +
+ {description && ( +
+ +
+ )} +
+ { + onChangeLocal(event); + }} + invalid={commonAttributes.invalid} + enableCounter + maxCount={schema.maxLength} + autoFocus={autofocus} + /> + {commonAttributes.errorMessageForField && ( +
+ {commonAttributes.errorMessageForField} +
+ )} + {commonAttributes.helperText && ( +

+ {commonAttributes.helperText} +

+ )} +
+ ); +} diff --git a/spiffworkflow-frontend/src/rjsf/custom_widgets/NumericRangeField/NumericRangeField.tsx b/spiffworkflow-frontend/src/rjsf/custom_widgets/NumericRangeField/NumericRangeField.tsx index 6152ac0e..db0fdadc 100644 --- a/spiffworkflow-frontend/src/rjsf/custom_widgets/NumericRangeField/NumericRangeField.tsx +++ b/spiffworkflow-frontend/src/rjsf/custom_widgets/NumericRangeField/NumericRangeField.tsx @@ -4,6 +4,7 @@ import { getTemplate, getUiOptions, } from '@rjsf/utils'; +import React from 'react'; import { TextInput } from '@carbon/react'; import { getCommonAttributes } from '../../helpers'; @@ -11,6 +12,8 @@ import { getCommonAttributes } from '../../helpers'; // compensation":{ // "title": "Compensation (yearly), USD", // "type": "object", +// "minimum": 0, +// "maximum": 999999999999, // "properties": { // "min": { // "type": "number" @@ -59,44 +62,60 @@ export default function NumericRangeField({ ); const formatNumberString = (numberString: string): string => { - if (numberString) { - return numberString.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + // this function will change the number string to a number with commas + // and a decimal point if needed. For example, 1000 will become 1,000 + // or 1000.5 will become 1,000.5 + + const numberStringNoCommas = numberString.replace(/,/g, ''); + + if (numberStringNoCommas) { + const parts = numberStringNoCommas.split('.'); + const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return parts.length > 1 ? `${integerPart}.${parts[1]}` : integerPart; } return ''; }; - const parseNumberString = (numberString: string) => - Number(numberString.replace(/,/g, '')); + const parseNumberString = (numberString: string) => { + if ( + (numberString === '-' && numberString.length === 1) || + numberString.endsWith('.') + ) { + return null; + } + return Number(numberString.replace(/,/g, '')); + }; - // create two number inputs for min and max compensation - const min = formData?.min || 0; - const max = formData?.max || 0; + if (schema.minimum === undefined || schema.maximum === undefined) { + throw new Error('minimum and maximum not defined'); + } + const minNumber = schema.minimum; + const maxNumber = schema.maximum; + const min = formData?.min; + const [minValue, setMinValue] = React.useState(min?.toString() || ''); + const max = formData?.max; + const [maxValue, setMaxValue] = React.useState(max?.toString() || ''); // the text input eventually breaks when the number gets too big. // we are not sure what the cut off really is but seems unlikely // people will need to go this high. - const maxNumber = 999_999_999_999; const onChangeLocal = (nameToChange: any, event: any) => { event.preventDefault(); const numberValue = parseNumberString(event.target.value); - if (numberValue > maxNumber) { - return; + // Validate and update the numeric range based on user input + if (nameToChange === 'min') { + setMinValue(formatNumberString(numberValue?.toString() || '')); + } + if (nameToChange === 'max') { + setMaxValue(formatNumberString(numberValue?.toString() || '')); } if (!disabled && !readonly) { - if (nameToChange === 'min' && numberValue > max) { - onChange({ - ...(formData || {}), - min: numberValue, - max: numberValue, - }); - } else { - onChange({ - ...(formData || {}), - ...{ max, min }, - [nameToChange]: numberValue, - }); - } + onChange({ + ...(formData || {}), + ...{ max, min }, + [nameToChange]: numberValue, + }); } }; @@ -121,24 +140,32 @@ export default function NumericRangeField({
{ - onChangeLocal('min', values); + value={formatNumberString(minValue)} + onChange={(event: any) => { + onChangeLocal('min', event); + setMinValue(event.target.value); }} invalid={commonAttributes.invalid} + helperText={`Min: ${formatNumberString(minNumber?.toString() || '')}`} autofocus={autofocus} /> onChangeLocal('max', values)} + value={formatNumberString(maxValue)} + onChange={(event: any) => { + onChangeLocal('max', event); + setMaxValue(event.target.value); + }} invalid={commonAttributes.invalid} + helperText={`Max: ${formatNumberString(maxNumber?.toString() || '')}`} />
{commonAttributes.errorMessageForField && (