feat(homepage): build announcements functionality on the new homepage (#13938)

This commit is contained in:
purnimagarg1 2025-07-08 23:46:00 +05:30 committed by GitHub
parent 2c4d629b53
commit d2d9d36987
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 924 additions and 126 deletions

View File

@ -203,6 +203,7 @@ import com.linkedin.datahub.graphql.resolvers.settings.applications.UpdateApplic
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;
import com.linkedin.datahub.graphql.resolvers.settings.user.UpdateUserHomePageSettingsResolver;
import com.linkedin.datahub.graphql.resolvers.settings.view.GlobalViewsSettingsResolver;
import com.linkedin.datahub.graphql.resolvers.settings.view.UpdateGlobalViewsSettingsResolver;
import com.linkedin.datahub.graphql.resolvers.siblings.SiblingsSearchResolver;
@ -1292,6 +1293,9 @@ public class GmsGraphQLEngine {
.dataFetcher(
"updateCorpUserViewsSettings",
new UpdateCorpUserViewsSettingsResolver(this.settingsService))
.dataFetcher(
"updateUserHomePageSettings",
new UpdateUserHomePageSettingsResolver(this.settingsService))
.dataFetcher(
"updateLineage",
new UpdateLineageResolver(this.entityService, this.lineageService))

View File

@ -0,0 +1,114 @@
package com.linkedin.datahub.graphql.resolvers.settings.user;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
import com.linkedin.common.UrnArray;
import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
import com.linkedin.datahub.graphql.generated.UpdateUserHomePageSettingsInput;
import com.linkedin.identity.CorpUserAppearanceSettings;
import com.linkedin.identity.CorpUserHomePageSettings;
import com.linkedin.identity.CorpUserSettings;
import com.linkedin.metadata.service.SettingsService;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nonnull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/** Resolver responsible for updating the authenticated user's HomePage-specific settings. */
@Slf4j
@RequiredArgsConstructor
public class UpdateUserHomePageSettingsResolver implements DataFetcher<CompletableFuture<Boolean>> {
private final SettingsService _settingsService;
@Override
public CompletableFuture<Boolean> get(DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
final UpdateUserHomePageSettingsInput input =
bindArgument(environment.getArgument("input"), UpdateUserHomePageSettingsInput.class);
return GraphQLConcurrencyUtils.supplyAsync(
() -> {
try {
final Urn userUrn = UrnUtils.getUrn(context.getActorUrn());
final CorpUserSettings maybeSettings =
_settingsService.getCorpUserSettings(context.getOperationContext(), userUrn);
final CorpUserSettings newSettings =
maybeSettings == null
? new CorpUserSettings()
.setAppearance(
new CorpUserAppearanceSettings().setShowSimplifiedHomepage(false))
: maybeSettings;
// Patch the new corp user settings. This does a R-M-F.
updateCorpUserSettings(newSettings, input);
_settingsService.updateCorpUserSettings(
context.getOperationContext(), userUrn, newSettings);
return true;
} catch (Exception e) {
log.error(
"Failed to perform user home page settings update against input {}, {}",
input.toString(),
e.getMessage());
throw new RuntimeException(
String.format(
"Failed to perform update to user home page settings against input %s",
input.toString()),
e);
}
},
this.getClass().getSimpleName(),
"get");
}
private static void updateCorpUserSettings(
@Nonnull final CorpUserSettings settings,
@Nonnull final UpdateUserHomePageSettingsInput input) {
final CorpUserHomePageSettings newHomePageSettings =
settings.hasHomePage() ? settings.getHomePage() : new CorpUserHomePageSettings();
updateCorpUserHomePageSettings(newHomePageSettings, input);
settings.setHomePage(newHomePageSettings);
}
private static void updateCorpUserHomePageSettings(
@Nonnull final CorpUserHomePageSettings settings,
@Nonnull final UpdateUserHomePageSettingsInput input) {
if (input.getPageTemplate() != null) {
settings.setPageTemplate(UrnUtils.getUrn(input.getPageTemplate()));
}
// Append to the list of existing dismissed announcements
if (input.getNewDismissedAnnouncements() != null) {
List<Urn> dismissedAnnouncements =
settings.hasDismissedAnnouncements()
? new ArrayList<>(settings.getDismissedAnnouncements())
: new ArrayList<>();
for (String announcement : input.getNewDismissedAnnouncements()) {
try {
Urn urn = Urn.createFromString(announcement);
if (!dismissedAnnouncements.contains(urn)) {
dismissedAnnouncements.add(urn);
}
} catch (URISyntaxException e) {
log.error("Invalid URN: ", announcement, e.getMessage());
throw new RuntimeException(e);
}
}
settings.setDismissedAnnouncements(new UrnArray(dismissedAnnouncements));
}
}
}

View File

@ -11,11 +11,14 @@ import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.featureflags.FeatureFlags;
import com.linkedin.datahub.graphql.generated.CorpUser;
import com.linkedin.datahub.graphql.generated.CorpUserAppearanceSettings;
import com.linkedin.datahub.graphql.generated.CorpUserHomePageSettings;
import com.linkedin.datahub.graphql.generated.CorpUserProperties;
import com.linkedin.datahub.graphql.generated.CorpUserViewsSettings;
import com.linkedin.datahub.graphql.generated.DataHubPageTemplate;
import com.linkedin.datahub.graphql.generated.DataHubView;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.types.common.mappers.CustomPropertiesMapper;
import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper;
import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper;
import com.linkedin.datahub.graphql.types.form.FormsMapper;
import com.linkedin.datahub.graphql.types.structuredproperty.StructuredPropertiesMapper;
@ -30,6 +33,8 @@ import com.linkedin.identity.CorpUserSettings;
import com.linkedin.identity.CorpUserStatus;
import com.linkedin.metadata.key.CorpUserKey;
import com.linkedin.structured.StructuredProperties;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@ -118,6 +123,11 @@ public class CorpUserMapper {
result.setViews(mapCorpUserViewsSettings(corpUserSettings.getViews()));
}
// Map Home page Settings.
if (corpUserSettings.hasHomePage()) {
result.setHomePage(mapCorpUserHomePageSettings(corpUserSettings.getHomePage()));
}
corpUser.setSettings(result);
}
@ -154,6 +164,27 @@ public class CorpUserMapper {
return viewsResult;
}
@Nonnull
private CorpUserHomePageSettings mapCorpUserHomePageSettings(
@Nonnull final com.linkedin.identity.CorpUserHomePageSettings homePageSettings) {
CorpUserHomePageSettings result = new CorpUserHomePageSettings();
if (homePageSettings.hasPageTemplate()) {
result.setPageTemplate(
(DataHubPageTemplate) UrnToEntityMapper.map(null, homePageSettings.getPageTemplate()));
}
if (homePageSettings.hasDismissedAnnouncements()) {
List<String> dismissedUrnStrings =
homePageSettings.getDismissedAnnouncements().stream()
.map(Urn::toString)
.collect(Collectors.toList());
result.setDismissedAnnouncementUrns(dismissedUrnStrings);
}
return result;
}
private void mapCorpUserKey(@Nonnull CorpUser corpUser, @Nonnull DataMap dataMap) {
CorpUserKey corpUserKey = new CorpUserKey(dataMap);
corpUser.setUsername(corpUserKey.getUsername());

View File

@ -768,6 +768,11 @@ type Mutation {
"""
updateCorpUserViewsSettings(input: UpdateCorpUserViewsSettingsInput!): Boolean
"""
Update the HomePage-related settings for a user.
"""
updateUserHomePageSettings(input: UpdateUserHomePageSettingsInput!): Boolean
"""
Update a user setting
"""
@ -4239,6 +4244,11 @@ type CorpUserHomePageSettings {
The default page template for the User.
"""
pageTemplate: DataHubPageTemplate
"""
List of urns of the announcements dismissed by the User.
"""
dismissedAnnouncementUrns: [String]
}
"""
@ -12446,6 +12456,21 @@ input UpdateCorpUserViewsSettingsInput {
defaultView: String
}
"""
Input required to update a user's home page settings.
"""
input UpdateUserHomePageSettingsInput {
"""
The URN of the page template to be rendered on the home page for the user.
"""
pageTemplate: String
"""
The list of urns of announcement posts dismissed by the user.
"""
newDismissedAnnouncements: [String]
}
"""
Information required to render an embedded version of an asset
"""

View File

@ -0,0 +1,197 @@
package com.linkedin.datahub.graphql.resolvers.settings.user;
import static com.linkedin.datahub.graphql.TestUtils.*;
import static org.mockito.ArgumentMatchers.any;
import static org.testng.Assert.*;
import com.linkedin.common.UrnArray;
import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.UpdateUserHomePageSettingsInput;
import com.linkedin.identity.CorpUserAppearanceSettings;
import com.linkedin.identity.CorpUserHomePageSettings;
import com.linkedin.identity.CorpUserSettings;
import com.linkedin.metadata.service.SettingsService;
import graphql.schema.DataFetchingEnvironment;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.CompletionException;
import org.mockito.Mockito;
import org.testng.annotations.Test;
public class UpdateUserHomePageSettingsResolverTest {
private static final Urn TEST_USER_URN = UrnUtils.getUrn("urn:li:corpuser:test");
private static final UpdateUserHomePageSettingsInput TEST_INPUT =
new UpdateUserHomePageSettingsInput();
private static final UpdateUserHomePageSettingsInput TEST_INPUT_WITH_TEMPLATE =
new UpdateUserHomePageSettingsInput();
private static final UpdateUserHomePageSettingsInput TEST_INPUT_NO_DISMISSED =
new UpdateUserHomePageSettingsInput();
static {
TEST_INPUT.setNewDismissedAnnouncements(Arrays.asList("urn:li:post:a1", "urn:li:post:a2"));
TEST_INPUT_WITH_TEMPLATE.setPageTemplate("urn:li:pageTemplate:homepage");
TEST_INPUT_WITH_TEMPLATE.setNewDismissedAnnouncements(
Collections.singletonList("urn:li:post:a3"));
}
@Test
public void testUpdateSettingsWithNoExistingSettings() throws Exception {
SettingsService mockService = initSettingsService(TEST_USER_URN, null);
UpdateUserHomePageSettingsResolver resolver =
new UpdateUserHomePageSettingsResolver(mockService);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertTrue(resolver.get(mockEnv).get());
CorpUserSettings expectedSettings =
new CorpUserSettings()
.setAppearance(new CorpUserAppearanceSettings().setShowSimplifiedHomepage(false))
.setHomePage(
new CorpUserHomePageSettings()
.setDismissedAnnouncements(
new UrnArray(
Arrays.asList(
Urn.createFromString("urn:li:post:a1"),
Urn.createFromString("urn:li:post:a2")))));
Mockito.verify(mockService, Mockito.times(1))
.updateCorpUserSettings(any(), Mockito.eq(TEST_USER_URN), Mockito.eq(expectedSettings));
}
@Test
public void testUpdateSettingsWithExistingSettingsAndAnnouncements() throws Exception {
CorpUserHomePageSettings existingHomePage =
new CorpUserHomePageSettings()
.setDismissedAnnouncements(
new UrnArray(Collections.singletonList(Urn.createFromString("urn:li:post:a1"))));
CorpUserSettings existingSettings =
new CorpUserSettings()
.setAppearance(new CorpUserAppearanceSettings().setShowSimplifiedHomepage(true))
.setHomePage(existingHomePage);
SettingsService mockService = initSettingsService(TEST_USER_URN, existingSettings);
UpdateUserHomePageSettingsResolver resolver =
new UpdateUserHomePageSettingsResolver(mockService);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertTrue(resolver.get(mockEnv).get());
CorpUserSettings expectedSettings =
new CorpUserSettings()
.setAppearance(new CorpUserAppearanceSettings().setShowSimplifiedHomepage(true))
.setHomePage(
new CorpUserHomePageSettings()
.setDismissedAnnouncements(
new UrnArray(
Arrays.asList(
Urn.createFromString("urn:li:post:a1"),
Urn.createFromString("urn:li:post:a2")))));
Mockito.verify(mockService, Mockito.times(1))
.updateCorpUserSettings(any(), Mockito.eq(TEST_USER_URN), Mockito.eq(expectedSettings));
}
@Test
public void testUpdateSettingsWithPageTemplate() throws Exception {
SettingsService mockService = initSettingsService(TEST_USER_URN, null);
UpdateUserHomePageSettingsResolver resolver =
new UpdateUserHomePageSettingsResolver(mockService);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_WITH_TEMPLATE);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertTrue(resolver.get(mockEnv).get());
CorpUserSettings expectedSettings =
new CorpUserSettings()
.setAppearance(new CorpUserAppearanceSettings().setShowSimplifiedHomepage(false))
.setHomePage(
new CorpUserHomePageSettings()
.setPageTemplate(UrnUtils.getUrn("urn:li:pageTemplate:homepage"))
.setDismissedAnnouncements(
new UrnArray(
Collections.singletonList(Urn.createFromString("urn:li:post:a3")))));
Mockito.verify(mockService, Mockito.times(1))
.updateCorpUserSettings(any(), Mockito.eq(TEST_USER_URN), Mockito.eq(expectedSettings));
}
@Test
public void testUpdateSettingsWithNoDismissedAnnouncements() throws Exception {
SettingsService mockService = initSettingsService(TEST_USER_URN, null);
UpdateUserHomePageSettingsResolver resolver =
new UpdateUserHomePageSettingsResolver(mockService);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_NO_DISMISSED);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertTrue(resolver.get(mockEnv).get());
CorpUserSettings expectedSettings =
new CorpUserSettings()
.setAppearance(new CorpUserAppearanceSettings().setShowSimplifiedHomepage(false))
.setHomePage(new CorpUserHomePageSettings());
Mockito.verify(mockService, Mockito.times(1))
.updateCorpUserSettings(any(), Mockito.eq(TEST_USER_URN), Mockito.eq(expectedSettings));
}
@Test
public void testGetCorpUserSettingsException() throws Exception {
SettingsService mockService = Mockito.mock(SettingsService.class);
Mockito.doThrow(RuntimeException.class)
.when(mockService)
.getCorpUserSettings(any(), Mockito.eq(TEST_USER_URN));
UpdateUserHomePageSettingsResolver resolver =
new UpdateUserHomePageSettingsResolver(mockService);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
}
@Test
public void testUpdateCorpUserSettingsException() throws Exception {
SettingsService mockService = initSettingsService(TEST_USER_URN, null);
Mockito.doThrow(RuntimeException.class)
.when(mockService)
.updateCorpUserSettings(
any(), Mockito.eq(TEST_USER_URN), Mockito.any(CorpUserSettings.class));
UpdateUserHomePageSettingsResolver resolver =
new UpdateUserHomePageSettingsResolver(mockService);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
QueryContext mockContext = getMockAllowContext(TEST_USER_URN.toString());
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
}
private static SettingsService initSettingsService(Urn user, CorpUserSettings existingSettings) {
SettingsService mockService = Mockito.mock(SettingsService.class);
Mockito.when(mockService.getCorpUserSettings(any(), Mockito.eq(user)))
.thenReturn(existingSettings);
return mockService;
}
}

View File

@ -129,6 +129,7 @@ export const user1 = {
__typename: 'CorpUserSettings',
appearance: { __typename: 'CorpUserAppearanceSettings', showSimplifiedHomepage: false, showThemeV2: false },
views: { __typename: 'CorpUserViewSettings', defaultView: null },
homePage: null,
},
editableInfo: null,
properties: null,
@ -201,6 +202,7 @@ const user2 = {
__typename: 'CorpUserSettings',
appearance: { __typename: 'CorpUserAppearanceSettings', showSimplifiedHomepage: false, showThemeV2: false },
views: { __typename: 'CorpUserViewSettings', defaultView: null },
homePage: null,
},
editableInfo: null,
info: null,

View File

@ -15,6 +15,7 @@ export const cardDefaults: CardProps = {
title: 'Title',
iconAlignment: 'horizontal',
isEmpty: false,
isCardClickable: true,
};
export const Card = ({
@ -30,6 +31,8 @@ export const Card = ({
maxWidth,
height,
isEmpty,
style,
isCardClickable = cardDefaults.isCardClickable,
}: CardProps) => {
return (
<>
@ -41,7 +44,14 @@ export const Card = ({
</TitleContainer>
</CardContainer>
) : (
<CardContainer hasButton={!!button} onClick={onClick} maxWidth={maxWidth} height={height} width={width}>
<CardContainer
isClickable={!!button && isCardClickable}
onClick={onClick}
maxWidth={maxWidth}
height={height}
width={width}
style={style}
>
<Header iconAlignment={iconAlignment}>
{icon}
<TitleContainer>

View File

@ -3,8 +3,8 @@ import styled from 'styled-components';
import { colors, radius, spacing, typography } from '@src/alchemy-components/theme';
import { IconAlignmentOptions } from '@src/alchemy-components/theme/config';
export const CardContainer = styled.div<{ hasButton?: boolean; width?: string; maxWidth?: string; height?: string }>(
({ hasButton, width, maxWidth, height }) => ({
export const CardContainer = styled.div<{ isClickable?: boolean; width?: string; maxWidth?: string; height?: string }>(
({ isClickable, width, maxWidth, height }) => ({
border: `1px solid ${colors.gray[100]}`,
borderRadius: radius.lg,
padding: spacing.md,
@ -19,7 +19,7 @@ export const CardContainer = styled.div<{ hasButton?: boolean; width?: string; m
width,
height,
'&:hover': hasButton
'&:hover': isClickable
? {
border: `1px solid ${({ theme }) => theme.styles['primary-color']}`,
cursor: 'pointer',

View File

@ -1,8 +1,8 @@
import { IconAlignmentOptions } from '@src/alchemy-components/theme/config';
export interface CardProps {
title: string;
subTitle?: string;
title: string | React.ReactNode;
subTitle?: string | React.ReactNode;
percent?: number;
button?: React.ReactNode;
onClick?: () => void;
@ -13,4 +13,6 @@ export interface CardProps {
maxWidth?: string;
height?: string;
isEmpty?: boolean;
style?: React.CSSProperties;
isCardClickable?: boolean;
}

View File

@ -20,6 +20,7 @@ export const Icon = ({
size = iconDefaults.size,
color = iconDefaults.color,
rotate = iconDefaults.rotate,
weight,
...props
}: IconProps) => {
const { filled, outlined } = getIconNames();
@ -52,6 +53,7 @@ export const Icon = ({
color: getColor(color),
}}
style={{ color: getColor(color) }}
weight={source === 'phosphor' ? weight : undefined} // Phosphor icons use 'weight' prop
/>
</IconWrapper>
);

View File

@ -15,11 +15,13 @@ const names = createEnum(AVAILABLE_ICONS);
export type IconNames = keyof typeof names;
export type MaterialIconVariant = 'filled' | 'outline';
export type PhosphorIconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone';
export type IconSource = 'material' | 'phosphor';
export interface IconPropsDefaults {
source: IconSource;
variant: MaterialIconVariant;
weight?: PhosphorIconWeight;
size: FontSizeOptions;
color: FontColorOptions;
rotate: RotationOptions;

View File

@ -87,7 +87,7 @@ export type BorderRadiusOptions = 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type BoxShadowOptions = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'inner' | 'outline' | 'dropdown' | 'none';
export type SpacingOptions = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
export type SpacingOptions = 'none' | 'normal' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
export type RotationOptions = '0' | '90' | '180' | '270';

View File

@ -10,7 +10,7 @@ import { HomePage } from '@app/home/HomePage';
import { HomePage as HomePageV2 } from '@app/homeV2/HomePage';
import { IntroduceYourself } from '@app/homeV2/introduce/IntroduceYourself';
import { useSetUserPersona } from '@app/homeV2/persona/useUserPersona';
import { HomePage as HomePageNew } from '@app/homepageV2/HomePage';
import { HomePage as HomePageV3 } from '@app/homeV3/HomePage';
import { useSetUserTitle } from '@app/identity/user/useUserTitle';
import { OnboardingContextProvider } from '@app/onboarding/OnboardingContextProvider';
import { useAppConfig } from '@app/useAppConfig';
@ -41,7 +41,7 @@ export const ProtectedRoutes = (): JSX.Element => {
let FinalHomePage;
if (isThemeV2) {
FinalHomePage = showHomepageRedesign ? HomePageNew : HomePageV2;
FinalHomePage = showHomepageRedesign ? HomePageV3 : HomePageV2;
} else {
FinalHomePage = HomePage;
}

View File

@ -6,7 +6,7 @@ import { useBatchGetStepStatesQuery } from '@graphql/step.generated';
export const useGetLastViewedAnnouncementTime = () => {
const { user } = useUserContext();
const finalStepId = `${user?.urn}-${LAST_VIEWED_ANNOUNCEMENT_TIME_STEP}`;
const { data, refetch } = useBatchGetStepStatesQuery({
const { data, refetch, loading } = useBatchGetStepStatesQuery({
skip: !user?.urn,
variables: { input: { ids: [finalStepId] } },
});
@ -16,5 +16,6 @@ export const useGetLastViewedAnnouncementTime = () => {
return {
time: (lastViewedAnnouncementTimeProperty?.value && Number(lastViewedAnnouncementTimeProperty?.value)) || null,
refetch,
loading,
};
};

View File

@ -0,0 +1,24 @@
import React from 'react';
import PersonalizationLoadingModal from '@app/homeV2/persona/PersonalizationLoadingModal';
import HomePageContent from '@app/homeV3/HomePageContent';
import Header from '@app/homeV3/header/Header';
import { HomePageContainer, PageWrapper, StyledVectorBackground } from '@app/homeV3/styledComponents';
import { SearchablePage } from '@app/searchV2/SearchablePage';
export const HomePage = () => {
return (
<>
<SearchablePage hideSearchBar>
<HomePageContainer>
<StyledVectorBackground />
<PageWrapper>
<Header />
<HomePageContent />
</PageWrapper>
</HomePageContainer>
</SearchablePage>
<PersonalizationLoadingModal />
</>
);
};

View File

@ -0,0 +1,38 @@
import React from 'react';
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',
},
];
const HomePageContent = () => {
return (
<ContentContainer>
<CenteredContainer>
<ContentDiv>
<Announcements />
{SAMPLE_MODULES.map((sampleModule) => (
<Module {...sampleModule} key={sampleModule.name} />
))}
</ContentDiv>
</CenteredContainer>
</ContentContainer>
);
};
export default HomePageContent;

View File

@ -0,0 +1,201 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { vi } from 'vitest';
import { useUserContext } from '@app/context/useUserContext';
import { useGetLastViewedAnnouncementTime } from '@app/homeV2/shared/useGetLastViewedAnnouncementTime';
import { useGetAnnouncementsForUser } from '@app/homeV3/announcements/useGetAnnouncementsForUser';
import { useListPostsQuery } from '@graphql/post.generated';
import { useUpdateUserHomePageSettingsMutation } from '@graphql/user.generated';
import { PostContentType, PostType } from '@types';
vi.mock('@app/context/useUserContext');
vi.mock('@app/homeV2/shared/useGetLastViewedAnnouncementTime');
vi.mock('@graphql/post.generated');
vi.mock('@graphql/user.generated');
const mockUser = {
settings: {
homePage: {
dismissedAnnouncementUrns: ['urn:1'],
},
},
};
const mockPosts = [
{
urn: 'urn:2',
postType: PostType.HomePageAnnouncement,
content: { contentType: PostContentType.Text },
},
{
urn: 'urn:3',
postType: PostType.HomePageAnnouncement,
content: { contentType: PostContentType.Text },
},
{
urn: 'urn:4',
postType: PostType.EntityAnnouncement,
content: { contentType: PostContentType.Text },
},
{
urn: 'urn:5',
postType: PostType.HomePageAnnouncement,
content: { contentType: PostContentType.Link },
},
];
describe('useGetAnnouncementsForUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return filtered announcements and loading state', () => {
(useUserContext as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
user: {
settings: {
homePage: {
dismissedAnnouncementUrns: ['urn:1', '', null, undefined, 'urn:2'],
},
},
},
});
(useGetLastViewedAnnouncementTime as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ time: 123 });
(useListPostsQuery as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
listPosts: {
posts: mockPosts,
},
},
loading: false,
error: null,
refetch: vi.fn(),
});
(useUpdateUserHomePageSettingsMutation as unknown as ReturnType<typeof vi.fn>).mockReturnValue([vi.fn()]);
const { result } = renderHook(() => useGetAnnouncementsForUser());
const callArgs = (useListPostsQuery as any).mock.calls[0][0];
const urnFilter = callArgs.variables.input.orFilters[0].and.find((f: any) => f.field === 'urn');
expect(urnFilter.values).toEqual(['urn:1', 'urn:2']);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
// Only posts with type HomePageAnnouncement and contentType Text
expect(result.current.announcements.map((p) => p.urn)).toEqual(['urn:2', 'urn:3']);
});
it('should call updateUserHomePageSettings on dismiss', async () => {
const updateUserHomePageSettings = vi.fn().mockResolvedValue({});
const refetch = vi.fn();
(useUserContext as any).mockReturnValue({ user: mockUser });
(useGetLastViewedAnnouncementTime as any).mockReturnValue({ time: 123 });
(useListPostsQuery as any).mockReturnValue({
data: { listPosts: { posts: mockPosts } },
loading: false,
error: null,
refetch,
});
(useUpdateUserHomePageSettingsMutation as any).mockReturnValue([updateUserHomePageSettings]);
const { result } = renderHook(() => useGetAnnouncementsForUser());
await act(async () => {
result.current.onDismissAnnouncement('urn:2');
await new Promise((resolve) => {
setTimeout(resolve, 2100);
});
});
expect(updateUserHomePageSettings).toHaveBeenCalledWith({
variables: { input: { newDismissedAnnouncements: ['urn:2'] } },
});
});
it('should filter out announcements that are in newDismissedUrns', async () => {
(useUserContext as any).mockReturnValue({
user: {
settings: {
homePage: {
dismissedAnnouncementUrns: [],
},
},
},
});
(useGetLastViewedAnnouncementTime as any).mockReturnValue({ time: 123 });
(useListPostsQuery as any).mockReturnValue({
data: { listPosts: { posts: mockPosts } },
loading: false,
error: null,
refetch: vi.fn(),
});
(useUpdateUserHomePageSettingsMutation as any).mockReturnValue([vi.fn()]);
const { result } = renderHook(() => useGetAnnouncementsForUser());
expect(result.current.announcements.map((p) => p.urn)).toEqual(['urn:2', 'urn:3']);
await act(async () => {
result.current.onDismissAnnouncement('urn:2');
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
});
expect(result.current.announcements.map((p) => p.urn)).toEqual(['urn:3']);
});
it('should handle when lastViewedAnnouncementsTime is undefined', () => {
(useUserContext as any).mockReturnValue({ user: mockUser });
(useGetLastViewedAnnouncementTime as any).mockReturnValue({});
const useListPostsQueryMock = useListPostsQuery as unknown as ReturnType<typeof vi.fn>;
useListPostsQueryMock.mockReturnValue({
data: { listPosts: { posts: mockPosts } },
loading: false,
error: null,
refetch: vi.fn(),
});
(useUpdateUserHomePageSettingsMutation as any).mockReturnValue([vi.fn()]);
renderHook(() => useGetAnnouncementsForUser());
const callArgs = useListPostsQueryMock.mock.calls[0][0];
const lastModifiedFilter = callArgs.variables.input.orFilters[0].and.find(
(f: any) => f.field === 'lastModified',
);
expect(lastModifiedFilter.values).toEqual(['0']);
});
it('should skip query if user is not present', () => {
(useUserContext as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ user: null });
(useGetLastViewedAnnouncementTime as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ time: 123 });
const useListPostsQueryMock = useListPostsQuery as unknown as ReturnType<typeof vi.fn>;
useListPostsQueryMock.mockReturnValue({
data: null,
loading: false,
error: null,
refetch: vi.fn(),
});
(useUpdateUserHomePageSettingsMutation as unknown as ReturnType<typeof vi.fn>).mockReturnValue([vi.fn()]);
renderHook(() => useGetAnnouncementsForUser());
expect(useListPostsQueryMock).toHaveBeenCalledWith(expect.objectContaining({ skip: true }));
});
it('should skip query if lastViewedTimeLoading is true', () => {
(useUserContext as any).mockReturnValue({ user: mockUser });
(useGetLastViewedAnnouncementTime as any).mockReturnValue({ time: null, loading: true });
const useListPostsQueryMock = useListPostsQuery as unknown as ReturnType<typeof vi.fn>;
useListPostsQueryMock.mockReturnValue({
data: null,
loading: false,
error: null,
refetch: vi.fn(),
});
(useUpdateUserHomePageSettingsMutation as any).mockReturnValue([vi.fn()]);
renderHook(() => useGetAnnouncementsForUser());
expect(useListPostsQueryMock).toHaveBeenCalledWith(expect.objectContaining({ skip: true }));
});
});

View File

@ -0,0 +1,73 @@
import { Card, Icon, Text, colors } from '@components';
import React from 'react';
import styled from 'styled-components';
import { StyledIcon } from '@app/homeV3/styledComponents';
import { Editor } from '@src/alchemy-components/components/Editor/Editor';
import { Post } from '@types';
const StyledEditor = styled(Editor)`
border: none;
&&& {
.remirror-editor {
padding: 0;
color: ${colors.violet[500]};
p {
margin-bottom: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: inherit;
}
}
}
`;
const cardStyles = {
backgroundColor: colors.violet[0],
padding: '8px',
border: 'none',
};
interface Props {
announcement: Post;
onDismiss: (urn: string) => void;
}
export const AnnouncementCard = ({ announcement, onDismiss }: Props) => {
return (
<Card
icon={<Icon icon="MegaphoneSimple" source="phosphor" color="violet" weight="fill" size="2xl" />}
title={
<Text color="violet" weight="semiBold" size="md" lineHeight="normal">
{announcement.content.title}
</Text>
}
subTitle={
announcement.content.description ? (
<StyledEditor content={announcement.content.description} readOnly />
) : undefined
}
button={
<StyledIcon
icon="X"
source="phosphor"
color="violet"
size="xl"
onClick={() => onDismiss(announcement.urn)}
/>
}
width="100%"
style={cardStyles}
isCardClickable={false}
/>
);
};

View File

@ -0,0 +1,32 @@
import React from 'react';
import styled from 'styled-components';
import { AnnouncementCard } from '@app/homeV3/announcements/AnnouncementCard';
import { useGetAnnouncementsForUser } from '@app/homeV3/announcements/useGetAnnouncementsForUser';
const AnnouncementsContainer = styled.div`
display: flex;
flex-direction: column;
padding: 0;
gap: 8px;
`;
export const Announcements = () => {
const { announcements, onDismissAnnouncement } = useGetAnnouncementsForUser();
const sortedAnnouncements = announcements.sort((a, b) => {
return b?.lastModified?.time - a?.lastModified?.time;
});
return (
<AnnouncementsContainer>
{sortedAnnouncements.map((announcement) => (
<AnnouncementCard
key={announcement.urn}
announcement={announcement}
onDismiss={onDismissAnnouncement}
/>
))}
</AnnouncementsContainer>
);
};

View File

@ -0,0 +1,82 @@
import { useState } from 'react';
import { useUserContext } from '@app/context/useUserContext';
import { useGetLastViewedAnnouncementTime } from '@app/homeV2/shared/useGetLastViewedAnnouncementTime';
import { useListPostsQuery } from '@graphql/post.generated';
import { useUpdateUserHomePageSettingsMutation } from '@graphql/user.generated';
import { FilterOperator, Post, PostContentType, PostType } from '@types';
export const useGetAnnouncementsForUser = () => {
const { user } = useUserContext();
const { time: lastViewedAnnouncementsTime, loading: lastViewedTimeLoading } = useGetLastViewedAnnouncementTime();
const [updateUserHomePageSettings] = useUpdateUserHomePageSettingsMutation();
const [newDismissedUrns, setNewDismissedUrns] = useState<string[]>([]);
const dismissedUrns = (user?.settings?.homePage?.dismissedAnnouncementUrns || []).filter((urn): urn is string =>
Boolean(urn),
);
const getUserPostsFilters = () => [
{
and: [
{
field: 'type',
condition: FilterOperator.Equal,
values: ['HOME_PAGE_ANNOUNCEMENT'],
},
{
field: 'urn',
condition: FilterOperator.Equal,
values: dismissedUrns,
negated: true,
},
{
field: 'lastModified',
condition: FilterOperator.GreaterThan,
values: [(lastViewedAnnouncementsTime || 0).toString()],
},
],
},
];
const inputs = {
start: 0,
count: 30,
orFilters: getUserPostsFilters(),
};
const {
data: postsData,
loading,
error,
refetch,
} = useListPostsQuery({
variables: {
input: inputs,
},
skip: !user || lastViewedTimeLoading,
});
const announcementsData: Post[] =
postsData?.listPosts?.posts
.filter((post) => post.postType === PostType.HomePageAnnouncement)
.filter((post) => post.content.contentType === PostContentType.Text)
.map((post) => post as Post) || [];
const onDismissAnnouncement = (urn: string) => {
setNewDismissedUrns((prev) => [...prev, urn]);
updateUserHomePageSettings({
variables: {
input: {
newDismissedAnnouncements: [urn],
},
},
});
};
const announcements = announcementsData.filter((announcement) => !newDismissedUrns.includes(announcement.urn));
return { announcements, loading, error, refetch, onDismissAnnouncement };
};

View File

@ -2,25 +2,20 @@ import { colors } from '@components';
import React from 'react';
import styled from 'styled-components';
import GreetingText from '@app/homepageV2/header/components/GreetingText';
import SearchBar from '@app/homepageV2/header/components/SearchBar';
import GreetingText from '@app/homeV3/header/components/GreetingText';
import SearchBar from '@app/homeV3/header/components/SearchBar';
import { CenteredContainer } from '@app/homeV3/styledComponents';
export const HeaderWrapper = styled.div`
display: flex;
justify-content: center;
padding: 27px 0 24px 0;
padding: 27px 40px 24px 40px;
width: 100%;
overflow: hidden;
background: linear-gradient(180deg, #f8fcff 0%, #fafafb 100%);
border: 1px solid ${colors.gray[100]};
border-radius: 12px 12px 0 0;
`;
const CenteredContainer = styled.div`
max-width: 1016px;
width: 100%;
`;
const Header = () => {
return (
<HeaderWrapper>

View File

@ -1,8 +1,8 @@
import React, { useMemo } from 'react';
import SampleLargeModule from '@app/homepageV2/module/modules/SampleLargeModule';
import YourAssetsModule from '@app/homepageV2/module/modules/YourAssetsModule';
import { ModuleProps } from '@app/homepageV2/module/types';
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(() => {

View File

@ -2,12 +2,12 @@ import { Loader, borders, colors, radius, spacing } from '@components';
import React from 'react';
import styled from 'styled-components';
import ModuleContainer from '@app/homepageV2/module/components/ModuleContainer';
import ModuleDescription from '@app/homepageV2/module/components/ModuleDescription';
import ModuleMenu from '@app/homepageV2/module/components/ModuleMenu';
import ModuleName from '@app/homepageV2/module/components/ModuleName';
import PublicModuleBadge from '@app/homepageV2/module/components/PublicModuleBadge';
import { ModuleProps } from '@app/homepageV2/module/types';
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`
position: relative;

View File

@ -1,7 +1,7 @@
import React from 'react';
import LargeModule from '@app/homepageV2/module/components/LargeModule';
import { ModuleProps } from '@app/homepageV2/module/types';
import LargeModule from '@app/homeV3/module/components/LargeModule';
import { ModuleProps } from '@app/homeV3/module/types';
export default function SampleLargeModule(props: ModuleProps) {
return <LargeModule {...props}>Content of the sample module</LargeModule>;

View File

@ -2,10 +2,10 @@ import React from 'react';
import { useUserContext } from '@app/context/useUserContext';
import { useGetAssetsYouOwn } from '@app/homeV2/reference/sections/assets/useGetAssetsYouOwn';
import EmptyContent from '@app/homepageV2/module/components/EmptyContent';
import EntityItem from '@app/homepageV2/module/components/EntityItem';
import LargeModule from '@app/homepageV2/module/components/LargeModule';
import { ModuleProps } from '@app/homepageV2/module/types';
import EmptyContent from '@app/homeV3/module/components/EmptyContent';
import EntityItem from '@app/homeV3/module/components/EntityItem';
import LargeModule from '@app/homeV3/module/components/LargeModule';
import { ModuleProps } from '@app/homeV3/module/types';
export default function YourAssetsModule(props: ModuleProps) {
const { user } = useUserContext();

View File

@ -1,4 +1,4 @@
import { colors } from '@components';
import { Icon, colors } from '@components';
import styled from 'styled-components';
import VectorBackground from '@images/homepage-vector.svg?react';
@ -6,42 +6,55 @@ import VectorBackground from '@images/homepage-vector.svg?react';
export const PageWrapper = styled.div`
width: 100%;
height: 100%;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0px 4px 8px 0px rgba(33, 23, 95, 0.08);
`;
export const HeaderWrapper = styled.div`
height: 160px;
width: 100%;
overflow: hidden;
background: linear-gradient(180deg, #f8fcff 0%, #fafafb 100%);
border: 1px solid ${colors.gray[100]};
border-radius: 12px 12px 0 0;
`;
export const ContentWrapper = styled.div`
position: relative;
width: 100%;
`;
export const HomePageContainer = styled.div`
position: relative;
flex: 1;
overflow: hidden;
`;
export const ContentContainer = styled.div`
position: relative; // to enable z-index
padding: 40px 160px 16px 160px;
background-color: ${colors.white};
height: 100%;
z-index: 1;
margin: 5px;
`;
export const StyledVectorBackground = styled(VectorBackground)`
width: 100%;
transform: rotate(0deg);
position: absolute;
background: linear-gradient(180deg, #fbfbff 0%, #fafafb 74.57%);
width: 100%;
height: 100%;
z-index: 0;
transform: rotate(0deg);
pointer-events: none;
border-radius: 12px;
background-color: ${colors.white};
`;
export const ContentContainer = styled.div`
z-index: 1;
padding: 40px 40px 16px 40px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
overflow: auto;
`;
export const CenteredContainer = styled.div`
max-width: 1016px;
width: 100%;
`;
export const ContentDiv = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
export const StyledIcon = styled(Icon)`
:hover {
cursor: pointer;
}
`;

View File

@ -1,21 +0,0 @@
import React from 'react';
import PersonalizationLoadingModal from '@app/homeV2/persona/PersonalizationLoadingModal';
import HomePageContent from '@app/homepageV2/HomePageContent';
import Header from '@app/homepageV2/header/Header';
import { PageWrapper } from '@app/homepageV2/styledComponents';
import { SearchablePage } from '@app/searchV2/SearchablePage';
export const HomePage = () => {
return (
<>
<SearchablePage hideSearchBar>
<PageWrapper>
<Header />
<HomePageContent />
</PageWrapper>
</SearchablePage>
<PersonalizationLoadingModal />
</>
);
};

View File

@ -1,35 +0,0 @@
import React from 'react';
import Module from '@app/homepageV2/module/Module';
import { ModuleProps } from '@app/homepageV2/module/types';
import { ContentContainer, ContentWrapper, StyledVectorBackground } from '@app/homepageV2/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',
},
];
const HomePageContent = () => {
return (
<ContentWrapper>
<StyledVectorBackground />
<ContentContainer>
{SAMPLE_MODULES.map((sampleModule) => (
<Module {...sampleModule} key={sampleModule.name} />
))}
</ContentContainer>
</ContentWrapper>
);
};
export default HomePageContent;

View File

@ -1,9 +0,0 @@
import React from 'react';
import { HeaderWrapper } from '@app/homepageV2/styledComponents';
const HomePageHeader = () => {
return <HeaderWrapper />;
};
export default HomePageHeader;

View File

@ -35,6 +35,12 @@ query getMe {
urn
}
}
homePage {
pageTemplate {
urn
}
dismissedAnnouncementUrns
}
}
}
platformPrivileges {

View File

@ -268,6 +268,10 @@ mutation updateCorpUserViewsSettings($input: UpdateCorpUserViewsSettingsInput!)
updateCorpUserViewsSettings(input: $input)
}
mutation updateUserHomePageSettings($input: UpdateUserHomePageSettingsInput!) {
updateUserHomePageSettings(input: $input)
}
query getUserOwnedAssets($urns: [String!]!) {
searchAcrossEntities(input: { query: "", filters: [{ field: "owners", values: $urns }] }) {
...searchResults

View File

@ -9,5 +9,10 @@ record CorpUserHomePageSettings {
/**
* The page template that will be rendered in the UI by default for this user
*/
pageTemplate: Urn
pageTemplate: optional Urn
/**
* The list of announcement urns that have been dismissed by the user
*/
dismissedAnnouncements: optional array [Urn]
}