Skip to content

Commit

Permalink
feat(canvasform): Use local SchemaBridge
Browse files Browse the repository at this point in the history
Currently, in case a schema contains a `oneOf` array, `uniforms`
combines all `oneOf` definitions in a single schema definition.

The issue with this approach is that combine potentially non-compatible
schemas, like the `errorHandler` one, since we need to specify a single
property.

This commit extends the `getField` method from the uniforms `JSONSchemaBridge`
to add the `oneOf` definitions into the field, this way, we could use
this information in the form to render an UI control to select a given
schema

relates: KaotoIO#948
relates: KaotoIO#560
  • Loading branch information
lordrip committed Mar 18, 2024
1 parent 658c10c commit 818bffb
Show file tree
Hide file tree
Showing 12 changed files with 392 additions and 12 deletions.
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"lodash.get": "^4.4.2",
"lodash.isempty": "^4.4.0",
"lodash.set": "^4.3.2",
"lodash.memoize": "^4.1.2",
"monaco-editor": "^0.45.0",
"monaco-yaml": "^5.1.1",
"react": "^18.2.0",
Expand Down Expand Up @@ -92,6 +93,7 @@
"@types/lodash.get": "^4.4.2",
"@types/lodash.isempty": "^4.4.9",
"@types/lodash.set": "^4.3.7",
"@types/lodash.memoize": "^4.1.9",
"@types/node": "^20.0.0",
"@types/react": "^18.2.25",
"@types/react-dom": "^18.2.10",
Expand Down
35 changes: 35 additions & 0 deletions packages/ui/src/components/Form/CustomAutoFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { AutoField } from '@kaoto-next/uniforms-patternfly';
import { ComponentType, createElement } from 'react';
import { useForm } from 'uniforms';
import { KaotoSchemaDefinition } from '../../models';

export type AutoFieldsProps = {
autoField?: ComponentType<{ name: string }>;
element?: ComponentType | string;
fields?: string[];
omitFields?: string[];
};

export function CustomAutoFields({
autoField = AutoField,
element = 'div',
fields,
omitFields = [],
...props
}: AutoFieldsProps) {
const { schema } = useForm();
const rootField = schema.getField('');

/** Special handling for oneOf schemas */
if (Array.isArray((rootField as KaotoSchemaDefinition['schema']).oneOf)) {
return createElement(element, props, [createElement(autoField!, { key: '', name: '' })]);
}

return createElement(
element,
props,
(fields ?? schema.getSubfields())
.filter((field) => !omitFields!.includes(field))
.map((field) => createElement(autoField!, { key: field, name: field })),
);
}
7 changes: 4 additions & 3 deletions packages/ui/src/components/Form/CustomAutoForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AutoField, AutoFields, AutoForm, ErrorsField } from '@kaoto-next/uniforms-patternfly';
import { AutoField, AutoForm, ErrorsField } from '@kaoto-next/uniforms-patternfly';
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import { useSchemaBridgeContext } from '../../hooks';
import { IDataTestID } from '../../models';
import { CustomAutoFieldDetector } from './CustomAutoField';
import { CustomAutoFields } from './CustomAutoFields';

