mirror of
https://github.com/datahub-project/datahub.git
synced 2025-11-01 03:09:12 +00:00
feat(applications): Manage applications screen (#13814)
This commit is contained in:
parent
5da40ec91e
commit
1228f9b1de
@ -199,6 +199,7 @@ import com.linkedin.datahub.graphql.resolvers.search.ScrollAcrossLineageResolver
|
||||
import com.linkedin.datahub.graphql.resolvers.search.SearchAcrossEntitiesResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.search.SearchAcrossLineageResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.search.SearchResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.settings.applications.UpdateApplicationsSettingsResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.settings.docPropagation.DocPropagationSettingsResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.settings.docPropagation.UpdateDocPropagationSettingsResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.settings.user.UpdateCorpUserViewsSettingsResolver;
|
||||
@ -955,7 +956,8 @@ public class GmsGraphQLEngine {
|
||||
this.searchBarConfiguration,
|
||||
this.homePageConfiguration,
|
||||
this.featureFlags,
|
||||
this.chromeExtensionConfiguration))
|
||||
this.chromeExtensionConfiguration,
|
||||
this.settingsService))
|
||||
.dataFetcher("me", new MeResolver(this.entityClient, featureFlags))
|
||||
.dataFetcher("search", new SearchResolver(this.entityClient))
|
||||
.dataFetcher(
|
||||
@ -1360,7 +1362,10 @@ public class GmsGraphQLEngine {
|
||||
.dataFetcher("updateForm", new UpdateFormResolver(this.entityClient))
|
||||
.dataFetcher(
|
||||
"updateDocPropagationSettings",
|
||||
new UpdateDocPropagationSettingsResolver(this.settingsService));
|
||||
new UpdateDocPropagationSettingsResolver(this.settingsService))
|
||||
.dataFetcher(
|
||||
"updateApplicationsSettings",
|
||||
new UpdateApplicationsSettingsResolver(this.settingsService));
|
||||
|
||||
if (featureFlags.isBusinessAttributeEntityEnabled()) {
|
||||
typeWiring
|
||||
|
||||
@ -16,6 +16,7 @@ import com.linkedin.datahub.graphql.featureflags.FeatureFlags;
|
||||
import com.linkedin.datahub.graphql.generated.AuthenticatedUser;
|
||||
import com.linkedin.datahub.graphql.generated.CorpUser;
|
||||
import com.linkedin.datahub.graphql.generated.PlatformPrivileges;
|
||||
import com.linkedin.datahub.graphql.resolvers.application.ApplicationAuthorizationUtils;
|
||||
import com.linkedin.datahub.graphql.resolvers.businessattribute.BusinessAttributeAuthorizationUtils;
|
||||
import com.linkedin.datahub.graphql.types.corpuser.mappers.CorpUserMapper;
|
||||
import com.linkedin.entity.EntityResponse;
|
||||
@ -98,6 +99,9 @@ public class MeResolver implements DataFetcher<CompletableFuture<AuthenticatedUs
|
||||
AuthorizationUtils.canManageStructuredProperties(context));
|
||||
platformPrivileges.setViewStructuredPropertiesPage(
|
||||
AuthorizationUtils.canViewStructuredPropertiesPage(context));
|
||||
platformPrivileges.setManageApplications(
|
||||
ApplicationAuthorizationUtils.canManageApplications(context));
|
||||
platformPrivileges.setManageFeatures(AuthorizationUtils.canManageFeatures(context));
|
||||
// Construct and return authenticated user object.
|
||||
final AuthenticatedUser authUser = new AuthenticatedUser();
|
||||
authUser.setCorpUser(corpUser);
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.application;
|
||||
|
||||
import static com.linkedin.metadata.Constants.APPLICATION_ENTITY_NAME;
|
||||
import static com.linkedin.metadata.authorization.ApiOperation.MANAGE;
|
||||
|
||||
import com.datahub.authorization.AuthUtil;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import java.util.List;
|
||||
import javax.annotation.Nonnull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class ApplicationAuthorizationUtils {
|
||||
|
||||
private ApplicationAuthorizationUtils() {}
|
||||
|
||||
/**
|
||||
* Returns true if the current user is authorized to edit any application entity. This is true if
|
||||
* the user has the EDIT_ENTITY privilege for applications.
|
||||
*/
|
||||
public static boolean canManageApplications(@Nonnull QueryContext context) {
|
||||
return AuthUtil.isAuthorizedEntityType(
|
||||
context.getOperationContext(), MANAGE, List.of(APPLICATION_ENTITY_NAME));
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,9 @@ import com.linkedin.metadata.config.TestsConfiguration;
|
||||
import com.linkedin.metadata.config.ViewsConfiguration;
|
||||
import com.linkedin.metadata.config.VisualConfiguration;
|
||||
import com.linkedin.metadata.config.telemetry.TelemetryConfiguration;
|
||||
import com.linkedin.metadata.service.SettingsService;
|
||||
import com.linkedin.metadata.version.GitVersion;
|
||||
import com.linkedin.settings.global.GlobalSettingsInfo;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@ -65,6 +67,7 @@ public class AppConfigResolver implements DataFetcher<CompletableFuture<AppConfi
|
||||
private final HomePageConfiguration _homePageConfig;
|
||||
private final FeatureFlags _featureFlags;
|
||||
private final ChromeExtensionConfiguration _chromeExtensionConfiguration;
|
||||
private final SettingsService _settingsService;
|
||||
|
||||
public AppConfigResolver(
|
||||
final GitVersion gitVersion,
|
||||
@ -81,7 +84,8 @@ public class AppConfigResolver implements DataFetcher<CompletableFuture<AppConfi
|
||||
final SearchBarConfiguration searchBarConfig,
|
||||
final HomePageConfiguration homePageConfig,
|
||||
final FeatureFlags featureFlags,
|
||||
final ChromeExtensionConfiguration chromeExtensionConfiguration) {
|
||||
final ChromeExtensionConfiguration chromeExtensionConfiguration,
|
||||
final SettingsService settingsService) {
|
||||
_gitVersion = gitVersion;
|
||||
_isAnalyticsEnabled = isAnalyticsEnabled;
|
||||
_ingestionConfiguration = ingestionConfiguration;
|
||||
@ -97,6 +101,7 @@ public class AppConfigResolver implements DataFetcher<CompletableFuture<AppConfi
|
||||
_homePageConfig = homePageConfig;
|
||||
_featureFlags = featureFlags;
|
||||
_chromeExtensionConfiguration = chromeExtensionConfiguration;
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -188,12 +193,24 @@ public class AppConfigResolver implements DataFetcher<CompletableFuture<AppConfi
|
||||
}
|
||||
visualConfig.setTheme(themeConfig);
|
||||
}
|
||||
if (_visualConfiguration != null && _visualConfiguration.getApplication() != null) {
|
||||
if (_settingsService != null) {
|
||||
ApplicationConfig applicationConfig = new ApplicationConfig();
|
||||
applicationConfig.setShowSidebarSectionWhenEmpty(
|
||||
_visualConfiguration.getApplication().isShowSidebarSectionWhenEmpty());
|
||||
final GlobalSettingsInfo globalSettings =
|
||||
_settingsService.getGlobalSettings(context.getOperationContext());
|
||||
if (globalSettings != null
|
||||
&& globalSettings.hasApplications()
|
||||
&& globalSettings.getApplications().hasEnabled()) {
|
||||
applicationConfig.setShowApplicationInNavigation(
|
||||
globalSettings.getApplications().isEnabled());
|
||||
applicationConfig.setShowSidebarSectionWhenEmpty(
|
||||
globalSettings.getApplications().isEnabled());
|
||||
} else {
|
||||
applicationConfig.setShowApplicationInNavigation(false);
|
||||
applicationConfig.setShowSidebarSectionWhenEmpty(false);
|
||||
}
|
||||
visualConfig.setApplication(applicationConfig);
|
||||
}
|
||||
|
||||
appConfig.setVisualConfig(visualConfig);
|
||||
|
||||
final TelemetryConfig telemetryConfig = new TelemetryConfig();
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.settings.applications;
|
||||
|
||||
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
|
||||
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
|
||||
import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
|
||||
import com.linkedin.datahub.graphql.exception.AuthorizationException;
|
||||
import com.linkedin.datahub.graphql.generated.UpdateApplicationsSettingsInput;
|
||||
import com.linkedin.metadata.service.SettingsService;
|
||||
import com.linkedin.settings.global.GlobalSettingsInfo;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/** Resolver responsible for updating the actions settings. */
|
||||
public class UpdateApplicationsSettingsResolver implements DataFetcher<CompletableFuture<Boolean>> {
|
||||
|
||||
private final SettingsService _settingsService;
|
||||
|
||||
public UpdateApplicationsSettingsResolver(@Nonnull final SettingsService settingsService) {
|
||||
_settingsService = Objects.requireNonNull(settingsService, "settingsService must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> get(final DataFetchingEnvironment environment)
|
||||
throws Exception {
|
||||
final QueryContext context = environment.getContext();
|
||||
final UpdateApplicationsSettingsInput input =
|
||||
bindArgument(environment.getArgument("input"), UpdateApplicationsSettingsInput.class);
|
||||
|
||||
return GraphQLConcurrencyUtils.supplyAsync(
|
||||
() -> {
|
||||
if (AuthorizationUtils.canManageFeatures(context)) {
|
||||
try {
|
||||
// First, fetch the existing global settings. This does a R-M-F.
|
||||
final GlobalSettingsInfo maybeGlobalSettings =
|
||||
_settingsService.getGlobalSettings(context.getOperationContext());
|
||||
|
||||
final GlobalSettingsInfo newGlobalSettings =
|
||||
maybeGlobalSettings != null ? maybeGlobalSettings : new GlobalSettingsInfo();
|
||||
|
||||
final com.linkedin.settings.global.ApplicationsSettings newApplicationsSettings =
|
||||
newGlobalSettings.hasApplications()
|
||||
? newGlobalSettings.getApplications()
|
||||
: new com.linkedin.settings.global.ApplicationsSettings().setEnabled(false);
|
||||
|
||||
// Next, patch the actions settings.
|
||||
updateApplicationsSettings(newApplicationsSettings, input);
|
||||
newGlobalSettings.setApplications(newApplicationsSettings);
|
||||
|
||||
// Finally, write back to GMS.
|
||||
_settingsService.updateGlobalSettings(
|
||||
context.getOperationContext(), newGlobalSettings);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(
|
||||
String.format("Failed to update action settings! %s", input), e);
|
||||
}
|
||||
}
|
||||
throw new AuthorizationException(
|
||||
"Unauthorized to perform this action. Please contact your DataHub administrator.");
|
||||
},
|
||||
this.getClass().getSimpleName(),
|
||||
"get");
|
||||
}
|
||||
|
||||
private static void updateApplicationsSettings(
|
||||
@Nonnull final com.linkedin.settings.global.ApplicationsSettings settings,
|
||||
@Nonnull final UpdateApplicationsSettingsInput input) {
|
||||
settings.setEnabled(input.getEnabled());
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,11 @@ extend type Mutation {
|
||||
updateDocPropagationSettings(
|
||||
input: UpdateDocPropagationSettingsInput!
|
||||
): Boolean!
|
||||
|
||||
"""
|
||||
Update the applications settings.
|
||||
"""
|
||||
updateApplicationsSettings(input: UpdateApplicationsSettingsInput!): Boolean!
|
||||
}
|
||||
|
||||
"""
|
||||
@ -177,6 +182,16 @@ type PlatformPrivileges {
|
||||
Whether the user can view the manage structured properties page.
|
||||
"""
|
||||
viewStructuredPropertiesPage: Boolean!
|
||||
|
||||
"""
|
||||
Whether the user can manage applications.
|
||||
"""
|
||||
manageApplications: Boolean!
|
||||
|
||||
"""
|
||||
Whether the user can manage platform features.
|
||||
"""
|
||||
manageFeatures: Boolean!
|
||||
}
|
||||
|
||||
"""
|
||||
@ -377,6 +392,11 @@ type ApplicationConfig {
|
||||
Whether to show the application sidebar section even when empty
|
||||
"""
|
||||
showSidebarSectionWhenEmpty: Boolean
|
||||
|
||||
"""
|
||||
Whether to show the application in the navigation sidebar
|
||||
"""
|
||||
showApplicationInNavigation: Boolean
|
||||
}
|
||||
|
||||
"""
|
||||
@ -751,6 +771,16 @@ type GlobalViewsSettings {
|
||||
defaultView: String
|
||||
}
|
||||
|
||||
"""
|
||||
Input required to update global applications settings.
|
||||
"""
|
||||
input UpdateApplicationsSettingsInput {
|
||||
"""
|
||||
Whether the Applications feature is enabled
|
||||
"""
|
||||
enabled: Boolean
|
||||
}
|
||||
|
||||
"""
|
||||
Input required to update doc propagation settings.
|
||||
"""
|
||||
|
||||
@ -0,0 +1,113 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.settings.applications;
|
||||
|
||||
import static com.linkedin.datahub.graphql.TestUtils.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.testng.Assert.*;
|
||||
|
||||
import com.linkedin.data.template.SetMode;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.generated.UpdateApplicationsSettingsInput;
|
||||
import com.linkedin.metadata.service.SettingsService;
|
||||
import com.linkedin.settings.global.ApplicationsSettings;
|
||||
import com.linkedin.settings.global.GlobalSettingsInfo;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import org.mockito.Mockito;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
public class UpdateApplicationsSettingsResolverTest {
|
||||
|
||||
private static final UpdateApplicationsSettingsInput TEST_INPUT_ENABLED =
|
||||
new UpdateApplicationsSettingsInput(true);
|
||||
private static final UpdateApplicationsSettingsInput TEST_INPUT_DISABLED =
|
||||
new UpdateApplicationsSettingsInput(false);
|
||||
|
||||
@Test
|
||||
public void testGetSuccessNoExistingSettings() throws Exception {
|
||||
SettingsService mockService = initSettingsService(null);
|
||||
UpdateApplicationsSettingsResolver resolver =
|
||||
new UpdateApplicationsSettingsResolver(mockService);
|
||||
|
||||
QueryContext mockContext = getMockAllowContext();
|
||||
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
|
||||
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_ENABLED);
|
||||
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
|
||||
|
||||
assertTrue(resolver.get(mockEnv).get());
|
||||
|
||||
Mockito.verify(mockService, Mockito.times(1))
|
||||
.updateGlobalSettings(
|
||||
any(),
|
||||
Mockito.eq(
|
||||
new GlobalSettingsInfo()
|
||||
.setApplications(new ApplicationsSettings().setEnabled(true))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSuccessExistingSettings() throws Exception {
|
||||
SettingsService mockService = initSettingsService(new ApplicationsSettings().setEnabled(false));
|
||||
UpdateApplicationsSettingsResolver resolver =
|
||||
new UpdateApplicationsSettingsResolver(mockService);
|
||||
|
||||
QueryContext mockContext = getMockAllowContext();
|
||||
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
|
||||
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_ENABLED);
|
||||
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
|
||||
|
||||
assertTrue(resolver.get(mockEnv).get());
|
||||
|
||||
Mockito.verify(mockService, Mockito.times(1))
|
||||
.updateGlobalSettings(
|
||||
any(),
|
||||
Mockito.eq(
|
||||
new GlobalSettingsInfo()
|
||||
.setApplications(new ApplicationsSettings().setEnabled(true))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSuccessDisableApplications() throws Exception {
|
||||
SettingsService mockService = initSettingsService(new ApplicationsSettings().setEnabled(true));
|
||||
UpdateApplicationsSettingsResolver resolver =
|
||||
new UpdateApplicationsSettingsResolver(mockService);
|
||||
|
||||
QueryContext mockContext = getMockAllowContext();
|
||||
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
|
||||
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_DISABLED);
|
||||
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
|
||||
|
||||
assertTrue(resolver.get(mockEnv).get());
|
||||
|
||||
Mockito.verify(mockService, Mockito.times(1))
|
||||
.updateGlobalSettings(
|
||||
any(),
|
||||
Mockito.eq(
|
||||
new GlobalSettingsInfo()
|
||||
.setApplications(new ApplicationsSettings().setEnabled(false))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetUnauthorized() throws Exception {
|
||||
SettingsService mockService = initSettingsService(new ApplicationsSettings().setEnabled(false));
|
||||
UpdateApplicationsSettingsResolver resolver =
|
||||
new UpdateApplicationsSettingsResolver(mockService);
|
||||
|
||||
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
|
||||
QueryContext mockContext = getMockDenyContext();
|
||||
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_ENABLED);
|
||||
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
|
||||
|
||||
assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
|
||||
}
|
||||
|
||||
private static SettingsService initSettingsService(
|
||||
ApplicationsSettings existingApplicationsSettings) {
|
||||
SettingsService mockService = Mockito.mock(SettingsService.class);
|
||||
|
||||
Mockito.when(mockService.getGlobalSettings(any()))
|
||||
.thenReturn(
|
||||
new GlobalSettingsInfo()
|
||||
.setApplications(existingApplicationsSettings, SetMode.IGNORE_NULL));
|
||||
|
||||
return mockService;
|
||||
}
|
||||
}
|
||||
@ -3737,6 +3737,8 @@ export const mocks = [
|
||||
manageBusinessAttributes: true,
|
||||
manageStructuredProperties: true,
|
||||
viewStructuredPropertiesPage: true,
|
||||
manageApplications: true,
|
||||
manageFeatures: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -4021,6 +4023,8 @@ export const platformPrivileges: PlatformPrivileges = {
|
||||
manageBusinessAttributes: true,
|
||||
manageStructuredProperties: true,
|
||||
viewStructuredPropertiesPage: true,
|
||||
manageApplications: true,
|
||||
manageFeatures: true,
|
||||
};
|
||||
|
||||
export const DomainMock1 = {
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { AnalyticsPage } from '@app/analyticsDashboard/components/AnalyticsPage';
|
||||
import { ManageApplications } from '@app/applications/ManageApplications';
|
||||
import { BrowseResultsPage } from '@app/browse/BrowseResultsPage';
|
||||
import { BusinessAttributes } from '@app/businessAttribute/BusinessAttributes';
|
||||
import { useUserContext } from '@app/context/useUserContext';
|
||||
@ -83,6 +84,7 @@ export const SearchRoutes = (): JSX.Element => {
|
||||
/>
|
||||
<Route path={PageRoutes.BROWSE_RESULTS} render={() => <BrowseResultsPage />} />
|
||||
{showTags ? <Route path={PageRoutes.MANAGE_TAGS} render={() => <ManageTags />} /> : null}
|
||||
<Route path={PageRoutes.MANAGE_APPLICATIONS} render={() => <ManageApplications />} />
|
||||
<Route path={PageRoutes.ANALYTICS} render={() => <AnalyticsPage />} />
|
||||
<Route path={PageRoutes.POLICIES} render={() => <Redirect to="/settings/permissions/policies" />} />
|
||||
<Route
|
||||
|
||||
191
datahub-web-react/src/app/applications/ApplicationsTable.tsx
Normal file
191
datahub-web-react/src/app/applications/ApplicationsTable.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { NetworkStatus } from '@apollo/client';
|
||||
import { Table } from '@components';
|
||||
import { message } from 'antd';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ApplicationActionsColumn,
|
||||
ApplicationDescriptionColumn,
|
||||
ApplicationNameColumn,
|
||||
ApplicationOwnersColumn,
|
||||
} from '@app/applications/ApplicationsTableColumns';
|
||||
import { ConfirmationModal } from '@app/sharedV2/modals/ConfirmationModal';
|
||||
import { AlignmentOptions } from '@src/alchemy-components/theme/config';
|
||||
import { useEntityRegistry } from '@src/app/useEntityRegistry';
|
||||
import { GetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
|
||||
import { EntityType } from '@src/types.generated';
|
||||
|
||||
import { useDeleteApplicationMutation } from '@graphql/application.generated';
|
||||
|
||||
interface Props {
|
||||
searchQuery: string;
|
||||
searchData: GetSearchResultsForMultipleQuery | undefined;
|
||||
loading: boolean;
|
||||
networkStatus: NetworkStatus;
|
||||
refetch: () => Promise<any>;
|
||||
}
|
||||
|
||||
const ApplicationsTable = ({ searchQuery, searchData, loading: propLoading, networkStatus, refetch }: Props) => {
|
||||
const entityRegistry = useEntityRegistry();
|
||||
const [deleteApplicationMutation] = useDeleteApplicationMutation();
|
||||
|
||||
// Optimize the applicationsData with useMemo to prevent unnecessary filtering on re-renders
|
||||
const applicationsData = useMemo(() => {
|
||||
return searchData?.searchAcrossEntities?.searchResults || [];
|
||||
}, [searchData]);
|
||||
|
||||
// Simplified state for delete confirmation modal
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [applicationUrnToDelete, setApplicationUrnToDelete] = useState('');
|
||||
const [applicationDisplayName, setApplicationDisplayName] = useState('');
|
||||
|
||||
// Filter applications based on search query and sort by name - optimized with useMemo
|
||||
const filteredApplications = useMemo(() => {
|
||||
return applicationsData
|
||||
.filter((result) => {
|
||||
const application = result.entity;
|
||||
const displayName = entityRegistry.getDisplayName(EntityType.Application, application);
|
||||
if (!searchQuery) return true;
|
||||
return displayName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const nameA = entityRegistry.getDisplayName(EntityType.Application, a.entity);
|
||||
const nameB = entityRegistry.getDisplayName(EntityType.Application, b.entity);
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
}, [applicationsData, searchQuery, entityRegistry]);
|
||||
|
||||
const isLoading = propLoading || networkStatus === NetworkStatus.refetch;
|
||||
|
||||
// Simplified function to initiate tag deletion
|
||||
const showDeleteConfirmation = useCallback(
|
||||
(applicationUrn: string) => {
|
||||
// Find the application entity from applicationsData
|
||||
const applicationData = applicationsData.find((result) => result.entity.urn === applicationUrn);
|
||||
if (!applicationData) {
|
||||
message.error('Failed to find application information');
|
||||
return;
|
||||
}
|
||||
|
||||
const fullDisplayName = entityRegistry.getDisplayName(EntityType.Application, applicationData.entity);
|
||||
|
||||
setApplicationUrnToDelete(applicationUrn);
|
||||
setApplicationDisplayName(fullDisplayName);
|
||||
setShowDeleteModal(true);
|
||||
},
|
||||
[entityRegistry, applicationsData],
|
||||
);
|
||||
|
||||
// Function to handle the actual application deletion
|
||||
const handleDeleteApplication = useCallback(() => {
|
||||
deleteApplicationMutation({
|
||||
variables: {
|
||||
urn: applicationUrnToDelete,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
message.success(`Application "${applicationDisplayName}" has been deleted`);
|
||||
refetch(); // Refresh the application list
|
||||
})
|
||||
.catch((e: any) => {
|
||||
message.error(`Failed to delete application: ${e.message}`);
|
||||
});
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setApplicationUrnToDelete('');
|
||||
setApplicationDisplayName('');
|
||||
}, [deleteApplicationMutation, refetch, applicationUrnToDelete, applicationDisplayName]);
|
||||
|
||||
const handleDeleteClose = useCallback(() => {
|
||||
setShowDeleteModal(false);
|
||||
setApplicationUrnToDelete('');
|
||||
setApplicationDisplayName('');
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: 'Application',
|
||||
key: 'application',
|
||||
render: (record) => {
|
||||
const application = record.entity;
|
||||
const displayName = entityRegistry.getDisplayName(EntityType.Application, application);
|
||||
return (
|
||||
<ApplicationNameColumn
|
||||
applicationUrn={application.urn}
|
||||
displayName={displayName}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
key: 'description',
|
||||
render: (record) => {
|
||||
return (
|
||||
<ApplicationDescriptionColumn
|
||||
key={`description-${record.entity.urn}`}
|
||||
applicationUrn={record.entity.urn}
|
||||
description={record.entity.properties?.description}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Owners',
|
||||
key: 'owners',
|
||||
render: (record) => {
|
||||
return (
|
||||
<ApplicationOwnersColumn
|
||||
key={`owners-${record.entity.urn}`}
|
||||
applicationUrn={record.entity.urn}
|
||||
owners={record.entity.ownership}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
alignment: 'right' as AlignmentOptions,
|
||||
render: (record) => {
|
||||
return (
|
||||
<ApplicationActionsColumn
|
||||
applicationUrn={record.entity.urn}
|
||||
onDelete={() => {
|
||||
showDeleteConfirmation(record.entity.urn);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[entityRegistry, searchQuery, showDeleteConfirmation],
|
||||
);
|
||||
|
||||
// Generate table data once with memoization
|
||||
const tableData = useMemo(() => {
|
||||
return filteredApplications.map((application) => ({
|
||||
...application,
|
||||
key: application.entity.urn,
|
||||
}));
|
||||
}, [filteredApplications]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table columns={columns} data={tableData} isLoading={isLoading} isScrollable rowKey="key" />
|
||||
<ConfirmationModal
|
||||
isOpen={showDeleteModal}
|
||||
handleClose={handleDeleteClose}
|
||||
handleConfirm={handleDeleteApplication}
|
||||
modalTitle="Delete Application"
|
||||
modalText={`Are you sure you want to delete the application "${applicationDisplayName}"? This action cannot be undone.`}
|
||||
closeButtonText="Cancel"
|
||||
confirmButtonText="Delete"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationsTable;
|
||||
@ -0,0 +1,153 @@
|
||||
import { Icon, colors, typography } from '@components';
|
||||
import { Dropdown } from 'antd';
|
||||
import React from 'react';
|
||||
import Highlight from 'react-highlighter';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { CardIcons } from '@app/govern/structuredProperties/styledComponents';
|
||||
import { useEntityRegistry } from '@app/useEntityRegistry';
|
||||
import { ExpandedOwner } from '@src/app/entity/shared/components/styled/ExpandedOwner/ExpandedOwner';
|
||||
import { EntityType, Ownership } from '@src/types.generated';
|
||||
|
||||
const ApplicationName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${colors.gray[600]};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const ApplicationDescription = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: ${colors.gray[1700]};
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const OwnersContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const ColumnContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const MenuItem = styled.div`
|
||||
display: flex;
|
||||
padding: 5px 70px 5px 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: ${colors.gray[600]};
|
||||
font-family: ${typography.fonts.body};
|
||||
`;
|
||||
|
||||
export const ApplicationNameColumn = React.memo(
|
||||
({
|
||||
applicationUrn,
|
||||
displayName,
|
||||
searchQuery,
|
||||
}: {
|
||||
applicationUrn: string;
|
||||
displayName: string;
|
||||
searchQuery?: string;
|
||||
}) => {
|
||||
const entityRegistry = useEntityRegistry();
|
||||
const url = entityRegistry.getEntityUrl(EntityType.Application, applicationUrn);
|
||||
|
||||
return (
|
||||
<ColumnContainer>
|
||||
<ApplicationName onClick={() => window.open(url, '_blank')} data-testid={`${applicationUrn}-name`}>
|
||||
<Highlight search={searchQuery}>{displayName}</Highlight>
|
||||
</ApplicationName>
|
||||
</ColumnContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ApplicationDescriptionColumn = React.memo(
|
||||
({ applicationUrn, description }: { applicationUrn: string; description: string }) => {
|
||||
return (
|
||||
<ColumnContainer>
|
||||
<ApplicationDescription data-testid={`${applicationUrn}-description`}>
|
||||
{description}
|
||||
</ApplicationDescription>
|
||||
</ColumnContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ApplicationOwnersColumn = React.memo(
|
||||
({ applicationUrn, owners }: { applicationUrn: string; owners: Ownership }) => {
|
||||
return (
|
||||
<ColumnContainer>
|
||||
<OwnersContainer>
|
||||
{owners?.owners?.map((ownerItem) => (
|
||||
<ExpandedOwner
|
||||
key={ownerItem.owner?.urn}
|
||||
entityUrn={applicationUrn}
|
||||
owner={ownerItem}
|
||||
hidePopOver
|
||||
/>
|
||||
))}
|
||||
</OwnersContainer>
|
||||
</ColumnContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ApplicationActionsColumn = React.memo(
|
||||
({ applicationUrn, onDelete }: { applicationUrn: string; onDelete: () => void }) => {
|
||||
const entityRegistry = useEntityRegistry();
|
||||
const url = entityRegistry.getEntityUrl(EntityType.Application, applicationUrn);
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: '0',
|
||||
label: (
|
||||
<MenuItem onClick={() => window.open(url, '_blank')} data-testid="action-edit">
|
||||
View
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(applicationUrn);
|
||||
}}
|
||||
>
|
||||
Copy Urn
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: (
|
||||
<MenuItem onClick={onDelete} data-testid="action-delete" style={{ color: colors.red[500] }}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardIcons>
|
||||
<Dropdown menu={{ items }} trigger={['click']} data-testid={`${applicationUrn}-actions-dropdown`}>
|
||||
<Icon icon="MoreVert" size="md" />
|
||||
</Dropdown>
|
||||
</CardIcons>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,56 @@
|
||||
import { Input } from '@components';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export interface ApplicationDetailsProps {
|
||||
applicationName: string;
|
||||
setApplicationName: React.Dispatch<React.SetStateAction<string>>;
|
||||
applicationDescription: string;
|
||||
setApplicationDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
const SectionContainer = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const FormSection = styled.div`
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Component for application name and description
|
||||
*/
|
||||
const ApplicationDetailsSection: React.FC<ApplicationDetailsProps> = ({
|
||||
applicationName,
|
||||
setApplicationName,
|
||||
applicationDescription,
|
||||
setApplicationDescription,
|
||||
}) => {
|
||||
return (
|
||||
<SectionContainer>
|
||||
<FormSection>
|
||||
<Input
|
||||
label="Name"
|
||||
inputTestId="application-name-input"
|
||||
value={applicationName}
|
||||
setValue={setApplicationName}
|
||||
placeholder="Enter application name"
|
||||
required
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<Input
|
||||
inputTestId="application-description-input"
|
||||
label="Description"
|
||||
value={applicationDescription}
|
||||
setValue={setApplicationDescription}
|
||||
placeholder="Add a description for your new application"
|
||||
type="textarea"
|
||||
/>
|
||||
</FormSection>
|
||||
</SectionContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationDetailsSection;
|
||||
@ -0,0 +1,137 @@
|
||||
import { Modal } from '@components';
|
||||
import { message } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ModalButton } from '@components/components/Modal/Modal';
|
||||
|
||||
import ApplicationDetailsSection from '@app/applications/CreateNewApplicationModal/ApplicationDetailsSection';
|
||||
import OwnersSection, { PendingOwner } from '@app/sharedV2/owners/OwnersSection';
|
||||
|
||||
import { useCreateApplicationMutation } from '@graphql/application.generated';
|
||||
import { useBatchAddOwnersMutation } from '@graphql/mutations.generated';
|
||||
|
||||
type CreateNewApplicationModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal for creating a new application with owners and applying it to entities
|
||||
*/
|
||||
const CreateNewApplicationModal: React.FC<CreateNewApplicationModalProps> = ({ onClose, open }) => {
|
||||
// Application details state
|
||||
const [applicationName, setApplicationName] = useState('');
|
||||
const [applicationDescription, setApplicationDescription] = useState('');
|
||||
|
||||
// Owners state
|
||||
const [pendingOwners, setPendingOwners] = useState<PendingOwner[]>([]);
|
||||
const [selectedOwnerUrns, setSelectedOwnerUrns] = useState<string[]>([]);
|
||||
|
||||
// Loading state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Mutations
|
||||
const [createApplicationMutation] = useCreateApplicationMutation();
|
||||
const [batchAddOwnersMutation] = useBatchAddOwnersMutation();
|
||||
|
||||
const onChangeOwners = (newOwners: PendingOwner[]) => {
|
||||
setPendingOwners(newOwners);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for creating the tag and applying it to entities
|
||||
*/
|
||||
const onOk = async () => {
|
||||
if (!applicationName) {
|
||||
// this should not happen due to validation in the modal, but doesnt hurt to be safe
|
||||
message.error('Application name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Step 1: Create the new application
|
||||
const createApplicationResult = await createApplicationMutation({
|
||||
variables: {
|
||||
input: {
|
||||
properties: {
|
||||
name: applicationName.trim(),
|
||||
description: applicationDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newApplicationUrn = createApplicationResult.data?.createApplication?.urn;
|
||||
|
||||
if (!newApplicationUrn) {
|
||||
message.error('Failed to create application. An unexpected error occurred');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Add owners if any
|
||||
if (pendingOwners.length > 0) {
|
||||
await batchAddOwnersMutation({
|
||||
variables: {
|
||||
input: {
|
||||
owners: pendingOwners,
|
||||
resources: [{ resourceUrn: newApplicationUrn }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
message.success(`Application "${applicationName}" successfully created`);
|
||||
setApplicationName('');
|
||||
setApplicationDescription('');
|
||||
setPendingOwners([]);
|
||||
setSelectedOwnerUrns([]);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
message.destroy();
|
||||
message.error(`Failed to create application. An unexpected error occurred: ${e.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Modal buttons configuration
|
||||
const buttons: ModalButton[] = [
|
||||
{
|
||||
text: 'Cancel',
|
||||
color: 'violet',
|
||||
variant: 'text',
|
||||
onClick: onClose,
|
||||
},
|
||||
{
|
||||
text: 'Create',
|
||||
id: 'createNewApplicationButton',
|
||||
color: 'violet',
|
||||
variant: 'filled',
|
||||
onClick: onOk,
|
||||
disabled: !applicationName || isLoading,
|
||||
isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal title="Create New Application" onCancel={onClose} buttons={buttons} open={open} centered width={500}>
|
||||
<ApplicationDetailsSection
|
||||
applicationName={applicationName}
|
||||
setApplicationName={setApplicationName}
|
||||
applicationDescription={applicationDescription}
|
||||
setApplicationDescription={setApplicationDescription}
|
||||
/>
|
||||
<OwnersSection
|
||||
selectedOwnerUrns={selectedOwnerUrns}
|
||||
setSelectedOwnerUrns={setSelectedOwnerUrns}
|
||||
existingOwners={[]}
|
||||
onChange={onChangeOwners}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateNewApplicationModal;
|
||||
47
datahub-web-react/src/app/applications/EmptyApplications.tsx
Normal file
47
datahub-web-react/src/app/applications/EmptyApplications.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Empty, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
isEmptySearch: boolean;
|
||||
};
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
`;
|
||||
|
||||
const StyledEmpty = styled(Empty)`
|
||||
.ant-empty-description {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyApplications = ({ isEmptySearch }: Props) => {
|
||||
return (
|
||||
<EmptyContainer>
|
||||
<StyledEmpty
|
||||
description={
|
||||
<>
|
||||
<Typography.Text data-testid="applications-not-found">
|
||||
{isEmptySearch ? 'No applications found for your search query' : 'No applications found'}
|
||||
</Typography.Text>
|
||||
<div>
|
||||
{!isEmptySearch && (
|
||||
<Typography.Paragraph>
|
||||
Applications can be used to organize data assets in DataHub.
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</EmptyContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyApplications;
|
||||
180
datahub-web-react/src/app/applications/ManageApplications.tsx
Normal file
180
datahub-web-react/src/app/applications/ManageApplications.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import { Button, PageTitle, Pagination, SearchBar, StructuredPopover } from '@components';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import ApplicationsTable from '@app/applications/ApplicationsTable';
|
||||
import CreateNewApplicationModal from '@app/applications/CreateNewApplicationModal/CreateNewApplicationModal';
|
||||
import EmptyApplications from '@app/applications/EmptyApplications';
|
||||
import { useUserContext } from '@app/context/useUserContext';
|
||||
import { PageContainer } from '@app/govern/structuredProperties/styledComponents';
|
||||
import { Message } from '@src/app/shared/Message';
|
||||
import { DEBOUNCE_SEARCH_MS } from '@src/app/shared/constants';
|
||||
import { useShowNavBarRedesign } from '@src/app/useShowNavBarRedesign';
|
||||
import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
|
||||
import { EntityType } from '@src/types.generated';
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0px;
|
||||
`;
|
||||
|
||||
const SearchContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0px;
|
||||
`;
|
||||
|
||||
// Simple loading indicator at the top of the page
|
||||
const LoadingBar = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: #1890ff;
|
||||
z-index: 1000;
|
||||
animation: loading 2s infinite ease-in-out;
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const ManageApplications = () => {
|
||||
const isShowNavBarRedesign = useShowNavBarRedesign();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('*');
|
||||
const [showCreateApplicationModal, setShowCreateApplicationModal] = useState(false);
|
||||
|
||||
const userContext = useUserContext();
|
||||
const canManageApplications = userContext?.platformPrivileges?.manageApplications;
|
||||
|
||||
useDebounce(() => setDebouncedSearchQuery(searchQuery), DEBOUNCE_SEARCH_MS, [searchQuery]);
|
||||
|
||||
// Search query configuration
|
||||
const searchInputs = useMemo(
|
||||
() => ({
|
||||
types: [EntityType.Application],
|
||||
query: debouncedSearchQuery,
|
||||
start: (currentPage - 1) * PAGE_SIZE,
|
||||
count: PAGE_SIZE,
|
||||
filters: [],
|
||||
}),
|
||||
[currentPage, debouncedSearchQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchData,
|
||||
loading: searchLoading,
|
||||
error: searchError,
|
||||
refetch,
|
||||
networkStatus,
|
||||
} = useGetSearchResultsForMultipleQuery({
|
||||
variables: { input: searchInputs },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const totalApplications = searchData?.searchAcrossEntities?.total || 0;
|
||||
|
||||
if (searchError) {
|
||||
return <Message type="error" content={`Failed to load applications: ${searchError.message}`} />;
|
||||
}
|
||||
// Create the Create Application button with proper permissions handling
|
||||
const renderCreateApplicationButton = () => {
|
||||
if (!canManageApplications) {
|
||||
return (
|
||||
<StructuredPopover
|
||||
title="You do not have permission to create applications"
|
||||
placement="left"
|
||||
showArrow
|
||||
mouseEnterDelay={0.1}
|
||||
mouseLeaveDelay={0.1}
|
||||
>
|
||||
<span>
|
||||
<Button size="md" color="violet" icon={{ icon: 'Plus', source: 'phosphor' }} disabled>
|
||||
Create Application
|
||||
</Button>
|
||||
</span>
|
||||
</StructuredPopover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setShowCreateApplicationModal(true)}
|
||||
size="md"
|
||||
color="violet"
|
||||
icon={{ icon: 'Plus', source: 'phosphor' }}
|
||||
>
|
||||
Create Application
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer $isShowNavBarRedesign={isShowNavBarRedesign}>
|
||||
{searchLoading && <LoadingBar />}
|
||||
|
||||
<HeaderContainer>
|
||||
<PageTitle title="Manage Applications" subTitle="Create and edit applications" />
|
||||
{renderCreateApplicationButton()}
|
||||
</HeaderContainer>
|
||||
|
||||
<SearchContainer>
|
||||
<SearchBar
|
||||
placeholder="Search applications..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e)}
|
||||
id="application-search-input"
|
||||
data-testid="application-search-input"
|
||||
width="280px"
|
||||
/>
|
||||
</SearchContainer>
|
||||
|
||||
{!searchLoading && totalApplications === 0 ? (
|
||||
<EmptyApplications isEmptySearch={debouncedSearchQuery.length > 0} />
|
||||
) : (
|
||||
<>
|
||||
<ApplicationsTable
|
||||
searchQuery={debouncedSearchQuery}
|
||||
searchData={searchData}
|
||||
loading={searchLoading}
|
||||
networkStatus={networkStatus}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={PAGE_SIZE}
|
||||
totalPages={totalApplications}
|
||||
loading={searchLoading}
|
||||
onPageChange={(page) => setCurrentPage(page)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CreateNewApplicationModal
|
||||
open={showCreateApplicationModal}
|
||||
onClose={() => {
|
||||
setShowCreateApplicationModal(false);
|
||||
setTimeout(() => refetch(), 3000);
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export { ManageApplications };
|
||||
@ -1,11 +1,5 @@
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
FileDoneOutlined,
|
||||
FileOutlined,
|
||||
ReadOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ListBullets } from '@phosphor-icons/react';
|
||||
import { AppstoreOutlined, FileOutlined, ReadOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { AppWindow, ListBullets } from '@phosphor-icons/react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '@app/entityV2/Entity';
|
||||
@ -50,23 +44,19 @@ export class ApplicationEntity implements Entity<Application> {
|
||||
|
||||
icon = (fontSize?: number, styleType?: IconStyleType, color?: string) => {
|
||||
if (styleType === IconStyleType.TAB_VIEW) {
|
||||
return <FileDoneOutlined className={TYPE_ICON_CLASS_NAME} />;
|
||||
return <AppWindow className={TYPE_ICON_CLASS_NAME} />;
|
||||
}
|
||||
|
||||
if (styleType === IconStyleType.HIGHLIGHT) {
|
||||
return (
|
||||
<FileDoneOutlined className={TYPE_ICON_CLASS_NAME} style={{ fontSize, color: color || '#B37FEB' }} />
|
||||
);
|
||||
return <AppWindow className={TYPE_ICON_CLASS_NAME} style={{ fontSize, color: color || '#B37FEB' }} />;
|
||||
}
|
||||
|
||||
if (styleType === IconStyleType.SVG) {
|
||||
return (
|
||||
<path d="M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-600 72h560v208H232V136zm560 480H232V408h560v208zm0 272H232V680h560v208zM304 240a40 40 0 1080 0 40 40 0 10-80 0zm0 272a40 40 0 1080 0 40 40 0 10-80 0zm0 272a40 40 0 1080 0 40 40 0 10-80 0z" />
|
||||
);
|
||||
return <AppWindow className={TYPE_ICON_CLASS_NAME} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FileDoneOutlined
|
||||
<AppWindow
|
||||
className={TYPE_ICON_CLASS_NAME}
|
||||
style={{
|
||||
fontSize,
|
||||
|
||||
@ -51,8 +51,6 @@ export const SidebarApplicationSection = ({ readOnly, properties }: Props) => {
|
||||
const application = entityData?.application?.application;
|
||||
const canEditApplication = !!entityData?.privileges?.canEditProperties;
|
||||
|
||||
console.log('application', application);
|
||||
|
||||
if (!application && !visualConfig.application?.showSidebarSectionWhenEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -87,7 +87,8 @@ describe('handleAccessRoles', () => {
|
||||
manageBusinessAttributes: true,
|
||||
manageStructuredProperties: true,
|
||||
viewStructuredPropertiesPage: true,
|
||||
|
||||
manageApplications: true,
|
||||
manageFeatures: true,
|
||||
__typename: 'PlatformPrivileges',
|
||||
},
|
||||
__typename: 'AuthenticatedUser',
|
||||
@ -171,6 +172,8 @@ describe('handleAccessRoles', () => {
|
||||
manageBusinessAttributes: true,
|
||||
manageStructuredProperties: true,
|
||||
viewStructuredPropertiesPage: true,
|
||||
manageApplications: true,
|
||||
manageFeatures: true,
|
||||
__typename: 'PlatformPrivileges',
|
||||
},
|
||||
__typename: 'AuthenticatedUser',
|
||||
@ -262,7 +265,8 @@ describe('handleAccessRoles', () => {
|
||||
manageBusinessAttributes: true,
|
||||
manageStructuredProperties: true,
|
||||
viewStructuredPropertiesPage: true,
|
||||
|
||||
manageApplications: true,
|
||||
manageFeatures: true,
|
||||
__typename: 'PlatformPrivileges',
|
||||
},
|
||||
__typename: 'AuthenticatedUser',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppWindow,
|
||||
BookBookmark,
|
||||
Gear,
|
||||
Globe,
|
||||
@ -156,6 +157,15 @@ export const NavSidebar = () => {
|
||||
link: PageRoutes.MANAGE_TAGS,
|
||||
isHidden: !showManageTags,
|
||||
},
|
||||
{
|
||||
type: NavBarMenuItemTypes.Item,
|
||||
title: 'Applications',
|
||||
key: 'applications',
|
||||
icon: <AppWindow />,
|
||||
selectedIcon: <AppWindow weight="fill" />,
|
||||
link: PageRoutes.MANAGE_APPLICATIONS,
|
||||
isHidden: !(appConfig.config.visualConfig.application?.showApplicationInNavigation ?? false),
|
||||
},
|
||||
{
|
||||
type: NavBarMenuItemTypes.Item,
|
||||
title: 'Domains',
|
||||
|
||||
@ -5,8 +5,10 @@ import styled from 'styled-components';
|
||||
|
||||
import analytics, { EventType } from '@app/analytics';
|
||||
import { useUserContext } from '@app/context/useUserContext';
|
||||
import { useAppConfig } from '@app/useAppConfig';
|
||||
import { useIsThemeV2, useIsThemeV2EnabledForUser, useIsThemeV2Toggleable } from '@app/useIsThemeV2';
|
||||
|
||||
import { useUpdateApplicationsSettingsMutation } from '@graphql/app.generated';
|
||||
import { useUpdateUserSettingMutation } from '@graphql/me.generated';
|
||||
import { UserSetting } from '@types';
|
||||
|
||||
@ -28,6 +30,7 @@ const StyledCard = styled.div`
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const SourceContainer = styled.div`
|
||||
@ -72,11 +75,18 @@ export const Preferences = () => {
|
||||
const isThemeV2 = useIsThemeV2();
|
||||
const [isThemeV2Toggleable] = useIsThemeV2Toggleable();
|
||||
const [isThemeV2EnabledForUser] = useIsThemeV2EnabledForUser();
|
||||
const userContext = useUserContext();
|
||||
const appConfig = useAppConfig();
|
||||
|
||||
const showSimplifiedHomepage = !!user?.settings?.appearance?.showSimplifiedHomepage;
|
||||
|
||||
const applicationsEnabled = appConfig.config?.visualConfig?.application?.showApplicationInNavigation ?? false;
|
||||
|
||||
const [updateUserSettingMutation] = useUpdateUserSettingMutation();
|
||||
const [updateApplicationsSettingsMutation] = useUpdateApplicationsSettingsMutation();
|
||||
|
||||
const showSimplifiedHomepageSetting = !isThemeV2;
|
||||
const canManageApplicationAppearance = userContext?.platformPrivileges?.manageFeatures;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@ -157,7 +167,35 @@ export const Preferences = () => {
|
||||
</StyledCard>
|
||||
</>
|
||||
)}
|
||||
{!showSimplifiedHomepageSetting && !isThemeV2Toggleable && (
|
||||
{canManageApplicationAppearance && (
|
||||
<StyledCard>
|
||||
<UserSettingRow>
|
||||
<TextContainer>
|
||||
<SettingText>Show Applications</SettingText>
|
||||
<DescriptionText>
|
||||
Applications are another way to organize your data, similar to Domains. They are
|
||||
hidden by default.
|
||||
</DescriptionText>
|
||||
</TextContainer>
|
||||
<Switch
|
||||
label=""
|
||||
checked={applicationsEnabled}
|
||||
onChange={async () => {
|
||||
await updateApplicationsSettingsMutation({
|
||||
variables: {
|
||||
input: {
|
||||
enabled: !applicationsEnabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
message.success({ content: 'Setting updated!', duration: 2 });
|
||||
appConfig?.refreshContext();
|
||||
}}
|
||||
/>
|
||||
</UserSettingRow>
|
||||
</StyledCard>
|
||||
)}
|
||||
{!showSimplifiedHomepageSetting && !isThemeV2Toggleable && !canManageApplicationAppearance && (
|
||||
<div style={{ color: colors.gray[1700] }}>No appearance settings found.</div>
|
||||
)}
|
||||
</SourceContainer>
|
||||
|
||||
@ -35,6 +35,7 @@ export enum PageRoutes {
|
||||
// Temporary route to view all data products
|
||||
DATA_PRODUCTS = '/search?filter__entityType___false___EQUAL___0=DATA_PRODUCT&page=1&query=%2A&unionType=0',
|
||||
MANAGE_TAGS = '/tags',
|
||||
MANAGE_APPLICATIONS = '/applications',
|
||||
}
|
||||
|
||||
export enum HelpLinkRoutes {
|
||||
|
||||
@ -54,6 +54,7 @@ query appConfig {
|
||||
}
|
||||
application {
|
||||
showSidebarSectionWhenEmpty
|
||||
showApplicationInNavigation
|
||||
}
|
||||
}
|
||||
telemetryConfig {
|
||||
@ -138,3 +139,7 @@ mutation updateGlobalViewsSettings($input: UpdateGlobalViewsSettingsInput!) {
|
||||
mutation updateDocPropagationSettings($input: UpdateDocPropagationSettingsInput!) {
|
||||
updateDocPropagationSettings(input: $input)
|
||||
}
|
||||
|
||||
mutation updateApplicationsSettings($input: UpdateApplicationsSettingsInput!) {
|
||||
updateApplicationsSettings(input: $input)
|
||||
}
|
||||
|
||||
@ -61,6 +61,8 @@ query getMe {
|
||||
manageBusinessAttributes
|
||||
manageStructuredProperties
|
||||
viewStructuredPropertiesPage
|
||||
manageApplications
|
||||
manageFeatures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
namespace com.linkedin.settings.global
|
||||
|
||||
|
||||
record ApplicationsSettings includes FeatureSettings {}
|
||||
@ -16,7 +16,7 @@ record GlobalSettingsInfo {
|
||||
/**
|
||||
* Settings related to the Views Feature
|
||||
*/
|
||||
views: optional GlobalViewsSettings
|
||||
views: optional GlobalViewsSettings
|
||||
|
||||
/**
|
||||
* Settings related to the documentation propagation feature
|
||||
@ -30,4 +30,9 @@ record GlobalSettingsInfo {
|
||||
* Global settings related to the home page for an instance
|
||||
*/
|
||||
homePage: optional GlobalHomePageSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings related to applications. If not enabled, applications won't show up in navigation
|
||||
*/
|
||||
applications: optional ApplicationsSettings
|
||||
}
|
||||
|
||||
@ -4,9 +4,6 @@ import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ApplicationConfig {
|
||||
/**
|
||||
* Whether to show the application sidebar section even when empty - will add noise to the UI for
|
||||
* teams that don't use applications.
|
||||
*/
|
||||
/** DEPRECATED: This is now controlled via the UI settings. */
|
||||
public boolean showSidebarSectionWhenEmpty;
|
||||
}
|
||||
|
||||
@ -32,5 +32,6 @@ public class VisualConfiguration {
|
||||
/** Boolean flag enabled shows the full title of an entity in lineage view by default */
|
||||
public boolean showFullTitleInLineage;
|
||||
|
||||
/** DEPRECATED: This is now controlled via the UI settings. */
|
||||
public ApplicationConfig application;
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@ visualConfig:
|
||||
# we only support default tab for domains right now. In order to implement for other entities, update React code
|
||||
domainDefaultTab: ${DOMAIN_DEFAULT_TAB:} # set to DOCUMENTATION_TAB to show documentation tab first
|
||||
application:
|
||||
# DEPRECATED: This is now controlled via the UI settings.
|
||||
showSidebarSectionWhenEmpty: ${APPLICATION_SHOW_SIDEBAR_SECTION_WHEN_EMPTY:false}
|
||||
searchResult:
|
||||
enableNameHighlight: ${SEARCH_RESULT_NAME_HIGHLIGHT_ENABLED:true} # Enables visual highlighting on search result names/descriptions.
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
describe("manage applications", () => {
|
||||
it("Manage Applications Page - Verify search bar placeholder", () => {
|
||||
cy.login();
|
||||
cy.visit("/applications");
|
||||
cy.get('[data-testid="application-search-input"]').should(
|
||||
"have.attr",
|
||||
"placeholder",
|
||||
"Search applications...",
|
||||
);
|
||||
});
|
||||
|
||||
it("Manage Applications Page - Verify search not exists", () => {
|
||||
cy.login();
|
||||
cy.visit("/applications");
|
||||
cy.get('[data-testid="page-title"]').should(
|
||||
"contain.text",
|
||||
"Manage Applications",
|
||||
);
|
||||
|
||||
cy.get('[data-testid="application-search-input"]', {
|
||||
timeout: 10000,
|
||||
}).should("be.visible");
|
||||
|
||||
// wait for the text "loading data" to disappear
|
||||
cy.get("body").should("not.contain", "loading data");
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[id="application-search-input"]').type("testtestnomatch", {
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[data-testid="applications-not-found"]').should(
|
||||
"contain.text",
|
||||
"No applications found for your search query",
|
||||
);
|
||||
});
|
||||
|
||||
it("Manage Applications Page - Verify create and delete", () => {
|
||||
cy.login();
|
||||
cy.visit("/applications");
|
||||
cy.get('[data-testid="page-title"]').should(
|
||||
"contain.text",
|
||||
"Manage Applications",
|
||||
);
|
||||
|
||||
// cy.get('Create Application', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
// wait for the text "loading data" to disappear
|
||||
cy.get("body").should("not.contain", "loading data");
|
||||
|
||||
cy.wait(500);
|
||||
|
||||
cy.contains("button", "Create Application").click();
|
||||
|
||||
cy.wait(500);
|
||||
|
||||
cy.get('[data-testid="application-name-input"]').type("test-new-name");
|
||||
|
||||
cy.get('[data-testid="application-description-input"]').type(
|
||||
"test new description",
|
||||
);
|
||||
|
||||
cy.get('[id="createNewApplicationButton"]').click();
|
||||
|
||||
// refresh the page
|
||||
cy.reload();
|
||||
|
||||
cy.get("body").should("contain", "test-new-name");
|
||||
|
||||
// Find the application row by name and delete it
|
||||
// First, wait for the page to load completely
|
||||
cy.get("body").should("not.contain", "loading data");
|
||||
|
||||
// Find the table row containing the application name and click its actions dropdown
|
||||
cy.contains("tr", "test-new-name").within(() => {
|
||||
cy.get('[data-testid$="MoreVertOutlinedIcon"]').click();
|
||||
});
|
||||
|
||||
// Click the delete option from the dropdown menu
|
||||
cy.get('[data-testid="action-delete"]').click();
|
||||
|
||||
// Verify the confirmation modal appears
|
||||
cy.contains("Delete Application").should("be.visible");
|
||||
|
||||
// Handle the confirmation modal
|
||||
cy.contains("button", "Delete").click();
|
||||
|
||||
cy.wait(500);
|
||||
|
||||
cy.reload();
|
||||
|
||||
cy.get("body").should("not.contain", "test-new-name");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user