Fix : database and shcema page followers (#21148)

* database and shcema page followers

* follow unffolow database

* fixed tests

* fixed test

* fixed comments

* removed playwright file

* cleanup code

* cleanup code

* fixed sonar
This commit is contained in:
Dhruv Parmar 2025-05-15 10:54:01 +05:30 committed by Pranita
parent 54d40535cf
commit 97c8cc06ab
10 changed files with 334 additions and 26 deletions

View File

@ -10,6 +10,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityTypeEndpoint } from '../support/entity/Entity.interface';
import { uuid } from '../utils/common';
import { GlobalSettingOptions, ServiceTypes } from './settings';
@ -25,6 +26,11 @@ export const SERVICE_TYPE = {
StoredProcedure: GlobalSettingOptions.STORED_PROCEDURES,
ApiService: GlobalSettingOptions.APIS,
};
export const FollowSupportedServices = [
EntityTypeEndpoint.DatabaseService,
EntityTypeEndpoint.DatabaseSchema,
EntityTypeEndpoint.Database,
];
export const VISIT_SERVICE_PAGE_DETAILS = {
[SERVICE_TYPE.Database]: {

View File

@ -12,6 +12,7 @@
*/
import { test } from '@playwright/test';
import { CustomPropertySupportedEntityList } from '../../constant/customProperty';
import { FollowSupportedServices } from '../../constant/service';
import { ApiCollectionClass } from '../../support/entity/ApiCollectionClass';
import { DatabaseClass } from '../../support/entity/DatabaseClass';
import { DatabaseSchemaClass } from '../../support/entity/DatabaseSchemaClass';
@ -158,6 +159,14 @@ entities.forEach((EntityClass) => {
await afterAction();
});
}
if (FollowSupportedServices.includes(entity.endpoint)) {
test(`Follow & Un-follow entity for Database Entity`, async ({
page,
}) => {
const entityName = entity.entityResponseData?.['displayName'];
await entity.followUnfollowEntity(page, entityName);
});
}
test(`Update displayName`, async ({ page }) => {
await entity.renameEntity(page, entity.entity.name);

View File

@ -144,16 +144,15 @@ export const DataAssetsHeader = ({
) : null;
}, [dataAsset]);
const excludeEntityService = useMemo(
() =>
[
EntityType.DATABASE,
EntityType.DATABASE_SCHEMA,
EntityType.API_COLLECTION,
...SERVICE_TYPES,
].includes(entityType),
[entityType]
);
const excludeEntityService = useMemo(() => {
const filteredServiceTypes = SERVICE_TYPES.filter(
(type) => type !== EntityType.DATABASE_SERVICE
);
return [EntityType.API_COLLECTION, ...filteredServiceTypes].includes(
entityType
);
}, [entityType]);
const hasFollowers = 'followers' in dataAsset;

View File

@ -292,7 +292,7 @@ describe('Test DatabaseDetails page', () => {
);
expect(getDatabaseDetailsByFQN).toHaveBeenCalledWith('bigquery.shopify', {
fields: 'owners,tags,domain,votes,extension,dataProducts',
fields: 'owners,tags,domain,votes,extension,dataProducts,followers',
include: 'all',
});
expect(entityHeader).toBeInTheDocument();

View File

@ -37,6 +37,7 @@ import { EntityName } from '../../components/Modals/EntityNameModal/EntityNameMo
import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1';
import { ROUTES } from '../../constants/constants';
import { FEED_COUNT_INITIAL_DATA } from '../../constants/entity.constants';
import { GlobalSettingOptions } from '../../constants/GlobalSettings.constants';
import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider';
import {
OperationPermission,
@ -54,13 +55,16 @@ import { Database } from '../../generated/entity/data/database';
import { PageType } from '../../generated/system/ui/uiCustomization';
import { Include } from '../../generated/type/include';
import { useLocationSearch } from '../../hooks/LocationSearch/useLocationSearch';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useCustomPages } from '../../hooks/useCustomPages';
import { useFqn } from '../../hooks/useFqn';
import { FeedCounts } from '../../interface/feed.interface';
import {
addFollowers,
getDatabaseDetailsByFQN,
getDatabaseSchemas,
patchDatabaseDetails,
removeFollowers,
restoreDatabase,
updateDatabaseVotes,
} from '../../rest/databaseAPI';
@ -111,11 +115,14 @@ const DatabaseDetails: FunctionComponent = () => {
const isMounting = useRef(true);
const [isTabExpanded, setIsTabExpanded] = useState(false);
const { version: currentVersion, deleted } = useMemo(
() => database,
[database]
);
const {
version: currentVersion,
deleted,
id: databaseId,
} = useMemo(() => database, [database]);
const { currentUser } = useApplicationStore();
const USERId = currentUser?.id ?? '';
const tier = getTierTags(database?.tags ?? []);
const [databasePermission, setDatabasePermission] =
@ -177,7 +184,15 @@ const DatabaseDetails: FunctionComponent = () => {
const getDetailsByFQN = () => {
setIsDatabaseDetailsLoading(true);
getDatabaseDetailsByFQN(decodedDatabaseFQN, {
fields: `${TabSpecificField.OWNERS},${TabSpecificField.TAGS},${TabSpecificField.DOMAIN},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION},${TabSpecificField.DATA_PRODUCTS}`,
fields: [
TabSpecificField.OWNERS,
TabSpecificField.TAGS,
TabSpecificField.DOMAIN,
TabSpecificField.VOTES,
TabSpecificField.EXTENSION,
TabSpecificField.DATA_PRODUCTS,
TabSpecificField.FOLLOWERS,
].join(','),
include: Include.All,
})
.then((res) => {
@ -325,6 +340,15 @@ const DatabaseDetails: FunctionComponent = () => {
});
};
const { isFollowing, followers = [] } = useMemo(
() => ({
isFollowing: database?.followers?.some(
({ id }) => id === currentUser?.id
),
followers: database?.followers ?? [],
}),
[database, currentUser]
);
const handleRestoreDatabase = useCallback(async () => {
try {
const { version: newVersion } = await restoreDatabase(database.id ?? '');
@ -431,6 +455,64 @@ const DatabaseDetails: FunctionComponent = () => {
showErrorToast(error as AxiosError);
}
};
const followDatabase = useCallback(async () => {
try {
const res = await addFollowers(
databaseId,
USERId ?? '',
GlobalSettingOptions.DATABASES
);
const { newValue } = res.changeDescription.fieldsAdded[0];
const newFollowers = [...(followers ?? []), ...newValue];
setDatabase((prev) => {
if (!prev) {
return prev;
}
return { ...prev, followers: newFollowers };
});
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.entity-follow-error', {
entity: getEntityName(database),
})
);
}
}, [USERId, databaseId]);
const unfollowDatabase = useCallback(async () => {
try {
const res = await removeFollowers(
databaseId,
USERId,
GlobalSettingOptions.DATABASES
);
const { oldValue } = res.changeDescription.fieldsDeleted[0];
setDatabase((pre) => {
if (!pre) {
return pre;
}
return {
...pre,
followers: pre.followers?.filter(
(follower) => follower.id !== oldValue[0].id
),
};
});
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.entity-unfollow-error', {
entity: getEntityName(database),
})
);
}
}, [USERId, database]);
const handleFollowClick = useCallback(async () => {
isFollowing ? await unfollowDatabase() : await followDatabase();
}, [isFollowing, unfollowDatabase, followDatabase]);
const toggleTabExpanded = () => {
setIsTabExpanded(!isTabExpanded);
@ -471,6 +553,7 @@ const DatabaseDetails: FunctionComponent = () => {
openTaskCount={feedCount.openTaskCount}
permissions={databasePermission}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={handleFollowClick}
onOwnerUpdate={handleUpdateOwner}
onProfilerSettingUpdate={() => setUpdateProfilerSetting(true)}
onRestoreDataAsset={handleRestoreDatabase}

View File

@ -40,6 +40,7 @@ import {
ROUTES,
} from '../../constants/constants';
import { FEED_COUNT_INITIAL_DATA } from '../../constants/entity.constants';
import { GlobalSettingOptions } from '../../constants/GlobalSettings.constants';
import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider';
import {
OperationPermission,
@ -56,13 +57,16 @@ import { Tag } from '../../generated/entity/classification/tag';
import { DatabaseSchema } from '../../generated/entity/data/databaseSchema';
import { PageType } from '../../generated/system/ui/page';
import { Include } from '../../generated/type/include';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useCustomPages } from '../../hooks/useCustomPages';
import { useFqn } from '../../hooks/useFqn';
import { useTableFilters } from '../../hooks/useTableFilters';
import { FeedCounts } from '../../interface/feed.interface';
import {
addFollowers,
getDatabaseSchemaDetailsByFQN,
patchDatabaseSchemaDetails,
removeFollowers,
restoreDatabaseSchema,
updateDatabaseSchemaVotes,
} from '../../rest/databaseAPI';
@ -85,6 +89,8 @@ import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const DatabaseSchemaPage: FunctionComponent = () => {
const { t } = useTranslation();
const { getEntityPermissionByFqn } = usePermissionProvider();
const { currentUser } = useApplicationStore();
const USERId = currentUser?.id ?? '';
const { setFilters, filters } = useTableFilters(INITIAL_TABLE_FILTERS);
const { tab: activeTab = EntityTabs.TABLE } =
@ -111,6 +117,15 @@ const DatabaseSchemaPage: FunctionComponent = () => {
const [updateProfilerSetting, setUpdateProfilerSetting] =
useState<boolean>(false);
const { isFollowing, followers = [] } = useMemo(
() => ({
isFollowing: databaseSchema?.followers?.some(
({ id }) => id === currentUser?.id
),
followers: databaseSchema?.followers ?? [],
}),
[currentUser, databaseSchema]
);
const extraDropdownContent = useMemo(
() =>
entityUtilClassBase.getManageExtraOptions(
@ -170,8 +185,16 @@ const DatabaseSchemaPage: FunctionComponent = () => {
const response = await getDatabaseSchemaDetailsByFQN(
decodedDatabaseSchemaFQN,
{
// eslint-disable-next-line max-len
fields: `${TabSpecificField.OWNERS},${TabSpecificField.USAGE_SUMMARY},${TabSpecificField.TAGS},${TabSpecificField.DOMAIN},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION},${TabSpecificField.DATA_PRODUCTS}`,
fields: [
TabSpecificField.OWNERS,
TabSpecificField.USAGE_SUMMARY,
TabSpecificField.TAGS,
TabSpecificField.DOMAIN,
TabSpecificField.VOTES,
TabSpecificField.EXTENSION,
TabSpecificField.FOLLOWERS,
TabSpecificField.DATA_PRODUCTS,
].join(','),
include: Include.All,
}
);
@ -500,6 +523,65 @@ const DatabaseSchemaPage: FunctionComponent = () => {
checkIfExpandViewSupported(tabs[0], activeTab, PageType.DatabaseSchema),
[tabs[0], activeTab]
);
const followSchema = useCallback(async () => {
try {
const res = await addFollowers(
databaseSchemaId,
USERId ?? '',
GlobalSettingOptions.DATABASE_SCHEMA
);
const { newValue } = res.changeDescription.fieldsAdded[0];
const newFollowers = [...(followers ?? []), ...newValue];
setDatabaseSchema((prev) => {
if (!prev) {
return prev;
}
return { ...prev, followers: newFollowers };
});
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.entity-follow-error', {
entity: getEntityName(databaseSchema),
})
);
}
}, [USERId, databaseSchemaId]);
const unFollowSchema = useCallback(async () => {
try {
const res = await removeFollowers(
databaseSchemaId,
USERId,
GlobalSettingOptions.DATABASE_SCHEMA
);
const { oldValue } = res.changeDescription.fieldsDeleted[0];
setDatabaseSchema((pre) => {
if (!pre) {
return pre;
}
return {
...pre,
followers: pre.followers?.filter(
(follower) => follower.id !== oldValue[0].id
),
};
});
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.entity-unfollow-error', {
entity: getEntityName(databaseSchema),
})
);
}
}, [USERId, databaseSchemaId]);
const handleFollowClick = useCallback(async () => {
isFollowing ? await unFollowSchema() : await followSchema();
}, [isFollowing, unFollowSchema, followSchema]);
if (isPermissionsLoading) {
return <Loader />;
}
@ -542,6 +624,7 @@ const DatabaseSchemaPage: FunctionComponent = () => {
extraDropdownContent={extraDropdownContent}
permissions={databaseSchemaPermission}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={handleFollowClick}
onOwnerUpdate={handleUpdateOwner}
onProfilerSettingUpdate={() => setUpdateProfilerSetting(true)}
onRestoreDataAsset={handleRestoreDatabaseSchema}

View File

@ -198,6 +198,7 @@ const API_FIELDS = [
'domain',
'votes',
'extension',
'followers',
'dataProducts',
];

View File

@ -94,8 +94,10 @@ import { getPipelines } from '../../rest/pipelineAPI';
import { searchQuery } from '../../rest/searchAPI';
import { getSearchIndexes } from '../../rest/SearchIndexAPI';
import {
addServiceFollower,
getServiceByFQN,
patchService,
removeServiceFollower,
restoreService,
} from '../../rest/serviceAPI';
import { getContainers } from '../../rest/storageAPI';
@ -173,7 +175,7 @@ const ServiceDetailsPage: FunctionComponent = () => {
const [workflowStatesData, setWorkflowStatesData] =
useState<WorkflowStatesData>();
const [isWorkflowStatusLoading, setIsWorkflowStatusLoading] = useState(true);
const USERId = currentUser?.id ?? '';
const {
paging: collateAgentPaging,
pageSize: collateAgentPageSize,
@ -225,6 +227,15 @@ const ServiceDetailsPage: FunctionComponent = () => {
const [isCollateAgentLoading, setIsCollateAgentLoading] = useState(false);
const [collateAgentsList, setCollateAgentsList] = useState<App[]>([]);
const { isFollowing, followers = [] } = useMemo(
() => ({
isFollowing: serviceDetails?.followers?.some(
({ id }) => id === currentUser?.id
),
followers: serviceDetails?.followers ?? [],
}),
[serviceDetails, currentUser]
);
const { CollateAIAgentsWidget } = useMemo(
() => serviceUtilClassBase.getAgentsTabWidgets(),
[]
@ -301,10 +312,11 @@ const ServiceDetailsPage: FunctionComponent = () => {
return shouldTestConnection(serviceCategory);
}, [serviceCategory]);
const { version: currentVersion, deleted } = useMemo(
() => serviceDetails,
[serviceDetails]
);
const {
version: currentVersion,
deleted,
id: serviceId,
} = useMemo(() => serviceDetails, [serviceDetails]);
const fetchServicePermission = useCallback(async () => {
setIsLoading(true);
@ -719,8 +731,10 @@ const ServiceDetailsPage: FunctionComponent = () => {
decodedServiceFQN,
{
fields: `${TabSpecificField.OWNERS},${TabSpecificField.TAGS},${
isMetadataService ? '' : TabSpecificField.DATA_PRODUCTS
},${isMetadataService ? '' : TabSpecificField.DOMAIN}`,
TabSpecificField.FOLLOWERS
},${isMetadataService ? '' : TabSpecificField.DATA_PRODUCTS},${
isMetadataService ? '' : TabSpecificField.DOMAIN
}`,
include: Include.All,
}
);
@ -738,6 +752,55 @@ const ServiceDetailsPage: FunctionComponent = () => {
}
}, [serviceCategory, decodedServiceFQN, isMetadataService]);
const followService = useCallback(async () => {
try {
const res = await addServiceFollower(serviceId, USERId ?? '');
const { newValue } = res.changeDescription.fieldsAdded[0];
const newFollowers = [...(followers ?? []), ...newValue];
setServiceDetails((prev) => {
if (!prev) {
return prev;
}
return { ...prev, followers: newFollowers };
});
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.entity-follow-error', {
entity: getEntityName(serviceDetails),
})
);
}
}, [USERId, serviceId]);
const unFollowService = useCallback(async () => {
try {
const res = await removeServiceFollower(serviceId, USERId);
const { oldValue } = res.changeDescription.fieldsDeleted[0];
setServiceDetails((pre) => {
if (!pre) {
return pre;
}
return {
...pre,
followers: pre.followers?.filter(
(follower) => follower.id !== oldValue[0].id
),
};
});
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.entity-unfollow-error', {
entity: getEntityName(serviceDetails),
})
);
}
}, [USERId, serviceId]);
const handleFollowClick = useCallback(async () => {
isFollowing ? await unFollowService() : await followService();
}, [isFollowing, unFollowService, followService]);
const handleUpdateDisplayName = useCallback(
async (data: EntityName) => {
if (isEmpty(serviceDetails)) {
@ -1427,6 +1490,7 @@ const ServiceDetailsPage: FunctionComponent = () => {
permissions={servicePermission}
showDomain={!isMetadataService}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={handleFollowClick}
onOwnerUpdate={handleUpdateOwner}
onRestoreDataAsset={handleRestoreService}
onTierUpdate={handleUpdateTier}

View File

@ -15,11 +15,13 @@ import { AxiosResponse } from 'axios';
import { Operation } from 'fast-json-patch';
import { PagingWithoutTotal, RestoreRequestType } from 'Models';
import { QueryVote } from '../components/Database/TableQueries/TableQueries.interface';
import { APPLICATION_JSON_CONTENT_TYPE_HEADER } from '../constants/constants';
import {
Database,
DatabaseProfilerConfig as ProfilerConfig,
} from '../generated/entity/data/database';
import { DatabaseSchema } from '../generated/entity/data/databaseSchema';
import { EntityReference } from '../generated/entity/type';
import { EntityHistory } from '../generated/type/entityHistory';
import { Include } from '../generated/type/include';
import { Paging } from '../generated/type/paging';
@ -86,6 +88,35 @@ export const patchDatabaseSchemaDetails = async (
return response.data;
};
export const addFollowers = async (
id: string,
userId: string,
path: string
) => {
const response = await APIClient.put<
string,
AxiosResponse<{
changeDescription: { fieldsAdded: { newValue: EntityReference[] }[] };
}>
>(`${path}/${id}/followers`, userId, APPLICATION_JSON_CONTENT_TYPE_HEADER);
return response.data;
};
export const removeFollowers = async (
id: string,
userId: string,
path: string
) => {
const response = await APIClient.delete<
string,
AxiosResponse<{
changeDescription: { fieldsDeleted: { oldValue: EntityReference[] }[] };
}>
>(`${path}/${id}/followers/${userId}`, APPLICATION_JSON_CONTENT_TYPE_HEADER);
return response.data;
};
export const getDatabaseSchemas = async ({
include = Include.NonDeleted,
databaseName,

View File

@ -14,9 +14,13 @@
import { AxiosResponse } from 'axios';
import { RestoreRequestType, ServicesUpdateRequest } from 'Models';
import { WILD_CARD_CHAR } from '../constants/char.constants';
import { PAGE_SIZE } from '../constants/constants';
import {
APPLICATION_JSON_CONTENT_TYPE_HEADER,
PAGE_SIZE,
} from '../constants/constants';
import { TabSpecificField } from '../enums/entity.enum';
import { SearchIndex } from '../enums/search.enum';
import { EntityReference } from '../generated/entity/type';
import { EntityHistory } from '../generated/type/entityHistory';
import { Include } from '../generated/type/include';
import { ListParams } from '../interface/API.interface';
@ -97,6 +101,34 @@ export const postService = async (
return response.data;
};
export const addServiceFollower = async (id: string, userId: string) => {
const response = await APIClient.put<
string,
AxiosResponse<{
changeDescription: { fieldsAdded: { newValue: EntityReference[] }[] };
}>
>(
`/services/databaseServices/${id}/followers`,
userId,
APPLICATION_JSON_CONTENT_TYPE_HEADER
);
return response.data;
};
export const removeServiceFollower = async (id: string, userId: string) => {
const response = await APIClient.delete<
string,
AxiosResponse<{
changeDescription: { fieldsDeleted: { oldValue: EntityReference[] }[] };
}>
>(
`/services/databaseServices/${id}/followers/${userId}`,
APPLICATION_JSON_CONTENT_TYPE_HEADER
);
return response.data;
};
export const patchService = async (
serviceCat: string,
id: string,