Chore(ui): AutoPilot status banner behaviour improvements (#20758)

* Modify the AutoPilot status banner visibility banner to hide once closed until and unless the status changes again

* Add test case for asyncDeleteProvider

* Fix the sonarcloud issues

* Fix the failing playwright tests

* Fix the playwright for delete service
This commit is contained in:
Aniket Katkar 2025-04-11 10:41:11 +05:30 committed by GitHub
parent ccee49f24b
commit fc58893239
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 206 additions and 54 deletions

View File

@ -122,8 +122,22 @@ export const deleteService = async (
// Closing the toast notification
await toastNotification(page, /deleted successfully!/, 5 * 60 * 1000); // Wait for up to 5 minutes for the toast notification to appear
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
const serviceSearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*${encodeURIComponent(
escapeESReservedCharacters(serviceName)
)}*`
);
await page.fill('[data-testid="searchbar"]', serviceName);
await serviceSearchResponse;
await page.waitForSelector(`[data-testid="service-name-${serviceName}"]`, {
state: 'hidden',
state: 'detached',
});
};

View File

@ -172,8 +172,7 @@ const APIEndpointDetails: React.FC<APIEndpointDetailsProps> = ({
);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? onToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -102,7 +102,7 @@ const DashboardDetails = ({
dashboardDetails.id
);
setDashboardPermissions(entityPermission);
} catch (error) {
} catch {
showErrorToast(
t('server.fetch-entity-permissions-error', {
entity: t('label.dashboard'),
@ -203,8 +203,7 @@ const DashboardDetails = ({
};
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -151,8 +151,7 @@ const DataModelDetails = ({
};
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -155,8 +155,7 @@ const MetricDetails: React.FC<MetricDetailsProps> = ({
getFeedCounts(EntityType.METRIC, decodedMetricFqn, handleFeedCount);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? onToggleDelete(version) : history.push(ROUTES.METRICS),
(isSoftDelete?: boolean) => !isSoftDelete && history.push(ROUTES.METRICS),
[]
);

View File

@ -97,7 +97,7 @@ const MlModelDetail: FC<MlModelDetailProp> = ({
mlModelDetail.id
);
setMlModelPermissions(entityPermission);
} catch (error) {
} catch {
showErrorToast(
t('server.fetch-entity-permissions-error', {
entity: t('label.ml-model'),
@ -294,8 +294,7 @@ const MlModelDetail: FC<MlModelDetailProp> = ({
}, [mlModelDetail, mlModelStoreColumn]);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -115,7 +115,7 @@ const PipelineDetails = ({
pipelineDetails.id
);
setPipelinePermissions(entityPermission);
} catch (error) {
} catch {
showErrorToast(
t('server.fetch-entity-permissions-error', {
entity: t('label.asset-lowercase'),
@ -254,8 +254,7 @@ const PipelineDetails = ({
};
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -17,7 +17,7 @@ import { AxiosError } from 'axios';
import classNames from 'classnames';
import { isUndefined } from 'lodash';
import { ServiceTypes } from 'Models';
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { PLATFORM_INSIGHTS_CHART } from '../../constants/ServiceInsightsTab.constants';
import { SystemChartType } from '../../enums/DataInsight.enum';
@ -27,7 +27,9 @@ import {
getCurrentDayStartGMTinMillis,
getDayAgoStartGMTinMillis,
} from '../../utils/date-time/DateTimeUtils';
import { updateAutoPilotStatus } from '../../utils/LocalStorageUtils';
import {
checkIfAutoPilotStatusIsDismissed,
filterDistributionChartItem,
getPlatformInsightsChartDataFormattingMethod,
getStatusIconFromStatusType,
@ -144,9 +146,40 @@ const ServiceInsightsTab = ({
workflowStatesData?.mainInstanceState?.status
);
const showAutoPilotStatus = useMemo(() => {
const isDataPresent =
!isWorkflowStatusLoading && !isUndefined(workflowStatesData);
const isStatusDismissed = checkIfAutoPilotStatusIsDismissed(
serviceDetails.fullyQualifiedName,
workflowStatesData?.mainInstanceState?.status
);
return isDataPresent && !isStatusDismissed;
}, [
isWorkflowStatusLoading,
workflowStatesData,
serviceDetails.fullyQualifiedName,
workflowStatesData?.mainInstanceState?.status,
]);
const onStatusBannerClose = useCallback(() => {
if (
serviceDetails.fullyQualifiedName &&
workflowStatesData?.mainInstanceState?.status
) {
updateAutoPilotStatus({
serviceFQN: serviceDetails.fullyQualifiedName,
status: workflowStatesData?.mainInstanceState?.status,
});
}
}, [
serviceDetails.fullyQualifiedName,
workflowStatesData?.mainInstanceState?.status,
]);
return (
<Row className="service-insights-tab" gutter={[16, 16]}>
{!isWorkflowStatusLoading && !isUndefined(workflowStatesData) && (
{showAutoPilotStatus && (
<Alert
closable
showIcon
@ -163,6 +196,7 @@ const ServiceInsightsTab = ({
</div>
}
message={message}
onClose={onStatusBannerClose}
/>
)}
{arrayOfWidgets.map(

View File

@ -241,8 +241,7 @@ const TopicDetails: React.FC<TopicDetailsProps> = ({
getFeedCounts(EntityType.TOPIC, decodedTopicFQN, handleFeedCount);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -218,6 +218,7 @@ const DeleteWidgetModal = ({
deleteType: values.deleteType,
prepareType,
isRecursiveDelete: isRecursiveDelete ?? false,
afterDeleteAction,
});
setIsLoading(false);
handleOnEntityDeleteCancel();
@ -234,6 +235,7 @@ const DeleteWidgetModal = ({
isRecursiveDelete,
handleOnEntityDeleteConfirm,
handleOnEntityDeleteCancel,
afterDeleteAction,
]
);

View File

@ -0,0 +1,14 @@
/*
* 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.
*/
export const LOCAL_STORAGE_AUTO_PILOT_STATUS =
'serviceAutoPilotDismissedStatuses';

View File

@ -24,6 +24,7 @@ export interface DeleteWidgetAsyncFormFields {
deleteType: DeleteType;
prepareType: boolean;
isRecursiveDelete: boolean;
afterDeleteAction?: (isSoftDelete?: boolean, version?: number) => void;
}
export interface AsyncDeleteContextType {

View File

@ -30,6 +30,8 @@ jest.mock('../../rest/miscAPI', () => ({
deleteAsyncEntity: jest.fn().mockImplementation(() => Promise.resolve()),
}));
const mockAfterDeleteAction = jest.fn();
describe('AsyncDeleteProvider', () => {
const mockResponse = {
entityName: 'DELETE',
@ -95,6 +97,7 @@ describe('AsyncDeleteProvider', () => {
mockError,
'server.delete-entity-error'
);
expect(mockAfterDeleteAction).not.toHaveBeenCalled();
});
it('should handle websocket response', async () => {
@ -158,4 +161,18 @@ describe('AsyncDeleteProvider', () => {
false
);
});
it('should execute afterDeleteAction if present', async () => {
(deleteAsyncEntity as jest.Mock).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useAsyncDeleteProvider(), { wrapper });
await act(async () => {
await result.current.handleOnAsyncEntityDeleteConfirm({
...mockDeleteParams,
afterDeleteAction: mockAfterDeleteAction,
});
});
expect(mockAfterDeleteAction).toHaveBeenCalledWith(true);
});
});

View File

@ -46,6 +46,7 @@ const AsyncDeleteProvider = ({ children }: AsyncDeleteProviderProps) => {
deleteType,
prepareType,
isRecursiveDelete,
afterDeleteAction,
}: DeleteWidgetAsyncFormFields) => {
try {
const response = await deleteAsyncEntity(
@ -73,6 +74,9 @@ const AsyncDeleteProvider = ({ children }: AsyncDeleteProviderProps) => {
setAsyncDeleteJob(response);
asyncDeleteJobRef.current = response;
showSuccessToast(response.message);
if (afterDeleteAction) {
afterDeleteAction(deleteType === DeleteType.SOFT_DELETE);
}
} catch (error) {
showErrorToast(
error as AxiosError,

View File

@ -329,8 +329,7 @@ const APICollectionPage: FunctionComponent = () => {
}, [currentVersion, decodedAPICollectionFQN]);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -160,7 +160,7 @@ const ContainerPage = () => {
await fetchContainerDetail(containerFQN);
getEntityFeedCount();
}
} catch (error) {
} catch {
showErrorToast(
t('server.fetch-entity-permissions-error', {
entity: t('label.asset-lowercase'),
@ -364,8 +364,7 @@ const ContainerPage = () => {
};
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -139,7 +139,7 @@ const DatabaseDetails: FunctionComponent = () => {
decodedDatabaseFQN
);
setDatabasePermission(response);
} catch (error) {
} catch {
// Error
} finally {
setIsLoading(false);
@ -368,8 +368,7 @@ const DatabaseDetails: FunctionComponent = () => {
);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -332,8 +332,7 @@ const DatabaseSchemaPage: FunctionComponent = () => {
}, [currentVersion, decodedDatabaseSchemaFQN]);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -108,7 +108,7 @@ function SearchIndexDetailsPage() {
timestamp: 0,
id: details.id,
});
} catch (error) {
} catch {
// Error here
} finally {
setLoading(false);
@ -482,8 +482,7 @@ function SearchIndexDetailsPage() {
}, [version]);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -106,6 +106,7 @@ import {
} from '../../utils/date-time/DateTimeUtils';
import entityUtilClassBase from '../../utils/EntityUtilClassBase';
import { getEntityFeedLink, getEntityName } from '../../utils/EntityUtils';
import { removeAutoPilotStatus } from '../../utils/LocalStorageUtils';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import {
getEditConnectionPath,
@ -961,16 +962,18 @@ const ServiceDetailsPage: FunctionComponent = () => {
}, []);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete
? handleToggleDelete(version)
: history.push(
getSettingPath(
GlobalSettingsMenuCategory.SERVICES,
getServiceRouteFromServiceType(serviceCategory)
)
),
[handleToggleDelete, serviceCategory]
(isSoftDelete?: boolean) => {
if (!isSoftDelete) {
removeAutoPilotStatus(serviceDetails.fullyQualifiedName ?? '');
history.push(
getSettingPath(
GlobalSettingsMenuCategory.SERVICES,
getServiceRouteFromServiceType(serviceCategory)
)
);
}
},
[serviceCategory, serviceDetails.fullyQualifiedName]
);
const handleRestoreService = useCallback(async () => {
@ -1342,6 +1345,11 @@ const ServiceDetailsPage: FunctionComponent = () => {
isWorkflowStatusLoading,
]);
const afterAutoPilotAppTrigger = useCallback(() => {
removeAutoPilotStatus(serviceDetails.fullyQualifiedName ?? '');
fetchWorkflowInstanceStates();
}, [serviceDetails.fullyQualifiedName, fetchWorkflowInstanceStates]);
if (isLoading) {
return <Loader />;
}
@ -1367,7 +1375,7 @@ const ServiceDetailsPage: FunctionComponent = () => {
isRecursiveDelete
afterDeleteAction={afterDeleteAction}
afterDomainUpdateAction={afterDomainUpdateAction}
afterTriggerAction={fetchWorkflowInstanceStates}
afterTriggerAction={afterAutoPilotAppTrigger}
dataAsset={serviceDetails}
disableRunAgentsButton={disableRunAgentsButton}
entityType={entityType}

View File

@ -130,7 +130,7 @@ const StoredProcedurePage = () => {
);
setStoredProcedurePermissions(permission);
} catch (error) {
} catch {
showErrorToast(
t('server.fetch-entity-permissions-error', {
entity: t('label.resource-permission-lowercase'),
@ -358,8 +358,7 @@ const StoredProcedurePage = () => {
);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);
@ -445,7 +444,7 @@ const StoredProcedurePage = () => {
const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs);
const tabs = getStoredProcedureDetailsPageTabs({
activeTab: activeTab as EntityTabs,
activeTab,
feedCount,
decodedStoredProcedureFQN,
entityName,

View File

@ -228,7 +228,7 @@ const TableDetailsPageV1: React.FC = () => {
data.nodes?.filter((node) => node?.fullyQualifiedName !== tableFqn) ??
[];
setDqFailureCount(updatedNodes.length);
} catch (error) {
} catch {
setDqFailureCount(0);
}
};
@ -259,7 +259,7 @@ const TableDetailsPageV1: React.FC = () => {
} else {
setDqFailureCount(failureCount);
}
} catch (error) {
} catch {
setTestCaseSummary(undefined);
}
};
@ -274,7 +274,7 @@ const TableDetailsPageV1: React.FC = () => {
entityId: tableDetails.id,
});
setQueryCount(response.paging.total);
} catch (error) {
} catch {
setQueryCount(0);
}
};
@ -324,7 +324,7 @@ const TableDetailsPageV1: React.FC = () => {
);
setTablePermissions(tablePermission);
} catch (error) {
} catch {
showErrorToast(
t('server.fetch-entity-permissions-error', {
entity: t('label.resource-permission-lowercase'),
@ -664,8 +664,7 @@ const TableDetailsPageV1: React.FC = () => {
}, [version, tableFqn]);
const afterDeleteAction = useCallback(
(isSoftDelete?: boolean, version?: number) =>
isSoftDelete ? handleToggleDelete(version) : history.push('/'),
(isSoftDelete?: boolean) => !isSoftDelete && history.push('/'),
[]
);

View File

@ -0,0 +1,18 @@
/*
* 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 { WorkflowStatus } from '../generated/governance/workflows/workflowInstance';
export interface AutoPilotStatus {
serviceFQN: string;
status: WorkflowStatus;
}

View File

@ -10,7 +10,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LOCAL_STORAGE_AUTO_PILOT_STATUS } from '../constants/LocalStorage.constants';
import { OM_SESSION_KEY } from '../hooks/useApplicationStore';
import { AutoPilotStatus } from './LocalStorageUtils.interface';
export const getOidcToken = (): string => {
return (
@ -38,3 +40,38 @@ export const setRefreshToken = (token: string) => {
session.refreshTokenKey = token;
localStorage.setItem(OM_SESSION_KEY, JSON.stringify(session));
};
export const getAutoPilotStatuses = (): Array<AutoPilotStatus> => {
return JSON.parse(
localStorage.getItem(LOCAL_STORAGE_AUTO_PILOT_STATUS) ?? '[]'
);
};
export const updateAutoPilotStatus = (workflowStatus: AutoPilotStatus) => {
const currentStatuses = getAutoPilotStatuses();
// Remove the status if it already exists for the serviceFQN
const filteredStatuses = currentStatuses.filter(
(status) => status.serviceFQN !== workflowStatus.serviceFQN
);
// Add the new status
const updatedStatuses: Array<AutoPilotStatus> = [
...filteredStatuses,
workflowStatus,
];
localStorage.setItem(
LOCAL_STORAGE_AUTO_PILOT_STATUS,
JSON.stringify(updatedStatuses)
);
};
export const removeAutoPilotStatus = (serviceFQN: string) => {
const currentStatuses = getAutoPilotStatuses();
const filteredStatuses = currentStatuses.filter(
(status) => status.serviceFQN !== serviceFQN
);
localStorage.setItem(
LOCAL_STORAGE_AUTO_PILOT_STATUS,
JSON.stringify(filteredStatuses)
);
};

View File

@ -36,6 +36,7 @@ import i18n from '../utils/i18next/LocalUtil';
import { Transi18next } from './CommonUtils';
import documentationLinksClassBase from './DocumentationLinksClassBase';
import Fqn from './Fqn';
import { getAutoPilotStatuses } from './LocalStorageUtils';
const { t } = i18n;
@ -296,3 +297,19 @@ export const filterDistributionChartItem = (item: {
return toLower(tag_name) === toLower(item.group);
};
export const checkIfAutoPilotStatusIsDismissed = (
serviceFQN?: string,
workflowStatus?: WorkflowStatus
) => {
if (!serviceFQN || !workflowStatus) {
return false;
}
const autoPilotStatuses = getAutoPilotStatuses();
return autoPilotStatuses.some(
(status) =>
status.serviceFQN === serviceFQN && status.status === workflowStatus
);
};