feat: 添加 Combo InputTable ui 组件 (#5690)

* feat: 添加 Combo InputTable ui 组件

* feat: 添加 Combo InputTable ui 组件

* 改成用 field.id

* 顺便把版本设置改成一个不处理也不报错的写法
This commit is contained in:
liaoxuezhi 2022-11-04 11:58:47 +08:00 committed by GitHub
parent 4f45647423
commit 88ff7c8912
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 653 additions and 35 deletions

View File

@ -166,10 +166,8 @@ function getPlugins(format = 'esm') {
main: true
}),
replace({
'preventAssignment': true,
'process.env.NODE_ENV': JSON.stringify('production'),
'__buildDate__': () => JSON.stringify(new Date()),
'__buildVersion': JSON.stringify(version)
preventAssignment: true,
__buildVersion: version
}),
typescript(typeScriptOptions),
commonjs({

View File

@ -93,7 +93,7 @@ import type {FormHorizontal} from './renderers/Form';
import {enableDebug, promisify, replaceText, wrapFetcher} from './utils/index';
// @ts-ignore
export const version = __buildVersion;
export const version = '__buildVersion';
export {
clearStoresCache,

View File

@ -9,6 +9,10 @@
white-space: nowrap;
min-width: auto;
> svg.icon {
top: 0;
}
&--primary {
@include button-variant(
var(--button-primary-default-bg-color),

View File

@ -1042,7 +1042,7 @@
justify-content: space-between;
.#{$ns}Button {
border: none;
border-color: transparent;
color: var(--Button--link-color);
}
}

View File

@ -210,7 +210,7 @@ export class ArrayInput extends React.Component<ArrayInputProps> {
disabled={disabled}
>
<Icon icon="plus" className="icon" />
<span>{__('Combo.add')}</span>
<span>{__('add')}</span>
</Button>
) : null}

View File

@ -0,0 +1,276 @@
/**
* combo
*
* <Form>
*
* :
*
* <Form
* defaultValues={{a: 1, b: 2, arr: [{a: 1, b: 2}]}}
* onSubmit={values => console.log(values)}
* >
* {({control, getValues, setValue, handleSubmit}) => (
* <>
* <Combo
* name="arr"
* control={control}
* label="Combo"
* mode="horizontal"
* itemRender={({
* control,
* getValues,
* setValue,
* handleSubmit
* }) => (
* <>
* <Controller
* name="a"
* label="A"
* mode="horizontal"
* horizontal={{justify: true}}
* control={control}
* rules={{maxLength: 20}}
* render={({field, fieldState}) => (
* <InputBox
* {...field}
* hasError={fieldState.error}
* disabled={false}
* />
* )}
* />
*
* <Controller
* name="b"
* control={control}
* rules={{maxLength: 20}}
* render={({field, fieldState}) => (
* <InputBox
* {...field}
* hasError={fieldState.error}
* disabled={false}
* />
* )}
* />
* </>
* )}
* />
* </>
* )}
* </Form>
*/
import {
ClassNamesFn,
localeable,
LocaleProps,
themeable,
ThemeProps,
TranslateFn
} from 'amis-core';
import React from 'react';
import {
Control,
RegisterOptions,
useFieldArray,
UseFieldArrayProps,
UseFormReturn
} from 'react-hook-form';
import useSubForm from '../hooks/use-sub-form';
import Button from './Button';
import FormField, {FormFieldProps} from './FormField';
import {Icon} from './icons';
export interface ComboProps<T = any>
extends ThemeProps,
LocaleProps,
Omit<FormFieldProps, 'children' | 'errors' | 'hasError' | 'className'>,
UseFieldArrayProps {
itemRender: (methods: UseFormReturn, index: number) => JSX.Element | null;
control: Control<any>;
fieldClassName?: string;
rules?: Omit<
RegisterOptions,
'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
> & {
[propName: string]: any;
};
/**
* label
*/
wrap?: boolean;
/**
*
*/
multiLine?: boolean;
itemsWrapperClassName?: string;
itemClassName?: string;
scaffold?: Record<string, any>;
addButtonClassName?: string;
addButtonText?: string;
addable?: boolean;
// draggable?: boolean;
// draggableTip?: string;
maxLength?: number;
minLength?: number;
removable?: boolean;
}
export function Combo({
control,
name,
wrap,
mode,
label,
labelAlign,
labelClassName,
description,
fieldClassName,
className,
multiLine,
itemsWrapperClassName,
itemClassName,
addButtonClassName,
itemRender,
translate: __,
classnames: cx,
addable,
scaffold,
addButtonText,
removable,
rules,
isRequired,
minLength,
maxLength
}: ComboProps) {
// 看文档是支持的,但是传入报错,后面看看
// let rules2: any = {...rules};
// if (isRequired) {
// rules2.required = true;
// }
const {fields, append, update, remove} = useFieldArray({
control,
name: name,
shouldUnregister: true
// rules: rules2
});
function renderBody() {
return (
<div
className={cx(
`Combo Combo--multi`,
className,
multiLine ? `Combo--ver` : `Combo--hor`
)}
>
<div className={cx(`Combo-items`, itemsWrapperClassName)}>
{fields.map((field, index) => (
<div key={field.id} className={cx(`Combo-item`, itemClassName)}>
<ComboItem
control={control}
update={update}
index={index}
value={field}
itemRender={itemRender}
translate={__}
classnames={cx}
/>
<a
onClick={() => remove(index)}
key="delete"
className={cx(
`Combo-delBtn ${
removable === false ||
(minLength && fields.length <= minLength)
? 'is-disabled'
: ''
}`
)}
data-tooltip={__('delete')}
data-position="bottom"
>
<Icon icon="status-close" className="icon" />
</a>
</div>
))}
</div>
{addable !== false && (!maxLength || fields.length < maxLength) ? (
<div className={cx(`Combo-toolbar`)}>
<Button
className={cx(`Combo-addBtn`, addButtonClassName)}
onClick={() => append({...scaffold})}
>
<Icon icon="plus" className="icon" />
<span>{__(addButtonText || 'add')}</span>
</Button>
</div>
) : null}
</div>
);
}
return wrap === false ? (
renderBody()
) : (
<FormField
className={fieldClassName}
label={label}
labelAlign={labelAlign}
labelClassName={labelClassName}
description={description}
mode={mode}
isRequired={isRequired}
hasError={false /*目前看来不支持,后续研究一下 */}
errors={undefined /*目前看来不支持,后续研究一下 */}
>
{renderBody()}
</FormField>
);
}
export interface ComboItemProps {
value: any;
control: Control<any>;
itemRender: (methods: UseFormReturn, index: number) => JSX.Element | null;
update: (index: number, data: Record<string, any>) => void;
index: number;
translate: TranslateFn;
classnames: ClassNamesFn;
}
export function ComboItem({
value,
itemRender,
index,
translate,
update,
classnames: cx
}: ComboItemProps) {
const methods = useSubForm(value, translate, data => update(index, data));
let child: any = itemRender(methods, index);
if (child?.type === React.Fragment) {
child = child.props.children;
}
if (Array.isArray(child)) {
child = (
<div className={cx('Form-row')}>
{child.map((child, index) => (
<div className={cx('Form-col')} key={child.key || index}>
{child}
</div>
))}
</div>
);
}
return <div className={cx('Combo-itemInner')}>{child}</div>;
}
export default themeable(localeable(Combo));

View File

@ -19,10 +19,11 @@ export interface FormProps extends ThemeProps, LocaleProps {
onSubmit: (value: any) => void;
forwardRef?: FormRef;
children?: (methods: UseFormReturn) => JSX.Element | null;
className?: string;
}
export function Form(props: FormProps) {
const {classnames: cx} = props;
const {classnames: cx, className} = props;
const methods = useForm({
defaultValues: props.defaultValues,
resolver: useValidationResolver(props.translate)
@ -51,7 +52,7 @@ export function Form(props: FormProps) {
return (
<form
className={cx('Form')}
className={cx('Form', className)}
onSubmit={methods.handleSubmit(props.onSubmit)}
noValidate
>

View File

@ -18,7 +18,9 @@ export interface FormFieldProps extends LocaleProps, ThemeProps {
leftFixed?: boolean | number | 'xs' | 'sm' | 'md' | 'lg';
justify?: boolean; // 两端对齐
};
label?: string;
label?: string | boolean;
labelAlign?: 'left' | 'right';
labelClassName?: string;
description?: string;
isRequired?: boolean;
hasError?: boolean;
@ -35,6 +37,8 @@ function FormField(props: FormFieldProps) {
hasError,
isRequired,
label,
labelAlign,
labelClassName,
description
} = props;
@ -45,6 +49,68 @@ function FormField(props: FormFieldProps) {
: [];
if (mode === 'horizontal') {
const horizontal = props.horizontal || {
leftFixed: true
};
return (
<div
data-role="form-item"
className={cx(`Form-item Form-item--horizontal`, className, {
'is-error': hasError,
[`is-required`]: isRequired,
'Form-item--horizontal-justify': horizontal.justify
})}
>
{label !== false ? (
<label
className={cx(
`Form-label`,
{
[`Form-itemColumn--${
typeof horizontal.leftFixed === 'string'
? horizontal.leftFixed
: 'normal'
}`]: horizontal.leftFixed,
[`Form-itemColumn--${horizontal.left}`]: !horizontal.leftFixed,
'Form-label--left': labelAlign === 'left'
},
labelClassName
)}
>
<span>
{label}
{isRequired && label ? (
<span className={cx(`Form-star`)}>*</span>
) : null}
</span>
</label>
) : null}
<div
className={cx(`Form-value`, {
// [`Form-itemColumn--offset${getWidthRate(horizontal.offset)}`]: !label && label !== false,
[`Form-itemColumn--${horizontal.right}`]:
!horizontal.leftFixed &&
!!horizontal.right &&
horizontal.right !== 12 - horizontal.left!
})}
>
{children}
{hasError && errors.length ? (
<ul className={cx(`Form-feedback`)}>
{errors.map((msg: string, key: number) => (
<li key={key}>{msg}</li>
))}
</ul>
) : null}
{description ? (
<div className={cx(`Form-description`)}>{description}</div>
) : null}
</div>
</div>
);
}
return (
@ -56,7 +122,7 @@ function FormField(props: FormFieldProps) {
})}
>
{label ? (
<label className={cx(`Form-label`)}>
<label className={cx(`Form-label`, labelClassName)}>
<span>
{label}
{isRequired && label ? (
@ -96,9 +162,14 @@ export interface ControllerProps
> & {
[propName: string]: any;
};
/**
* false
*/
wrap?: boolean;
}
export function Controller(props: ControllerProps) {
const {render, name, shouldUnregister, defaultValue, control, ...rest} =
const {render, name, shouldUnregister, defaultValue, control, wrap, ...rest} =
props;
let rules = {...props.rules};
@ -108,12 +179,15 @@ export function Controller(props: ControllerProps) {
return (
<ReactHookFormController
name={name}
name={name || ''}
rules={rules}
shouldUnregister={shouldUnregister}
defaultValue={defaultValue}
control={control}
render={methods => (
render={methods =>
wrap === false ? (
render(methods)
) : (
<ThemedFormField
{...rest}
hasError={!!methods.fieldState.error}
@ -121,7 +195,8 @@ export function Controller(props: ControllerProps) {
>
{render(methods)}
</ThemedFormField>
)}
)
}
/>
);
}

View File

@ -0,0 +1,215 @@
import {
ClassNamesFn,
localeable,
LocaleProps,
themeable,
ThemeProps,
TranslateFn
} from 'amis-core';
import React from 'react';
import {
Control,
useFieldArray,
UseFieldArrayProps,
UseFormReturn
} from 'react-hook-form';
import useSubForm from '../hooks/use-sub-form';
import Button from './Button';
import FormField, {FormFieldProps} from './FormField';
import {Icon} from './icons';
export interface InputTabbleProps<T = any>
extends ThemeProps,
LocaleProps,
Omit<
FormFieldProps,
'children' | 'errors' | 'hasError' | 'isRequired' | 'className'
>,
UseFieldArrayProps {
control: Control<any>;
fieldClassName?: string;
columns: Array<{
title?: string;
className?: string;
thRender?: () => JSX.Element;
tdRender: (methods: UseFormReturn, index: number) => JSX.Element | null;
}>;
/**
* label
*/
wrap?: boolean;
scaffold?: any;
addable?: boolean;
addButtonClassName?: string;
addButtonText?: string;
maxLength?: number;
minLength?: number;
removable?: boolean;
}
export function InputTable({
control,
name,
wrap,
mode,
label,
labelAlign,
labelClassName,
description,
fieldClassName,
className,
translate: __,
classnames: cx,
removable,
columns,
addable,
addButtonText,
addButtonClassName,
scaffold,
minLength,
maxLength
}: InputTabbleProps) {
const {fields, append, update, remove} = useFieldArray({
control,
name: name
});
if (!Array.isArray(columns)) {
columns = [];
}
function renderBody() {
return (
<div className={cx(`Table`, className)}>
<div className={cx(`Table-contentWrap`)}>
<table className={cx(`Table-table`)}>
<thead>
<tr>
{columns.map((item, index) => (
<th key={index} className={item.className}>
{item.thRender ? item.thRender() : item.title}
</th>
))}
<th key="operation">{__('Table.operation')}</th>
</tr>
</thead>
<tbody>
{fields.length ? (
fields.map((field, index) => (
<tr key={field.id}>
<InputTableRow
key="columns"
control={control}
update={update}
index={index}
value={field}
columns={columns}
translate={__}
classnames={cx}
/>
<td key="operation">
<Button
level="link"
key="delete"
className={cx(
`Table-delBtn ${
removable === false ||
(minLength && fields.length <= minLength)
? 'is-disabled'
: ''
}`
)}
onClick={() => remove(index)}
>
{__('delete')}
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={columns.length + 1}>
<Icon
icon="desk-empty"
className={cx('Table-placeholder-empty-icon', 'icon')}
/>
{__('placeholder.noData')}
</td>
</tr>
)}
</tbody>
</table>
</div>
{addable !== false && (!maxLength || fields.length < maxLength) ? (
<div className={cx(`InputTable-toolbar`)}>
<Button
className={cx(addButtonClassName)}
onClick={() => append({...scaffold})}
size="sm"
>
<Icon icon="plus" className="icon" />
<span>{__(addButtonText || 'add')}</span>
</Button>
</div>
) : null}
</div>
);
}
return wrap === false ? (
renderBody()
) : (
<FormField
className={fieldClassName}
label={label}
labelAlign={labelAlign}
labelClassName={labelClassName}
description={description}
mode={mode}
hasError={false /*目前看来不支持,后续研究一下 */}
errors={undefined /*目前看来不支持,后续研究一下 */}
>
{renderBody()}
</FormField>
);
}
export interface InputTableRowProps {
value: any;
control: Control<any>;
columns: Array<{
tdRender: (methods: UseFormReturn, index: number) => JSX.Element | null;
className?: string;
}>;
update: (index: number, data: Record<string, any>) => void;
index: number;
translate: TranslateFn;
classnames: ClassNamesFn;
}
export function InputTableRow({
value,
columns,
index,
translate,
update,
classnames: cx
}: InputTableRowProps) {
const methods = useSubForm(value, translate, data => update(index, data));
return (
<>
{columns.map((item, index) => (
<td key={index} className={item.className}>
{item.tdRender(methods, index)}
</td>
))}
</>
);
}
export default themeable(localeable(InputTable));

View File

@ -113,6 +113,10 @@ import Timeline from './Timeline';
import ImageGallery from './ImageGallery';
import BaiduMapPicker from './BaiduMapPicker';
import MultilineText from './MultilineText';
import Form from './Form';
import FormField, {Controller} from './FormField';
import Combo from './Combo';
import InputTable from './InputTable';
export {
NotFound,
@ -227,5 +231,10 @@ export {
Timeline,
ImageGallery,
BaiduMapPicker,
MultilineText
MultilineText,
Form,
FormField,
Controller,
Combo,
InputTable
};

View File

@ -0,0 +1,45 @@
import {useCallback, useState} from 'react';
import isFunction from 'lodash/isFunction';
import {TranslateFn} from 'amis-core';
import {useForm, UseFormReturn} from 'react-hook-form';
import debounce from 'lodash/debounce';
import React from 'react';
import useValidationResolver from './use-validation-resolver';
const useSubForm = (
defaultValue: any,
translate: TranslateFn,
onUpdate: (data: any) => void
): UseFormReturn => {
const methods = useForm({
defaultValues: defaultValue,
mode: 'onChange', // 每次修改都验证
shouldUnregister: true,
resolver: useValidationResolver(translate)
});
// 数据修改后,自动提交更新到上层
const lazySubmit = React.useRef(
debounce(methods.handleSubmit(onUpdate), 250, {
leading: false,
trailing: true
})
);
// 销毁的时候要 cancel
React.useEffect(() => {
return () => lazySubmit.current.cancel();
}, []);
// 监控数值变化,自动同步到上层
React.useEffect(() => {
const unsubscribe = methods.watch(() => {
lazySubmit.current();
});
return () => unsubscribe.unsubscribe();
}, [methods.watch]);
return methods;
};
export default useSubForm;

View File

@ -1339,7 +1339,7 @@ export default class ComboControl extends React.Component<ComboProps> {
{
type: 'dropdown-button',
icon: addIcon ? <Icon icon="plus" className="icon" /> : '',
label: __(addButtonText || 'Combo.add'),
label: __(addButtonText || 'add'),
level: 'info',
size: 'sm',
closeOnClick: true
@ -1357,7 +1357,7 @@ export default class ComboControl extends React.Component<ComboProps> {
) : tabsMode ? (
<a onClick={this.addItem}>
{addIcon ? <Icon icon="plus" className="icon" /> : null}
<span>{__(addButtonText || 'Combo.add')}</span>
<span>{__(addButtonText || 'add')}</span>
</a>
) : isObject(addBtn) ? (
render('add-button', {
@ -1371,7 +1371,7 @@ export default class ComboControl extends React.Component<ComboProps> {
onClick={this.addItem}
>
{addIcon ? <Icon icon="plus" className="icon" /> : null}
<span>{__(addButtonText || 'Combo.add')}</span>
<span>{__(addButtonText || 'add')}</span>
</Button>
))}
</>

View File

@ -30,9 +30,9 @@
"types": ["typePatches"],
"references": [],
"include": [
"**.ts",
"**.tsx",
"**.jsx",
"**/*.ts",
"**/*.tsx",
"**/*.jsx",
"scripts/fis3plugin.ts",
"scripts/markdownPlugin.ts",
"scripts/mockApiPlugin.ts",

View File

@ -32,12 +32,7 @@ export default defineConfig({
dimensions: false
}
}),
monacoEditorPlugin({}),
replace({
preventAssignment: true,
__buildDate__: () => JSON.stringify(new Date()),
__buildVersion: JSON.stringify('dev')
})
monacoEditorPlugin({})
],
optimizeDeps: {
include: ['amis-formula/lib/doc'],