diff --git a/.changeset/fix-required-validator-check.md b/.changeset/fix-required-validator-check.md new file mode 100644 index 000000000..f381b6a7a --- /dev/null +++ b/.changeset/fix-required-validator-check.md @@ -0,0 +1,6 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix required validator to check rule.required flag before validating. + diff --git a/.changeset/fresh-otters-ring.md b/.changeset/fresh-otters-ring.md new file mode 100644 index 000000000..2d1db961a --- /dev/null +++ b/.changeset/fresh-otters-ring.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Remove isRequired prop in Form component. diff --git a/.changeset/popular-owls-retire.md b/.changeset/popular-owls-retire.md new file mode 100644 index 000000000..ec0556813 --- /dev/null +++ b/.changeset/popular-owls-retire.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Improve rule management in form fields. diff --git a/src/components/form/Form/Field.stories.tsx b/src/components/form/Form/Field.stories.tsx index 4c5f73a41..6e8ea9f1b 100644 --- a/src/components/form/Form/Field.stories.tsx +++ b/src/components/form/Form/Field.stories.tsx @@ -3,8 +3,7 @@ import { StoryFn } from '@storybook/react-vite'; import { baseProps } from '../../../stories/lists/baseProps'; import { Block } from '../../Block'; -import { Field } from './Field'; -import { CubeFieldProps } from './use-field/types'; +import { CubeFieldProps, Field } from './Field'; export default { title: 'Forms/Field', diff --git a/src/components/form/Form/Form.tsx b/src/components/form/Form/Form.tsx index 2dbde3bde..c48884cd3 100644 --- a/src/components/form/Form/Form.tsx +++ b/src/components/form/Form/Form.tsx @@ -100,7 +100,6 @@ function Form( children, labelPosition, orientation, - isRequired, necessityIndicator, isDisabled, isReadOnly, @@ -248,7 +247,6 @@ function Form( insideForm={true} isDisabled={isDisabled} isReadOnly={isReadOnly} - isRequired={isRequired} validationState={validationState} > {children} diff --git a/src/components/form/Form/field.test.tsx b/src/components/form/Form/field.test.tsx index 17bf3409d..26a181ce1 100644 --- a/src/components/form/Form/field.test.tsx +++ b/src/components/form/Form/field.test.tsx @@ -391,4 +391,45 @@ describe('Legacy ', () => { expect(getByText('Must be at least 5 characters')).toBeInTheDocument(); }); }); + + it('should add required validation rule when isRequired prop is true', async () => { + const { getByRole, formInstance } = renderWithForm( + , + ); + + const input = getByRole('textbox'); + + // Type something and then clear to trigger required validation + await act(async () => { + await userEvent.type(input, 'test'); + await userEvent.clear(input); + await userEvent.tab(); // Trigger onBlur validation + }); + + await waitFor(() => { + expect( + formInstance.getFieldInstance('test')?.errors?.length, + ).toBeGreaterThan(0); + }); + }); + + it('should not duplicate required rule when isRequired prop is true and rules already contain required', async () => { + const { formInstance } = renderWithForm( + , + ); + + const field = formInstance.getFieldInstance('test'); + const requiredRules = field?.rules?.filter( + (rule) => 'required' in rule && rule.required === true, + ); + + // Should have exactly one required rule (not duplicated) + expect(requiredRules?.length).toBe(1); + expect(requiredRules?.[0].message).toBe('Custom required message'); + }); }); diff --git a/src/components/form/Form/use-field/types.ts b/src/components/form/Form/use-field/types.ts index 8b50ccef2..e68a363dd 100644 --- a/src/components/form/Form/use-field/types.ts +++ b/src/components/form/Form/use-field/types.ts @@ -1,47 +1,24 @@ import { ReactNode } from 'react'; import { + FieldCoreProps, ValidateTrigger, - ValidationRule, ValidationState, } from '../../../../shared/index'; -import { Props } from '../../../../tasty/index'; import { CubeFieldData, FieldTypes } from '../types'; import { CubeFormInstance } from '../use-form'; -export interface CubeFieldProps { +export interface UseFieldProps extends FieldCoreProps { /** The initial value of the input. */ defaultValue?: any; - /** The unique ID of the field */ - id?: string; - /** The id prefix for the field to avoid collisions between forms */ - idPrefix?: string; - /** Function that checks whether to perform update of the form state. */ - shouldUpdate?: boolean | ((prevValues, nextValues) => boolean); - /** Validation rules */ - rules?: ValidationRule[]; /** The form instance */ form?: CubeFormInstance; - /** Field name. It's used as a key the form data. */ - name?: string; /** The validation state of the field */ validationState?: ValidationState; - /** Debounce in milliseconds for validation */ - validationDelay?: number; /** Whether to show valid state */ showValid?: boolean; /** On which event perform the validation for the field */ validateTrigger?: ValidateTrigger; - /** - * @deprecated Use `errorMessage` for error messages and `description` for field descriptions instead. - * Message for the field. Some additional information or error notice - */ - message?: ReactNode; - /** Description for the field. Will be placed below the label */ - description?: ReactNode; - /** Error message for the field. Always displayed in danger state regardless of validation state */ - errorMessage?: ReactNode; - labelProps?: Props; } export type FieldReturnValue = { diff --git a/src/components/form/Form/use-field/use-field-props.tsx b/src/components/form/Form/use-field/use-field-props.tsx index 188e2ee22..889dba992 100644 --- a/src/components/form/Form/use-field/use-field-props.tsx +++ b/src/components/form/Form/use-field/use-field-props.tsx @@ -9,7 +9,7 @@ import { useField } from './use-field'; import type { ValidateTrigger } from '../../../../shared/index'; import type { FieldTypes } from '../types'; -import type { CubeFieldProps } from './types'; +import type { UseFieldProps } from './types'; export type UseFieldPropsParams = { valuePropsMapper?: ({ value, onChange }) => any; @@ -26,7 +26,7 @@ export type UseFieldPropsParams = { export function useFieldProps< T extends FieldTypes, - Props extends CubeFieldProps, + Props extends UseFieldProps, >(props: Props, params: UseFieldPropsParams = {}): Props { // We use ref here to "memoize" initial value const isDisabledRef = useRef(params.unsafe__isDisabled ?? false); diff --git a/src/components/form/Form/use-field/use-field.ts b/src/components/form/Form/use-field/use-field.ts index 086bfa35a..f80f403b8 100644 --- a/src/components/form/Form/use-field/use-field.ts +++ b/src/components/form/Form/use-field/use-field.ts @@ -6,7 +6,7 @@ import { useFormProps } from '../Form'; import { FieldTypes } from '../types'; import { delayValidationRule } from '../validation'; -import { CubeFieldProps, FieldReturnValue } from './types'; +import { FieldReturnValue, UseFieldProps } from './types'; const ID_MAP = {}; @@ -40,7 +40,7 @@ export type UseFieldParams = { defaultValidationTrigger?: ValidateTrigger; }; -export function useField>( +export function useField>( props: Props, params: UseFieldParams, ): FieldReturnValue { @@ -61,11 +61,32 @@ export function useField>( validationDelay, showValid, shouldUpdate, + isRequired: isRequiredProp, } = props; - if (rules && rules.length && validationDelay) { - rules.unshift(delayValidationRule(validationDelay)); - } + const processedRules = useMemo(() => { + let finalRules = rules; + + // If isRequired prop is set, ensure there's a required rule + if (isRequiredProp) { + const hasRequiredRule = finalRules?.some( + (rule) => 'required' in rule && rule.required === true, + ); + + if (!hasRequiredRule) { + finalRules = finalRules + ? [{ required: true }, ...finalRules] + : [{ required: true }]; + } + } + + // Add delay rule if needed + if (finalRules && finalRules.length && validationDelay) { + return [delayValidationRule(validationDelay), ...finalRules]; + } + + return finalRules; + }, [rules, validationDelay, isRequiredProp]); const nonInput = !name; const fieldName: string = name != null ? name : ''; @@ -98,10 +119,12 @@ export function useField>( let field = form?.getFieldInstance(fieldName); if (field) { - field.rules = rules; + field.rules = processedRules; } - let isRequired = rules && !!rules.find((rule) => rule.required); + let isRequired = !!processedRules?.find( + (rule) => 'required' in rule && rule.required === true, + ); useEffect(() => { if (!form) return; diff --git a/src/components/form/Form/validation.ts b/src/components/form/Form/validation.ts index c16c7fb4d..cd99815ae 100644 --- a/src/components/form/Form/validation.ts +++ b/src/components/form/Form/validation.ts @@ -46,7 +46,9 @@ const TYPE_CHECKERS = { const TYPE_LIST = Object.keys(TYPE_CHECKERS); const VALIDATORS = { - async required(value) { + async required(value, rule) { + if (!rule.required) return Promise.resolve(); + if (Array.isArray(value)) { return value.length ? Promise.resolve() : Promise.reject(); } diff --git a/src/components/overlays/Dialog/DialogForm.tsx b/src/components/overlays/Dialog/DialogForm.tsx index c255cebda..fc6650b5d 100644 --- a/src/components/overlays/Dialog/DialogForm.tsx +++ b/src/components/overlays/Dialog/DialogForm.tsx @@ -57,7 +57,6 @@ export function DialogForm( labelStyles, labelPosition, requiredMark, - isRequired, necessityIndicator, necessityLabel, isReadOnly, @@ -110,7 +109,6 @@ export function DialogForm( labelStyles={labelStyles} labelPosition={labelPosition} requiredMark={requiredMark} - isRequired={isRequired} necessityIndicator={necessityIndicator} necessityLabel={necessityLabel} isReadOnly={isReadOnly} diff --git a/src/shared/form.ts b/src/shared/form.ts index 65dbe3349..cfdc534c3 100644 --- a/src/shared/form.ts +++ b/src/shared/form.ts @@ -23,28 +23,22 @@ export type ValidationState = 'invalid' | 'valid'; /** On which event perform the validation for the field */ export type ValidateTrigger = 'onBlur' | 'onChange' | 'onSubmit'; -export interface FieldBaseProps extends FormBaseProps { +/** Core field identity and validation props */ +export interface FieldCoreProps { + /** The unique ID of the field */ + id?: string; + /** The id prefix for the field to avoid collisions between forms */ + idPrefix?: string; /** The field name */ name?: string; - /** The label of the field */ - label?: ReactNode; - /** Validation rules */ - rules?: ValidationRule[]; /** The form instance */ form?: any; - /** An additional content next to the label */ - extra?: ReactNode; - /** The validation state of the field */ - validationState?: ValidationState; + /** Function that checks whether to perform update of the form state. */ + shouldUpdate?: boolean | ((prevValues, nextValues) => boolean); + /** Validation rules */ + rules?: ValidationRule[]; /** Debounce in milliseconds for validation */ validationDelay?: number; - /** On which event perform the validation for the field */ - validateTrigger?: ValidateTrigger; - necessityIndicator?: NecessityIndicator; - necessityLabel?: ReactNode; - labelSuffix?: ReactNode; - /** Custom label props */ - labelProps?: Props; /** * @deprecated Use `errorMessage` for error messages and `description` for field descriptions instead. * Message for the field. Some additional information or error notice @@ -54,16 +48,24 @@ export interface FieldBaseProps extends FormBaseProps { description?: ReactNode; /** Error message for the field. Always displayed in danger state regardless of validation state */ errorMessage?: ReactNode; + /** Whether the field is required */ + isRequired?: boolean; + /** Custom label props */ + labelProps?: Props; +} + +export interface FieldBaseProps extends FormBaseProps, FieldCoreProps { + /** The label of the field */ + label?: ReactNode; + /** An additional content next to the label */ + extra?: ReactNode; + necessityIndicator?: NecessityIndicator; + necessityLabel?: ReactNode; + labelSuffix?: ReactNode; /** A tooltip that is shown inside the label */ tooltip?: ReactNode; /** Whether the element should receive focus on render */ autoFocus?: boolean; - /** The unique ID of the field */ - id?: string; - /** The id prefix for the field to avoid collisions between forms */ - idPrefix?: string; - /** Function that checks whether to perform update of the form state. */ - shouldUpdate?: boolean | ((prevValues, nextValues) => boolean); /** Whether the field is hidden. */ isHidden?: boolean; /** Whether the field is disabled. */ @@ -87,8 +89,6 @@ export interface FormBaseProps { labelPosition?: LabelPosition; /** Whether the field presents required mark */ requiredMark?: boolean; - /** Whether the field is required */ - isRequired?: boolean; /** The type of necessity indicator */ necessityIndicator?: NecessityIndicator; /** That can replace the necessity label */