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:
karanh37 2023-12-01 11:33:58 +05:30 committed by GitHub
parent 7fcdf08ca4
commit 71b8b1d5fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 106 additions and 87 deletions

View File

@ -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}

View File

@ -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();

View File

@ -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;
let label;
if (searchIndex === SearchIndex.USER) {
label = (
<Space>
<ProfilePicture
className="d-flex"
id=""
name={option.name ?? ''}
type="circle"
width="24"
/>
<span className="m-l-xs">{getEntityName(option)}</span>
</Space>
);
} else {
label = (
<div <div
className="d-flex items-center gap-2" className="d-flex items-center gap-2"
data-testid={`option-${option.value}`}> data-testid={`option-${value}`}>
<div className="flex-center data-asset-icon"> <div className="flex-center data-asset-icon">
{getEntityIcon(option.reference.type)} {getEntityIcon(reference.type)}
</div> </div>
<div className="d-flex flex-col"> <div className="d-flex flex-col">
<span className="text-grey-muted text-xs"> <span className="text-grey-muted text-xs">{reference.type}</span>
{option.reference.type} <span className="font-medium truncate w-56">{displayName}</span>
</span>
<span className="font-medium truncate w-56">
{option.displayName}
</span>
</div> </div>
</div> </div>
); );
}
return { return { label, value, reference, displayName };
label,
value: option.value,
reference: option.reference,
displayName: option.displayName,
};
}); });
}, [options]); }, [options, searchIndex]);
const debounceFetcher = useMemo( const debounceFetcher = useMemo(
() => debounce(loadOptions, debounceTimeout), () => debounce(loadOptions, debounceTimeout),

View File

@ -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}
}}
/> />
); );
}; };

View File

@ -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' },
};

View File

@ -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}