feat(home) Get correct home page template and render rows/modules (#13978)

This commit is contained in:
Chris Collins 2025-07-08 17:47:11 -04:00 committed by GitHub
parent 8fdee30d5f
commit b9b97e4bfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 379 additions and 61 deletions

View File

@ -202,6 +202,7 @@ 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.homePage.GlobalHomePageSettingsResolver;
import com.linkedin.datahub.graphql.resolvers.settings.user.UpdateCorpUserViewsSettingsResolver;
import com.linkedin.datahub.graphql.resolvers.settings.user.UpdateUserHomePageSettingsResolver;
import com.linkedin.datahub.graphql.resolvers.settings.view.GlobalViewsSettingsResolver;
@ -771,6 +772,8 @@ public class GmsGraphQLEngine {
configureMetadataAttributionResolver(builder);
configureVersionPropertiesResolvers(builder);
configureVersionSetResolvers(builder);
configureGlobalHomePageSettingsResolvers(builder);
configurePageTemplateRowResolvers(builder);
}
private void configureOrganisationRoleResolvers(RuntimeWiring.Builder builder) {
@ -1088,7 +1091,10 @@ public class GmsGraphQLEngine {
"listBusinessAttributes", new ListBusinessAttributesResolver(this.entityClient))
.dataFetcher(
"docPropagationSettings",
new DocPropagationSettingsResolver(this.settingsService)));
new DocPropagationSettingsResolver(this.settingsService))
.dataFetcher(
"globalHomePageSettings",
new GlobalHomePageSettingsResolver(this.settingsService)));
}
private DataFetcher getEntitiesResolver() {
@ -3498,4 +3504,35 @@ public class GmsGraphQLEngine {
"versionsSearch",
new VersionsSearchResolver(this.entityClient, this.viewService)));
}
private void configureGlobalHomePageSettingsResolvers(final RuntimeWiring.Builder builder) {
builder.type(
"GlobalHomePageSettings",
typeWiring ->
typeWiring.dataFetcher(
"defaultTemplate",
new LoadableTypeResolver<>(
dataHubPageTemplateType,
(env) -> {
final GlobalHomePageSettings homePageSettings = env.getSource();
return homePageSettings.getDefaultTemplate() != null
? homePageSettings.getDefaultTemplate().getUrn()
: null;
})));
}
private void configurePageTemplateRowResolvers(final RuntimeWiring.Builder builder) {
builder.type(
"DataHubPageTemplateRow",
typeWiring ->
typeWiring.dataFetcher(
"modules",
new LoadableTypeBatchResolver<>(
dataHubPageModuleType,
(env) ->
((DataHubPageTemplateRow) env.getSource())
.getModules().stream()
.map(DataHubPageModule::getUrn)
.collect(Collectors.toList()))));
}
}

View File

@ -0,0 +1,63 @@
package com.linkedin.datahub.graphql.resolvers.settings.homePage;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
import com.linkedin.datahub.graphql.generated.DataHubPageTemplate;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.generated.GlobalHomePageSettings;
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;
import lombok.extern.slf4j.Slf4j;
/** Retrieves the Global Settings related to the Home Page feature. */
@Slf4j
public class GlobalHomePageSettingsResolver
implements DataFetcher<CompletableFuture<GlobalHomePageSettings>> {
private final SettingsService _settingsService;
public GlobalHomePageSettingsResolver(final SettingsService settingsService) {
_settingsService = Objects.requireNonNull(settingsService, "settingsService must not be null");
}
@Override
public CompletableFuture<GlobalHomePageSettings> get(final DataFetchingEnvironment environment)
throws Exception {
final QueryContext context = environment.getContext();
return GraphQLConcurrencyUtils.supplyAsync(
() -> {
try {
final GlobalSettingsInfo globalSettings =
_settingsService.getGlobalSettings(context.getOperationContext());
final GlobalHomePageSettings defaultSettings = new GlobalHomePageSettings();
return globalSettings != null && globalSettings.hasHomePage()
? mapGlobalHomePageSettings(globalSettings.getHomePage())
: defaultSettings;
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve Global Home Page Settings", e);
}
},
this.getClass().getSimpleName(),
"get");
}
private static GlobalHomePageSettings mapGlobalHomePageSettings(
@Nonnull final com.linkedin.settings.global.GlobalHomePageSettings settings) {
final GlobalHomePageSettings result = new GlobalHomePageSettings();
// Map defaultTemplate settings field
if (settings.hasDefaultTemplate()) {
DataHubPageTemplate template = new DataHubPageTemplate();
template.setUrn(settings.getDefaultTemplate().toString());
template.setType(EntityType.DATAHUB_PAGE_TEMPLATE);
result.setDefaultTemplate(template);
}
return result;
}
}

