Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-required-validator-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cube-dev/ui-kit": patch
---

Fix required validator to check rule.required flag before validating.

5 changes: 5 additions & 0 deletions .changeset/fresh-otters-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": minor
---

Remove isRequired prop in Form component.
5 changes: 5 additions & 0 deletions .changeset/popular-owls-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": patch
---

Improve rule management in form fields.
3 changes: 1 addition & 2 deletions src/components/form/Form/Field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 0 additions & 2 deletions src/components/form/Form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ function Form<T extends FieldTypes>(
children,
labelPosition,
orientation,
isRequired,
necessityIndicator,
isDisabled,
isReadOnly,
Expand Down Expand Up @@ -248,7 +247,6 @@ function Form<T extends FieldTypes>(
insideForm={true}
isDisabled={isDisabled}
isReadOnly={isReadOnly}
isRequired={isRequired}
validationState={validationState}
>
{children}
Expand Down
41 changes: 41 additions & 0 deletions src/components/form/Form/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,4 +391,45 @@ describe('Legacy <Field />', () => {
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(
<TextInput isRequired name="test" label="Test Input" />,
);

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(
<TextInput
isRequired
name="test"
label="Test Input"
rules={[{ required: true, message: 'Custom required message' }]}
/>,
);

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');
});
});
27 changes: 2 additions & 25 deletions src/components/form/Form/use-field/types.ts
Original file line number Diff line number Diff line change
@@ -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<T extends FieldTypes> {
export interface UseFieldProps<T extends FieldTypes> 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<T>;
/** 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<T extends FieldTypes> = {
Expand Down
4 changes: 2 additions & 2 deletions src/components/form/Form/use-field/use-field-props.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,7 +26,7 @@ export type UseFieldPropsParams = {

export function useFieldProps<
T extends FieldTypes,
Props extends CubeFieldProps<T>,
Props extends UseFieldProps<T>,
>(props: Props, params: UseFieldPropsParams = {}): Props {
// We use ref here to "memoize" initial value
const isDisabledRef = useRef(params.unsafe__isDisabled ?? false);
Expand Down
37 changes: 30 additions & 7 deletions src/components/form/Form/use-field/use-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand Down Expand Up @@ -40,7 +40,7 @@ export type UseFieldParams = {
defaultValidationTrigger?: ValidateTrigger;
};

export function useField<T extends FieldTypes, Props extends CubeFieldProps<T>>(
export function useField<T extends FieldTypes, Props extends UseFieldProps<T>>(
props: Props,
params: UseFieldParams,
): FieldReturnValue<T> {
Expand All @@ -61,11 +61,32 @@ export function useField<T extends FieldTypes, Props extends CubeFieldProps<T>>(
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 : '';
Expand Down Expand Up @@ -98,10 +119,12 @@ export function useField<T extends FieldTypes, Props extends CubeFieldProps<T>>(
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;
Expand Down
4 changes: 3 additions & 1 deletion src/components/form/Form/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
2 changes: 0 additions & 2 deletions src/components/overlays/Dialog/DialogForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ export function DialogForm<T extends FieldTypes = FieldTypes>(
labelStyles,
labelPosition,
requiredMark,
isRequired,
necessityIndicator,
necessityLabel,
isReadOnly,
Expand Down Expand Up @@ -110,7 +109,6 @@ export function DialogForm<T extends FieldTypes = FieldTypes>(
labelStyles={labelStyles}
labelPosition={labelPosition}
requiredMark={requiredMark}
isRequired={isRequired}
necessityIndicator={necessityIndicator}
necessityLabel={necessityLabel}
isReadOnly={isReadOnly}
Expand Down
48 changes: 24 additions & 24 deletions src/shared/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand All @@ -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 */
Expand Down
Loading