mirror of
				https://github.com/open-metadata/OpenMetadata.git
				synced 2025-10-31 10:39:30 +00:00 
			
		
		
		
	fix: add autocomplete widget for dynamic forms (#14188)
* chore(ui): add DataAssetsAsyncSelectList Component * add style for entity icon * address comments * fix: add search index prop * fix: update async select widget * fix: add unit tests --------- Co-authored-by: Sachin Chaurasiya <sachinchaurasiyachotey87@gmail.com>
This commit is contained in:
		
							parent
							
								
									7fcdf08ca4
								
							
						
					
					
						commit
						71b8b1d5fe
					
				| @ -43,7 +43,6 @@ import { ReactComponent as IconDropdown } from '../../../assets/svg/menu.svg'; | |||||||
| import Loader from '../../../components/Loader/Loader'; | import Loader from '../../../components/Loader/Loader'; | ||||||
| import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; | import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; | ||||||
| import TabsLabel from '../../../components/TabsLabel/TabsLabel.component'; | import TabsLabel from '../../../components/TabsLabel/TabsLabel.component'; | ||||||
| import { APPLICATION_UI_SCHEMA } from '../../../constants/Applications.constant'; |  | ||||||
| import { DE_ACTIVE_COLOR } from '../../../constants/constants'; | import { DE_ACTIVE_COLOR } from '../../../constants/constants'; | ||||||
| import { | import { | ||||||
|   GlobalSettingOptions, |   GlobalSettingOptions, | ||||||
| @ -335,7 +334,6 @@ const AppDetails = () => { | |||||||
|                     serviceCategory={ServiceCategory.DASHBOARD_SERVICES} |                     serviceCategory={ServiceCategory.DASHBOARD_SERVICES} | ||||||
|                     serviceType="" |                     serviceType="" | ||||||
|                     showTestConnection={false} |                     showTestConnection={false} | ||||||
|                     uiSchema={APPLICATION_UI_SCHEMA} |  | ||||||
|                     validator={validator} |                     validator={validator} | ||||||
|                     onCancel={noop} |                     onCancel={noop} | ||||||
|                     onSubmit={onConfigSave} |                     onSubmit={onConfigSave} | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ | |||||||
|  */ |  */ | ||||||
| import { act, fireEvent, render, screen } from '@testing-library/react'; | import { act, fireEvent, render, screen } from '@testing-library/react'; | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
|  | import { SearchIndex } from '../../enums/search.enum'; | ||||||
| import { searchQuery } from '../../rest/searchAPI'; | import { searchQuery } from '../../rest/searchAPI'; | ||||||
| import DataAssetAsyncSelectList from './DataAssetAsyncSelectList'; | import DataAssetAsyncSelectList from './DataAssetAsyncSelectList'; | ||||||
| import { DataAssetOption } from './DataAssetAsyncSelectList.interface'; | import { DataAssetOption } from './DataAssetAsyncSelectList.interface'; | ||||||
| @ -21,6 +22,41 @@ jest.mock('../../utils/TableUtils'); | |||||||
| jest.mock('../../utils/EntityUtils', () => ({ | jest.mock('../../utils/EntityUtils', () => ({ | ||||||
|   getEntityName: jest.fn().mockReturnValue('Test'), |   getEntityName: jest.fn().mockReturnValue('Test'), | ||||||
| })); | })); | ||||||
|  | jest.mock('../common/ProfilePicture/ProfilePicture', () => { | ||||||
|  |   return jest | ||||||
|  |     .fn() | ||||||
|  |     .mockReturnValue(<p data-testid="profile-pic">ProfilePicture</p>); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const mockUserData = { | ||||||
|  |   data: { | ||||||
|  |     hits: { | ||||||
|  |       hits: [ | ||||||
|  |         { | ||||||
|  |           _source: { | ||||||
|  |             id: '4d499590-89ef-438d-9c49-3f05c4041144', | ||||||
|  |             name: 'admin', | ||||||
|  |             fullyQualifiedName: 'admin', | ||||||
|  |             entityType: 'user', | ||||||
|  |             displayName: 'admin', | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           _source: { | ||||||
|  |             id: '93057069-3836-4fc0-b85e-a456e52b4424', | ||||||
|  |             name: 'user1', | ||||||
|  |             fullyQualifiedName: 'user1', | ||||||
|  |             entityType: 'user', | ||||||
|  |             displayName: 'user1', | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |       total: { | ||||||
|  |         value: 2, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| const mockSearchAPIResponse = { | const mockSearchAPIResponse = { | ||||||
|   data: { |   data: { | ||||||
| @ -88,6 +124,28 @@ describe('DataAssetAsyncSelectList', () => { | |||||||
|     expect(searchQuery).toHaveBeenCalledTimes(1); |     expect(searchQuery).toHaveBeenCalledTimes(1); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |   it('should render profile picture if search index is user', async () => { | ||||||
|  |     const mockSearchQuery = searchQuery as jest.Mock; | ||||||
|  |     mockSearchQuery.mockImplementationOnce((params) => { | ||||||
|  |       expect(params).toEqual( | ||||||
|  |         expect.objectContaining({ searchIndex: SearchIndex.USER }) | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       return Promise.resolve(mockUserData.data); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const { container } = render( | ||||||
|  |       <DataAssetAsyncSelectList searchIndex={SearchIndex.USER} /> | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     await act(async () => { | ||||||
|  |       toggleOpen(container); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     expect(searchQuery).toHaveBeenCalledTimes(1); | ||||||
|  |     expect(screen.getAllByTestId('profile-pic')).toHaveLength(2); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|   it('should call onChange when an option is selected', async () => { |   it('should call onChange when an option is selected', async () => { | ||||||
|     const mockOnChange = jest.fn(); |     const mockOnChange = jest.fn(); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ | |||||||
|  *  See the License for the specific language governing permissions and |  *  See the License for the specific language governing permissions and | ||||||
|  *  limitations under the License. |  *  limitations under the License. | ||||||
|  */ |  */ | ||||||
| import { Select, SelectProps } from 'antd'; | import { Select, SelectProps, Space } from 'antd'; | ||||||
| import { AxiosError } from 'axios'; | import { AxiosError } from 'axios'; | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import React, { FC, useCallback, useMemo, useRef, useState } from 'react'; | import React, { FC, useCallback, useMemo, useRef, useState } from 'react'; | ||||||
| @ -22,6 +22,7 @@ import { searchQuery } from '../../rest/searchAPI'; | |||||||
| import { getEntityName } from '../../utils/EntityUtils'; | import { getEntityName } from '../../utils/EntityUtils'; | ||||||
| import { getEntityIcon } from '../../utils/TableUtils'; | import { getEntityIcon } from '../../utils/TableUtils'; | ||||||
| import { showErrorToast } from '../../utils/ToastUtils'; | import { showErrorToast } from '../../utils/ToastUtils'; | ||||||
|  | import ProfilePicture from '../common/ProfilePicture/ProfilePicture'; | ||||||
| import { | import { | ||||||
|   DataAssetAsyncSelectListProps, |   DataAssetAsyncSelectListProps, | ||||||
|   DataAssetOption, |   DataAssetOption, | ||||||
| @ -78,6 +79,7 @@ const DataAssetAsyncSelectList: FC<DataAssetAsyncSelectListProps> = ({ | |||||||
|             type: _source.entityType, |             type: _source.entityType, | ||||||
|           }, |           }, | ||||||
|           displayName: entityName, |           displayName: entityName, | ||||||
|  |           name: _source.name, | ||||||
|         }; |         }; | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
| @ -112,32 +114,41 @@ const DataAssetAsyncSelectList: FC<DataAssetAsyncSelectListProps> = ({ | |||||||
| 
 | 
 | ||||||
|   const optionList = useMemo(() => { |   const optionList = useMemo(() => { | ||||||
|     return options.map((option) => { |     return options.map((option) => { | ||||||
|       const label = ( |       const { value, reference, displayName } = option; | ||||||
|         <div |  | ||||||
|           className="d-flex items-center gap-2" |  | ||||||
|           data-testid={`option-${option.value}`}> |  | ||||||
|           <div className="flex-center data-asset-icon"> |  | ||||||
|             {getEntityIcon(option.reference.type)} |  | ||||||
|           </div> |  | ||||||
|           <div className="d-flex flex-col"> |  | ||||||
|             <span className="text-grey-muted text-xs"> |  | ||||||
|               {option.reference.type} |  | ||||||
|             </span> |  | ||||||
|             <span className="font-medium truncate w-56"> |  | ||||||
|               {option.displayName} |  | ||||||
|             </span> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
| 
 | 
 | ||||||
|       return { |       let label; | ||||||
|         label, |       if (searchIndex === SearchIndex.USER) { | ||||||
|         value: option.value, |         label = ( | ||||||
|         reference: option.reference, |           <Space> | ||||||
|         displayName: option.displayName, |             <ProfilePicture | ||||||
|       }; |               className="d-flex" | ||||||
|  |               id="" | ||||||
|  |               name={option.name ?? ''} | ||||||
|  |               type="circle" | ||||||
|  |               width="24" | ||||||
|  |             /> | ||||||
|  |             <span className="m-l-xs">{getEntityName(option)}</span> | ||||||
|  |           </Space> | ||||||
|  |         ); | ||||||
|  |       } else { | ||||||
|  |         label = ( | ||||||
|  |           <div | ||||||
|  |             className="d-flex items-center gap-2" | ||||||
|  |             data-testid={`option-${value}`}> | ||||||
|  |             <div className="flex-center data-asset-icon"> | ||||||
|  |               {getEntityIcon(reference.type)} | ||||||
|  |             </div> | ||||||
|  |             <div className="d-flex flex-col"> | ||||||
|  |               <span className="text-grey-muted text-xs">{reference.type}</span> | ||||||
|  |               <span className="font-medium truncate w-56">{displayName}</span> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       return { label, value, reference, displayName }; | ||||||
|     }); |     }); | ||||||
|   }, [options]); |   }, [options, searchIndex]); | ||||||
| 
 | 
 | ||||||
|   const debounceFetcher = useMemo( |   const debounceFetcher = useMemo( | ||||||
|     () => debounce(loadOptions, debounceTimeout), |     () => debounce(loadOptions, debounceTimeout), | ||||||
|  | |||||||
| @ -11,51 +11,24 @@ | |||||||
|  *  limitations under the License. |  *  limitations under the License. | ||||||
|  */ |  */ | ||||||
| import { WidgetProps } from '@rjsf/utils'; | import { WidgetProps } from '@rjsf/utils'; | ||||||
| import React, { useCallback } from 'react'; | import React from 'react'; | ||||||
| import { PAGE_SIZE_MEDIUM } from '../../constants/constants'; |  | ||||||
| import { SearchIndex } from '../../enums/search.enum'; | import { SearchIndex } from '../../enums/search.enum'; | ||||||
| import { searchQuery } from '../../rest/searchAPI'; | import DataAssetAsyncSelectList from '../DataAssetAsyncSelectList/DataAssetAsyncSelectList'; | ||||||
| import { getEntityName } from '../../utils/EntityUtils'; | import { DataAssetOption } from '../DataAssetAsyncSelectList/DataAssetAsyncSelectList.interface'; | ||||||
| import { AsyncSelect } from '../AsyncSelect/AsyncSelect'; |  | ||||||
| 
 | 
 | ||||||
| const AsyncSelectWidget = ({ | const AsyncSelectWidget = ({ onChange, schema }: WidgetProps) => { | ||||||
|   onFocus, |   const handleChange = (value: DataAssetOption | DataAssetOption[]) => { | ||||||
|   onBlur, |     const data = value.map((item: DataAssetOption) => item.reference); | ||||||
|   onChange, |     onChange(data); | ||||||
|   schema, |   }; | ||||||
|   ...rest |  | ||||||
| }: WidgetProps) => { |  | ||||||
|   const type = rest?.uiSchema?.['ui:options']?.autoCompleteType as SearchIndex; |  | ||||||
| 
 |  | ||||||
|   const fetchEntities = useCallback(async (searchText: string) => { |  | ||||||
|     try { |  | ||||||
|       const res = await searchQuery({ |  | ||||||
|         pageNumber: 1, |  | ||||||
|         pageSize: PAGE_SIZE_MEDIUM, |  | ||||||
|         searchIndex: type ?? SearchIndex.TABLE, |  | ||||||
|         query: searchText, |  | ||||||
|       }); |  | ||||||
| 
 |  | ||||||
|       return res.hits.hits.map((value) => ({ |  | ||||||
|         label: getEntityName(value._source), |  | ||||||
|         value: value._source.id, |  | ||||||
|       })); |  | ||||||
|     } catch (_) { |  | ||||||
|       return []; |  | ||||||
|     } |  | ||||||
|   }, []); |  | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <AsyncSelect |     <DataAssetAsyncSelectList | ||||||
|       api={fetchEntities} |  | ||||||
|       className="d-block" |  | ||||||
|       data-testid="edit-query-used-in" |  | ||||||
|       defaultValue={schema.value} |       defaultValue={schema.value} | ||||||
|       mode="multiple" |       mode="multiple" | ||||||
|       placeholder={schema.placeholder ?? ''} |       placeholder={schema.placeholder ?? ''} | ||||||
|       onChange={(value) => { |       searchIndex={schema?.autoCompleteType ?? SearchIndex.TABLE} | ||||||
|         onChange(value); |       onChange={handleChange} | ||||||
|       }} |  | ||||||
|     /> |     /> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -12,7 +12,6 @@ | |||||||
|  */ |  */ | ||||||
| import { t } from 'i18next'; | import { t } from 'i18next'; | ||||||
| import { StepperStepType } from 'Models'; | import { StepperStepType } from 'Models'; | ||||||
| import { SearchIndex } from '../enums/search.enum'; |  | ||||||
| 
 | 
 | ||||||
| export const STEPS_FOR_APP_INSTALL: Array<StepperStepType> = [ | export const STEPS_FOR_APP_INSTALL: Array<StepperStepType> = [ | ||||||
|   { |   { | ||||||
| @ -22,19 +21,3 @@ export const STEPS_FOR_APP_INSTALL: Array<StepperStepType> = [ | |||||||
|   { name: t('label.configure'), step: 2 }, |   { name: t('label.configure'), step: 2 }, | ||||||
|   { name: t('label.schedule'), step: 3 }, |   { name: t('label.schedule'), step: 3 }, | ||||||
| ]; | ]; | ||||||
| 
 |  | ||||||
| export const APPLICATION_UI_SCHEMA = { |  | ||||||
|   databases: { |  | ||||||
|     'ui:widget': 'autoComplete', |  | ||||||
|     'ui:options': { |  | ||||||
|       autoCompleteType: SearchIndex.DATABASE, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   owner: { |  | ||||||
|     'ui:widget': 'autoComplete', |  | ||||||
|     'ui:options': { |  | ||||||
|       autoCompleteType: SearchIndex.USER, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   type: { 'ui:widget': 'hidden' }, |  | ||||||
| }; |  | ||||||
|  | |||||||
| @ -25,10 +25,7 @@ import FormBuilder from '../../components/common/FormBuilder/FormBuilder'; | |||||||
| import IngestionStepper from '../../components/IngestionStepper/IngestionStepper.component'; | import IngestionStepper from '../../components/IngestionStepper/IngestionStepper.component'; | ||||||
| import Loader from '../../components/Loader/Loader'; | import Loader from '../../components/Loader/Loader'; | ||||||
| import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; | import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; | ||||||
| import { | import { STEPS_FOR_APP_INSTALL } from '../../constants/Applications.constant'; | ||||||
|   APPLICATION_UI_SCHEMA, |  | ||||||
|   STEPS_FOR_APP_INSTALL, |  | ||||||
| } from '../../constants/Applications.constant'; |  | ||||||
| import { | import { | ||||||
|   GlobalSettingOptions, |   GlobalSettingOptions, | ||||||
|   GlobalSettingsMenuCategory, |   GlobalSettingsMenuCategory, | ||||||
| @ -169,7 +166,6 @@ const AppInstall = () => { | |||||||
|               serviceCategory={ServiceCategory.DASHBOARD_SERVICES} |               serviceCategory={ServiceCategory.DASHBOARD_SERVICES} | ||||||
|               serviceType="" |               serviceType="" | ||||||
|               showTestConnection={false} |               showTestConnection={false} | ||||||
|               uiSchema={APPLICATION_UI_SCHEMA} |  | ||||||
|               validator={validator} |               validator={validator} | ||||||
|               onCancel={() => setActiveServiceStep(1)} |               onCancel={() => setActiveServiceStep(1)} | ||||||
|               onSubmit={onSaveConfiguration} |               onSubmit={onSaveConfiguration} | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 karanh37
						karanh37