diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.tsx index c95b5e11baee..499f76722e9f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.tsx @@ -12,7 +12,7 @@ */ import Form, { IChangeEvent } from '@rjsf/core'; -import { RegistryFieldsType } from '@rjsf/utils'; +import { RegistryFieldsType, RJSFSchema } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; import { Alert } from 'antd'; @@ -30,6 +30,7 @@ import { useApplicationStore } from '../../../../hooks/useApplicationStore'; import { ConfigData } from '../../../../interface/service.interface'; import { getPipelineServiceHostIp } from '../../../../rest/ingestionPipelineAPI'; import brandClassBase from '../../../../utils/BrandData/BrandClassBase'; +import connectionsRouterClassBase from '../../../../utils/ConnectionsRouterClassBase'; import i18n, { Transi18next } from '../../../../utils/i18next/LocalUtil'; import { formatFormDataForSubmit } from '../../../../utils/JSONSchemaFormUtils'; import { @@ -41,6 +42,7 @@ import AirflowMessageBanner from '../../../common/AirflowMessageBanner/AirflowMe import BooleanFieldTemplate from '../../../common/Form/JSONSchema/JSONSchemaTemplate/BooleanFieldTemplate'; import WorkflowArrayFieldTemplate from '../../../common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate'; import FormBuilder from '../../../common/FormBuilder/FormBuilder'; +import FormBuilderV1 from '../../../common/FormBuilderV1/FormBuilderV1'; import InlineAlert from '../../../common/InlineAlert/InlineAlert'; import TestConnection from '../../../common/TestConnection/TestConnection'; import { ConnectionConfigFormProps } from './ConnectionConfigForm.interface'; @@ -66,6 +68,8 @@ const ConnectionConfigForm = ({ const { isAirflowAvailable, platform } = useAirflowStatus(); const [hostIp, setHostIp] = useState(); + const isEmbeddedMode = connectionsRouterClassBase.isEmbeddedMode(); + const fetchHostIp = async () => { try { const { status, data } = await getPipelineServiceHostIp(); @@ -96,8 +100,8 @@ const ConnectionConfigForm = ({ }; const customFields: RegistryFieldsType = { - BooleanField: BooleanFieldTemplate, - ArrayField: WorkflowArrayFieldTemplate, + ...(isEmbeddedMode ? {} : { BooleanField: BooleanFieldTemplate }), + ...(isEmbeddedMode ? {} : { ArrayField: WorkflowArrayFieldTemplate }), }; const { connSch, validConfig } = useMemo( @@ -109,6 +113,7 @@ const ConnectionConfigForm = ({ }), [data, serviceCategory, serviceType] ); + const connectionSchema = connSch.schema as RJSFSchema; const shouldShowIPAlert = useMemo(() => { return ( @@ -124,16 +129,20 @@ const ConnectionConfigForm = ({ // Remove the filters property from the schema // Since it'll have a separate form in the next step const propertiesWithoutDefaultFilterPatternFields = useMemo( - () => getFilteredSchema(connSch.schema.properties), - [connSch.schema.properties] + () => + getFilteredSchema( + connectionSchema.properties as Record | undefined + ), + [connectionSchema.properties] ); - const schemaWithoutDefaultFilterPatternFields = useMemo( + const schemaWithoutDefaultFilterPatternFields = useMemo( () => ({ - ...connSch.schema, - properties: propertiesWithoutDefaultFilterPatternFields, + ...connectionSchema, + properties: + propertiesWithoutDefaultFilterPatternFields as RJSFSchema['properties'], }), - [connSch.schema, propertiesWithoutDefaultFilterPatternFields] + [connectionSchema, propertiesWithoutDefaultFilterPatternFields] ); // UI Schema to hide the nested default filter pattern fields @@ -153,61 +162,86 @@ const ConnectionConfigForm = ({ } }, [formRef.current?.state?.formData]); + const formChildren = ( + <> + {isEmpty(connSch.schema) && ( +
+ {t('message.no-config-available')} +
+ )} + {shouldShowIPAlert && ( + } + values={{ hostIp, brandName: brandClassBase.getPageTitle() }} + /> + } + type="info" + /> + )} + {!isEmpty(connSch.schema) && + isAirflowAvailable && + formRef.current?.state?.formData && ( + formRef.current?.state?.formData} + hostIp={hostIp} + isTestingDisabled={disableTestConnection} + serviceCategory={serviceCategory} + serviceName={data?.name} + onValidateFormRequiredFields={handleRequiredFieldsValidation} + /> + )} + {!isUndefined(inlineAlertDetails) && ( + + )} + + ); + return ( - - {isEmpty(connSch.schema) && ( -
- {t('message.no-config-available')} -
- )} - {shouldShowIPAlert && ( - } - values={{ hostIp, brandName: brandClassBase.getPageTitle() }} - /> - } - type="info" - /> - )} - {!isEmpty(connSch.schema) && - isAirflowAvailable && - formRef.current?.state?.formData && ( - formRef.current?.state?.formData} - hostIp={hostIp} - isTestingDisabled={disableTestConnection} - serviceCategory={serviceCategory} - serviceName={data?.name} - onValidateFormRequiredFields={handleRequiredFieldsValidation} - /> - )} - {!isUndefined(inlineAlertDetails) && ( - - )} -
+ {isEmbeddedMode ? ( + + {formChildren} + + ) : ( + + {formChildren} + + )}
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx index c93242728c8e..ee82fa24902f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx @@ -44,16 +44,14 @@ import { DatabaseServiceSearchSource } from '../../../interface/search.interface import { ServicesType } from '../../../interface/service.interface'; import { getServices, searchService } from '../../../rest/serviceAPI'; import { getServiceLogo } from '../../../utils/CommonUtils'; +import connectionsRouterClassBase from '../../../utils/ConnectionsRouterClassBase'; import { getColumnSorter, getEntityName, highlightSearchText, } from '../../../utils/EntityUtils'; import { checkPermission } from '../../../utils/PermissionsUtils'; -import { - getAddServicePath, - getServiceDetailsPath, -} from '../../../utils/RouterUtils'; +import { getServiceDetailsPath } from '../../../utils/RouterUtils'; import serviceUtilClassBase from '../../../utils/ServiceUtilClassBase'; import { getOptionalFields, @@ -85,7 +83,7 @@ const Services = ({ serviceName }: ServicesProps) => { const navigate = useNavigate(); const handleAddServiceClick = () => { - navigate(getAddServicePath(serviceName)); + navigate(connectionsRouterClassBase.getAddServicePath(serviceName)); }; const [isLoading, setIsLoading] = useState(true); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/FormBuilderV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/FormBuilderV1.test.tsx index b53d3492c6fb..1052cf3f5df7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/FormBuilderV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/FormBuilderV1.test.tsx @@ -100,6 +100,10 @@ describe('FormBuilderV1', () => { jest.clearAllMocks(); }); + it('sets a displayName for React DevTools and linting', () => { + expect(FormBuilderV1.displayName).toBe('FormBuilderV1'); + }); + it('renders with formatted form data and default actions', () => { render(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/FormBuilderV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/FormBuilderV1.tsx index 3e0ef6c46ef6..374d70ea788a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/FormBuilderV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/FormBuilderV1.tsx @@ -15,124 +15,158 @@ import { Button } from '@openmetadata/ui-core-components'; import Form, { IChangeEvent } from '@rjsf/core'; import { RJSFSchema } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; -import { useEffect, useState } from 'react'; +import { forwardRef, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { transformErrors } from '../../../utils/formUtils'; import { formatFormDataForRender } from '../../../utils/JSONSchemaFormUtils'; +import CoreArrayField from './fields/CoreArrayField'; +import CoreBooleanField from './fields/CoreBooleanField'; +import CoreOneOfField from './fields/CoreOneOfField'; import LayoutGridField from './fields/LayoutGridField'; import { FormBuilderV1Props } from './FormBuilderV1.interface'; import { CoreArrayFieldTemplate } from './templates/CoreArrayFieldTemplate'; import { CoreFieldErrorTemplate } from './templates/CoreFieldErrorTemplate'; import { CoreFieldTemplate } from './templates/CoreFieldTemplate'; import { CoreObjectFieldTemplate } from './templates/CoreObjectFieldTemplate'; +import { CoreWrapIfAdditionalTemplate } from './templates/CoreWrapIfAdditionalTemplate'; import CoreCheckboxWidget from './widgets/CoreCheckboxWidget'; import CoreInputWidget from './widgets/CoreInputWidget'; +import CorePasswordWidget from './widgets/CorePasswordWidget'; import CoreRadioWidget from './widgets/CoreRadioWidget'; import CoreSelectWidget from './widgets/CoreSelectWidget'; import CoreTextAreaWidget from './widgets/CoreTextAreaWidget'; -const FormBuilderV1 = ({ - formData, - schema, - okText, - cancelText, - isLoading, - hideCancelButton = false, - status = 'initial', - onCancel, - onSubmit, - uiSchema, - children, - ...props -}: FormBuilderV1Props) => { - const { t } = useTranslation(); +const defaultFields = { + AnyOfField: CoreOneOfField, + ArrayField: CoreArrayField, + BooleanField: CoreBooleanField, + OneOfField: CoreOneOfField, + LayoutGridField, +}; - const [localFormData, setLocalFormData] = useState( - formatFormDataForRender((formData ?? {}) as Record) - ); +const defaultWidgets = { + CheckboxWidget: CoreCheckboxWidget, + EmailWidget: CoreInputWidget, + PasswordWidget: CorePasswordWidget, + RadioWidget: CoreRadioWidget, + SelectWidget: CoreSelectWidget, + TextWidget: CoreInputWidget, + TextareaWidget: CoreTextAreaWidget, + URLWidget: CoreInputWidget, + UpDownWidget: CoreInputWidget, +}; - useEffect(() => { - setLocalFormData( - formatFormDataForRender((formData ?? {}) as Record) - ); - }, [formData]); +const FormBuilderV1 = forwardRef( + ( + { + formData, + schema, + okText, + cancelText, + isLoading, + hideCancelButton = false, + status = 'initial', + onCancel, + onSubmit, + uiSchema, + children, + ...props + }, + ref + ) => { + const { t } = useTranslation(); - const handleCancel = () => { - setLocalFormData( + const [localFormData, setLocalFormData] = useState( formatFormDataForRender((formData ?? {}) as Record) ); - onCancel?.(); - }; - const handleFormChange = (e: IChangeEvent) => { - setLocalFormData(e.formData ?? {}); - props.onChange && props.onChange(e); - }; + useEffect(() => { + setLocalFormData( + formatFormDataForRender((formData ?? {}) as Record) + ); + }, [formData]); + + const handleCancel = () => { + setLocalFormData( + formatFormDataForRender((formData ?? {}) as Record) + ); + onCancel?.(); + }; - const isSubmitting = status === 'waiting'; + const handleFormChange = (e: IChangeEvent) => { + setLocalFormData(e.formData ?? {}); + props.onChange && props.onChange(e); + }; - const fields = { - LayoutGridField, - }; + const isSubmitting = status === 'waiting'; - const widgets = { - CheckboxWidget: CoreCheckboxWidget, - EmailWidget: CoreInputWidget, - PasswordWidget: CoreInputWidget, - RadioWidget: CoreRadioWidget, - SelectWidget: CoreSelectWidget, - TextWidget: CoreInputWidget, - TextareaWidget: CoreTextAreaWidget, - URLWidget: CoreInputWidget, - UpDownWidget: CoreInputWidget, - }; + const mergedFields = useMemo( + () => ({ + ...defaultFields, + ...(props.fields ?? {}), + }), + [props.fields] + ); - return ( -
- {children} -
- {!hideCancelButton && ( + const mergedWidgets = useMemo( + () => ({ + ...defaultWidgets, + ...(props.widgets ?? {}), + }), + [props.widgets] + ); + + return ( + + {children} +
+ {!hideCancelButton && ( + + )} - )} - -
- - ); -}; +
+ + ); + } +); + +FormBuilderV1.displayName = 'FormBuilderV1'; export default FormBuilderV1; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreArrayField.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreArrayField.tsx new file mode 100644 index 000000000000..7ed90d61337d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreArrayField.tsx @@ -0,0 +1,180 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HintText, Label, Tooltip } from '@openmetadata/ui-core-components'; +import { FieldProps } from '@rjsf/utils'; +import { Copy01, XClose } from '@untitledui/icons'; +import { isEmpty, startCase } from 'lodash'; +import { useCallback, useState } from 'react'; +import { Input as AriaInput } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; +import { useClipboard } from '../../../../hooks/useClipBoard'; +import { splitCSV } from '../../../../utils/CSV/CSV.utils'; + +const CoreArrayField = (props: FieldProps) => { + const { + idSchema, + formData, + onChange, + disabled, + readonly, + schema, + formContext, + onBlur, + rawErrors, + label, + required, + } = props; + + const { t } = useTranslation(); + const id = idSchema.$id; + const fieldName = id.split('/').pop() ?? ''; + const isFilterPattern = /FilterPattern/.test(id); + const value: string[] = formData ?? []; + const [inputValue, setInputValue] = useState(''); + + const { onCopyToClipBoard, onPasteFromClipBoard, hasCopied } = useClipboard( + JSON.stringify(value) + ); + + const isDisabled = disabled || readonly; + const isInvalid = !!rawErrors?.length; + + const handleFocus = useCallback(() => { + let focusId = id; + if (isFilterPattern) { + focusId = id.split('/').slice(0, 2).join('/'); + } + formContext?.handleFocus?.(focusId); + }, [id, isFilterPattern, formContext]); + + const addValues = useCallback( + (newValues: string[]) => { + const filtered = newValues.map((v) => v.trim()).filter(Boolean); + if (isEmpty(filtered)) { + return; + } + onChange(Array.from(new Set([...value, ...filtered]))); + }, + [value, onChange] + ); + + const commitInput = useCallback(() => { + if (inputValue.trim()) { + addValues(splitCSV(inputValue)); + setInputValue(''); + } + }, [inputValue, addValues]); + + const handleKeyDown = useCallback( + async (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitInput(); + } + if ((e.ctrlKey || e.metaKey) && e.key === 'v') { + e.preventDefault(); + const text = await onPasteFromClipBoard(); + if (!text) { + return; + } + let values: string[] = []; + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + values = parsed.map(String); + } + } catch { + values = splitCSV(text); + } + if (!isEmpty(values)) { + addValues(values); + } + } + }, + [commitInput, onPasteFromClipBoard, addValues] + ); + + const placeholder = isFilterPattern + ? t('message.filter-pattern-placeholder') + : ''; + + const fieldLabel = label || schema.title || startCase(fieldName); + + return ( +
+ {fieldLabel && } +
+ {value.map((v, idx) => ( + + {v} + {!isDisabled && ( + + )} + + ))} + {!isDisabled && ( + { + commitInput(); + onBlur(id, value); + }} + onChange={(e) => setInputValue(e.target.value)} + onFocus={handleFocus} + onKeyDown={handleKeyDown} + /> + )} + + + +
+ {isInvalid && rawErrors && {rawErrors[0]}} +
+ ); +}; + +export default CoreArrayField; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreBooleanField.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreBooleanField.tsx new file mode 100644 index 000000000000..f6c70ea68432 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreBooleanField.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Toggle } from '@openmetadata/ui-core-components'; +import { FieldProps } from '@rjsf/utils'; +import { startCase } from 'lodash'; + +const CoreBooleanField = (props: FieldProps) => { + return ( + { + props.formContext?.handleFocus?.(props.idSchema.$id); + props.onChange(value); + }} + /> + ); +}; + +export default CoreBooleanField; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreOneOfField.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreOneOfField.test.tsx new file mode 100644 index 000000000000..0408e2625583 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreOneOfField.test.tsx @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FieldProps } from '@rjsf/utils'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import CoreOneOfField from './CoreOneOfField'; + +jest.mock('@openmetadata/ui-core-components', () => ({ + RadioButton: jest.fn( + ({ + label, + onSelect, + selectedValue, + value, + }: { + label: string; + onSelect?: (value: string) => void; + selectedValue?: string; + value: string; + }) => ( + + ) + ), + RadioGroup: jest.fn( + ({ + children, + onChange, + value, + }: { + children: React.ReactElement[]; + onChange: (value: string) => void; + value: string; + }) => ( +
+ {children.map((child) => + React.cloneElement(child, { + onSelect: onChange, + selectedValue: value, + }) + )} +
+ ) + ), + Typography: jest.fn( + ({ + children, + as: Tag = 'span', + ...props + }: { + children: React.ReactNode; + as?: React.ElementType; + }) => {children} + ), +})); + +const getBaseProps = (formData: Record) => + ({ + formData, + idSchema: { $id: 'root/type' }, + name: 'type', + onBlur: jest.fn(), + onChange: jest.fn(), + onFocus: jest.fn(), + registry: { + fields: { + SchemaField: ({ schema }: { schema: { title?: string } }) => ( +
{schema.title}
+ ), + }, + schemaUtils: { + getClosestMatchingOption: ( + data: Record, + _options: unknown[], + selectedOption: number + ) => (data.type === 'second' ? 1 : selectedOption), + getDefaultFormState: ( + _schema: unknown, + data: Record | undefined + ) => data ?? {}, + retrieveSchema: (schema: unknown) => schema, + sanitizeDataForNewSchema: ( + _newSchema: unknown, + _currentSchema: unknown, + data: Record + ) => data, + toIdSchema: () => ({ $id: 'root/type' }), + }, + }, + schema: { + oneOf: [ + { title: 'First', type: 'object' }, + { title: 'Second', type: 'object' }, + ], + title: 'Type', + }, + } as unknown as FieldProps); + +describe('CoreOneOfField', () => { + it('keeps the selected option when form data matches multiple options', () => { + const formData = {}; + const props = getBaseProps(formData); + const { rerender } = render(); + + fireEvent.click(screen.getByRole('radio', { name: 'Second' })); + + expect(screen.getByRole('radio', { name: 'Second' })).toHaveAttribute( + 'aria-checked', + 'true' + ); + + rerender(); + + expect(screen.getByRole('radio', { name: 'Second' })).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreOneOfField.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreOneOfField.tsx new file mode 100644 index 000000000000..a173c00ddfdd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/fields/CoreOneOfField.tsx @@ -0,0 +1,216 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + RadioButton, + RadioGroup, + Typography, +} from '@openmetadata/ui-core-components'; +import { + FieldProps, + getDiscriminatorFieldFromSchema, + RJSFSchema, +} from '@rjsf/utils'; +import { startCase } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +const CoreOneOfField = (props: FieldProps) => { + const { + schema, + formData, + onChange, + registry, + uiSchema, + idSchema, + errorSchema, + disabled, + readonly, + required, + idPrefix, + idSeparator, + onBlur, + onFocus, + name, + label, + hideLabel, + hideError, + autofocus, + rawErrors, + formContext, + } = props; + + const { schemaUtils, fields } = registry; + const { SchemaField } = fields; + + const options = useMemo( + () => (schema.oneOf ?? schema.anyOf ?? []) as RJSFSchema[], + [schema.oneOf, schema.anyOf] + ); + + const resolvedOptions = useMemo( + () => options.map((opt) => schemaUtils.retrieveSchema(opt, formData)), + [options, formData, schemaUtils] + ); + + const getMatchingOption = useCallback( + (currentOption: number, data: unknown, fieldOptions: RJSFSchema[]) => + schemaUtils.getClosestMatchingOption( + data, + fieldOptions, + currentOption, + getDiscriminatorFieldFromSchema(schema) + ), + [schema, schemaUtils] + ); + + const [selectedOption, setSelectedOption] = useState(() => + getMatchingOption(0, formData, resolvedOptions) + ); + + useEffect(() => { + setSelectedOption((currentOption) => { + const matchingOption = getMatchingOption( + currentOption, + formData, + resolvedOptions + ); + + return matchingOption !== currentOption ? matchingOption : currentOption; + }); + }, [formData, getMatchingOption, resolvedOptions]); + + const selectedSchema = resolvedOptions[selectedOption] ?? {}; + + const selectedIdSchema = useMemo( + () => + schemaUtils.toIdSchema( + selectedSchema, + idSchema.$id, + formData, + idPrefix ?? '', + idSeparator ?? '/' + ), + [selectedSchema, idSchema.$id, formData, idPrefix, idSeparator, schemaUtils] + ); + + const handleOptionChange = (newIndex: number) => { + if (newIndex === selectedOption) { + return; + } + + const newSchema = resolvedOptions[newIndex]; + const currentSchema = + selectedOption >= 0 ? resolvedOptions[selectedOption] : undefined; + const sanitizedFormData = schemaUtils.sanitizeDataForNewSchema( + newSchema, + currentSchema, + formData + ); + const newFormData = newSchema + ? schemaUtils.getDefaultFormState( + newSchema, + sanitizedFormData, + 'excludeObjectChildren' + ) + : sanitizedFormData; + + setSelectedOption(newIndex); + onChange( + newFormData ?? undefined, + undefined, + `${idSchema.$id}${schema.oneOf ? '__oneof_select' : '__anyof_select'}` + ); + }; + + const fieldLabel = label ?? schema.title ?? startCase(name); + + return ( +
+ {!hideLabel && fieldLabel && ( +
+ + {fieldLabel} + + {required && ( + + * + + )} +
+ )} + handleOptionChange(Number(val))}> + {resolvedOptions.map((option, index) => { + const optTitle = + option.title ?? + startCase( + typeof option.type === 'string' + ? option.type + : `Option ${index + 1}` + ); + + return ( + + `tw:flex-1 tw:min-w-[140px] tw:rounded-xl tw:border tw:px-4 tw:py-3 tw:transition-colors ${ + renderProps.isSelected + ? 'tw:border-primary' + : 'tw:border-secondary hover:tw:border-brand-300' + }` + } + key={index} + label={optTitle} + value={String(index)} + /> + ); + })} + + + +
+ ); +}; + +export default CoreOneOfField; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/templates/CoreArrayFieldTemplate.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/templates/CoreArrayFieldTemplate.tsx index e951305dd799..14f3450b7ab0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/templates/CoreArrayFieldTemplate.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilderV1/templates/CoreArrayFieldTemplate.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Button } from '@openmetadata/ui-core-components'; +import { Button, Typography } from '@openmetadata/ui-core-components'; import { ArrayFieldTemplateProps } from '@rjsf/utils'; import { Plus, Trash01 } from '@untitledui/icons'; import { Fragment, FunctionComponent } from 'react'; @@ -24,10 +24,14 @@ export const CoreArrayFieldTemplate: FunctionComponent< return ( -
-