Improvements: Automator requirements oss (#19128)

* Fix the ingestion success screen text overflow issue

* Fix the icon alignment issue in the in the entity header

* Fix the permissions API calls for the ingestion pipelines to use fqn instead of names

* Introduce the moreActionButtonProps in the PipelineActions component

* Add the "Database" field in the advanced search for the "Database Schema" asset.
Change the fields listing behavior in the advanced search modal, to show the common fields for the multiple assets instead of union of the fields.
Change the search index for the "Name" and "Display Name" field suggestions to use the selected data assets instead of the all "Data Assets" index.

* Remove the unnecessary ellipsis prop

* Scan and add the missing filters for all the asset to have hierarchical parent filters.

* Fix unit test

* Fix the advanced search filter field search
This commit is contained in:
Aniket Katkar 2025-01-07 08:57:15 +05:30
parent 678160fbcc
commit 6b230f3440
15 changed files with 329 additions and 30 deletions

View File

@ -55,7 +55,7 @@ const EntityHeaderTitle = ({
data-testid={`${serviceName}-${name}`}
gutter={12}
wrap={false}>
{icon && <Col>{icon}</Col>}
{icon && <Col className="flex-center">{icon}</Col>}
<Col
className={
deleted || badge ? 'w-max-full-140' : 'entity-header-content'

View File

@ -15,11 +15,18 @@ import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { mockIngestionListTableProps } from '../../../../../mocks/IngestionListTable.mock';
import { usePermissionProvider } from '../../../../../context/PermissionProvider/PermissionProvider';
import { mockIngestionData } from '../../../../../mocks/Ingestion.mock';
import {
mockESIngestionData,
mockIngestionListTableProps,
} from '../../../../../mocks/IngestionListTable.mock';
import { ENTITY_PERMISSIONS } from '../../../../../mocks/Permissions.mock';
import { deleteIngestionPipelineById } from '../../../../../rest/ingestionPipelineAPI';
import IngestionListTable from './IngestionListTable';
const mockGetEntityPermissionByFqn = jest.fn();
jest.mock('../../../../../hooks/useApplicationStore', () => ({
useApplicationStore: jest.fn(() => ({
theme: { primaryColor: '#fff' },
@ -260,4 +267,33 @@ describe('Ingestion', () => {
expect(deleteIngestionPipelineById).toHaveBeenCalledWith('id');
});
it('should fetch the permissions for all the ingestion pipelines', async () => {
(usePermissionProvider as jest.Mock).mockImplementation(() => ({
getEntityPermissionByFqn: mockGetEntityPermissionByFqn,
}));
await act(async () => {
render(
<IngestionListTable
{...mockIngestionListTableProps}
ingestionData={[mockESIngestionData, mockIngestionData]}
/>,
{
wrapper: MemoryRouter,
}
);
});
expect(mockGetEntityPermissionByFqn).toHaveBeenNthCalledWith(
1,
'ingestionPipeline',
mockESIngestionData.fullyQualifiedName
);
expect(mockGetEntityPermissionByFqn).toHaveBeenNthCalledWith(
2,
'ingestionPipeline',
mockIngestionData.fullyQualifiedName
);
});
});

View File

@ -157,7 +157,10 @@ function IngestionListTable({
const fetchIngestionPipelinesPermission = useCallback(async () => {
try {
const promises = ingestionData.map((item) =>
getEntityPermissionByFqn(ResourceEntity.INGESTION_PIPELINE, item.name)
getEntityPermissionByFqn(
ResourceEntity.INGESTION_PIPELINE,
item.fullyQualifiedName ?? ''
)
);
const response = await Promise.allSettled(promises);

View File

@ -11,10 +11,11 @@
* limitations under the License.
*/
import { IngestionServicePermission } from '../../../../context/PermissionProvider/PermissionProvider.interface';
import { ServiceCategory } from '../../../../enums/service.enum';
import { IngestionPipeline } from '../../../../generated/entity/services/ingestionPipelines/ingestionPipeline';
import { SelectedRowDetails } from './ingestion.interface';
import { ButtonProps } from 'antd';
import { IngestionServicePermission } from '../../../../../../context/PermissionProvider/PermissionProvider.interface';
import { ServiceCategory } from '../../../../../../enums/service.enum';
import { IngestionPipeline } from '../../../../../../generated/entity/services/ingestionPipelines/ingestionPipeline';
import { SelectedRowDetails } from '../../ingestion.interface';
export interface PipelineActionsProps {
pipeline: IngestionPipeline;
@ -28,4 +29,5 @@ export interface PipelineActionsProps {
handleEnableDisableIngestion?: (id: string) => Promise<void>;
handleIsConfirmationModalOpen: (value: boolean) => void;
onIngestionWorkflowsUpdate?: () => void;
moreActionButtonProps?: ButtonProps;
}

View File

@ -22,8 +22,8 @@ import { Operation } from '../../../../../../generated/entity/policies/accessCon
import { PipelineType } from '../../../../../../generated/entity/services/ingestionPipelines/ingestionPipeline';
import { getLoadingStatus } from '../../../../../../utils/CommonUtils';
import { getLogsViewerPath } from '../../../../../../utils/RouterUtils';
import { PipelineActionsProps } from '../../PipelineActions.interface';
import './pipeline-actions.less';
import { PipelineActionsProps } from './PipelineActions.interface';
import PipelineActionsDropdown from './PipelineActionsDropdown';
function PipelineActions({
@ -38,6 +38,7 @@ function PipelineActions({
handleIsConfirmationModalOpen,
onIngestionWorkflowsUpdate,
handleEditClick,
moreActionButtonProps,
}: Readonly<PipelineActionsProps>) {
const history = useHistory();
const { t } = useTranslation();
@ -168,6 +169,7 @@ function PipelineActions({
handleIsConfirmationModalOpen={handleIsConfirmationModalOpen}
ingestion={pipeline}
ingestionPipelinePermissions={ingestionPipelinePermissions}
moreActionButtonProps={moreActionButtonProps}
serviceCategory={serviceCategory}
serviceName={serviceName}
triggerIngestion={triggerIngestion}

View File

@ -11,6 +11,7 @@
* limitations under the License.
*/
import { ButtonProps } from 'antd';
import { IngestionServicePermission } from '../../../../../../context/PermissionProvider/PermissionProvider.interface';
import { IngestionPipeline } from '../../../../../../generated/entity/services/ingestionPipelines/ingestionPipeline';
import { SelectedRowDetails } from '../../ingestion.interface';
@ -26,4 +27,5 @@ export interface PipelineActionsDropdownProps {
handleDeleteSelection: (row: SelectedRowDetails) => void;
handleIsConfirmationModalOpen: (value: boolean) => void;
onIngestionWorkflowsUpdate?: () => void;
moreActionButtonProps?: ButtonProps;
}

View File

@ -324,4 +324,26 @@ describe('PipelineActionsDropdown', () => {
expect(screen.queryByText('KillIngestionPipelineModal')).toBeNull();
});
it('should pass the moreActionButtonProps to the more action button', async () => {
const mockOnClick = jest.fn();
await act(async () => {
render(
<PipelineActionsDropdown
{...mockPipelineActionsDropdownProps}
moreActionButtonProps={{
onClick: mockOnClick,
}}
/>,
{
wrapper: MemoryRouter,
}
);
});
await clickOnMoreActions();
expect(mockOnClick).toHaveBeenCalled();
});
});

View File

@ -50,6 +50,7 @@ function PipelineActionsDropdown({
handleIsConfirmationModalOpen,
onIngestionWorkflowsUpdate,
ingestionPipelinePermissions,
moreActionButtonProps,
}: Readonly<PipelineActionsDropdownProps>) {
const history = useHistory();
const { t } = useTranslation();
@ -270,6 +271,7 @@ function PipelineActionsDropdown({
icon={<MoreIcon />}
type="link"
onClick={() => setIsOpen((value) => !value)}
{...moreActionButtonProps}
/>
</Dropdown>
{isKillModalOpen && selectedPipeline && id === selectedPipeline?.id && (

View File

@ -101,10 +101,7 @@ const SuccessScreen = ({
<Card>
<Space>
<IconSuccessBadge data-testid="success-icon" width="20px" />
<Typography.Paragraph
className="m-b-0"
data-testid="success-line"
ellipsis={{ rows: 3 }}>
<Typography.Paragraph className="m-b-0" data-testid="success-line">
{isUndefined(successMessage) ? (
<span>
<span className="m-r-xss font-semibold">

View File

@ -351,3 +351,9 @@ export const EXPLORE_ROOT_INDEX_MAPPING = {
],
Governance: [SearchIndex.GLOSSARY_TERM],
};
export const SEARCH_INDICES_WITH_COLUMNS_FIELD = [
SearchIndex.TABLE,
SearchIndex.DASHBOARD_DATA_MODEL,
SearchIndex.DATA_ASSET,
SearchIndex.ALL,
];

View File

@ -62,6 +62,7 @@ export enum EntityFields {
DATABASE_DISPLAY_NAME = 'database.displayName',
DATABASE_SCHEMA_DISPLAY_NAME = 'databaseSchema.displayName',
COLUMN = 'columns.name.keyword',
API_COLLECTION = 'apiCollection.displayName.keyword',
CHART = 'charts.displayName.keyword',
TASK = 'tasks.displayName.keyword',
GLOSSARY_TERM_STATUS = 'status',

View File

@ -13,8 +13,8 @@
import { AddIngestionButtonProps } from '../components/Settings/Services/Ingestion/AddIngestionButton.interface';
import { IngestionListTableProps } from '../components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.interface';
import { PipelineActionsProps } from '../components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface';
import { PipelineActionsDropdownProps } from '../components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface';
import { PipelineActionsProps } from '../components/Settings/Services/Ingestion/PipelineActions.interface';
import { ServiceCategory } from '../enums/service.enum';
import { DatabaseServiceType } from '../generated/entity/data/database';
import { ConfigType } from '../generated/entity/services/databaseService';

View File

@ -11,7 +11,10 @@
* limitations under the License.
*/
import { EntityFields } from '../enums/AdvancedSearch.enum';
import { AdvancedSearchClassBase } from './AdvancedSearchClassBase';
import { SearchIndex } from '../enums/search.enum';
import advancedSearchClassBase, {
AdvancedSearchClassBase,
} from './AdvancedSearchClassBase';
jest.mock('../rest/miscAPI', () => ({
getAggregateFieldOptions: jest.fn().mockImplementation(() =>
@ -50,3 +53,135 @@ describe('AdvancedSearchClassBase', () => {
]);
});
});
describe('getEntitySpecificQueryBuilderFields', () => {
it('should return table specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.TABLE,
]);
expect(Object.keys(result)).toEqual([
EntityFields.DATABASE,
EntityFields.DATABASE_SCHEMA,
EntityFields.TABLE_TYPE,
EntityFields.COLUMN_DESCRIPTION_STATUS,
]);
});
it('should return pipeline specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.PIPELINE,
]);
expect(Object.keys(result)).toEqual([EntityFields.TASK]);
});
it('should return dashboard specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.DASHBOARD,
]);
expect(Object.keys(result)).toEqual([
EntityFields.DATA_MODEL,
EntityFields.CHART,
EntityFields.PROJECT,
]);
});
it('should return topic specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.TOPIC,
]);
expect(Object.keys(result)).toEqual([EntityFields.SCHEMA_FIELD]);
});
it('should return mlModel specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.MLMODEL,
]);
expect(Object.keys(result)).toEqual([EntityFields.FEATURE]);
});
it('should return container specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.CONTAINER,
]);
expect(Object.keys(result)).toEqual([EntityFields.CONTAINER_COLUMN]);
});
it('should return searchIndex specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.SEARCH_INDEX,
]);
expect(Object.keys(result)).toEqual([EntityFields.FIELD]);
});
it('should return dataModel specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.DASHBOARD_DATA_MODEL,
]);
expect(Object.keys(result)).toEqual([
EntityFields.DATA_MODEL_TYPE,
EntityFields.PROJECT,
]);
});
it('should return apiEndpoint specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.API_ENDPOINT_INDEX,
]);
expect(Object.keys(result)).toEqual([
EntityFields.API_COLLECTION,
EntityFields.REQUEST_SCHEMA_FIELD,
EntityFields.RESPONSE_SCHEMA_FIELD,
]);
});
it('should return glossary specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.GLOSSARY_TERM,
]);
expect(Object.keys(result)).toEqual([EntityFields.GLOSSARY_TERM_STATUS]);
});
it('should return databaseSchema specific fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.DATABASE_SCHEMA,
]);
expect(Object.keys(result)).toEqual([EntityFields.DATABASE]);
});
it('should return empty fields for multiple indices with no common fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.TABLE,
SearchIndex.PIPELINE,
]);
expect(Object.keys(result)).toEqual([]);
});
it('should return combined fields for multiple indices with common fields', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
SearchIndex.TABLE,
SearchIndex.DATABASE_SCHEMA,
]);
expect(Object.keys(result)).toEqual([EntityFields.DATABASE]);
});
it('should return empty object for unknown index', () => {
const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([
'UNKNOWN_INDEX' as SearchIndex,
]);
expect(Object.keys(result)).toEqual([]);
});
});

