blob: 171b5f675c7dd7e8d665283cb800f7d966370fbb [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, {
ForwardRefRenderFunction,
forwardRef,
useImperativeHandle,
useEffect,
} from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import isEmpty from 'lodash/isEmpty';
import classnames from 'classnames';
import { scrollElementIntoView } from '@/utils';
import type * as Type from '@/common/interface';
import type {
JSONSchema,
FormProps,
FormRef,
BaseUIOptions,
FormKit,
InputGroupOptions,
} from './types';
import {
Legend,
Select,
Check,
Switch,
Timezone,
Upload,
Textarea,
Input,
Button as SfButton,
InputGroup,
TagSelector,
} from './components';
export * from './types';
/**
* TODO:
* - [!] Standardised `Admin/Plugins/Config/index.tsx` method for generating dynamic form configurations.
* - Normalize and document `formData[key].hidden && 'd-none'`
* - Normalize and document `hiddenSubmit`
* - Improving field hints for `formData`
* - Optimise form data updates
* * Automatic field type conversion
* * Dynamic field generation
*/
/**
* json schema form
* @param schema json schema
* @param uiSchema ui schema
* @param formData form data
* @param onChange change event
* @param onSubmit submit event
*/
const SchemaForm: ForwardRefRenderFunction<FormRef, FormProps> = (
{
schema,
uiSchema = {},
refreshConfig,
formData,
onChange,
onSubmit,
hiddenSubmit = false,
},
ref,
) => {
const { t } = useTranslation('translation', {
keyPrefix: 'form',
});
const { required = [], properties = {} } = schema || {};
// check required field
const excludes = required.filter((key) => !properties[key]);
if (excludes.length > 0) {
console.error(t('not_found_props', { key: excludes.join(', ') }));
}
formData ||= {};
const keys = Object.keys(properties);
/**
* Prevent components such as `select` from having default values,
* which are not generated on `formData`
*/
const setDefaultValueAsDomBehaviour = () => {
keys.forEach((k) => {
const fieldVal = formData![k]?.value;
const metaProp = properties[k];
const uiCtrl = uiSchema[k]?.['ui:widget'];
if (!metaProp || !uiCtrl || fieldVal !== undefined) {
return;
}
if (uiCtrl === 'select' && metaProp.enum?.[0] !== undefined) {
formData![k] = {
errorMsg: '',
isInvalid: false,
value: metaProp.enum?.[0],
};
}
});
};
useEffect(() => {
setDefaultValueAsDomBehaviour();
}, [formData]);
const formKitWithContext: FormKit = {
refreshConfig() {
if (typeof refreshConfig === 'function') {
refreshConfig();
}
},
};
/**
* Form validation
* - Currently only dynamic forms are in use, the business form validation has been handed over to the server
*/
const requiredValidator = () => {
const errors: string[] = [];
required.forEach((key) => {
if (!formData![key] || !formData![key].value) {
errors.push(key);
}
});
return errors;
};
const syncValidator = () => {
const errors: Array<{ key: string; msg: string }> = [];
const promises: Array<{
key: string;
promise;
}> = [];
keys.forEach((key) => {
const { validator } = uiSchema[key]?.['ui:options'] || {};
if (validator instanceof Function) {
const value = formData![key]?.value;
promises.push({
key,
promise: validator(value, formData),
});
}
});
return Promise.allSettled(promises.map((item) => item.promise)).then(
(results) => {
results.forEach((result, index) => {
const { key } = promises[index];
if (result.status === 'rejected') {
errors.push({
key,
msg: result.reason.message,
});
}
if (result.status === 'fulfilled') {
const msg = result.value;
if (typeof msg === 'string') {
errors.push({
key,
msg,
});
}
}
});
return errors;
},
);
};
const validator = async (): Promise<boolean> => {
const errors = requiredValidator();
if (errors.length > 0) {
formData = errors.reduce((acc, cur) => {
acc[cur] = {
...formData![cur],
isInvalid: true,
errorMsg:
uiSchema[cur]?.['ui:options']?.empty ||
`${properties[cur]?.title} ${t('empty')}`,
};
return acc;
}, formData || {});
if (onChange instanceof Function) {
onChange({ ...formData });
}
scrollElementIntoView(document.getElementById(errors[0]));
return false;
}
const syncErrors = await syncValidator();
if (syncErrors.length > 0) {
formData = syncErrors.reduce((acc, cur) => {
acc[cur.key] = {
...formData![cur.key],
isInvalid: true,
errorMsg: cur.msg || `${properties[cur.key].title} ${t('invalid')}`,
};
return acc;
}, formData || {});
if (onChange instanceof Function) {
onChange({ ...formData });
}
scrollElementIntoView(document.getElementById(syncErrors[0].key));
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const isValid = await validator();
if (!isValid) {
return;
}
Object.keys(formData!).forEach((key) => {
formData![key].isInvalid = false;
formData![key].errorMsg = '';
});
if (onChange instanceof Function) {
onChange(formData!);
}
if (onSubmit instanceof Function) {
onSubmit(e);
}
};
useImperativeHandle(ref, () => ({
validator,
}));
if (!formData || !schema || isEmpty(schema.properties)) {
return null;
}
return (
<Form noValidate onSubmit={handleSubmit}>
{keys.map((key) => {
const {
title,
description,
enum: enumValues = [],
enumNames = [],
max_length = 0,
max,
min = 0,
} = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } =
uiSchema?.[key] || {};
formData ||= {};
const fieldState = formData[key];
if (uiOpt?.class_name) {
uiOpt.className = uiOpt.class_name;
}
const uiSimplify = widget === 'legend' || uiOpt?.simplify;
let groupClassName: BaseUIOptions['field_class_name'] = uiOpt?.simplify
? 'mb-2'
: 'mb-3';
if (widget === 'legend') {
groupClassName = 'mb-0';
}
if (uiOpt?.field_class_name) {
groupClassName = uiOpt.field_class_name;
}
const readOnly = uiOpt?.readOnly || false;
return (
<Form.Group
key={`${title}-${key}`}
controlId={key}
className={classnames(
groupClassName,
formData[key].hidden ? 'd-none' : null,
)}>
{/* Uniform processing `label` */}
{title && !uiSimplify ? <Form.Label>{title}</Form.Label> : null}
{/* Handling of individual specific controls */}
{widget === 'legend' ? (
<Legend title={title} className={String(uiOpt?.className)} />
) : null}
{widget === 'select' ? (
<Select
desc={description}
fieldName={key}
onChange={onChange}
enumValues={enumValues}
enumNames={enumNames}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'radio' || widget === 'checkbox' ? (
<Check
type={widget}
fieldName={key}
onChange={onChange}
enumValues={enumValues}
enumNames={enumNames}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'switch' ? (
<Switch
label={uiOpt && 'label' in uiOpt ? uiOpt.label : ''}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'timezone' ? (
<Timezone
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'upload' ? (
<Upload
type={
uiOpt && 'imageType' in uiOpt ? uiOpt.imageType : undefined
}
acceptType={
uiOpt && 'acceptType' in uiOpt ? uiOpt.acceptType : ''
}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
imgClassNames={
uiOpt && 'className' in uiOpt ? uiOpt.className : ''
}
/>
) : null}
{widget === 'textarea' ? (
<Textarea
placeholder={
uiOpt && 'placeholder' in uiOpt ? uiOpt.placeholder : ''
}
rows={uiOpt && 'rows' in uiOpt ? uiOpt.rows : 3}
className={uiOpt && 'className' in uiOpt ? uiOpt.className : ''}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'input' ? (
<Input
type={uiOpt && 'inputType' in uiOpt ? uiOpt.inputType : 'text'}
inputMode={
uiOpt && 'inputMode' in uiOpt ? uiOpt.inputMode : 'text'
}
placeholder={
uiOpt && 'placeholder' in uiOpt ? uiOpt.placeholder : ''
}
min={min}
max={max}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'button' ? (
<SfButton
fieldName={key}
text={uiOpt && 'text' in uiOpt ? uiOpt.text : ''}
action={uiOpt && 'action' in uiOpt ? uiOpt.action : undefined}
formKit={formKitWithContext}
readOnly={readOnly}
variant={
uiOpt && 'variant' in uiOpt ? uiOpt.variant : undefined
}
size={uiOpt && 'size' in uiOpt ? uiOpt.size : undefined}
title={uiOpt && 'title' in uiOpt ? uiOpt?.title : ''}
/>
) : null}
{widget === 'input_group' ? (
<InputGroup
formKitWithContext={formKitWithContext}
uiOpt={uiOpt as InputGroupOptions}
prefixText={
(uiOpt && 'prefixText' in uiOpt && uiOpt.prefixText) || ''
}
suffixText={
(uiOpt && 'suffixText' in uiOpt && uiOpt.suffixText) || ''
}>
<Input
type={
uiOpt && 'inputType' in uiOpt ? uiOpt.inputType : 'text'
}
inputMode={
uiOpt && 'inputMode' in uiOpt ? uiOpt.inputMode : 'text'
}
placeholder={
uiOpt && 'placeholder' in uiOpt ? uiOpt.placeholder : ''
}
min={min}
max={max}
fieldName={key}
onChange={onChange}
formData={formData}
readOnly={readOnly}
/>
</InputGroup>
) : null}
{widget === 'tag_selector' ? (
<TagSelector
maxTagLength={max_length}
fieldName={key}
onChange={onChange}
formData={formData}
description={description}
/>
) : null}
{/* Unified handling of `Feedback` and `Text` */}
<Form.Control.Feedback type="invalid">
{fieldState?.errorMsg}
</Form.Control.Feedback>
{description && widget !== 'tag_selector' ? (
<Form.Text dangerouslySetInnerHTML={{ __html: description }} />
) : null}
</Form.Group>
);
})}
{!hiddenSubmit && (
<Button variant="primary" type="submit">
{t('btn_submit')}
</Button>
)}
</Form>
);
};
export const initFormData = (schema: JSONSchema): Type.FormDataType => {
const formData: Type.FormDataType = {};
const props: JSONSchema['properties'] = schema?.properties || {};
Object.keys(props).forEach((key) => {
const prop = props[key];
let defaultVal: any = '';
if (Array.isArray(prop.default) && prop.enum && prop.enum.length > 0) {
// for checkbox default values
defaultVal = prop.enum;
} else {
defaultVal = prop?.default;
}
formData[key] = {
value: defaultVal,
isInvalid: false,
errorMsg: '',
};
});
return formData;
};
export const mergeFormData = (
target: Type.FormDataType | null,
origin: Type.FormDataType | null,
) => {
if (!target) {
return origin;
}
if (!origin) {
return target;
}
Object.keys(target).forEach((k) => {
const oi = origin[k];
if (oi && oi.value !== undefined) {
target[k] = {
value: oi.value,
isInvalid: false,
errorMsg: '',
};
}
});
return target;
};
export default forwardRef(SchemaForm);