ant-design-vue/components/form/FormItem.tsx

536 lines
17 KiB
Vue

import type {
PropType,
ExtractPropTypes,
ComputedRef,
Ref,
ComponentPublicInstance,
HTMLAttributes,
} from 'vue';
import {
onMounted,
reactive,
watch,
defineComponent,
computed,
nextTick,
shallowRef,
watchEffect,
onBeforeUnmount,
toRaw,
} from 'vue';
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 cloneDeep from 'lodash-es/cloneDeep';
import PropTypes from '../_util/vue-types';
import Row from '../grid/Row';
import type { ColProps } from '../grid/Col';
import { filterEmpty } from '../_util/props-util';
import { validateRules as validateRulesUtil } from './utils/validateUtil';
import { getNamePath } from './utils/valueUtil';
import { toArray } from './utils/typeUtil';
import { warning } from '../vc-util/warning';
import find from 'lodash-es/find';
import { tuple } from '../_util/type';
import type {
FormLabelAlign,
InternalNamePath,
Rule,
RuleError,
RuleObject,
ValidateOptions,
} from './interface';
import useConfigInject from '../config-provider/hooks/useConfigInject';
import { useInjectForm } from './context';
import FormItemLabel from './FormItemLabel';
import FormItemInput from './FormItemInput';
import type { FormItemStatusContextProps } from './FormItemContext';
import { FormItemInputContext, useProvideFormItemContext } from './FormItemContext';
import useDebounce from './utils/useDebounce';
import classNames from '../_util/classNames';
import useStyle from './style';
const ValidateStatuses = tuple('success', 'warning', 'error', 'validating', '');
export type ValidateStatus = (typeof ValidateStatuses)[number];
export interface FieldExpose {
fieldValue: Ref<any>;
fieldId: ComputedRef<any>;
fieldName: ComputedRef<any>;
resetField: () => void;
clearValidate: () => void;
namePath: ComputedRef<InternalNamePath>;
rules?: ComputedRef<Rule[]>;
validateRules: (options: ValidateOptions) => Promise<void> | Promise<RuleError[]>;
}
const iconMap: { [key: string]: any } = {
success: CheckCircleFilled,
warning: ExclamationCircleFilled,
error: CloseCircleFilled,
validating: LoadingOutlined,
};
function getPropByPath(obj: any, namePathList: any, strict?: boolean) {
let tempObj = obj;
const keyArr = namePathList;
let i = 0;
try {
for (let len = keyArr.length; i < len - 1; ++i) {
if (!tempObj && !strict) break;
const key = keyArr[i];
if (key in tempObj) {
tempObj = tempObj[key];
} else {
if (strict) {
throw Error('please transfer a valid name path to form item!');
}
break;
}
}
if (strict && !tempObj) {
throw Error('please transfer a valid name path to form item!');
}
} catch (error) {
console.error('please transfer a valid name path to form item!');
}
return {
o: tempObj,
k: keyArr[i],
v: tempObj ? tempObj[keyArr[i]] : undefined,
};
}
export const formItemProps = () => ({
htmlFor: String,
prefixCls: String,
label: PropTypes.any,
help: PropTypes.any,
extra: PropTypes.any,
labelCol: { type: Object as PropType<ColProps & HTMLAttributes> },
wrapperCol: { type: Object as PropType<ColProps & HTMLAttributes> },
hasFeedback: { type: Boolean, default: false },
colon: { type: Boolean, default: undefined },
labelAlign: String as PropType<FormLabelAlign>,
prop: { type: [String, Number, Array] as PropType<string | number | Array<string | number>> },
name: { type: [String, Number, Array] as PropType<string | number | Array<string | number>> },
rules: [Array, Object] as PropType<Rule[] | Rule>,
autoLink: { type: Boolean, default: true },
required: { type: Boolean, default: undefined },
validateFirst: { type: Boolean, default: undefined },
validateStatus: PropTypes.oneOf(tuple('', 'success', 'warning', 'error', 'validating')),
validateTrigger: { type: [String, Array] as PropType<string | string[]> },
messageVariables: { type: Object as PropType<Record<string, string>> },
hidden: Boolean,
noStyle: Boolean,
});
export type FormItemProps = Partial<ExtractPropTypes<ReturnType<typeof formItemProps>>>;
export type FormItemExpose = {
onFieldBlur: () => void;
onFieldChange: () => void;
clearValidate: () => void;
resetField: () => void;
};
export type FormItemInstance = ComponentPublicInstance<FormItemProps, FormItemExpose>;
let indexGuid = 0;
// default form item id prefix.
const defaultItemNamePrefixCls = 'form_item';
export default defineComponent({
compatConfig: { MODE: 3 },
name: 'AFormItem',
inheritAttrs: false,
__ANT_NEW_FORM_ITEM: true,
props: formItemProps(),
slots: ['help', 'label', 'extra'],
setup(props, { slots, attrs, expose }) {
warning(props.prop === undefined, `\`prop\` is deprecated. Please use \`name\` instead.`);
const eventKey = `form-item-${++indexGuid}`;
const { prefixCls } = useConfigInject('form', props);
const [wrapSSR, hashId] = useStyle(prefixCls);
const itemRef = shallowRef<HTMLDivElement>();
const formContext = useInjectForm();
const fieldName = computed(() => props.name || props.prop);
const errors = shallowRef([]);
const validateDisabled = shallowRef(false);
const inputRef = shallowRef();
const namePath = computed(() => {
const val = fieldName.value;
return getNamePath(val);
});
const fieldId = computed(() => {
if (!namePath.value.length) {
return undefined;
} else {
const formName = formContext.name.value;
const mergedId = namePath.value.join('_');
return formName ? `${formName}_${mergedId}` : `${defaultItemNamePrefixCls}_${mergedId}`;
}
});
const getNewFieldValue = () => {
const model = formContext.model.value;
if (!model || !fieldName.value) {
return;
} else {
return getPropByPath(model, namePath.value, true).v;
}
};
const fieldValue = computed(() => getNewFieldValue());
const initialValue = shallowRef(cloneDeep(fieldValue.value));
const mergedValidateTrigger = computed(() => {
let validateTrigger =
props.validateTrigger !== undefined
? props.validateTrigger
: formContext.validateTrigger.value;
validateTrigger = validateTrigger === undefined ? 'change' : validateTrigger;
return toArray(validateTrigger);
});
const rulesRef = computed<Rule[]>(() => {
let formRules = formContext.rules.value;
const selfRules = props.rules;
const requiredRule =
props.required !== undefined
? { required: !!props.required, trigger: mergedValidateTrigger.value }
: [];
const prop = getPropByPath(formRules, namePath.value);
formRules = formRules ? prop.o[prop.k] || prop.v : [];
const rules = [].concat(selfRules || formRules || []);
if (find(rules, rule => rule.required)) {
return rules;
} else {
return rules.concat(requiredRule);
}
});
const isRequired = computed(() => {
const rules = rulesRef.value;
let isRequired = false;
if (rules && rules.length) {
rules.every(rule => {
if (rule.required) {
isRequired = true;
return false;
}
return true;
});
}
return isRequired || props.required;
});
const validateState = shallowRef();
watchEffect(() => {
validateState.value = props.validateStatus;
});
const messageVariables = computed(() => {
let variables: Record<string, string> = {};
if (typeof props.label === 'string') {
variables.label = props.label;
} else if (props.name) {
variables.label = String(props.name);
}
if (props.messageVariables) {
variables = { ...variables, ...props.messageVariables };
}
return variables;
});
const validateRules = (options: ValidateOptions) => {
// no name, no value, so the validate result is incorrect
if (namePath.value.length === 0) {
return;
}
const { validateFirst = false } = props;
const { triggerName } = options || {};
let filteredRules = rulesRef.value;
if (triggerName) {
filteredRules = filteredRules.filter(rule => {
const { trigger } = rule;
if (!trigger && !mergedValidateTrigger.value.length) {
return true;
}
const triggerList = toArray(trigger || mergedValidateTrigger.value);
return triggerList.includes(triggerName);
});
}
if (!filteredRules.length) {
return Promise.resolve();
}
const promise = validateRulesUtil(
namePath.value,
fieldValue.value,
filteredRules as RuleObject[],
{
validateMessages: formContext.validateMessages.value,
...options,
},
validateFirst,
messageVariables.value,
);
validateState.value = 'validating';
errors.value = [];
promise
.catch(e => e)
.then((results: RuleError[] = []) => {
if (validateState.value === 'validating') {
const res = results.filter(result => result && result.errors.length);
validateState.value = res.length ? 'error' : 'success';
errors.value = res.map(r => r.errors);
formContext.onValidate(
fieldName.value,
!errors.value.length,
errors.value.length ? toRaw(errors.value[0]) : null,
);
}
});
return promise;
};
const onFieldBlur = () => {
validateRules({ triggerName: 'blur' });
};
const onFieldChange = () => {
if (validateDisabled.value) {
validateDisabled.value = false;
return;
}
validateRules({ triggerName: 'change' });
};
const clearValidate = () => {
validateState.value = props.validateStatus;
validateDisabled.value = false;
errors.value = [];
};
const resetField = () => {
validateState.value = props.validateStatus;
validateDisabled.value = true;
errors.value = [];
const model = formContext.model.value || {};
const value = fieldValue.value;
const prop = getPropByPath(model, namePath.value, true);
if (Array.isArray(value)) {
prop.o[prop.k] = [].concat(initialValue.value);
} else {
prop.o[prop.k] = initialValue.value;
}
// reset validateDisabled after onFieldChange triggered
nextTick(() => {
validateDisabled.value = false;
});
};
const htmlFor = computed(() => {
return props.htmlFor === undefined ? fieldId.value : props.htmlFor;
});
const onLabelClick = () => {
const id = htmlFor.value;
if (!id || !inputRef.value) {
return;
}
const control = inputRef.value.$el.querySelector(`[id="${id}"]`);
if (control && control.focus) {
control.focus();
}
};
expose({
onFieldBlur,
onFieldChange,
clearValidate,
resetField,
});
useProvideFormItemContext(
{
id: fieldId,
onFieldBlur: () => {
if (props.autoLink) {
onFieldBlur();
}
},
onFieldChange: () => {
if (props.autoLink) {
onFieldChange();
}
},
clearValidate,
},
computed(() => {
return !!(props.autoLink && formContext.model.value && fieldName.value);
}),
);
let registered = false;
watch(
fieldName,
val => {
if (val) {
if (!registered) {
registered = true;
formContext.addField(eventKey, {
fieldValue,
fieldId,
fieldName,
resetField,
clearValidate,
namePath,
validateRules,
rules: rulesRef,
});
}
} else {
registered = false;
formContext.removeField(eventKey);
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
formContext.removeField(eventKey);
});
const debounceErrors = useDebounce(errors);
const mergedValidateStatus = computed(() => {
if (props.validateStatus !== undefined) {
return props.validateStatus;
} else if (debounceErrors.value.length) {
return 'error';
}
return validateState.value;
});
const itemClassName = computed(() => ({
[`${prefixCls.value}-item`]: true,
[hashId.value]: true,
// Status
[`${prefixCls.value}-item-has-feedback`]: mergedValidateStatus.value && props.hasFeedback,
[`${prefixCls.value}-item-has-success`]: mergedValidateStatus.value === 'success',
[`${prefixCls.value}-item-has-warning`]: mergedValidateStatus.value === 'warning',
[`${prefixCls.value}-item-has-error`]: mergedValidateStatus.value === 'error',
[`${prefixCls.value}-item-is-validating`]: mergedValidateStatus.value === 'validating',
[`${prefixCls.value}-item-hidden`]: props.hidden,
}));
const formItemInputContext = reactive<FormItemStatusContextProps>({});
FormItemInputContext.useProvide(formItemInputContext);
watchEffect(() => {
let feedbackIcon: any;
if (props.hasFeedback) {
const IconNode = mergedValidateStatus.value && iconMap[mergedValidateStatus.value];
feedbackIcon = IconNode ? (
<span
class={classNames(
`${prefixCls.value}-item-feedback-icon`,
`${prefixCls.value}-item-feedback-icon-${mergedValidateStatus.value}`,
)}
>
<IconNode />
</span>
) : null;
}
Object.assign(formItemInputContext, {
status: mergedValidateStatus.value,
hasFeedback: props.hasFeedback,
feedbackIcon,
isFormItemInput: true,
});
});
const marginBottom = shallowRef<number>(null);
const showMarginOffset = shallowRef(false);
const updateMarginBottom = () => {
if (itemRef.value) {
const itemStyle = getComputedStyle(itemRef.value);
marginBottom.value = parseInt(itemStyle.marginBottom, 10);
}
};
onMounted(() => {
watch(
showMarginOffset,
() => {
if (showMarginOffset.value) {
updateMarginBottom();
}
},
{ flush: 'post', immediate: true },
);
});
const onErrorVisibleChanged = (nextVisible: boolean) => {
if (!nextVisible) {
marginBottom.value = null;
}
};
return () => {
if (props.noStyle) return slots.default?.();
const help = props.help ?? (slots.help ? filterEmpty(slots.help()) : null);
const withHelp = !!(
(help !== undefined && help !== null && Array.isArray(help) && help.length) ||
debounceErrors.value.length
);
showMarginOffset.value = withHelp;
return wrapSSR(
<div
class={[
itemClassName.value,
withHelp ? `${prefixCls.value}-item-with-help` : '',
attrs.class,
]}
ref={itemRef}
>
<Row
{...attrs}
class={`${prefixCls.value}-row`}
key="row"
v-slots={{
default: () => (
<>
{/* Label */}
<FormItemLabel
{...props}
htmlFor={htmlFor.value}
required={isRequired.value}
requiredMark={formContext.requiredMark.value}
prefixCls={prefixCls.value}
onClick={onLabelClick}
label={props.label ?? slots.label?.()}
/>
{/* Input Group */}
<FormItemInput
{...props}
errors={
help !== undefined && help !== null ? toArray(help) : debounceErrors.value
}
marginBottom={marginBottom.value}
prefixCls={prefixCls.value}
status={mergedValidateStatus.value}
ref={inputRef}
help={help}
extra={props.extra ?? slots.extra?.()}
v-slots={{ default: slots.default }}
onErrorVisibleChanged={onErrorVisibleChanged}
></FormItemInput>
</>
),
}}
></Row>
{!!marginBottom.value && (
<div
class={`${prefixCls.value}-margin-offset`}
style={{
marginBottom: `-${marginBottom.value}px`,
}}
/>
)}
</div>,
);
};
},
});