From c2bba2eb282299ef606f3152c5fa54e461b79855 Mon Sep 17 00:00:00 2001 From: tanjinzhou <415800467@qq.com> Date: Fri, 28 May 2021 17:31:15 +0800 Subject: [PATCH] refactor: form --- components/_util/hooks/useConfigInject.ts | 16 +++- components/config-provider/index.tsx | 6 +- components/form/ErrorList.tsx | 53 +++++++++++ components/form/Form.tsx | 19 +++- components/form/FormItem.tsx | 3 + components/form/FormItemInput.tsx | 108 ++++++++++++++++++++++ components/form/FormItemLabel.tsx | 92 ++++++++++++++++++ components/form/context.ts | 49 ++++++++++ components/form/interface.ts | 2 + components/locale-provider/index.tsx | 9 ++ components/locale/default.ts | 69 +++++++++++++- 11 files changed, 419 insertions(+), 7 deletions(-) create mode 100644 components/form/ErrorList.tsx create mode 100644 components/form/FormItemInput.tsx create mode 100644 components/form/FormItemLabel.tsx create mode 100644 components/form/context.ts diff --git a/components/_util/hooks/useConfigInject.ts b/components/_util/hooks/useConfigInject.ts index 5ab278cd8..78c1e3b4f 100644 --- a/components/_util/hooks/useConfigInject.ts +++ b/components/_util/hooks/useConfigInject.ts @@ -1,3 +1,4 @@ +import { RequiredMark } from '../../form/Form'; import { computed, ComputedRef, inject, UnwrapRef } from 'vue'; import { ConfigProviderProps, @@ -17,6 +18,9 @@ export default ( getTargetContainer: ComputedRef<() => HTMLElement>; space: ComputedRef<{ size: SizeType | number }>; pageHeader: ComputedRef<{ ghost: boolean }>; + form?: ComputedRef<{ + requiredMark?: RequiredMark; + }>; } => { const configProvider = inject>( 'configProvider', @@ -26,7 +30,17 @@ export default ( const direction = computed(() => configProvider.direction); const space = computed(() => configProvider.space); const pageHeader = computed(() => configProvider.pageHeader); + const form = computed(() => configProvider.form); const size = computed(() => props.size || configProvider.componentSize); const getTargetContainer = computed(() => props.getTargetContainer); - return { configProvider, prefixCls, direction, size, getTargetContainer, space, pageHeader }; + return { + configProvider, + prefixCls, + direction, + size, + getTargetContainer, + space, + pageHeader, + form, + }; }; diff --git a/components/config-provider/index.tsx b/components/config-provider/index.tsx index b745b300a..e70086852 100644 --- a/components/config-provider/index.tsx +++ b/components/config-provider/index.tsx @@ -13,6 +13,7 @@ import LocaleProvider, { Locale, ANT_MARK } from '../locale-provider'; import { TransformCellTextProps } from '../table/interface'; import LocaleReceiver from '../locale-provider/LocaleReceiver'; import { withInstall } from '../_util/type'; +import { RequiredMark } from '../form/Form'; export type SizeType = 'small' | 'middle' | 'large' | undefined; @@ -99,6 +100,9 @@ export const configProviderProps = { }, virtual: PropTypes.looseBool, dropdownMatchSelectWidth: PropTypes.looseBool, + form: { + type: Object as PropType<{ requiredMark?: RequiredMark }>, + }, }; export type ConfigProviderProps = Partial>; @@ -159,7 +163,7 @@ const ConfigProvider = defineComponent({ export const defaultConfigProvider: UnwrapRef = reactive({ getPrefixCls: (suffixCls: string, customizePrefixCls?: string) => { if (customizePrefixCls) return customizePrefixCls; - return `ant-${suffixCls}`; + return suffixCls ? `ant-${suffixCls}` : 'ant'; }, renderEmpty: defaultRenderEmpty, direction: 'ltr', diff --git a/components/form/ErrorList.tsx b/components/form/ErrorList.tsx new file mode 100644 index 000000000..c6423c271 --- /dev/null +++ b/components/form/ErrorList.tsx @@ -0,0 +1,53 @@ +import { useInjectFormItemPrefix } from './context'; +import { VueNode } from '../_util/type'; +import { computed, defineComponent, ref, watch } from '@vue/runtime-core'; +import classNames from '../_util/classNames'; +import Transition, { getTransitionProps } from '../_util/transition'; + +export interface ErrorListProps { + errors?: VueNode[]; + /** @private Internal Usage. Do not use in your production */ + help?: VueNode; + /** @private Internal Usage. Do not use in your production */ + onDomErrorVisibleChange?: (visible: boolean) => void; +} + +export default defineComponent({ + name: 'ErrorList', + setup(props) { + const { prefixCls, status } = useInjectFormItemPrefix(); + const visible = computed(() => props.errors && props.errors.length); + const innerStatus = ref(status.value); + // Memo status in same visible + watch([() => visible, () => status], () => { + if (visible.value && status.value) { + innerStatus.value = status.value; + } + }); + return () => { + const baseClassName = `${prefixCls.value}-item-explain`; + const transitionProps = getTransitionProps('show-help', { + onAfterLeave: () => props.onDomErrorVisibleChange?.(false), + }); + return ( + + {visible ? ( +
+ {props.errors?.map((error: any, index: number) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {error} +
+ ))} +
+ ) : null} +
+ ); + }; + }, +}); diff --git a/components/form/Form.tsx b/components/form/Form.tsx index 75ed99579..5185a0c6a 100755 --- a/components/form/Form.tsx +++ b/components/form/Form.tsx @@ -1,4 +1,12 @@ -import { defineComponent, inject, provide, PropType, computed, ExtractPropTypes } from 'vue'; +import { + defineComponent, + inject, + provide, + PropType, + computed, + ExtractPropTypes, + HTMLAttributes, +} from 'vue'; import PropTypes from '../_util/vue-types'; import classNames from '../_util/classNames'; import warning from '../_util/warning'; @@ -16,6 +24,9 @@ import { tuple, VueNode } from '../_util/type'; import { ColProps } from '../grid/Col'; import { InternalNamePath, NamePath, ValidateErrorEntity, ValidateOptions } from './interface'; +export type RequiredMark = boolean | 'optional'; +export type FormLayout = 'horizontal' | 'inline' | 'vertical'; + export type ValidationRule = { /** validation error message */ message?: VueNode; @@ -45,11 +56,13 @@ export type ValidationRule = { export const formProps = { layout: PropTypes.oneOf(tuple('horizontal', 'inline', 'vertical')), - labelCol: { type: Object as PropType }, - wrapperCol: { type: Object as PropType }, + labelCol: { type: Object as PropType }, + wrapperCol: { type: Object as PropType }, colon: PropTypes.looseBool, labelAlign: PropTypes.oneOf(tuple('left', 'right')), prefixCls: PropTypes.string, + requiredMark: { type: [String, Boolean] as PropType }, + /** @deprecated Will warning in future branch. Pls use `requiredMark` instead. */ hideRequiredMark: PropTypes.looseBool, model: PropTypes.object, rules: { type: Object as PropType<{ [k: string]: ValidationRule[] | ValidationRule }> }, diff --git a/components/form/FormItem.tsx b/components/form/FormItem.tsx index 09d47a220..becb1d165 100644 --- a/components/form/FormItem.tsx +++ b/components/form/FormItem.tsx @@ -36,6 +36,9 @@ import find from 'lodash-es/find'; import { tuple, VueNode } from '../_util/type'; import { ValidateOptions } from './interface'; +const ValidateStatuses = tuple('success', 'warning', 'error', 'validating', ''); +export type ValidateStatus = typeof ValidateStatuses[number]; + const iconMap = { success: CheckCircleFilled, warning: ExclamationCircleFilled, diff --git a/components/form/FormItemInput.tsx b/components/form/FormItemInput.tsx new file mode 100644 index 000000000..7382421c5 --- /dev/null +++ b/components/form/FormItemInput.tsx @@ -0,0 +1,108 @@ +import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined'; +import CloseCircleFilled from '@ant-design/icons-vue/CloseCircleFilled'; +import CheckCircleFilled from '@ant-design/icons-vue/CheckCircleFilled'; +import ExclamationCircleFilled from '@ant-design/icons-vue/ExclamationCircleFilled'; + +import Col, { ColProps } from '../grid/col'; +import { useProvideForm, useInjectForm, useProvideFormItemPrefix } from './context'; +import ErrorList from './ErrorList'; +import classNames from '../_util/classNames'; +import { ValidateStatus } from './FormItem'; +import { VueNode } from '../_util/type'; +import { computed, defineComponent, HTMLAttributes, onUnmounted } from 'vue'; + +interface FormItemInputMiscProps { + prefixCls: string; + errors: VueNode[]; + hasFeedback?: boolean; + validateStatus?: ValidateStatus; + onDomErrorVisibleChange: (visible: boolean) => void; +} + +export interface FormItemInputProps { + wrapperCol?: ColProps; + help?: VueNode; + extra?: VueNode; + status?: ValidateStatus; +} + +const iconMap: { [key: string]: any } = { + success: CheckCircleFilled, + warning: ExclamationCircleFilled, + error: CloseCircleFilled, + validating: LoadingOutlined, +}; +const FormItemInput = defineComponent({ + slots: ['help', 'extra', 'errors'], + setup(props, { slots }) { + const formContext = useInjectForm(); + const { wrapperCol: contextWrapperCol } = formContext; + + // Pass to sub FormItem should not with col info + const subFormContext = { ...formContext }; + delete subFormContext.labelCol; + delete subFormContext.wrapperCol; + useProvideForm(subFormContext); + + useProvideFormItemPrefix({ + prefixCls: computed(() => props.prefixCls), + status: computed(() => props.status), + }); + + return () => { + const { + prefixCls, + wrapperCol, + help = slots.help?.(), + errors = slots.errors?.(), + onDomErrorVisibleChange, + hasFeedback, + validateStatus, + extra = slots.extra?.(), + } = props; + const baseClassName = `${prefixCls}-item`; + + const mergedWrapperCol: ColProps & HTMLAttributes = + wrapperCol || contextWrapperCol?.value || {}; + + const className = classNames(`${baseClassName}-control`, mergedWrapperCol.class); + + onUnmounted(() => { + onDomErrorVisibleChange(false); + }); + + // Should provides additional icon if `hasFeedback` + const IconNode = validateStatus && iconMap[validateStatus]; + const icon = + hasFeedback && IconNode ? ( + + + + ) : null; + + const inputDom = ( +
+
{slots.default?.()}
+ {icon} +
+ ); + const errorListDom = ( + + ); + + // If extra = 0, && will goes wrong + // 0&&error -> 0 + const extraDom = extra ?
{extra}
: null; + + return ( + + {inputDom} + {errorListDom} + {extraDom} + + ); + }; + }, +}); + +export default FormItemInput; diff --git a/components/form/FormItemLabel.tsx b/components/form/FormItemLabel.tsx new file mode 100644 index 000000000..57624bac2 --- /dev/null +++ b/components/form/FormItemLabel.tsx @@ -0,0 +1,92 @@ +import Col, { ColProps } from '../grid/col'; +import { FormLabelAlign } from './interface'; +import { useInjectForm } from './context'; +import { RequiredMark } from './Form'; +import { useLocaleReceiver } from '../locale-provider/LocaleReceiver'; +import defaultLocale from '../locale/default'; +import classNames from '../_util/classNames'; +import { VueNode } from '../_util/type'; +import { FunctionalComponent, HTMLAttributes } from 'vue'; + +export interface FormItemLabelProps { + colon?: boolean; + htmlFor?: string; + label?: VueNode; + labelAlign?: FormLabelAlign; + labelCol?: ColProps & HTMLAttributes; + requiredMark?: RequiredMark; + required?: boolean; + prefixCls: string; +} + +const FormItemLabel: FunctionalComponent = (props, { slots }) => { + const { prefixCls, htmlFor, labelCol, labelAlign, colon, required, requiredMark } = props; + const [formLocale] = useLocaleReceiver('Form'); + const label = props.label ?? slots.label?.(); + if (!label) return null; + const { + vertical, + labelAlign: contextLabelAlign, + labelCol: contextLabelCol, + colon: contextColon, + } = useInjectForm(); + const mergedLabelCol: FormItemLabelProps['labelCol'] = labelCol || contextLabelCol?.value || {}; + + const mergedLabelAlign: FormLabelAlign | undefined = labelAlign || contextLabelAlign?.value; + + const labelClsBasic = `${prefixCls}-item-label`; + const labelColClassName = classNames( + labelClsBasic, + mergedLabelAlign === 'left' && `${labelClsBasic}-left`, + mergedLabelCol.class, + ); + + let labelChildren = label; + // Keep label is original where there should have no colon + const computedColon = colon === true || (contextColon?.value !== false && colon !== false); + const haveColon = computedColon && !vertical.value; + // Remove duplicated user input colon + if (haveColon && typeof label === 'string' && (label as string).trim() !== '') { + labelChildren = (label as string).replace(/[:|:]\s*$/, ''); + } + + labelChildren = ( + <> + {labelChildren} + {slots.tooltip?.({ class: `${prefixCls}-item-tooltip` })} + + ); + + // Add required mark if optional + if (requiredMark === 'optional' && !required) { + labelChildren = ( + <> + {labelChildren} + + {formLocale.value?.optional || defaultLocale.Form?.optional} + + + ); + } + + const labelClassName = classNames({ + [`${prefixCls}-item-required`]: required, + [`${prefixCls}-item-required-mark-optional`]: requiredMark === 'optional', + [`${prefixCls}-item-no-colon`]: !computedColon, + }); + return ( + + + + ); +}; + +FormItemLabel.displayName = 'FormItemLabel'; + +export default FormItemLabel; diff --git a/components/form/context.ts b/components/form/context.ts new file mode 100644 index 000000000..68005e317 --- /dev/null +++ b/components/form/context.ts @@ -0,0 +1,49 @@ +import { inject, InjectionKey, provide, ComputedRef, computed } from 'vue'; +import { ColProps } from '../grid'; +import { RequiredMark } from './Form'; +import { ValidateStatus } from './FormItem'; +import { FormLabelAlign } from './interface'; + +export interface FormContextProps { + vertical: ComputedRef; + name?: ComputedRef; + colon?: ComputedRef; + labelAlign?: ComputedRef; + labelCol?: ComputedRef; + wrapperCol?: ComputedRef; + requiredMark?: ComputedRef; + //itemRef: (name: (string | number)[]) => (node: React.ReactElement) => void; +} + +export const FormContextKey: InjectionKey = Symbol('formContextKey'); + +export const useProvideForm = (state: FormContextProps) => { + provide(FormContextKey, state); +}; + +export const useInjectForm = () => { + return inject(FormContextKey, { + labelAlign: computed(() => 'right' as FormLabelAlign), + vertical: computed(() => false), + }); +}; + +/** Used for ErrorList only */ +export interface FormItemPrefixContextProps { + prefixCls: ComputedRef; + status?: ComputedRef; +} + +export const FormItemPrefixContextKey: InjectionKey = Symbol( + 'formItemPrefixContextKey', +); + +export const useProvideFormItemPrefix = (state: FormItemPrefixContextProps) => { + provide(FormItemPrefixContextKey, state); +}; + +export const useInjectFormItemPrefix = () => { + return inject(FormItemPrefixContextKey, { + prefixCls: computed(() => ''), + }); +}; diff --git a/components/form/interface.ts b/components/form/interface.ts index 76c35da82..27180a9c4 100644 --- a/components/form/interface.ts +++ b/components/form/interface.ts @@ -1,5 +1,7 @@ import { VueNode } from '../_util/type'; +export type FormLabelAlign = 'left' | 'right'; + export type InternalNamePath = (string | number)[]; export type NamePath = string | number | InternalNamePath; diff --git a/components/locale-provider/index.tsx b/components/locale-provider/index.tsx index 854db71b7..38465a203 100644 --- a/components/locale-provider/index.tsx +++ b/components/locale-provider/index.tsx @@ -5,6 +5,7 @@ import interopDefault from '../_util/interopDefault'; import { ModalLocale, changeConfirmLocale } from '../modal/locale'; import warning from '../_util/warning'; import { withInstall } from '../_util/type'; +import { ValidateMessages } from '../form/interface'; export interface Locale { locale: string; Pagination?: Object; @@ -17,6 +18,14 @@ export interface Locale { Transfer?: Object; Select?: Object; Upload?: Object; + + Form?: { + optional?: string; + defaultValidateMessages: ValidateMessages; + }; + Image?: { + preview: string; + }; } export interface LocaleProviderProps { diff --git a/components/locale/default.ts b/components/locale/default.ts index fb35a9563..518a461e2 100644 --- a/components/locale/default.ts +++ b/components/locale/default.ts @@ -3,14 +3,13 @@ import DatePicker from '../date-picker/locale/en_US'; import TimePicker from '../time-picker/locale/en_US'; import Calendar from '../calendar/locale/en_US'; // import ColorPicker from '../color-picker/locale/en_US'; - +const typeTemplate = '${label} is not a valid ${type}'; export default { locale: 'en', Pagination, DatePicker, TimePicker, Calendar, - // ColorPicker, global: { placeholder: 'Please select', }, @@ -18,11 +17,18 @@ export default { filterTitle: 'Filter menu', filterConfirm: 'OK', filterReset: 'Reset', + filterEmptyText: 'No filters', + emptyText: 'No data', selectAll: 'Select current page', selectInvert: 'Invert current page', + selectNone: 'Clear all data', + selectionAll: 'Select all data', sortTitle: 'Sort', expand: 'Expand row', collapse: 'Collapse row', + triggerDesc: 'Click to sort descending', + triggerAsc: 'Click to sort ascending', + cancelSort: 'Click to cancel sorting', }, Modal: { okText: 'OK', @@ -38,6 +44,12 @@ export default { searchPlaceholder: 'Search here', itemUnit: 'item', itemsUnit: 'items', + remove: 'Remove', + selectCurrent: 'Select current page', + removeCurrent: 'Remove current page', + selectAll: 'Select all data', + removeAll: 'Remove all data', + selectInvert: 'Invert current page', }, Upload: { uploading: 'Uploading...', @@ -61,4 +73,57 @@ export default { PageHeader: { back: 'Back', }, + Form: { + optional: '(optional)', + defaultValidateMessages: { + default: 'Field validation error for ${label}', + required: 'Please enter ${label}', + enum: '${label} must be one of [${enum}]', + whitespace: '${label} cannot be a blank character', + date: { + format: '${label} date format is invalid', + parse: '${label} cannot be converted to a date', + invalid: '${label} is an invalid date', + }, + types: { + string: typeTemplate, + method: typeTemplate, + array: typeTemplate, + object: typeTemplate, + number: typeTemplate, + date: typeTemplate, + boolean: typeTemplate, + integer: typeTemplate, + float: typeTemplate, + regexp: typeTemplate, + email: typeTemplate, + url: typeTemplate, + hex: typeTemplate, + }, + string: { + len: '${label} must be ${len} characters', + min: '${label} must be at least ${min} characters', + max: '${label} must be up to ${max} characters', + range: '${label} must be between ${min}-${max} characters', + }, + number: { + len: '${label} must be equal to ${len}', + min: '${label} must be minimum ${min}', + max: '${label} must be maximum ${max}', + range: '${label} must be between ${min}-${max}', + }, + array: { + len: 'Must be ${len} ${label}', + min: 'At least ${min} ${label}', + max: 'At most ${max} ${label}', + range: 'The amount of ${label} must be between ${min}-${max}', + }, + pattern: { + mismatch: '${label} does not match the pattern ${pattern}', + }, + }, + }, + Image: { + preview: 'Preview', + }, };