Skip to content
61 changes: 55 additions & 6 deletions src/client/components/DynamicUi/DynamicUi.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Fragment, PropsWithChildren, forwardRef, useRef } from 'react';
import React, { Fragment, PropsWithChildren, forwardRef, useRef, useState } from 'react';
import * as ReactIs from 'react-is';
import semverGt from 'semver/functions/gt';
import semverPrerelease from 'semver/functions/prerelease';
Expand All @@ -13,6 +13,7 @@ import {
FormFieldComponentMap,
type GenericFormFieldTypeComponentMap,
} from './FormFields';
import { combineChangeValidationFns, combineSubmitValidationFns, type CombinedChangeValidationFn } from './utils/combineValidationFns';
import { parseCustomFormConfig } from './utils/parseCustomFormConfig';

const REACT_VERSION_IS_SUPPORTED = semverSatisfies(React.version, '>=18.0.0 <19', { includePrerelease: true });
Expand Down Expand Up @@ -53,12 +54,23 @@ export type DynamicUiFormFieldRef = React.ForwardedRef<DynamicUiRefFunctions>;
export type DynamicUiComponentProps<TState = any> = {
formField: DataModels.FlowNodeInstances.UserTaskFormField;
state?: TState;
onValidate?: (id: string, type: FormFieldTypes, value: any) => Promise<Array<string>>;
};
export type UserTaskResult = DataModels.FlowNodeInstances.UserTaskResult;
export type FormState = {
[formFieldId: string]: JSONValue;
};

export type FormFieldTypes = 'string' | 'number' | 'decimal-number' | 'boolean' | 'enum' | 'date' | string;

export type ClientValidationFn = (
id: string,
type: FormFieldTypes,
value: any,
) => Promise<string | void>;

export type ServerValidationFn = (formData: FormData) => Promise<string | void>;

