diff --git a/.changeset/wild-parents-suffer.md b/.changeset/wild-parents-suffer.md
new file mode 100644
index 000000000..7c7f847f8
--- /dev/null
+++ b/.changeset/wild-parents-suffer.md
@@ -0,0 +1,5 @@
+---
+'@cube-dev/ui-kit': minor
+---
+
+Form logic internal fixes.
diff --git a/src/components/content/Tag/Tag.tsx b/src/components/content/Tag/Tag.tsx
index 7476070a5..c40fae10c 100644
--- a/src/components/content/Tag/Tag.tsx
+++ b/src/components/content/Tag/Tag.tsx
@@ -34,7 +34,7 @@ const TagElement = tasty({
closable: '0 (2.5x - 1bw) 0 (1x - 1bw)',
},
fill: {
- '': '#dark.04',
+ '': '#light',
...Object.keys(THEMES).reduce((map, type) => {
map[`[data-type="${type}"]`] = THEMES[type].fill;
diff --git a/src/components/fields/Checkbox/checkbox-group.test.tsx b/src/components/fields/Checkbox/checkbox-group.test.tsx
index 8cbedd896..2c2d937a8 100644
--- a/src/components/fields/Checkbox/checkbox-group.test.tsx
+++ b/src/components/fields/Checkbox/checkbox-group.test.tsx
@@ -1,4 +1,4 @@
-import { act, render, renderWithForm, userEvent } from '../../../test';
+import { render, renderWithForm, userEvent } from '../../../test';
import { Field, Checkbox } from '../../../index';
jest.mock('../../../_internal/hooks/use-warn');
@@ -20,17 +20,24 @@ describe('', () => {
it('should work with new
', async () => {
const { formInstance, getAllByRole } = renderWithForm(
-
+
One
Two
,
+ {
+ formProps: {
+ defaultValues: {
+ test: ['one'],
+ },
+ },
+ },
);
const checkbox = getAllByRole('checkbox');
expect(checkbox[0]).toBeChecked();
expect(checkbox[1]).not.toBeChecked();
- await act(async () => await userEvent.click(checkbox[1]));
+ await userEvent.click(checkbox[1]);
expect(checkbox[0]).toBeChecked();
expect(checkbox[1]).toBeChecked();
@@ -40,17 +47,24 @@ describe('', () => {
it('should interop with ', async () => {
const { getAllByRole, formInstance } = renderWithForm(
-
+
Buy milk
Buy coffee
Buy bread
,
+ {
+ formProps: {
+ defaultValues: {
+ test: ['two'],
+ },
+ },
+ },
);
const checkbox = getAllByRole('checkbox');
- await act(async () => await userEvent.click(checkbox[0]));
+ await userEvent.click(checkbox[0]);
expect(checkbox[0]).toBeChecked();
expect(checkbox[1]).toBeChecked();
diff --git a/src/components/fields/Checkbox/checkbox.test.tsx b/src/components/fields/Checkbox/checkbox.test.tsx
index 98b53bdf1..ad8d4d149 100644
--- a/src/components/fields/Checkbox/checkbox.test.tsx
+++ b/src/components/fields/Checkbox/checkbox.test.tsx
@@ -1,6 +1,6 @@
import userEvent from '@testing-library/user-event';
-import { act, render, renderWithForm } from '../../../test';
+import { render, renderWithForm } from '../../../test';
import { Field } from '../../form';
import { Checkbox } from './Checkbox';
@@ -12,7 +12,7 @@ describe('', () => {
const { getByRole } = render(Test);
const checkboxElement = getByRole('checkbox');
- await act(async () => await userEvent.click(checkboxElement));
+ await userEvent.click(checkboxElement);
expect(checkboxElement).toBeChecked();
});
@@ -24,7 +24,7 @@ describe('', () => {
const checkboxElement = getByRole('checkbox');
- await act(async () => await userEvent.click(checkboxElement));
+ await userEvent.click(checkboxElement);
expect(checkboxElement).toBeChecked();
expect(formInstance.getFieldValue('test')).toBe(true);
@@ -39,7 +39,7 @@ describe('', () => {
const checkboxElement = getByRole('checkbox');
- await act(async () => await userEvent.click(checkboxElement));
+ await userEvent.click(checkboxElement);
expect(checkboxElement).toBeChecked();
expect(formInstance.getFieldValue('test')).toBe(true);
diff --git a/src/components/fields/ComboBox/ComboBox.stories.tsx b/src/components/fields/ComboBox/ComboBox.stories.tsx
index faa2069e5..3c7f350c7 100644
--- a/src/components/fields/ComboBox/ComboBox.stories.tsx
+++ b/src/components/fields/ComboBox/ComboBox.stories.tsx
@@ -4,6 +4,7 @@ import { userEvent, within } from '@storybook/test';
import { SELECTED_KEY_ARG } from '../../../stories/FormFieldArgs';
import { baseProps } from '../../../stories/lists/baseProps';
+import { Form, useForm } from '../../form/index';
import { ComboBox, CubeComboBoxProps } from './ComboBox';
@@ -32,6 +33,26 @@ const Template: StoryFn> = (
>
);
+const FormTemplate: StoryFn> = (
+ args: CubeComboBoxProps,
+) => {
+ const [form] = useForm();
+
+ return (
+
+ );
+};
+
export const Default = Template.bind({});
Default.args = {};
@@ -101,3 +122,5 @@ With1LongOptionFiltered.play = async ({ canvasElement }) => {
await userEvent.type(combobox, 'Red');
};
+
+export const WithinForm = FormTemplate.bind({});
diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx
index 7f7821ab5..f5408e6a0 100644
--- a/src/components/fields/ComboBox/ComboBox.tsx
+++ b/src/components/fields/ComboBox/ComboBox.tsx
@@ -5,6 +5,7 @@ import {
ReactElement,
RefObject,
useMemo,
+ useState,
} from 'react';
import {
useButton,
@@ -133,12 +134,19 @@ export const ComboBox = forwardRef(function ComboBox(
props: CubeComboBoxProps,
ref: ForwardedRef,
) {
+ const [rerender, setRerender] = useState({});
+
props = useProviderProps(props);
props = useFormProps(props);
props = useFieldProps(props, {
valuePropsMapper: ({ value, onChange }) => ({
inputValue: value != null ? value : '',
- onInputChange: (val) => onChange(val, !props.allowsCustomValue),
+ onInputChange: (val) => {
+ onChange(val, !props.allowsCustomValue);
+ if (rerender) {
+ setRerender({});
+ }
+ },
onSelectionChange: onChange,
}),
});
diff --git a/src/components/fields/RadioGroup/Radio.tsx b/src/components/fields/RadioGroup/Radio.tsx
index dc18c3750..9661ec691 100644
--- a/src/components/fields/RadioGroup/Radio.tsx
+++ b/src/components/fields/RadioGroup/Radio.tsx
@@ -162,6 +162,8 @@ export interface CubeRadioProps
inputStyles?: Styles;
/* The visual type of the radio button */
type?: 'button' | 'radio';
+ value?: string;
+ onChange?: (value: string) => void;
}
function Radio(props: CubeRadioProps, ref) {
diff --git a/src/components/fields/RadioGroup/radio.test.tsx b/src/components/fields/RadioGroup/radio.test.tsx
index 4a1eb5a93..f0ff5ab1e 100644
--- a/src/components/fields/RadioGroup/radio.test.tsx
+++ b/src/components/fields/RadioGroup/radio.test.tsx
@@ -1,5 +1,4 @@
import {
- act,
renderWithForm,
renderWithRoot,
screen,
@@ -19,7 +18,7 @@ describe(' and ', () => {
,
);
const radio = getAllByRole('radio');
- await act(async () => await userEvent.click(radio[0]));
+ await userEvent.click(radio[0]);
expect(radio[0]).toBeChecked();
});
@@ -33,7 +32,7 @@ describe(' and ', () => {
);
const radio = screen.getAllByRole('radio');
- await act(async () => await userEvent.click(radio[0]));
+ await userEvent.click(radio[0]);
expect(radio[0]).toBeChecked();
diff --git a/src/components/fields/Select/select.test.tsx b/src/components/fields/Select/select.test.tsx
index ff39f440f..b18f5f628 100644
--- a/src/components/fields/Select/select.test.tsx
+++ b/src/components/fields/Select/select.test.tsx
@@ -1,9 +1,4 @@
-import {
- act,
- renderWithForm,
- renderWithRoot,
- userEvent,
-} from '../../../test/index';
+import { renderWithForm, renderWithRoot, userEvent } from '../../../test';
import { Field } from '../../form';
import { Select } from './Select';
@@ -21,10 +16,10 @@ describe('', () => {
);
const select = getByRole('button');
- await act(async () => await userEvent.click(select));
+ await userEvent.click(select);
const options = getAllByRole('option');
- await act(async () => await userEvent.click(options[1]));
+ await userEvent.click(options[1]);
expect(select).toHaveTextContent('Red');
});
@@ -41,10 +36,10 @@ describe('', () => {
);
const select = getByRole('button');
- await act(async () => await userEvent.click(select));
+ await userEvent.click(select);
const options = getAllByRole('option');
- await act(async () => await userEvent.click(options[1]));
+ await userEvent.click(options[1]);
expect(select).toHaveTextContent('Red');
expect(formInstance.getFieldValue('test')).toBe('2');
@@ -60,10 +55,10 @@ describe('', () => {
);
const select = getByRole('button');
- await act(async () => await userEvent.click(select));
+ await userEvent.click(select);
const options = getAllByRole('option');
- await act(async () => await userEvent.click(options[1]));
+ await userEvent.click(options[1]);
expect(select).toHaveTextContent('Red');
expect(formInstance.getFieldValue('test')).toBe('2');
diff --git a/src/components/fields/Switch/switch.test.tsx b/src/components/fields/Switch/switch.test.tsx
index f90e14482..9410d8140 100644
--- a/src/components/fields/Switch/switch.test.tsx
+++ b/src/components/fields/Switch/switch.test.tsx
@@ -1,4 +1,4 @@
-import { renderWithForm, userEvent, render, act } from '../../../test';
+import { renderWithForm, userEvent, render } from '../../../test';
import { Field, Switch } from '../../../index';
jest.mock('../../../_internal/hooks/use-warn');
@@ -8,7 +8,7 @@ describe('', () => {
const { getByRole } = render();
const switchElement = getByRole('switch');
- await act(async () => await userEvent.click(switchElement));
+ await userEvent.click(switchElement);
expect(switchElement).toBeChecked();
});
@@ -22,7 +22,7 @@ describe('', () => {
const switchElement = getByRole('switch');
- await act(async () => await userEvent.click(switchElement));
+ await userEvent.click(switchElement);
expect(switchElement).toBeChecked();
expect(formInstance.getFieldValue('test')).toBe(true);
@@ -35,7 +35,7 @@ describe('', () => {
const switchElement = getByRole('switch');
- await act(async () => await userEvent.click(switchElement));
+ await userEvent.click(switchElement);
expect(switchElement).toBeChecked();
expect(formInstance.getFieldValue('test')).toBe(true);
diff --git a/src/components/form/Form/ComplexForm.stories.tsx b/src/components/form/Form/ComplexForm.stories.tsx
index 4d2e87c62..14e3e67cd 100644
--- a/src/components/form/Form/ComplexForm.stories.tsx
+++ b/src/components/form/Form/ComplexForm.stories.tsx
@@ -259,8 +259,11 @@ const Template: StoryFn = (args) => {
Test
- = (args) => {
},
]}
necessityIndicator={'label'}
- defaultValue="tenphi@gmail.com"
- shouldUpdate={({ email }) => {
- return !!email;
- }}
- >
-
-
+ shouldUpdate={(prevValues, { email }) => !!email}
+ />
diff --git a/src/components/form/Form/field.test.tsx b/src/components/form/Form/field.test.tsx
index c07ef2c4d..90fa4306e 100644
--- a/src/components/form/Form/field.test.tsx
+++ b/src/components/form/Form/field.test.tsx
@@ -9,10 +9,10 @@ import { Field } from './Field';
jest.mock('../../../_internal/hooks/use-warn');
describe('Legacy ', () => {
- it('should set default value as value', () => {
+ it.skip('should set default value as value', () => {
const { getByRole, formInstance } = renderWithForm(
-
+
,
);
@@ -22,7 +22,7 @@ describe('Legacy ', () => {
expect(formInstance.getFieldValue('test')).toBe('Hello, World!');
});
- it('should update default value', () => {
+ it.skip('should update default value', () => {
const { rerender, formInstance } = renderWithForm(
@@ -40,7 +40,7 @@ describe('Legacy ', () => {
expect(formInstance.getFieldValue('test')).toBe('World!');
});
- it('should not update default value if field is touched', async () => {
+ it.skip('should not update default value if field is touched', async () => {
const { rerender, formInstance, getByRole } = renderWithForm(
@@ -51,10 +51,8 @@ describe('Legacy ', () => {
const input = getByRole('textbox');
- await act(async () => {
- await userEvent.clear(input);
- await userEvent.type(input, 'Test!');
- });
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Test!');
rerender(
@@ -74,10 +72,8 @@ describe('Legacy ', () => {
const input = getByRole('textbox');
- await act(async () => {
- await userEvent.clear(input);
- await userEvent.type(input, 'Hello!');
- });
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Hello!');
rerender(
@@ -90,16 +86,21 @@ describe('Legacy ', () => {
it('should change value', async () => {
const { getByRole, formInstance } = renderWithForm(
-
+
,
+ {
+ formProps: {
+ defaultValues: {
+ test: 'Hello',
+ },
+ },
+ },
);
const input = getByRole('textbox');
- await act(async () => {
- await userEvent.type(input, ', World!');
- });
+ await userEvent.type(input, ', World!');
expect(input).toHaveValue('Hello, World!');
expect(formInstance.getFieldValue('test')).toBe('Hello, World!');
@@ -147,9 +148,7 @@ describe('Legacy ', () => {
expect(cliRadio).toBeChecked();
- await act(async () => {
- await userEvent.click(gitRadio);
- });
+ await userEvent.click(gitRadio);
expect(gitRadio).toBeChecked();
});
@@ -159,27 +158,26 @@ describe('Legacy ', () => {
const [deployMode] = useState('git');
return (
-
+
Deploy with CLI
Deploy with GIT
);
}
- const { getByRole, formInstance } = renderWithForm();
+ const { getByRole, formInstance } = renderWithForm(, {
+ formProps: {
+ defaultValues: {
+ test: 'cli',
+ },
+ },
+ });
const cliRadio = getByRole('radio', { name: 'Deploy with CLI' });
const gitRadio = getByRole('radio', { name: 'Deploy with GIT' });
expect(cliRadio).toBeChecked();
- await act(async () => {
- await userEvent.click(gitRadio);
- });
+ await userEvent.click(gitRadio);
expect(formInstance.getFieldValue('test')).toBe('git');
expect(gitRadio).toBeChecked();
@@ -197,7 +195,7 @@ describe('Legacy ', () => {
const cliRadio = getByRole('radio', { name: 'Deploy with CLI' });
- await act(async () => await userEvent.click(cliRadio));
+ await userEvent.click(cliRadio);
expect(formInstance.getFieldValue('test')).toBe('cli');
expect(onChange).toHaveBeenCalled();
diff --git a/src/components/form/Form/submit-error.test.tsx b/src/components/form/Form/submit-error.test.tsx
index 6bba27109..cc66257af 100644
--- a/src/components/form/Form/submit-error.test.tsx
+++ b/src/components/form/Form/submit-error.test.tsx
@@ -1,5 +1,5 @@
import userEvents from '@testing-library/user-event';
-import { waitFor, act } from '@testing-library/react';
+import { waitFor } from '@testing-library/react';
import { renderWithForm } from '../../../test/index';
import { Submit } from '../../actions/index';
@@ -26,10 +26,8 @@ describe('', () => {
const submit = getByRole('button');
const input = getByRole('textbox');
- await act(async () => {
- await userEvents.type(input, 'test');
- await userEvents.click(submit);
- });
+ await userEvents.type(input, 'test');
+ await userEvents.click(submit);
await waitFor(() => {
// onSubmitFailed callback should only be called if onSubmit callback is called and failed
@@ -63,10 +61,8 @@ describe('', () => {
const submit = getByRole('button');
const input = getByRole('textbox');
- await act(async () => {
- await userEvents.type(input, 'test');
- await userEvents.click(submit);
- });
+ await userEvents.type(input, 'test');
+ await userEvents.click(submit);
await waitFor(() => {
// onSubmitFailed callback should only be called if onSubmit callback is called and failed
@@ -84,7 +80,7 @@ describe('', () => {
expect(submitErrorElement).toBeInTheDocument();
});
- await act(() => userEvents.type(input, 'changed'));
+ await userEvents.type(input, 'changed');
await waitFor(() => {
expect(submitErrorElement).not.toBeInTheDocument();
@@ -111,10 +107,8 @@ describe('', () => {
const submit = getByRole('button');
const input = getByRole('textbox');
- await act(async () => {
- await userEvents.type(input, 'test');
- await userEvents.click(submit);
- });
+ await userEvents.type(input, 'test');
+ await userEvents.click(submit);
await waitFor(() => {
// onSubmitFailed callback should only be called if onSubmit callback is called and failed
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 16a5e8a5f..baffc335e 100644
--- a/src/components/form/Form/use-field/use-field-props.tsx
+++ b/src/components/form/Form/use-field/use-field-props.tsx
@@ -81,13 +81,13 @@ export function useFieldProps<
);
// eslint-disable-next-line react-hooks/rules-of-hooks
- const onChangeEvent = useEvent((value, dontTouch: boolean) =>
- field?.onChange?.(
+ const onChangeEvent = useEvent((value, dontTouch: boolean) => {
+ return field?.onChange?.(
value,
dontTouch,
field?.validateTrigger ?? defaultValidationTrigger,
- ),
- );
+ );
+ });
const valueProps = !isOutsideOfForm
? valuePropsMapper({ value: field.value, onChange: onChangeEvent })
diff --git a/src/components/form/Form/use-field/use-field.ts b/src/components/form/Form/use-field/use-field.ts
index 2edd964f4..90b325b7f 100644
--- a/src/components/form/Form/use-field/use-field.ts
+++ b/src/components/form/Form/use-field/use-field.ts
@@ -1,7 +1,7 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
import { ValidateTrigger } from '../../../../shared/index';
-import { useEvent, useIsFirstRender } from '../../../../_internal/index';
+import { useEvent, useWarn } from '../../../../_internal/index';
import { useFormProps } from '../Form';
import { FieldTypes } from '../types';
import { delayValidationRule } from '../validation';
@@ -36,6 +36,12 @@ function removeId(name, id) {
ID_MAP[name] = ID_MAP[name].filter((_id) => _id !== id);
}
+const UNCONTROLLED_FIELDS = [
+ 'defaultValue',
+ 'defaultSelectedKey',
+ 'defaultSelected',
+];
+
export type UseFieldParams = {
defaultValidationTrigger?: ValidateTrigger;
};
@@ -47,7 +53,6 @@ export function useField>(
props = useFormProps(props);
let {
- defaultValue,
id,
idPrefix,
name,
@@ -60,19 +65,24 @@ export function useField>(
showValid,
shouldUpdate,
} = props;
-
- if (rules && rules.length && validationDelay) {
- rules.unshift(delayValidationRule(validationDelay));
- }
-
const nonInput = !name;
const fieldName: string = name != null ? name : '';
- const isFirstRender = useIsFirstRender();
let [fieldId, setFieldId] = useState(
id || (idPrefix ? `${idPrefix}_${fieldName}` : fieldName),
);
+ const uncontrolledKey = Object.keys(props).find((key) =>
+ UNCONTROLLED_FIELDS.includes(key),
+ );
+
+ useWarn(uncontrolledKey && fieldName, {
+ key: uncontrolledKey,
+ args: [
+ "It's not allowed to use field in uncontrolled mode if it's connected to the form. Use 'defaultValues' prop on Form component to set the default value for each field. You can also disconnect the input from the form by removing 'name' property.",
+ ],
+ });
+
useEffect(() => {
let newId;
@@ -95,41 +105,23 @@ export function useField>(
let field = form?.getFieldInstance(fieldName);
- if (field) {
- field.rules = rules;
+ // if there is no field
+ if (form && !field && fieldName) {
+ field = form.createField(fieldName, true);
}
- let isRequired = rules && !!rules.find((rule) => rule.required);
-
- useEffect(() => {
- if (!form) return;
-
- if (field) {
- form.forceReRender();
- } else {
- form.createField(fieldName);
- }
- }, [field]);
-
- if (form) {
- if (isFirstRender) {
- if (!field) {
- field = form.createField(fieldName, true);
- }
-
- if (field?.value == null && defaultValue != null) {
- form.setFieldValue(fieldName, defaultValue, false, true);
- form.updateInitialFieldsValue({ [fieldName]: defaultValue });
-
- field = form?.getFieldInstance(fieldName);
- }
- }
+ if (field) {
+ // copy rules to the field rules
+ field.rules = [...(rules ?? [])];
- if (!field?.touched && defaultValue != null) {
- form.setFieldValue(fieldName, defaultValue, false, true);
+ // if there are some rules and a delay then add a rule that delays the validation
+ if (field.rules && field.rules.length && validationDelay) {
+ field.rules.unshift(delayValidationRule(validationDelay));
}
}
+ let isRequired = rules && !!rules.find((rule) => rule.required);
+
const onChangeHandler = useEvent((val: any, dontTouch: boolean) => {
if (!form) return;
@@ -174,43 +166,25 @@ export function useField>(
let inputValue = field?.inputValue;
- return useMemo(
- () => ({
- id: fieldId,
- name: fieldName,
- value: inputValue,
- validateTrigger,
- form,
- field,
- nonInput,
-
- validationState:
- validationState ??
- (field?.errors?.length
- ? 'invalid'
- : showValid && field?.status === 'valid'
- ? 'valid'
- : undefined),
- ...(isRequired && { isRequired }),
- message: message ?? (field?.status === 'invalid' && field?.errors?.[0]),
- onBlur: onBlurHandler,
- onChange: onChangeHandler,
- }),
- [
- form,
- field,
- field?.errors?.length,
- field?.status,
- field?.inputValue,
- fieldId,
- fieldName,
- isRequired,
- onBlurHandler,
- onChangeHandler,
- validateTrigger,
- validationState,
- showValid,
- nonInput,
- ],
- );
+ return {
+ id: fieldId,
+ name: fieldName,
+ value: inputValue,
+ validateTrigger,
+ form,
+ field,
+ nonInput,
+
+ validationState:
+ validationState ??
+ (field?.errors?.length
+ ? 'invalid'
+ : showValid && field?.status === 'valid'
+ ? 'valid'
+ : undefined),
+ ...(isRequired && { isRequired }),
+ message: message ?? (field?.status === 'invalid' && field?.errors?.[0]),
+ onBlur: onBlurHandler,
+ onChange: onChangeHandler,
+ };
}
diff --git a/src/tokens.ts b/src/tokens.ts
index 492e675e8..8c17f4686 100644
--- a/src/tokens.ts
+++ b/src/tokens.ts
@@ -48,6 +48,7 @@ const TOKENS = {
transition: '80ms',
'clear-color': 'transparent',
'border-color': color('dark', 0.1),
+ 'border-opaque-color': 'rgb(227, 227, 233)',
'shadow-color': color('dark-03', 0.1),
'draft-color': color('dark', 0.2),
'minor-color': color('dark', 0.65),