interface CustomAutoFormProps extends IDataTestID {
model: unknown;
Expand All @@ -17,7 +18,7 @@ interface CustomAutoFormProps extends IDataTestID {
export type CustomAutoFormRef = { fields: HTMLElement[]; form: typeof AutoForm };

export const CustomAutoForm = forwardRef<CustomAutoFormRef, CustomAutoFormProps>((props, forwardedRef) => {
const schemaBridge = useSchemaBridgeContext();
const { schemaBridge } = useSchemaBridgeContext();
const formRef = useRef<typeof AutoForm>();
const fieldsRefs = useRef<HTMLElement[]>([]);
const sortedFieldsNames = useMemo(() => {
Expand Down Expand Up @@ -70,7 +71,7 @@ export const CustomAutoForm = forwardRef<CustomAutoFormRef, CustomAutoFormProps>
/>
))
) : (
<AutoFields omitFields={props.omitFields} />
<CustomAutoFields omitFields={props.omitFields} />
)}
<ErrorsField />
</AutoForm>
Expand Down
64 changes: 64 additions & 0 deletions packages/ui/src/components/Form/schema-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { csvDataFormatSchema } from '../../stubs/csv.dataformat';
import { errorHandlerSchema } from '../../stubs/error-handler';
import { SchemaBridge } from './schema-bridge';

describe('SchemaBridge', () => {
let schemaBridge: SchemaBridge;

it('error handler - should return `refErrorHandler` field', () => {
schemaBridge = new SchemaBridge({ validator: () => null, schema: errorHandlerSchema });
schemaBridge.getSubfields();
Object.keys(errorHandlerSchema.properties!).forEach((key) => {
schemaBridge.getSubfields(key);
});

const field = schemaBridge.getField('refErrorHandler');
expect(field).toEqual({
description: 'References to an existing or custom error handler.',
oneOf: [
{ type: 'string' },
{
additionalProperties: false,
properties: {
id: { description: 'The id of this node', title: 'Id', type: 'string' },
ref: { description: 'References to an existing or custom error handler.', title: 'Ref', type: 'string' },
},
type: 'object',
},
],
required: ['ref'],
title: 'Ref Error Handler',
});
});

describe('dataformat', () => {
it('should return `header` field', () => {
schemaBridge = new SchemaBridge({ validator: () => null, schema: csvDataFormatSchema });
schemaBridge.getSubfields();
Object.keys(csvDataFormatSchema.properties!).forEach((key) => {
schemaBridge.getSubfields(key);
});

const field = schemaBridge.getField('header');
expect(field).toEqual({
description: 'To configure the CSV headers',
items: {
type: 'string',
},
title: 'Header',
type: 'array',
});
});

it('should return `header` initial value', () => {
schemaBridge = new SchemaBridge({ validator: () => null, schema: csvDataFormatSchema });
schemaBridge.getSubfields();
Object.keys(csvDataFormatSchema.properties!).forEach((key) => {
schemaBridge.getSubfields(key);
});

const field = schemaBridge.getInitialValue('header');
expect(field).toEqual([]);
});
});
});
54 changes: 54 additions & 0 deletions packages/ui/src/components/Form/schema-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { JSONSchema4 } from 'json-schema';
import { joinName } from 'uniforms';
import { JSONSchemaBridge } from 'uniforms-bridge-json-schema';
import { resolveRefIfNeeded } from '../../utils';
import memoize from 'lodash/memoize';

export class SchemaBridge extends JSONSchemaBridge {
/**
* Regular expression to match the dot number or dot dollar sign in the field name
* for example: `$.` or `.0` or `.1` or `.2` etc.
*/
static DOT_NUMBEROR_OR_DOLLAR_REGEXP = /\.\d+|\.\$/;

constructor(options: ConstructorParameters<typeof JSONSchemaBridge>[0]) {
super(options);
this.getField = memoize(this.getField.bind(this));
}

getField(name: string): Record<string, unknown> {
/** Process schemas in the parent class */
const originalField = super.getField(name);
SchemaBridge.DOT_NUMBEROR_OR_DOLLAR_REGEXP.lastIndex = 0;
if (SchemaBridge.DOT_NUMBEROR_OR_DOLLAR_REGEXP.test(name)) {
return originalField;
}

const fieldNames = joinName(null, name);
const field = fieldNames.reduce((definition, next, index, array) => {
const prevName = joinName(array.slice(0, index));

if (definition.type === 'object') {
definition = definition.properties[joinName.unescape(next)];

/** Enhance schemas with the definitions if available in the oneOf property */
if (Object.keys(definition).length === 0 && Array.isArray(this._compiledSchema[prevName].oneOf)) {
let oneOfDefinition = this._compiledSchema[prevName].oneOf.find((oneOf: JSONSchema4) => {
return Array.isArray(oneOf.required) && oneOf.required[0] === next;
}).properties[next];

oneOfDefinition = resolveRefIfNeeded(oneOfDefinition, this.schema);
Object.assign(definition, oneOfDefinition);
} else if (definition.$ref) {
/** Resolve $ref if needed */
Object.assign(definition, resolveRefIfNeeded(definition, this.schema));
}
}

return definition;
}, this.schema);

/** At this point, the super._compiledSchemas is populated, so we can return the enhanced field */
return field;
}
}
2 changes: 1 addition & 1 deletion packages/ui/src/components/Form/schema.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Ajv, { ValidateFunction } from 'ajv-draft-04';
import addFormats from 'ajv-formats';
import { filterDOMProps, FilterDOMPropsKeys } from 'uniforms';
import { JSONSchemaBridge as SchemaBridge } from 'uniforms-bridge-json-schema';
import { KaotoSchemaDefinition } from '../../models/kaoto-schema';
import { SchemaBridge } from './schema-bridge';

export class SchemaService {
static readonly DROPDOWN_PLACEHOLDER = 'Select an option...';
Expand Down
46 changes: 46 additions & 0 deletions packages/ui/src/hooks/applied-schema.hook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useMemo } from 'react';
import { useForm } from 'uniforms';
import { KaotoSchemaDefinition } from '../models';
import { ROOT_PATH, getValue } from '../utils';
import { getAppliedSchemaIndex } from '../utils/get-applied-schema-index';
import { OneOfSchemas } from '../utils/get-oneof-schema-list';
import { useSchemaBridgeContext } from './schema-bridge.hook';

interface AppliedSchema {
index: number;
name: string;
description?: string;
schema: KaotoSchemaDefinition['schema'];
model: Record<string, unknown>;
}

export const useAppliedSchema = (fieldName: string, oneOfSchemas: OneOfSchemas[]): AppliedSchema | undefined => {
const form = useForm();
const { schemaBridge } = useSchemaBridgeContext();

const result = useMemo(() => {
const currentModel = getValue(form.model, fieldName === '' ? ROOT_PATH : fieldName);

const oneOfList = oneOfSchemas.map((oneOf) => oneOf.schema);
const index = getAppliedSchemaIndex(
currentModel,
oneOfList,
schemaBridge?.schema as KaotoSchemaDefinition['schema'],
);
if (index === -1) {
return undefined;
}

const foundSchema = oneOfSchemas[index];

return {
index,
name: foundSchema.name,
description: foundSchema.description,
schema: foundSchema.schema,
model: currentModel,
};
}, [fieldName, form.model, oneOfSchemas, schemaBridge?.schema]);

return result;
};
1 change: 1 addition & 0 deletions packages/ui/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './applied-schema.hook';
export * from './entities';
export * from './local-storage.hook';
export * from './schema-bridge.hook';
4 changes: 3 additions & 1 deletion packages/ui/src/hooks/schema-bridge.hook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { useSchemaBridgeContext } from './schema-bridge.hook';

describe('useSchemaBridgeContext', () => {
const wrapper = ({ children }: PropsWithChildren) => (
<SchemaBridgeProvider schema={{}}>{children}</SchemaBridgeProvider>
<SchemaBridgeProvider schema={{}} parentRef={null}>
{children}
</SchemaBridgeProvider>
);

it('should throw an error if used outside of SchemaBridgeProvider', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/hooks/schema-bridge.hook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SchemaBridgeContext } from '../providers/schema-bridge.provider';
export const useSchemaBridgeContext = () => {
const ctx = useContext(SchemaBridgeContext);

if (!ctx) throw new Error('useSchemaBridgeContext needs to be called inside `SchemaBridgeProvider`');
if (!ctx.schemaBridge) throw new Error('useSchemaBridgeContext needs to be called inside `SchemaBridgeProvider`');

return ctx;
};
16 changes: 10 additions & 6 deletions packages/ui/src/providers/schema-bridge.provider.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { FunctionComponent, PropsWithChildren, createContext, useMemo } from 'react';
import { FunctionComponent, PropsWithChildren, RefObject, createContext, useMemo } from 'react';
import { JSONSchemaBridge as SchemaBridge } from 'uniforms-bridge-json-schema';
import { SchemaService } from '../components/Form/schema.service';
import { KaotoSchemaDefinition } from '../models/kaoto-schema';

interface SchemaBridgeProviderProps {
schema: KaotoSchemaDefinition['schema'] | undefined;
parentRef?: RefObject<HTMLElement>;
}

export const SchemaBridgeContext = createContext<SchemaBridge | undefined>(undefined);
export const SchemaBridgeContext = createContext<{
schemaBridge: SchemaBridge | undefined;
parentRef: RefObject<HTMLElement> | null;
}>({ schemaBridge: undefined, parentRef: null });

export const SchemaBridgeProvider: FunctionComponent<PropsWithChildren<SchemaBridgeProviderProps>> = (props) => {
const schemaBridge = useMemo(() => {
const value = useMemo(() => {
const schemaService = new SchemaService();
const schemaBridge = schemaService.getSchemaBridge(props.schema);

return schemaBridge;
}, [props.schema]);
return { schemaBridge, parentRef: props.parentRef ?? null };
}, [props.parentRef, props.schema]);

return <SchemaBridgeContext.Provider value={schemaBridge}>{props.children}</SchemaBridgeContext.Provider>;
return <SchemaBridgeContext.Provider value={value}>{props.children}</SchemaBridgeContext.Provider>;
};
Loading

0 comments on commit 818bffb

Please sign in to comment.