export function DynamicUi(
props: PropsWithChildren<{
/** UserTaskInstance with a defined dynamic form */
Expand All @@ -67,6 +79,10 @@ export function DynamicUi(
headerComponent?: JSX.Element;
/** Callback, that will be called when the form is submitted */
onSubmit: (result: UserTaskResult, rawFormData: FormData, task: UserTaskInstance) => Promise<void>;
/** Callback, that will be called when the form is submitted */
onSubmitValidation?: Array<ServerValidationFn>;
/** Callback, that will be called after the focus on a formfield changes */
onFocusChangeValidation?: Array<ClientValidationFn>;
/** Custom class name for the root element */
className?: string;
/** Custom class names for the different parts of the component */
Expand Down Expand Up @@ -111,13 +127,22 @@ export function DynamicUi(
...(props.customFieldComponents ? props.customFieldComponents : {}),
};

const onSubmit = (formData: FormData) => {
const userTaskResult = transformFormDataToUserTaskResult(formData, formFields, formFieldRefs);
const onSubmit = async (formData: FormData) => {
if (props.onSubmitValidation) {
const validationFns = [props.onSubmitValidation].flat();
const combinedValidationFn = combineSubmitValidationFns(validationFns);
const validationErrors = await combinedValidationFn(formData);
if (validationErrors.length > 0) {
setGlobalError(validationErrors);
}
}

const userTaskResult = transformFormDataToUserTaskResult(formData, formFields, formFieldRefs);
props.onSubmit(userTaskResult, formData, mapUserTask(props.task));
};

function onFormDataChange(event: React.FormEvent<HTMLFormElement>) {
async function onFormDataChange(event: React.FormEvent<HTMLFormElement>) {
setGlobalError(['']);
const target = event.target as HTMLInputElement;
if (timeoutRef.current != null) {
window.clearTimeout(timeoutRef.current);
Expand All @@ -133,6 +158,14 @@ export function DynamicUi(
}, 100);
}

function setGlobalError(errorMessage: Array<string>) {
const errorElement = document.getElementById(props.task.flowNodeInstanceId);
if (errorElement) {
errorElement.style.display = 'block';
errorElement.innerText = errorMessage.join('\n');
}
}

const rootClassNames: string = classNames(
'app-sdk-default-styles app-sdk-mx-auto app-sdk-block app-sdk-h-full app-sdk-min-h-[200px] app-sdk-rounded-lg app-sdk-shadow-lg app-sdk-shadow-[color:var(--asdk-dui-shadow-color)] sm:app-sdk-w-full sm:app-sdk-max-w-lg',
props.classNames?.wrapper ?? '',
Expand Down Expand Up @@ -168,7 +201,6 @@ export function DynamicUi(
: classNames(...rootClassNames.split(' ').filter((c) => c !== 'dark' && c !== 'app-sdk-dark'))
}
data-dynamic-ui
data-blablabl
>
<form
ref={formRef}
Expand Down Expand Up @@ -239,9 +271,22 @@ export function DynamicUi(

const ref = formFieldRefs.get(field.id)?.ref;

let combinedValidationFn: CombinedChangeValidationFn =
undefined;

if (props.onFocusChangeValidation) {
const validationFns = [props.onFocusChangeValidation].flat();
combinedValidationFn = combineChangeValidationFns(validationFns);
}

return (
<Fragment key={field.id}>
<ReactElement ref={ref} formField={field} state={props.state?.[field.id]} />
<ReactElement
ref={ref}
formField={field}
state={props.state?.[field.id]}
onValidate={combinedValidationFn}
/>
</Fragment>
);
})}
Expand All @@ -253,6 +298,10 @@ export function DynamicUi(
props.classNames?.footer ?? '',
)}
>
<h1
id={props.task.flowNodeInstanceId}
className="app-sdk-w-fit app-sdk-mx-auto app-sdk-text-red-600 app-sdk-hidden app-sdk-mb-2"
/>
<FormButtons confirmFormField={confirmFormField} />
</footer>
</form>
Expand Down
29 changes: 24 additions & 5 deletions src/client/components/DynamicUi/FormFields/BooleanFormField.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
import React from 'react';
import React, { useState } from 'react';

import { DynamicUiComponentProps, DynamicUiFormFieldRef } from '../DynamicUi';
import { parseCustomFormConfig } from '../utils/parseCustomFormConfig';

export function BooleanFormField(
props: DynamicUiComponentProps<string | null>,
{ formField, state, onValidate }: DynamicUiComponentProps<string | Array<string> | null>,
ref: DynamicUiFormFieldRef,
): JSX.Element {
const { formField } = props;
const hintId = `${formField.id}-hint`;
const parsedCustomFormConfig = parseCustomFormConfig(formField.customForm);

const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState('');

function onFocusLeave(e: any) {
if (onValidate) {
onValidate(formField.id, formField.type, e.target.checked).then((res) => {
setErrorMessage(res.join('\n'));
setIsValid(false);
});
}
}

function resetErrors() {
setErrorMessage('');
setIsValid(true);
}

return (
<div className="app-sdk-relative app-sdk-flex app-sdk-items-start">
<div className="app-sdk-flex app-sdk-h-5 app-sdk-items-center">
<input
type="checkbox"
className="app-sdk-form-checkbox app-sdk-border app-sdk-h-4 app-sdk-w-4 app-sdk-rounded app-sdk-border-[color:var(--asdk-dui-border-color)] app-sdk-bg-[color:var(--asdk-dui-formfield-background-color)] app-sdk-text-[color:var(--asdk-dui-formfield-checkbox-text-color)] app-sdk-placeholder-[color:var(--asdk-dui-formfield-placeholder-text-color)] focus:app-sdk-border-[color:var(--asdk-dui-focus-color)] focus:app-sdk-ring-[color:var(--asdk-dui-focus-color)] dark:app-sdk-border-2 dark:app-sdk-border-solid dark:app-sdk-border-transparent dark:focus:app-sdk-shadow-app-sdk-dark"
defaultChecked={(props.state && props.state !== 'false') || formField.defaultValue === 'true'}
className="app-sdk-bg-[color:var(--asdk-dui-formfield-background-color)] app-sdk-form-checkbox app-sdk-border app-sdk-h-4 app-sdk-w-4 app-sdk-rounded app-sdk-border-[color:var(--asdk-dui-border-color)] app-sdk-text-[color:var(--asdk-dui-formfield-checkbox-text-color)] app-sdk-placeholder-[color:var(--asdk-dui-formfield-placeholder-text-color)] focus:app-sdk-border-[color:var(--asdk-dui-focus-color)] focus:app-sdk-ring-[color:var(--asdk-dui-focus-color)] dark:app-sdk-border-2 dark:app-sdk-border-solid dark:app-sdk-border-transparent dark:focus:app-sdk-shadow-app-sdk-dark"
defaultChecked={(state && state !== 'false') || formField.defaultValue === 'true'}
onBlur={onFocusLeave}
onChange={resetErrors}
id={formField.id}
name={formField.id}
aria-describedby={parsedCustomFormConfig?.hint ? hintId : undefined}
Expand All @@ -33,6 +51,7 @@ export function BooleanFormField(
{parsedCustomFormConfig.hint}
</p>
)}
{!isValid && <pre className="app-sdk-text-red-600">{errorMessage}</pre>}
</div>
</div>
);
Expand Down
32 changes: 27 additions & 5 deletions src/client/components/DynamicUi/FormFields/DateFormField.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
import React from 'react';
import React, { useState } from 'react';

import { DynamicUiComponentProps, DynamicUiFormFieldRef } from '../DynamicUi';
import { parseCustomFormConfig } from '../utils/parseCustomFormConfig';

export function DateFormField(props: DynamicUiComponentProps<string | null>, ref: DynamicUiFormFieldRef) {
const { formField } = props;
export function DateFormField(
{ formField, state, onValidate }: DynamicUiComponentProps<string | Array<string> | null>,
ref: DynamicUiFormFieldRef,
) {
const hintId = `${formField.id}-hint`;
const parsedCustomFormConfig = parseCustomFormConfig(formField.customForm);

if (!isValidDate(formField.defaultValue)) {
console.warn(`[@5minds/processcube_app_sdk:DynamicUi]\t\tInvalid default value for date field "${formField.id}"`);
}

const defaultValue = props.state || formField.defaultValue?.toString();
const defaultValue = state || formField.defaultValue?.toString();

const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState('');

function onFocusLeave(e: any) {
if (onValidate) {
onValidate(formField.id, formField.type, e.target.valueAsDate).then((res) => {
setErrorMessage(res.join('\n'));
setIsValid(false);
});
}
}

function resetErrors() {
setErrorMessage('');
setIsValid(true);
}

return (
<div>
Expand All @@ -21,9 +40,11 @@ export function DateFormField(props: DynamicUiComponentProps<string | null>, ref
</label>
<div className="app-sdk-mt-1">
<input
className="app-sdk-form-input app-sdk-text-app-sdk-inherit app-sdk-border app-sdk-py-2 app-sdk-px-3 app-sdk-block app-sdk-w-full app-sdk-rounded-md app-sdk-border-[color:var(--asdk-dui-border-color)] app-sdk-bg-[color:var(--asdk-dui-formfield-background-color)] app-sdk-placeholder-[color:var(--asdk-dui-formfield-placeholder-text-color)] app-sdk-shadow-sm invalid:app-sdk-border-[color:var(--asdk-dui-formfield-invalid-color)] invalid:app-sdk-ring-1 invalid:app-sdk-ring-[color:var(--asdk-dui-formfield-invalid-color)] focus:app-sdk-border-[color:var(--asdk-dui-focus-color)] focus:app-sdk-ring-[color:var(--asdk-dui-focus-color)] sm:app-sdk-text-sm dark:app-sdk-border-solid dark:app-sdk-border-transparent dark:invalid:app-sdk-shadow-app-sdk-dark-invalid dark:focus:app-sdk-shadow-app-sdk-dark"
className={`${!isValid ? 'app-sdk-gbg-red-600/20' : 'app-sdk-bg-[color:var(--asdk-dui-formfield-background-color)]'} app-sdk-form-input app-sdk-text-app-sdk-inherit app-sdk-border app-sdk-py-2 app-sdk-px-3 app-sdk-block app-sdk-w-full app-sdk-rounded-md app-sdk-border-[color:var(--asdk-dui-border-color)] app-sdk-placeholder-[color:var(--asdk-dui-formfield-placeholder-text-color)] app-sdk-shadow-sm invalid:app-sdk-border-[color:var(--asdk-dui-formfield-invalid-color)] invalid:app-sdk-ring-1 invalid:app-sdk-ring-[color:var(--asdk-dui-formfield-invalid-color)] focus:app-sdk-border-[color:var(--asdk-dui-focus-color)] focus:app-sdk-ring-[color:var(--asdk-dui-focus-color)] sm:app-sdk-text-sm dark:app-sdk-border-solid dark:app-sdk-border-transparent dark:invalid:app-sdk-shadow-app-sdk-dark-invalid dark:focus:app-sdk-shadow-app-sdk-dark`}
type="date"
defaultValue={defaultValue}
onBlur={onFocusLeave}
onChange={resetErrors}
id={formField.id}
name={formField.id}
aria-describedby={hintId}
Expand All @@ -38,6 +59,7 @@ export function DateFormField(props: DynamicUiComponentProps<string | null>, ref
{parsedCustomFormConfig?.hint}
</p>
)}
{!isValid && <pre className="app-sdk-text-red-600">{errorMessage}</pre>}
</div>
);
}
Expand Down
36 changes: 31 additions & 5 deletions src/client/components/DynamicUi/FormFields/DecimalFormField.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';
import React, { useRef, useState } from 'react';

import { DynamicUiComponentProps, DynamicUiFormFieldRef } from '../DynamicUi';
import { isNumber } from '../utils/isNumber';
import { parseCustomFormConfig } from '../utils/parseCustomFormConfig';

export function DecimalFormField(props: DynamicUiComponentProps<string | null>, ref: DynamicUiFormFieldRef) {
const { formField } = props;
export function DecimalFormField(
{ formField, state, onValidate }: DynamicUiComponentProps<string | Array<string> | null>,
ref: DynamicUiFormFieldRef,
) {
const hintId = `${formField.id}-hint`;
const parsedCustomFormConfig = parseCustomFormConfig(formField.customForm);

Expand All @@ -15,18 +17,41 @@ export function DecimalFormField(props: DynamicUiComponentProps<string | null>,
);
}

const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const inputRef = useRef<HTMLInputElement>(null);

function onFocusLeave(e: any) {
if (onValidate) {
onValidate(formField.id, formField.type, e.target.value).then((res) => {
setErrorMessage(res.join('\n'));
setIsValid(false);
});
}
}

function resetErrors() {
setErrorMessage('');
setIsValid(true);
if (inputRef.current) {
inputRef.current.focus();
}
}

return (
<div>
<label className="app-sdk-block app-sdk-text-sm app-sdk-font-medium" htmlFor={formField.id}>
{formField.label}
</label>
<div className="app-sdk-mt-1">
<input
className="app-sdk-form-input app-sdk-text-app-sdk-inherit app-sdk-border app-sdk-py-2 app-sdk-px-3 app-sdk-block app-sdk-w-full app-sdk-rounded-md app-sdk-border-[color:var(--asdk-dui-border-color)] app-sdk-bg-[color:var(--asdk-dui-formfield-background-color)] app-sdk-placeholder-[color:var(--asdk-dui-formfield-placeholder-text-color)] app-sdk-shadow-sm invalid:app-sdk-border-[color:var(--asdk-dui-formfield-invalid-color)] invalid:app-sdk-ring-1 invalid:app-sdk-ring-[color:var(--asdk-dui-formfield-invalid-color)] focus:app-sdk-border-[color:var(--asdk-dui-focus-color)] focus:app-sdk-ring-[color:var(--asdk-dui-focus-color)] sm:app-sdk-text-sm dark:app-sdk-border-solid dark:app-sdk-border-transparent dark:invalid:app-sdk-shadow-app-sdk-dark-invalid dark:focus:app-sdk-shadow-app-sdk-dark"
className={`${!isValid ? 'app-sdk-bg-red-600/20' : ''} app-sdk-form-input app-sdk-text-app-sdk-inherit app-sdk-border app-sdk-py-2 app-sdk-px-3 app-sdk-block app-sdk-w-full app-sdk-rounded-md app-sdk-border-[color:var(--asdk-dui-border-color)] app-sdk-bg-[color:var(--asdk-dui-formfield-background-color)] app-sdk-placeholder-[color:var(--asdk-dui-formfield-placeholder-text-color)] app-sdk-shadow-sm invalid:app-sdk-border-[color:var(--asdk-dui-formfield-invalid-color)] invalid:app-sdk-ring-1 invalid:app-sdk-ring-[color:var(--asdk-dui-formfield-invalid-color)] focus:app-sdk-border-[color:var(--asdk-dui-focus-color)] focus:app-sdk-ring-[color:var(--asdk-dui-focus-color)] sm:app-sdk-text-sm dark:app-sdk-border-solid dark:app-sdk-border-transparent dark:invalid:app-sdk-shadow-app-sdk-dark-invalid dark:focus:app-sdk-shadow-app-sdk-dark`}
type="number"
step="0.01"
placeholder={parsedCustomFormConfig?.placeholder || '0.00'}
defaultValue={props.state || formField.defaultValue?.toString()}
onBlur={onFocusLeave}
onChange={resetErrors}
defaultValue={state || formField.defaultValue?.toString()}
id={formField.id}
name={formField.id}
aria-describedby={hintId}
Expand All @@ -41,6 +66,7 @@ export function DecimalFormField(props: DynamicUiComponentProps<string | null>,
{parsedCustomFormConfig?.hint}
</p>
)}
{!isValid && <pre className="app-sdk-text-red-600">{errorMessage}</pre>}
</div>
);
}
Loading