feat(applications): Manage applications screen (#13814)

This commit is contained in:
Gabe Lyons 2025-07-08 06:50:33 -07:00 committed by GitHub
parent 5da40ec91e
commit 1228f9b1de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1225 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
"""

View File

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

View File

@ -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 = {

View File

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

View 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;

View File

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

View File

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

View File

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

View 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;

View 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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,6 +61,8 @@ query getMe {
manageBusinessAttributes
manageStructuredProperties
viewStructuredPropertiesPage
manageApplications
manageFeatures
}
}
}

View File

@ -0,0 +1,4 @@
namespace com.linkedin.settings.global
record ApplicationsSettings includes FeatureSettings {}

View File

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

View File

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

View File

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

View File

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

View File

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