diff --git a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/AnyOfField.tsx b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/AnyOfField.tsx index b08c517e9..f9ce0b802 100644 --- a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/AnyOfField.tsx +++ b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/AnyOfField.tsx @@ -1,6 +1,6 @@ -import { FunctionComponent, useContext } from 'react'; +import { FunctionComponent } from 'react'; import { KaotoSchemaDefinition } from '../../../../../models'; -import { SchemaContext, SchemaProvider } from '../providers/SchemaProvider'; +import { SchemaProvider } from '../providers/SchemaProvider'; import { FieldProps } from '../typings'; import { AutoField } from './AutoField'; @@ -9,14 +9,6 @@ interface AnyOfFieldProps extends FieldProps { } export const AnyOfField: FunctionComponent = ({ propName, anyOf }) => { - const { schema } = useContext(SchemaContext); - - if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) { - return null; - } else if (!schema) { - return
AnyOfField - Schema not defined
; - } - return ( <> {anyOf?.map((schema, index) => { diff --git a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/EnumField.tsx b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/EnumField.tsx index 81a880add..78f9c5d2f 100644 --- a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/EnumField.tsx +++ b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/EnumField.tsx @@ -1,6 +1,5 @@ import { FormGroup, FormGroupLabelHelp, Popover } from '@patternfly/react-core'; import { FunctionComponent, useCallback, useContext, useMemo } from 'react'; -import { isDefined } from '../../../../../utils'; import { useFieldValue } from '../hooks/field-value'; import { SchemaContext } from '../providers/SchemaProvider'; import { FieldProps } from '../typings'; diff --git a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectField.tsx b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectField.tsx index 2c0ae8a39..2598d21b8 100644 --- a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectField.tsx +++ b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectField.tsx @@ -1,11 +1,11 @@ +import { Button } from '@patternfly/react-core'; +import { TrashIcon } from '@patternfly/react-icons'; import { FunctionComponent, useContext, useMemo } from 'react'; import { isDefined, ROOT_PATH } from '../../../../../../utils'; import { SchemaContext } from '../../providers/SchemaProvider'; import { FieldProps } from '../../typings'; -import { ObjectFieldInner } from './ObjectFieldInner'; -import { TrashIcon } from '@patternfly/react-icons'; -import { Button } from '@patternfly/react-core'; import { FieldWrapper } from '../FieldWrapper'; +import { ObjectFieldGrouping } from './ObjectFieldGrouping'; export const ObjectField: FunctionComponent = ({ propName, onRemove }) => { const { schema } = useContext(SchemaContext); @@ -33,7 +33,7 @@ export const ObjectField: FunctionComponent = ({ propName, onRemove ); if (propName === ROOT_PATH || !schema.title) { - return ; + return ; } return ( @@ -45,7 +45,7 @@ export const ObjectField: FunctionComponent = ({ propName, onRemove defaultValue={schema.default} actions={actions} > - + ); }; diff --git a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectFieldGrouping.tsx b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectFieldGrouping.tsx new file mode 100644 index 000000000..861dbdf86 --- /dev/null +++ b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectFieldGrouping.tsx @@ -0,0 +1,57 @@ +import { FormFieldGroupExpandable, FormFieldGroupHeader } from '@patternfly/react-core'; +import { FunctionComponent, useContext, useMemo } from 'react'; +import { FilteredFieldContext } from '../../../../../../providers'; +import { getFieldGroupsV2, getFilteredProperties, isDefined } from '../../../../../../utils'; +import { capitalizeString } from '../../../../../../utils/capitalize-string'; +import { SchemaContext, SchemaProvider } from '../../providers/SchemaProvider'; +import { FieldProps } from '../../typings'; +import { AnyOfField } from '../AnyOfField'; +import { ObjectFieldInner } from './ObjectFieldInner'; + +const SPACE_REGEX = /\s/g; + +export const ObjectFieldGrouping: FunctionComponent = ({ propName }) => { + const { schema } = useContext(SchemaContext); + if (!isDefined(schema)) { + throw new Error(`ObjectFieldGrouping: schema is not defined for ${propName}`); + } + + const { filteredFieldText } = useContext(FilteredFieldContext); + + const groupedProperties = useMemo(() => { + const cleanQueryTerm = filteredFieldText.replace(SPACE_REGEX, '').toLowerCase(); + const filteredProperties = getFilteredProperties(schema.properties, cleanQueryTerm); + return getFieldGroupsV2(filteredProperties); + }, [filteredFieldText, schema.properties]); + + const requiredProperties = Array.isArray(schema.required) ? schema.required : []; + + return ( + <> + {/* Common properties */} + + + + + {/* AnyOf field */} + {Array.isArray(schema.anyOf) && } + + {/* Grouped properties */} + {groupedProperties.groups.map(([groupName, groupProperties]) => { + const name = capitalizeString(groupName); + + return ( + } + > + + + + + ); + })} + + ); +}; diff --git a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectFieldInner.tsx b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectFieldInner.tsx index 2f8de3705..69ffd4bcd 100644 --- a/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectFieldInner.tsx +++ b/packages/ui/src/components/Visualization/Canvas/FormV2/fields/ObjectField/ObjectFieldInner.tsx @@ -2,17 +2,18 @@ import { FunctionComponent, useContext } from 'react'; import { isDefined } from '../../../../../../utils'; import { SchemaContext, SchemaProvider } from '../../providers/SchemaProvider'; import { FieldProps } from '../../typings'; -import { AnyOfField } from '../AnyOfField'; import { AutoField } from '../AutoField'; -export const ObjectFieldInner: FunctionComponent = ({ propName }) => { +interface ObjectFieldInnerProps extends FieldProps { + requiredProperties: string[]; +} + +export const ObjectFieldInner: FunctionComponent = ({ propName, requiredProperties }) => { const { schema } = useContext(SchemaContext); - if (!schema) { - return
ObjectField - Schema not defined
; + if (!isDefined(schema)) { + throw new Error(`ObjectFieldInner: schema is not defined for ${propName}`); } - const requiredProperties = Array.isArray(schema.required) ? schema.required : []; - return ( <> {Object.entries(schema.properties ?? {}) @@ -30,8 +31,6 @@ export const ObjectFieldInner: FunctionComponent = ({ propName }) => ); })} - - {Array.isArray(schema.anyOf) && } ); }; diff --git a/packages/ui/src/stubs/timer.component.schema.ts b/packages/ui/src/stubs/timer.component.schema.ts index 36fdc6146..f79e36843 100644 --- a/packages/ui/src/stubs/timer.component.schema.ts +++ b/packages/ui/src/stubs/timer.component.schema.ts @@ -1,17 +1,19 @@ -export const TimerComponentSchema = { +import { KaotoSchemaDefinition } from '../models'; + +export const TimerComponentSchema: KaotoSchemaDefinition['schema'] = { $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', properties: { timerName: { title: 'Timer Name', - group: 'consumer', + $comment: 'group:consumer', description: 'The name of the timer', type: 'string', deprecated: false, }, delay: { title: 'Delay', - group: 'consumer', + $comment: 'group:consumer', description: 'The number of milliseconds to wait before the first event is generated. Should not be used in conjunction with the time option. The default value is 1000.', format: 'duration', @@ -21,7 +23,7 @@ export const TimerComponentSchema = { }, fixedRate: { title: 'Fixed Rate', - group: 'consumer', + $comment: 'group:consumer', description: 'Events take place at approximately regular intervals, separated by the specified period.', type: 'boolean', deprecated: false, @@ -29,7 +31,7 @@ export const TimerComponentSchema = { }, includeMetadata: { title: 'Include Metadata', - group: 'consumer', + $comment: 'group:consumer', description: 'Whether to include metadata in the exchange such as fired time, timer name, timer count etc.', type: 'boolean', deprecated: false, @@ -37,7 +39,7 @@ export const TimerComponentSchema = { }, period: { title: 'Period', - group: 'consumer', + $comment: 'group:consumer', description: 'Generate periodic events every period. Must be zero or positive value. The default value is 1000.', format: 'duration', type: 'string', @@ -46,7 +48,7 @@ export const TimerComponentSchema = { }, repeatCount: { title: 'Repeat Count', - group: 'consumer', + $comment: 'group:consumer', description: 'Specifies a maximum limit for the number of fires. Therefore, if you set it to 1, the timer will only fire once. If you set it to 5, it will only fire five times. A value of zero or negative means fire forever.', type: 'integer', @@ -54,7 +56,7 @@ export const TimerComponentSchema = { }, bridgeErrorHandler: { title: 'Bridge Error Handler', - group: 'consumer (advanced)', + $comment: 'group:consumer (advanced)', description: 'Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored.', type: 'boolean', @@ -63,16 +65,16 @@ export const TimerComponentSchema = { }, exceptionHandler: { title: 'Exception Handler', - group: 'consumer (advanced)', + $comment: 'group:consumer (advanced)', description: 'To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored.', type: 'string', deprecated: false, - $comment: 'class:org.apache.camel.spi.ExceptionHandler', + format: 'class:org.apache.camel.spi.ExceptionHandler', }, exchangePattern: { title: 'Exchange Pattern', - group: 'consumer (advanced)', + $comment: 'group:consumer (advanced)', description: 'Sets the exchange pattern when the consumer creates an exchange.', type: 'string', deprecated: false, @@ -80,7 +82,7 @@ export const TimerComponentSchema = { }, daemon: { title: 'Daemon', - group: 'advanced', + $comment: 'group:advanced', description: 'Specifies whether the thread associated with the timer endpoint runs as a daemon. The default value is true.', type: 'boolean', @@ -89,14 +91,14 @@ export const TimerComponentSchema = { }, pattern: { title: 'Pattern', - group: 'advanced', + $comment: 'group:advanced', description: 'Allows you to specify a custom Date pattern to use for setting the time option using URI syntax.', type: 'string', deprecated: false, }, synchronous: { title: 'Synchronous', - group: 'advanced', + $comment: 'group:advanced', description: 'Sets whether synchronous processing should be strictly used', type: 'boolean', deprecated: false, @@ -104,7 +106,7 @@ export const TimerComponentSchema = { }, time: { title: 'Time', - group: 'advanced', + $comment: 'group:advanced', description: "A java.util.Date the first event should be generated. If using the URI, the pattern expected is: yyyy-MM-dd HH:mm:ss or yyyy-MM-dd'T'HH:mm:ss.", type: 'string', @@ -112,15 +114,15 @@ export const TimerComponentSchema = { }, timer: { title: 'Timer', - group: 'advanced', + $comment: 'group:advanced', description: 'To use a custom Timer', type: 'string', deprecated: false, - $comment: 'class:java.util.Timer', + format: 'class:java.util.Timer', }, runLoggingLevel: { title: 'Run Logging Level', - group: 'scheduler', + $comment: 'group:scheduler', description: 'The consumer logs a start/complete log line when it polls. This option allows you to configure the logging level for that.', type: 'string', diff --git a/packages/ui/src/utils/get-field-groups.test.ts b/packages/ui/src/utils/get-field-groups.test.ts index 5e2a978c3..758b4366b 100644 --- a/packages/ui/src/utils/get-field-groups.test.ts +++ b/packages/ui/src/utils/get-field-groups.test.ts @@ -1,7 +1,8 @@ +import { KaotoSchemaDefinition } from '../models'; import { TimerComponentSchema } from '../stubs/timer.component.schema'; -import { getFieldGroups } from './get-field-groups'; +import { getFieldGroups, getFieldGroupsV2 } from './get-field-groups'; -describe('useFieldGroups', () => { +describe('getFieldGroups', () => { let inputValue: { [name: string]: unknown }; it('should get a object with common array and groups object containing advance groups array', () => { @@ -106,3 +107,137 @@ describe('useFieldGroups', () => { expect(propertiesArray).toEqual(expectedOutputValue); }); }); + +describe('getFieldGroupsV2', () => { + let inputValue: KaotoSchemaDefinition['schema']['properties']; + + it('should get a object with common array and groups object containing advance groups array', () => { + inputValue = { + id: { + type: 'string', + title: 'Id', + description: 'Sets the id of this node', + }, + description: { + type: 'string', + title: 'Description', + description: 'Sets the description of this node', + }, + disabled: { + type: 'boolean', + title: 'Disabled', + description: + 'Whether to disable this EIP from the route during build time. Once an EIP has been disabled then it cannot be enabled later at runtime.', + $comment: 'group:advanced', + }, + correlationExpression: { + title: 'Correlation Expression', + description: + 'The expression used to calculate the correlation key to use for aggregation. The Exchange which has the same correlation key is aggregated together. If the correlation key could not be evaluated an Exception is thrown. You can disable this by using the ignoreBadCorrelationKeys option.', + type: 'object', + format: 'expression', + $comment: 'group:common', + }, + optimisticLockRetryPolicy: { + title: 'Optimistic Lock Retry Policy', + description: 'To configure optimistic locking', + $ref: '#/definitions/org.apache.camel.model.OptimisticLockRetryPolicyDefinition', + format: 'class:org.apache.camel.model.OptimisticLockRetryPolicyDefinition', + $comment: 'group:advanced', + type: 'object', + additionalProperties: false, + properties: { + exponentialBackOff: { + type: 'boolean', + title: 'Exponential Back Off', + description: 'Enable exponential backoff', + }, + maximumRetries: { + type: 'number', + title: 'Maximum Retries', + description: 'Sets the maximum number of retries', + }, + maximumRetryDelay: { + type: 'string', + title: 'Maximum Retry Delay', + description: + 'Sets the upper value of retry in millis between retries, when using exponential or random backoff', + default: '1000', + }, + randomBackOff: { + type: 'boolean', + title: 'Random Back Off', + description: 'Enables random backoff', + }, + retryDelay: { + type: 'string', + title: 'Retry Delay', + description: 'Sets the delay in millis between retries', + default: '50', + }, + }, + }, + }; + + const expectedOutputValue = { + common: { + id: inputValue.id, + description: inputValue.description, + correlationExpression: inputValue.correlationExpression, + }, + groups: { + advanced: { + disabled: inputValue.disabled, + optimisticLockRetryPolicy: inputValue.optimisticLockRetryPolicy, + }, + }, + }; + const propertiesArray = getFieldGroupsV2(inputValue); + expect(propertiesArray).toEqual(expectedOutputValue); + }); + + it('should get an object with common group and groups object containing different groups array', () => { + const expectedOutputValue = { + common: { + timerName: TimerComponentSchema.properties!.timerName, + delay: TimerComponentSchema.properties!.delay, + fixedRate: TimerComponentSchema.properties!.fixedRate, + includeMetadata: TimerComponentSchema.properties!.includeMetadata, + period: TimerComponentSchema.properties!.period, + repeatCount: TimerComponentSchema.properties!.repeatCount, + }, + groups: { + 'consumer (advanced)': { + bridgeErrorHandler: TimerComponentSchema.properties!.bridgeErrorHandler, + exceptionHandler: TimerComponentSchema.properties!.exceptionHandler, + exchangePattern: TimerComponentSchema.properties!.exchangePattern, + }, + advanced: { + daemon: TimerComponentSchema.properties!.daemon, + pattern: TimerComponentSchema.properties!.pattern, + synchronous: TimerComponentSchema.properties!.synchronous, + time: TimerComponentSchema.properties!.time, + timer: TimerComponentSchema.properties!.timer, + }, + scheduler: { + runLoggingLevel: TimerComponentSchema.properties!.runLoggingLevel, + }, + }, + }; + + const propertiesArray = getFieldGroupsV2(TimerComponentSchema.properties); + + expect(propertiesArray).toEqual(expectedOutputValue); + }); + + it('should get an object with empty common object and empty groups object', () => { + inputValue = {}; + + const expectedOutputValue = { + common: {}, + groups: {}, + }; + const propertiesArray = getFieldGroupsV2(inputValue); + expect(propertiesArray).toEqual(expectedOutputValue); + }); +}); diff --git a/packages/ui/src/utils/get-field-groups.ts b/packages/ui/src/utils/get-field-groups.ts index 1e58d9001..00775648c 100644 --- a/packages/ui/src/utils/get-field-groups.ts +++ b/packages/ui/src/utils/get-field-groups.ts @@ -1,3 +1,5 @@ +import { KaotoSchemaDefinition } from '../models'; +import { extractGroup } from './get-tagged-field-from-string'; import { getValue } from './get-value'; import { isDefined } from './is-defined'; @@ -20,3 +22,44 @@ export const getFieldGroups = (fields?: { [name: string]: unknown }) => { return propertiesArray; }; + +interface FieldGroups { + common: Record; + groups: [string, Record][]; +} + +export const getFieldGroupsV2 = (properties?: KaotoSchemaDefinition['schema']['properties']): FieldGroups => { + if (!isDefined(properties)) return { common: {}, groups: [] }; + + const groupedProperties = Object.entries(properties).reduce( + (acc, [name, definition]) => { + // "$comment": "group:advanced" or "$comment": "group:consumer (advanced)" + const group = extractGroup('group', definition.$comment); + + if (group === '' || group === 'common' || group === 'producer' || group === 'consumer') { + acc.common[name] = definition; + } else { + acc.groups[group] ??= {}; + acc.groups[group][name] = definition; + } + + return acc; + }, + { common: {}, groups: {} } as { + common: Record; + groups: Record>; + }, + ); + + /** Prioritize advanced properties */ + const groupArray = Object.entries(groupedProperties.groups).sort((a, b) => { + if (a[0] === 'advanced') { + return 1; + } else if (b[0] === 'advanced') { + return -1; + } + return 0; + }); + + return { common: groupedProperties.common, groups: groupArray }; +}; diff --git a/packages/ui/src/utils/get-tagged-field-from-string.test.ts b/packages/ui/src/utils/get-tagged-field-from-string.test.ts new file mode 100644 index 000000000..8baf6ea22 --- /dev/null +++ b/packages/ui/src/utils/get-tagged-field-from-string.test.ts @@ -0,0 +1,20 @@ +import { extractGroup } from './get-tagged-field-from-string'; + +describe('getTaggedFieldFromString', () => { + it.each([ + ['producer', 'group:producer', 'group'], + ['consumer', 'group:consumer', 'group'], + ['common', 'group:common', 'group'], + ['consumer (advanced)', 'group:consumer (advanced)', 'group'], + ['java.util.List', 'bean:java.util.List', 'bean'], + ['String', 'bean:String', 'bean'], + ['common', 'group:common|bean:String', 'group'], + ['common (advanced)', 'group:common (advanced)|bean:String', 'group'], + ['String', 'group:common|bean:String', 'bean'], + ['String', 'group:common (advanced)|bean:String', 'bean'], + ])('should return `%s` from `%s` string, when requesting `%s`', (expected, input, tag) => { + const result = extractGroup(tag, input); + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/ui/src/utils/get-tagged-field-from-string.ts b/packages/ui/src/utils/get-tagged-field-from-string.ts new file mode 100644 index 000000000..a5f9124cc --- /dev/null +++ b/packages/ui/src/utils/get-tagged-field-from-string.ts @@ -0,0 +1,21 @@ +/** + * Get the regex for a tag + * Given a tagged value like group:producer (advanced), this function + * will return the regex to match the value, up to the next pipe character + * or the end of the string. + * @param tag + * @returns + */ +const getTagRegex = (tag: string) => { + return new RegExp(`${tag}:(.*?)(?:\\||$)`); +}; + +export const extractGroup = (tag: string, input?: string): string => { + if (!input) { + return ''; + } + + const regex = getTagRegex(tag); + const match = regex.exec(input); + return match?.[1] ?? ''; +};