Skip to content

Commit

Permalink
feat(CanvasForm): Add support for ErrorHandler
Browse files Browse the repository at this point in the history
Currently, there's partial support for `oneOf` in the `uniforms` library.

This commit adds a new `OneOfField` component that shows a selector to
pick which schema should the form generate.

Also, if a model is already defined, the closest schema is selected
automatically.

fix: #560
relates: #948
  • Loading branch information
lordrip committed Mar 20, 2024
1 parent e62ffa9 commit 813e266
Show file tree
Hide file tree
Showing 14 changed files with 897 additions and 15 deletions.
11 changes: 11 additions & 0 deletions packages/ui-tests/cypress/fixtures/flows/errorHandlerCR.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- errorHandler:
defaultErrorHandler:
level: ERROR
redeliveryPolicy:
backOffMultiplier: "2.0"
collisionAvoidanceFactor: "0.15"
maximumRedeliveryDelay: "60000"
redeliveryDelay: "1000"
retriesExhaustedLogLevel: ERROR
retryAttemptedLogInterval: "1"
retryAttemptedLogLevel: DEBUG
34 changes: 34 additions & 0 deletions packages/ui/src/components/Form/CustomAutoField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,42 @@ import { AutoFieldProps } from 'uniforms';
import { CustomAutoField } from './CustomAutoField';
import { DisabledField } from './customField/DisabledField';
import { TypeaheadField } from './customField/TypeaheadField';
import { OneOfField } from './OneOf/OneOfField';