View File

@ -22,6 +22,7 @@ import {
SelectFieldSettings,
} from 'react-awesome-query-builder';
import AntdConfig from 'react-awesome-query-builder/lib/config/antd';
import { SEARCH_INDICES_WITH_COLUMNS_FIELD } from '../constants/AdvancedSearch.constants';
import { EntityFields, SuggestionField } from '../enums/AdvancedSearch.enum';
import { SearchIndex } from '../enums/search.enum';
import { getAggregateFieldOptions } from '../rest/miscAPI';
@ -143,6 +144,24 @@ class AdvancedSearchClassBase {
};
};
/**
* Fields specific to database schema
*/
databaseSchemaQueryBuilderFields: Fields = {
[EntityFields.DATABASE]: {
label: t('label.database'),
type: 'select',
mainWidgetProps: this.mainWidgetProps,
fieldSettings: {
asyncFetch: this.autocomplete({
searchIndex: SearchIndex.DATABASE_SCHEMA,
entityField: EntityFields.DATABASE,
}),
useAsyncSearch: true,
},
},
};
/**
* Fields specific to tables
*/
@ -206,6 +225,37 @@ class AdvancedSearchClassBase {
},
};
/**
* Fields specific to stored procedures
*/
storedProcedureQueryBuilderFields: Fields = {
[EntityFields.DATABASE]: {
label: t('label.database'),
type: 'select',
mainWidgetProps: this.mainWidgetProps,
fieldSettings: {
asyncFetch: this.autocomplete({
searchIndex: SearchIndex.STORED_PROCEDURE,
entityField: EntityFields.DATABASE,
}),
useAsyncSearch: true,
},
},
[EntityFields.DATABASE_SCHEMA]: {
label: t('label.database-schema'),
type: 'select',
mainWidgetProps: this.mainWidgetProps,
fieldSettings: {
asyncFetch: this.autocomplete({
searchIndex: SearchIndex.STORED_PROCEDURE,
entityField: EntityFields.DATABASE_SCHEMA,
}),
useAsyncSearch: true,
},
},
};
/**
* Fields specific to pipelines
*/
@ -246,6 +296,18 @@ class AdvancedSearchClassBase {
* Fields specific to API endpoints
*/
apiEndpointQueryBuilderFields: Fields = {
[EntityFields.API_COLLECTION]: {
label: t('label.api-collection'),
type: 'select',
mainWidgetProps: this.mainWidgetProps,
fieldSettings: {
asyncFetch: this.autocomplete({
searchIndex: SearchIndex.API_ENDPOINT_INDEX,
entityField: EntityFields.API_COLLECTION,
}),
useAsyncSearch: true,
},
},
[EntityFields.REQUEST_SCHEMA_FIELD]: {
label: t('label.request-schema-field'),
type: 'select',
@ -438,6 +500,17 @@ class AdvancedSearchClassBase {
renderButton: isExplorePage
? renderAdvanceSearchButtons
: renderQueryBuilderFilterButtons,
customFieldSelectProps: {
...this.baseConfig.settings.customFieldSelectProps,
// Adding filterOption to search by label
// Since the default search behavior is by value which gives incorrect results
// Ex. for search term 'name', it will return 'Task' in results as well
// since value for 'Task' is 'tasks.displayName.keyword'
filterOption: (input: string, option: { label: string }) => {
return option.label.toLowerCase().includes(input.toLowerCase());
},
},
},
};
@ -477,7 +550,7 @@ class AdvancedSearchClassBase {
mainWidgetProps: this.mainWidgetProps,
fieldSettings: {
asyncFetch: this.autocomplete({
searchIndex: SearchIndex.DATA_ASSET,
searchIndex: entitySearchIndex,
entityField: EntityFields.DISPLAY_NAME_KEYWORD,
}),
useAsyncSearch: true,
@ -498,7 +571,7 @@ class AdvancedSearchClassBase {
mainWidgetProps: this.mainWidgetProps,
fieldSettings: {
asyncFetch: this.autocomplete({
searchIndex: SearchIndex.DATA_ASSET,
searchIndex: entitySearchIndex,
entityField: EntityFields.NAME_KEYWORD,
}),
useAsyncSearch: true,
@ -627,18 +700,14 @@ class AdvancedSearchClassBase {
}
// Since the column field key 'columns.name.keyword` is common in table and data model,
// Following function is used to get the column field config based on the search index
// or if it is an explore page
// Following function is used to get the column field config if all the search Indices have columns field
// or for ALL and DATA_ASSET search indices
public getColumnConfig = (entitySearchIndex: SearchIndex[]) => {
const searchIndexWithColumns = entitySearchIndex.filter(
(index) =>
index === SearchIndex.TABLE ||
index === SearchIndex.DASHBOARD_DATA_MODEL ||
index === SearchIndex.DATA_ASSET ||
index === SearchIndex.ALL
const shouldAddColumnField = entitySearchIndex.every((index) =>
SEARCH_INDICES_WITH_COLUMNS_FIELD.includes(index)
);
return !isEmpty(searchIndexWithColumns)
return shouldAddColumnField
? {
[EntityFields.COLUMN]: {
label: t('label.column'),
@ -646,7 +715,7 @@ class AdvancedSearchClassBase {
mainWidgetProps: this.mainWidgetProps,
fieldSettings: {
asyncFetch: this.autocomplete({
searchIndex: searchIndexWithColumns,
searchIndex: entitySearchIndex,
entityField: EntityFields.COLUMN,
}),
useAsyncSearch: true,
@ -674,6 +743,8 @@ class AdvancedSearchClassBase {
[SearchIndex.DASHBOARD_DATA_MODEL]: this.dataModelQueryBuilderFields,
[SearchIndex.API_ENDPOINT_INDEX]: this.apiEndpointQueryBuilderFields,
[SearchIndex.GLOSSARY_TERM]: this.glossaryQueryBuilderFields,
[SearchIndex.DATABASE_SCHEMA]: this.databaseSchemaQueryBuilderFields,
[SearchIndex.STORED_PROCEDURE]: this.storedProcedureQueryBuilderFields,
[SearchIndex.ALL]: {
...this.tableQueryBuilderFields,
...this.pipelineQueryBuilderFields,
@ -699,9 +770,29 @@ class AdvancedSearchClassBase {
},
};
entitySearchIndex.forEach((index) => {
configs = { ...configs, ...(configIndexMapping[index] ?? {}) };
});
// Find out the common fields between the selected indices
if (!isEmpty(entitySearchIndex)) {
const firstIndex = entitySearchIndex[0];
// Fields config for the first index
configs = { ...configIndexMapping[firstIndex] };
// Iterate over the rest of the indices to see the common fields
entitySearchIndex.slice(1).forEach((index) => {
// Get the current config for the current iteration index
const currentConfig = configIndexMapping[index] ?? {};
// Filter out the fields that are not common between the current and previous configs
configs = Object.keys(configs).reduce((acc, key) => {
// If the key exists in the current config, add it to the accumulator
if (currentConfig[key]) {
acc[key] = configs[key];
}
return acc;
}, {} as Fields);
});
}
return configs;
}

View File

@ -359,7 +359,7 @@ export const getSuccessMessage = (
return (
<Typography.Text>
<Typography.Text className="font-medium">{`"${ingestionName}"`}</Typography.Text>
<Typography.Text className="font-medium break-word">{`"${ingestionName}"`}</Typography.Text>
<Typography.Text>
{status === FormSubmitType.ADD ? createMessage : updateMessage}
</Typography.Text>