View File

@ -0,0 +1,117 @@
package com.linkedin.datahub.graphql.resolvers.settings.homePage;
import static com.linkedin.datahub.graphql.TestUtils.*;
import static org.mockito.ArgumentMatchers.any;
import static org.testng.Assert.*;
import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.data.template.SetMode;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.DataHubPageTemplate;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.metadata.service.SettingsService;
import com.linkedin.settings.global.GlobalHomePageSettings;
import com.linkedin.settings.global.GlobalSettingsInfo;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletionException;
import org.mockito.Mockito;
import org.testng.Assert;
import org.testng.annotations.Test;
public class GlobalHomePageSettingsResolverTest {
private static final Urn TEST_TEMPLATE_URN =
UrnUtils.getUrn("urn:li:dataHubPageTemplate:test-template");
private static final Urn TEST_USER_URN = UrnUtils.getUrn("urn:li:corpuser:test");
@Test
public void testGetSuccessNullSettings() throws Exception {
SettingsService mockService = initSettingsService(null);
GlobalHomePageSettingsResolver resolver = new GlobalHomePageSettingsResolver(mockService);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
com.linkedin.datahub.graphql.generated.GlobalHomePageSettings result =
resolver.get(mockEnv).get();
Assert.assertNotNull(result); // Empty settings
Assert.assertNull(result.getDefaultTemplate());
}
@Test
public void testGetSuccessEmptySettings() throws Exception {
SettingsService mockService = initSettingsService(new GlobalHomePageSettings());
GlobalHomePageSettingsResolver resolver = new GlobalHomePageSettingsResolver(mockService);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
com.linkedin.datahub.graphql.generated.GlobalHomePageSettings result =
resolver.get(mockEnv).get();
Assert.assertNull(result.getDefaultTemplate());
}
@Test
public void testGetSuccessExistingSettings() throws Exception {
SettingsService mockService =
initSettingsService(new GlobalHomePageSettings().setDefaultTemplate(TEST_TEMPLATE_URN));
GlobalHomePageSettingsResolver resolver = new GlobalHomePageSettingsResolver(mockService);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
com.linkedin.datahub.graphql.generated.GlobalHomePageSettings result =
resolver.get(mockEnv).get();
Assert.assertNotNull(result.getDefaultTemplate());
DataHubPageTemplate template = result.getDefaultTemplate();
Assert.assertEquals(template.getUrn(), TEST_TEMPLATE_URN.toString());
Assert.assertEquals(template.getType(), EntityType.DATAHUB_PAGE_TEMPLATE);
}
@Test
public void testGetSuccessSettingsWithoutDefaultTemplate() throws Exception {
SettingsService mockService = initSettingsService(new GlobalHomePageSettings());
GlobalHomePageSettingsResolver resolver = new GlobalHomePageSettingsResolver(mockService);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
com.linkedin.datahub.graphql.generated.GlobalHomePageSettings result =
resolver.get(mockEnv).get();
Assert.assertNull(result.getDefaultTemplate());
}
@Test
public void testGetException() throws Exception {
SettingsService mockService = Mockito.mock(SettingsService.class);
Mockito.doThrow(RuntimeException.class).when(mockService).getGlobalSettings(any());
GlobalHomePageSettingsResolver resolver = new GlobalHomePageSettingsResolver(mockService);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
}
private static SettingsService initSettingsService(
GlobalHomePageSettings existingHomePageSettings) {
SettingsService mockService = Mockito.mock(SettingsService.class);
Mockito.when(mockService.getGlobalSettings(any()))
.thenReturn(
new GlobalSettingsInfo().setHomePage(existingHomePageSettings, SetMode.IGNORE_NULL));
return mockService;
}
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import EntityRegistryProvider from '@app/EntityRegistryProvider';
import GlobalSettingsProvider from '@app/context/GlobalSettingsProvider';
import UserContextProvider from '@app/context/UserContextProvider';
import { NavBarProvider } from '@app/homeV2/layout/navBarRedesign/NavBarContext';
import SearchContextProvider from '@app/search/context/SearchContextProvider';
@ -16,19 +17,21 @@ interface Props {
export default function AppProviders({ children }: Props) {
return (
<AppConfigProvider>
<UserContextProvider>
<EntityRegistryProvider>
<BrowserTitleProvider>
<EducationStepsProvider>
<QuickFiltersProvider>
<SearchContextProvider>
<NavBarProvider>{children}</NavBarProvider>
</SearchContextProvider>
</QuickFiltersProvider>
</EducationStepsProvider>
</BrowserTitleProvider>
</EntityRegistryProvider>
</UserContextProvider>
<GlobalSettingsProvider>
<UserContextProvider>
<EntityRegistryProvider>
<BrowserTitleProvider>
<EducationStepsProvider>
<QuickFiltersProvider>
<SearchContextProvider>
<NavBarProvider>{children}</NavBarProvider>
</SearchContextProvider>
</QuickFiltersProvider>
</EducationStepsProvider>
</BrowserTitleProvider>
</EntityRegistryProvider>
</UserContextProvider>
</GlobalSettingsProvider>
</AppConfigProvider>
);
}

View File

@ -0,0 +1,18 @@
import React, { useContext } from 'react';
import { GetHomePageSettingsQuery } from '@graphql/app.generated';
export const DEFAULT_GLOBAL_SETTINGS = {
globalHomePageSettings: {
defaultTemplate: null,
},
};
export const GlobalSettingsContext = React.createContext<{
settings: GetHomePageSettingsQuery;
loaded: boolean;
}>({ settings: DEFAULT_GLOBAL_SETTINGS, loaded: false });
export function useGlobalSettings() {
return useContext(GlobalSettingsContext);
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import { DEFAULT_GLOBAL_SETTINGS, GlobalSettingsContext } from '@app/context/GlobalSettingsContext';
import { useGetHomePageSettingsQuery } from '@graphql/app.generated';
export default function GlobalSettingsProvider({ children }: { children: React.ReactNode }) {
const { data } = useGetHomePageSettingsQuery();
return (
<GlobalSettingsContext.Provider
value={{
settings: data || DEFAULT_GLOBAL_SETTINGS,
loaded: !!data,
}}
>
{children}
</GlobalSettingsContext.Provider>
);
}

View File

@ -10,7 +10,11 @@ const MAX_ASSETS_TO_FETCH = 50;
export const useGetAssetsYouOwn = (user?: CorpUser | null, count = MAX_ASSETS_TO_FETCH) => {
const { groupUrns, loading: groupDataLoading } = useGetUserGroupUrns(user?.urn);
const { loading, data, error } = useGetSearchResultsForMultipleQuery({
const {
loading: searchLoading,
data,
error,
} = useGetSearchResultsForMultipleQuery({
variables: {
input: {
query: '*',
@ -38,6 +42,7 @@ export const useGetAssetsYouOwn = (user?: CorpUser | null, count = MAX_ASSETS_TO
const entities =
originEntities.map((entity) => entityRegistry.getGenericEntityProperties(entity.type, entity)) || [];
const total = data?.searchAcrossEntities?.total || 0;
const loading = searchLoading || groupDataLoading || !data;
return { originEntities, entities, loading: loading || groupDataLoading, error, total };
return { originEntities, entities, loading, error, total };
};

View File

@ -1,34 +1,26 @@
import React from 'react';
import { useGlobalSettings } from '@app/context/GlobalSettingsContext';
import { useUserContext } from '@app/context/useUserContext';
import { Announcements } from '@app/homeV3/announcements/Announcements';
import Module from '@app/homeV3/module/Module';
import { ModuleProps } from '@app/homeV3/module/types';
import { CenteredContainer, ContentContainer, ContentDiv } from '@app/homeV3/styledComponents';
const SAMPLE_MODULES: ModuleProps[] = [
{
name: 'Your Assets',
description: 'These are assets you are the owner',
type: 'yourAssets',
visibility: 'personal',
},
{
name: 'Sample large module',
description: 'Description of the sample module',
type: 'sampleLarge',
visibility: 'global',
},
];
import TemplateRow from '@app/homeV3/templateRow/TemplateRow';
const HomePageContent = () => {
const { settings } = useGlobalSettings();
const { user } = useUserContext();
const template = user?.settings?.homePage?.pageTemplate || settings.globalHomePageSettings?.defaultTemplate;
return (
<ContentContainer>
<CenteredContainer>
<ContentDiv>
<Announcements />
{SAMPLE_MODULES.map((sampleModule) => (
<Module {...sampleModule} key={sampleModule.name} />
))}
{template?.properties.rows.map((row, i) => {
const key = `templateRow-${i}`;
return <TemplateRow key={key} row={row} />;
})}
</ContentDiv>
</CenteredContainer>
</ContentContainer>

View File

@ -4,13 +4,16 @@ import { ModuleProps } from '@app/homeV3/module/types';
import SampleLargeModule from '@app/homeV3/modules/SampleLargeModule';
import YourAssetsModule from '@app/homeV3/modules/YourAssetsModule';
export default function Module(props: ModuleProps) {
const Component = useMemo(() => {
// TODO: implement logic to map props.type to component
if (props.type === 'sampleLarge') return SampleLargeModule;
if (props.type === 'yourAssets') return YourAssetsModule;
return SampleLargeModule;
}, [props.type]);
import { DataHubPageModuleType } from '@types';
return <Component {...props} />;
export default function Module(props: ModuleProps) {
const { module } = props;
const Component = useMemo(() => {
if (module.properties.type === DataHubPageModuleType.OwnedAssets) return YourAssetsModule;
// TODO: remove the sample large module once we have other modules to fill this out
console.error(`Issue finding module with type ${module.properties.type}`);
return SampleLargeModule;
}, [module.properties.type]);
return <Component module={module} />;
}

View File

@ -3,10 +3,8 @@ import React from 'react';
import styled from 'styled-components';
import ModuleContainer from '@app/homeV3/module/components/ModuleContainer';
import ModuleDescription from '@app/homeV3/module/components/ModuleDescription';
import ModuleMenu from '@app/homeV3/module/components/ModuleMenu';
import ModuleName from '@app/homeV3/module/components/ModuleName';
import PublicModuleBadge from '@app/homeV3/module/components/PublicModuleBadge';
import { ModuleProps } from '@app/homeV3/module/types';
const ModuleHeader = styled.div`
@ -51,20 +49,15 @@ interface Props extends ModuleProps {
loading?: boolean;
}
export default function LargeModule({
children,
name,
description,
visibility,
loading,
}: React.PropsWithChildren<Props>) {
export default function LargeModule({ children, module, loading }: React.PropsWithChildren<Props>) {
const { name } = module.properties;
return (
<ModuleContainer $height="316px">
<ModuleHeader>
<ModuleName text={name} />
<ModuleDescription text={description} />
{/* TODO: implement description for modules CH-548 */}
{/* <ModuleDescription text={description} /> */}
<FloatingRightHeaderSection>
<PublicModuleBadge isPublic={visibility === 'global'} />
<ModuleMenu />
</FloatingRightHeaderSection>
</ModuleHeader>

View File

@ -5,6 +5,7 @@ const ModuleContainer = styled.div<{ $height: string }>`
background: ${colors.white};
border: ${borders['1px']} ${colors.gray[100]};
border-radius: ${radius.lg};
flex: 1;
height: ${(props) => props.$height};
box-shadow:

View File

@ -1,9 +1,7 @@
// TODO: adapt to DataHubPageModuleProperties
import { PageModuleFragment } from '@graphql/template.generated';
// the current props are just to draft some components
export interface ModuleProps {
name: string;
type: string;
visibility: 'personal' | 'global';
description?: string;
isPublic?: boolean;
module: PageModuleFragment;
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import styled from 'styled-components';
import Module from '@app/homeV3/module/Module';
import { PageTemplateRowFragment } from '@graphql/template.generated';
const RowWrapper = styled.div`
display: flex;
gap: 16px;
width: 100%;
flex: 1;
`;
interface Props {
row: PageTemplateRowFragment;
}
export default function TemplateRow({ row }: Props) {
return (
<RowWrapper>
{row.modules.map((module) => (
<Module key={module.urn} module={module} />
))}
</RowWrapper>
);
}

View File

@ -132,6 +132,14 @@ query getDocPropagationSettings {
}
}
query getHomePageSettings {
globalHomePageSettings {
defaultTemplate {
...pageTemplateFields
}
}
}
mutation updateGlobalViewsSettings($input: UpdateGlobalViewsSettingsInput!) {
updateGlobalViewsSettings(input: $input)
}

View File

@ -37,7 +37,7 @@ query getMe {
}
homePage {
pageTemplate {
urn
...pageTemplateFields
}
dismissedAnnouncementUrns
}

View File

@ -0,0 +1,33 @@
fragment pageTemplateFields on DataHubPageTemplate {
urn
type
properties {
rows {
...PageTemplateRow
}
surface {
surfaceType
}
visibility {
scope
}
}
}
fragment PageTemplateRow on DataHubPageTemplateRow {
modules {
...PageModule
}
}
fragment PageModule on DataHubPageModule {
urn
type
properties {
name
type
visibility {
scope
}
}
}