describe('CustomAutoField', () => {
it('should return `OneOfField` if `props.oneOf` is an array with a length > 0', () => {
const props: AutoFieldProps = {
oneOf: [{ type: 'string' }],
name: 'test',
};

const result = CustomAutoField(props);

expect(result).toBe(OneOfField);
});

it('should NOT return `OneOfField` if `props.oneOf` is an empty array', () => {
const props: AutoFieldProps = {
oneOf: [],
name: 'test',
};

const result = CustomAutoField(props);

expect(result).not.toBe(OneOfField);
});

it('should NOT return `OneOfField` if `props.oneOf` is not an array', () => {
const props: AutoFieldProps = {
oneOf: undefined,
name: 'test',
};

const result = CustomAutoField(props);

expect(result).not.toBe(OneOfField);
});

it('should return `RadioField` if `props.options` & `props.checkboxes` are defined and `props.fieldType` is not `Array`', () => {
const props: AutoFieldProps = {
options: [],
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/components/Form/CustomAutoField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@kaoto-next/uniforms-patternfly';
import { createAutoField } from 'uniforms';
import { getValue } from '../../utils';
import { OneOfField } from './OneOf/OneOfField';
import { BeanReferenceField } from './bean/BeanReferenceField';
import { DisabledField } from './customField/DisabledField';
import { TypeaheadField } from './customField/TypeaheadField';
Expand All @@ -21,6 +22,10 @@ import { PropertiesField } from './properties/PropertiesField';
* In case a field is not supported, it will render a DisabledField
*/
export const CustomAutoField = createAutoField((props) => {
if (Array.isArray(props.oneOf) && props.oneOf.length > 0) {
return OneOfField;
}

if (props.options) {
return props.checkboxes && props.fieldType !== Array ? RadioField : TypeaheadField;
}
Expand Down
115 changes: 115 additions & 0 deletions packages/ui/src/components/Form/OneOf/OneOfField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { AutoForm } from '@kaoto-next/uniforms-patternfly';
import { act, fireEvent, render } from '@testing-library/react';
import { FunctionComponent, PropsWithChildren, useEffect, useRef, useState } from 'react';
import { KaotoSchemaDefinition } from '../../../models/kaoto-schema';
import { SchemaBridgeContext } from '../../../providers/schema-bridge.provider';
import { SchemaService } from '../schema.service';
import { OneOfField } from './OneOfField';

describe('OneOfField', () => {
const oneOfSchema: KaotoSchemaDefinition['schema'] = {
oneOf: [
{ title: 'Name schema', type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
{ title: 'Number schema', type: 'object', properties: { amount: { type: 'number' } }, required: ['amount'] },
{ title: 'Boolean schema', type: 'object', properties: { isValid: { type: 'boolean' } }, required: ['isValid'] },
],
type: 'object',
properties: {
name: {},
amount: {},
isValid: {},
},
};

it('should render', () => {
const wrapper = render(<OneOfField name="" oneOf={oneOfSchema.oneOf} />, {
wrapper: (props) => (
<UniformsWrapper model={{}} schema={oneOfSchema}>
{props.children}
</UniformsWrapper>
),
});

expect(wrapper.asFragment()).toMatchSnapshot();
});

it('should render correctly', () => {
const wrapper = render(<OneOfField name="" oneOf={oneOfSchema.oneOf} />, {
wrapper: (props) => (
<UniformsWrapper model={{}} schema={oneOfSchema}>
{props.children}
</UniformsWrapper>
),
});

const dropDownToggle = wrapper.getByText(SchemaService.DROPDOWN_PLACEHOLDER);
expect(dropDownToggle).toBeInTheDocument();
});

it('should render the appropriate schema when given a matching model', () => {
const wrapper = render(<OneOfField name="" oneOf={oneOfSchema.oneOf} />, {
wrapper: (props) => (
<UniformsWrapper model={{ name: 'John Doe' }} schema={oneOfSchema}>
{props.children}
</UniformsWrapper>
),
});

const nameField = wrapper.getByTestId('text-field');
expect(nameField).toBeInTheDocument();
expect(nameField).toHaveAttribute('label', 'Name');
expect(nameField).toHaveValue('John Doe');
});

it('should render a new selected schema', async () => {
const wrapper = render(<OneOfField name="" oneOf={oneOfSchema.oneOf} />, {
wrapper: (props) => (
<UniformsWrapper model={{}} schema={oneOfSchema}>
{props.children}
</UniformsWrapper>
),
});

await act(async () => {
const dropDownToggle = wrapper.getByText(SchemaService.DROPDOWN_PLACEHOLDER);
expect(dropDownToggle).toBeInTheDocument();

fireEvent.click(dropDownToggle);
});

await act(async () => {
const schema2Item = wrapper.getByText('Number schema');
fireEvent.click(schema2Item);
});

const nameField = wrapper.getByTestId('text-field');
expect(nameField).toBeInTheDocument();
expect(nameField).toHaveAttribute('label', 'Amount');
expect(nameField).toHaveValue('');
});
});

const UniformsWrapper: FunctionComponent<
PropsWithChildren<{
model: Record<string, unknown>;
schema: KaotoSchemaDefinition['schema'];
}>
> = (props) => {
const schemaBridge = new SchemaService().getSchemaBridge(props.schema);
const divRef = useRef<HTMLDivElement>(null);
const [, setLastRenderTimestamp] = useState<number>(-1);

useEffect(() => {
/** Force re-render to update the divRef */
setLastRenderTimestamp(Date.now());
}, []);

return (
<SchemaBridgeContext.Provider value={{ schemaBridge, parentRef: divRef }}>
<AutoForm model={props.model} schema={schemaBridge}>
{props.children}
</AutoForm>
<div ref={divRef} />
</SchemaBridgeContext.Provider>
);
};
111 changes: 111 additions & 0 deletions packages/ui/src/components/Form/OneOf/OneOfField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { GuaranteedProps, connectField } from 'uniforms';
import { useAppliedSchema, useSchemaBridgeContext } from '../../../hooks';
import { KaotoSchemaDefinition } from '../../../models';
import { SchemaBridgeProvider } from '../../../providers/schema-bridge.provider';
import { ROOT_PATH, isDefined } from '../../../utils';
import { OneOfSchemas, getOneOfSchemaList } from '../../../utils/get-oneof-schema-list';
import { CustomAutoForm, CustomAutoFormRef } from '../CustomAutoForm';
import { SchemaService } from '../schema.service';
import { OneOfSchemaList } from './OneOfSchemaList';

interface OneOfComponentProps extends GuaranteedProps<unknown> {
oneOf: KaotoSchemaDefinition['schema'][];
}

const applyDefinitionsToSchema = (
schema?: KaotoSchemaDefinition['schema'],
rootSchema?: KaotoSchemaDefinition['schema'],
) => {
if (!isDefined(schema) || !isDefined(rootSchema)) {
return schema;
}

return Object.assign({}, schema, {
definitions: rootSchema.definitions,
});
};

const OneOfComponent: FunctionComponent<OneOfComponentProps> = ({ name: propsName, oneOf, onChange }) => {
const formRef = useRef<CustomAutoFormRef>(null);
const divRef = useRef<HTMLDivElement>(null);

const { schemaBridge, parentRef } = useSchemaBridgeContext();
const oneOfSchemas: OneOfSchemas[] = useMemo(
() => getOneOfSchemaList(oneOf, schemaBridge?.schema),
[oneOf, schemaBridge?.schema],
);

const appliedSchema = useAppliedSchema(propsName, oneOfSchemas);
const [selectedSchema, setSelectedSchema] = useState<KaotoSchemaDefinition['schema'] | undefined>(
applyDefinitionsToSchema(appliedSchema?.schema, schemaBridge?.schema),
);
const [selectedSchemaName, setSelectedSchemaName] = useState<string | undefined>(appliedSchema?.name);

const onSchemaChanged = useCallback(
(schemaName: string | undefined) => {
if (schemaName === selectedSchemaName) return;
/** Remove existing properties */
const path = propsName === '' ? ROOT_PATH : `${propsName}.${selectedSchemaName}`;
onChange(undefined, path);

if (!isDefined(schemaName)) {
setSelectedSchema(undefined);
setSelectedSchemaName(undefined);
return;
}

const selectedSchema = oneOfSchemas.find((schema) => schema.name === schemaName);
const schemaWithDefinitions = applyDefinitionsToSchema(selectedSchema?.schema, schemaBridge?.schema);
setSelectedSchema(schemaWithDefinitions);
setSelectedSchemaName(schemaName);
},
[onChange, oneOfSchemas, propsName, schemaBridge?.schema, selectedSchemaName],
);

useEffect(() => {
formRef.current?.form.reset();
}, []);

const handleOnChangeIndividualProp = useCallback(
(path: string, value: unknown) => {
const updatedPath = propsName === '' ? path : `${propsName}.${path}`;
onChange(value, updatedPath);
},
[onChange, propsName],
);

return (
<OneOfSchemaList
name={propsName}
oneOfSchemas={oneOfSchemas}
selectedSchemaName={selectedSchemaName}
onSchemaChanged={onSchemaChanged}
>
{isDefined(selectedSchema) && (
<SchemaBridgeProvider schema={selectedSchema} parentRef={divRef}>
{isDefined(parentRef?.current) &&
createPortal(
<>
<CustomAutoForm
key={propsName}
ref={formRef}
model={appliedSchema?.model ?? {}}
onChange={handleOnChangeIndividualProp}
sortFields={false}
omitFields={SchemaService.OMIT_FORM_FIELDS}
data-testid={`${propsName}-autoform`}
/>
<div data-testid={`${propsName}-form-placeholder`} ref={divRef} />
</>,
parentRef.current,
propsName,
)}
</SchemaBridgeProvider>
)}
</OneOfSchemaList>
);
};

export const OneOfField = connectField(OneOfComponent as unknown as Parameters<typeof connectField>[0]);
92 changes: 92 additions & 0 deletions packages/ui/src/components/Form/OneOf/OneOfSchemaList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { act, fireEvent, render } from '@testing-library/react';
import { OneOfSchemas } from '../../../utils/get-oneof-schema-list';
import { SchemaService } from '../schema.service';
import { OneOfSchemaList } from './OneOfSchemaList';

describe('OneOfSchemaList', () => {
const oneOfSchemas: OneOfSchemas[] = [
{ name: 'Schema 1', schema: { type: 'object', properties: { name: { type: 'string' } } } },
{ name: 'Schema 2', schema: { type: 'object', properties: { amount: { type: 'number' } } } },
{ name: 'Schema 3', schema: { type: 'object', properties: { isValid: { type: 'boolean' } } } },
];

it('should render', () => {
const wrapper = render(<OneOfSchemaList name="" oneOfSchemas={oneOfSchemas} onSchemaChanged={() => {}} />);

expect(wrapper.asFragment()).toMatchSnapshot();
});

it('should render the dropwdown toggle correctly', () => {
const wrapper = render(<OneOfSchemaList name="" oneOfSchemas={oneOfSchemas} onSchemaChanged={() => {}} />);

const dropDownToggle = wrapper.getByText(SchemaService.DROPDOWN_PLACEHOLDER);
expect(dropDownToggle).toBeInTheDocument();
});

it('should render a control with the selected schema name and provided children', () => {
const wrapper = render(
<OneOfSchemaList name="" oneOfSchemas={oneOfSchemas} selectedSchemaName="Schema 1" onSchemaChanged={() => {}}>
<div data-testid="children">Children</div>
</OneOfSchemaList>,
);

const control = wrapper.getByTestId('Schema 1-chip');
expect(control).toBeInTheDocument();

const children = wrapper.getByTestId('children');
expect(children).toBeInTheDocument();
});

it('should notify the parent when a schema is selected', async () => {
const schemaChangedSpy = jest.fn();

const wrapper = render(
<OneOfSchemaList
name=""
oneOfSchemas={oneOfSchemas}
selectedSchemaName={undefined}
onSchemaChanged={schemaChangedSpy}
>
<div data-testid="children">Children</div>
</OneOfSchemaList>,
);

await act(async () => {
const dropDownToggle = wrapper.getByText(SchemaService.DROPDOWN_PLACEHOLDER);
expect(dropDownToggle).toBeInTheDocument();

fireEvent.click(dropDownToggle);
});

await act(async () => {
const schema2Item = wrapper.getByText('Schema 2');
fireEvent.click(schema2Item);
});

expect(schemaChangedSpy).toHaveBeenCalledWith('Schema 2');
});

it('should notify the parent when a schema is deselected', async () => {
const schemaChangedSpy = jest.fn();

const wrapper = render(
<OneOfSchemaList
name=""
oneOfSchemas={oneOfSchemas}
selectedSchemaName="Schema 2"
onSchemaChanged={schemaChangedSpy}
>
<div data-testid="children">Children</div>
</OneOfSchemaList>,
);

await act(async () => {
const chip = wrapper.getByLabelText('close');
expect(chip).toBeInTheDocument();

fireEvent.click(chip);
});

expect(schemaChangedSpy).toHaveBeenCalledWith(undefined);
});
});
Loading

0 comments on commit 813e266

Please sign in to comment.