diff --git a/packages/ui/package.json b/packages/ui/package.json index 116bea758..9bdf14b54 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", @@ -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", diff --git a/packages/ui/src/components/Form/CustomAutoFields.tsx b/packages/ui/src/components/Form/CustomAutoFields.tsx new file mode 100644 index 000000000..4e2434411 --- /dev/null +++ b/packages/ui/src/components/Form/CustomAutoFields.tsx @@ -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 })), + ); +} diff --git a/packages/ui/src/components/Form/CustomAutoForm.tsx b/packages/ui/src/components/Form/CustomAutoForm.tsx index f8bc1b4c9..807c4bfc2 100644 --- a/packages/ui/src/components/Form/CustomAutoForm.tsx +++ b/packages/ui/src/components/Form/CustomAutoForm.tsx @@ -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; @@ -17,7 +18,7 @@ interface CustomAutoFormProps extends IDataTestID { export type CustomAutoFormRef = { fields: HTMLElement[]; form: typeof AutoForm }; export const CustomAutoForm = forwardRef((props, forwardedRef) => { - const schemaBridge = useSchemaBridgeContext(); + const { schemaBridge } = useSchemaBridgeContext(); const formRef = useRef(); const fieldsRefs = useRef([]); const sortedFieldsNames = useMemo(() => { @@ -70,7 +71,7 @@ export const CustomAutoForm = forwardRef /> )) ) : ( - + )} diff --git a/packages/ui/src/components/Form/schema-bridge.test.ts b/packages/ui/src/components/Form/schema-bridge.test.ts new file mode 100644 index 000000000..63c1e65ee --- /dev/null +++ b/packages/ui/src/components/Form/schema-bridge.test.ts @@ -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([]); + }); + }); +}); diff --git a/packages/ui/src/components/Form/schema-bridge.ts b/packages/ui/src/components/Form/schema-bridge.ts new file mode 100644 index 000000000..c8fc19839 --- /dev/null +++ b/packages/ui/src/components/Form/schema-bridge.ts @@ -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[0]) { + super(options); + this.getField = memoize(this.getField.bind(this)); + } + + getField(name: string): Record { + /** 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; + } +} diff --git a/packages/ui/src/components/Form/schema.service.ts b/packages/ui/src/components/Form/schema.service.ts index d53a2e65b..e7ff7bd59 100644 --- a/packages/ui/src/components/Form/schema.service.ts +++ b/packages/ui/src/components/Form/schema.service.ts @@ -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...'; diff --git a/packages/ui/src/hooks/applied-schema.hook.tsx b/packages/ui/src/hooks/applied-schema.hook.tsx new file mode 100644 index 000000000..344f22068 --- /dev/null +++ b/packages/ui/src/hooks/applied-schema.hook.tsx @@ -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; +} + +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; +}; diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 525229212..b1886c631 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './applied-schema.hook'; export * from './entities'; export * from './local-storage.hook'; export * from './schema-bridge.hook'; diff --git a/packages/ui/src/hooks/schema-bridge.hook.test.tsx b/packages/ui/src/hooks/schema-bridge.hook.test.tsx index 25bfbf8cc..d9b371861 100644 --- a/packages/ui/src/hooks/schema-bridge.hook.test.tsx +++ b/packages/ui/src/hooks/schema-bridge.hook.test.tsx @@ -5,7 +5,9 @@ import { useSchemaBridgeContext } from './schema-bridge.hook'; describe('useSchemaBridgeContext', () => { const wrapper = ({ children }: PropsWithChildren) => ( - {children} + + {children} + ); it('should throw an error if used outside of SchemaBridgeProvider', () => { diff --git a/packages/ui/src/hooks/schema-bridge.hook.tsx b/packages/ui/src/hooks/schema-bridge.hook.tsx index 4410723af..758fbd657 100644 --- a/packages/ui/src/hooks/schema-bridge.hook.tsx +++ b/packages/ui/src/hooks/schema-bridge.hook.tsx @@ -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; }; diff --git a/packages/ui/src/providers/schema-bridge.provider.tsx b/packages/ui/src/providers/schema-bridge.provider.tsx index ded72ac19..a4e6a99c8 100644 --- a/packages/ui/src/providers/schema-bridge.provider.tsx +++ b/packages/ui/src/providers/schema-bridge.provider.tsx @@ -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; } -export const SchemaBridgeContext = createContext(undefined); +export const SchemaBridgeContext = createContext<{ + schemaBridge: SchemaBridge | undefined; + parentRef: RefObject | null; +}>({ schemaBridge: undefined, parentRef: null }); export const SchemaBridgeProvider: FunctionComponent> = (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 {props.children}; + return {props.children}; }; diff --git a/packages/ui/src/stubs/csv.dataformat.ts b/packages/ui/src/stubs/csv.dataformat.ts new file mode 100644 index 000000000..f1c182dde --- /dev/null +++ b/packages/ui/src/stubs/csv.dataformat.ts @@ -0,0 +1,171 @@ +import { KaotoSchemaDefinition } from '../models'; + +export const csvDataFormatSchema: KaotoSchemaDefinition['schema'] = { + type: 'object', + additionalProperties: false, + properties: { + allowMissingColumnNames: { + type: 'boolean', + title: 'Allow Missing Column Names', + description: 'Whether to allow missing column names.', + }, + captureHeaderRecord: { + type: 'boolean', + title: 'Capture Header Record', + description: 'Whether the unmarshalling should capture the header record and store it in the message header', + }, + commentMarker: { + type: 'string', + title: 'Comment Marker', + description: 'Sets the comment marker of the reference format.', + }, + commentMarkerDisabled: { + type: 'boolean', + title: 'Comment Marker Disabled', + description: 'Disables the comment marker of the reference format.', + }, + delimiter: { + type: 'string', + title: 'Delimiter', + description: 'Sets the delimiter to use. The default value is , (comma)', + }, + escape: { + type: 'string', + title: 'Escape', + description: 'Sets the escape character to use', + }, + escapeDisabled: { + type: 'boolean', + title: 'Escape Disabled', + description: 'Use for disabling using escape character', + }, + formatName: { + type: 'string', + title: 'Format Name', + description: 'The name of the format to use, the default value is CSVFormat.DEFAULT', + default: 'DEFAULT', + enum: ['DEFAULT', 'EXCEL', 'INFORMIX_UNLOAD', 'INFORMIX_UNLOAD_CSV', 'MYSQL', 'RFC4180'], + }, + formatRef: { + type: 'string', + title: 'Format Ref', + description: + 'The reference format to use, it will be updated with the other format options, the default value is CSVFormat.DEFAULT', + }, + header: { + type: 'array', + title: 'Header', + description: 'To configure the CSV headers', + items: { + type: 'string', + }, + }, + headerDisabled: { + type: 'boolean', + title: 'Header Disabled', + description: 'Use for disabling headers', + }, + id: { + type: 'string', + title: 'Id', + description: 'The id of this node', + }, + ignoreEmptyLines: { + type: 'boolean', + title: 'Ignore Empty Lines', + description: 'Whether to ignore empty lines.', + }, + ignoreHeaderCase: { + type: 'boolean', + title: 'Ignore Header Case', + description: 'Sets whether or not to ignore case when accessing header names.', + }, + ignoreSurroundingSpaces: { + type: 'boolean', + title: 'Ignore Surrounding Spaces', + description: 'Whether to ignore surrounding spaces', + }, + lazyLoad: { + type: 'boolean', + title: 'Lazy Load', + description: + 'Whether the unmarshalling should produce an iterator that reads the lines on the fly or if all the lines must be read at one.', + }, + marshallerFactoryRef: { + type: 'string', + title: 'Marshaller Factory Ref', + description: + 'Sets the implementation of the CsvMarshallerFactory interface which is able to customize marshalling/unmarshalling behavior by extending CsvMarshaller or creating it from scratch.', + }, + nullString: { + type: 'string', + title: 'Null String', + description: 'Sets the null string', + }, + nullStringDisabled: { + type: 'boolean', + title: 'Null String Disabled', + description: 'Used to disable null strings', + }, + quote: { + type: 'string', + title: 'Quote', + description: 'Sets the quote which by default is', + }, + quoteDisabled: { + type: 'boolean', + title: 'Quote Disabled', + description: 'Used to disable quotes', + }, + quoteMode: { + type: 'string', + title: 'Quote Mode', + description: 'Sets the quote mode', + enum: ['ALL', 'ALL_NON_NULL', 'MINIMAL', 'NON_NUMERIC', 'NONE'], + }, + recordConverterRef: { + type: 'string', + title: 'Record Converter Ref', + description: 'Refers to a custom CsvRecordConverter to lookup from the registry to use.', + }, + recordSeparator: { + type: 'string', + title: 'Record Separator', + description: 'Sets the record separator (aka new line) which by default is new line characters (CRLF)', + }, + recordSeparatorDisabled: { + type: 'string', + title: 'Record Separator Disabled', + description: 'Used for disabling record separator', + }, + skipHeaderRecord: { + type: 'boolean', + title: 'Skip Header Record', + description: 'Whether to skip the header record in the output', + }, + trailingDelimiter: { + type: 'boolean', + title: 'Trailing Delimiter', + description: 'Sets whether or not to add a trailing delimiter.', + }, + trim: { + type: 'boolean', + title: 'Trim', + description: 'Sets whether or not to trim leading and trailing blanks.', + }, + useMaps: { + type: 'boolean', + title: 'Use Maps', + description: + 'Whether the unmarshalling should produce maps (HashMap)for the lines values instead of lists. It requires to have header (either defined or collected).', + }, + useOrderedMaps: { + type: 'boolean', + title: 'Use Ordered Maps', + description: + 'Whether the unmarshalling should produce ordered maps (LinkedHashMap) for the lines values instead of lists. It requires to have header (either defined or collected).', + }, + }, + title: 'CSV', + description: 'Handle CSV (Comma Separated Values) payloads.', +};