feat(homepage): add reset to default template functionality and update home page settings option (#14202)

This commit is contained in:
purnimagarg1 2025-07-24 23:38:20 +05:30 committed by GitHub
parent bcee2a4fb7
commit b1ef86b83e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 254 additions and 69 deletions

View File

@ -102,6 +102,9 @@ public class MeResolver implements DataFetcher<CompletableFuture<AuthenticatedUs
platformPrivileges.setManageApplications(
ApplicationAuthorizationUtils.canManageApplications(context));
platformPrivileges.setManageFeatures(AuthorizationUtils.canManageFeatures(context));
platformPrivileges.setManageHomePageTemplates(
AuthorizationUtils.canManageHomePageTemplates(context));
// Construct and return authenticated user object.
final AuthenticatedUser authUser = new AuthenticatedUser();
authUser.setCorpUser(corpUser);

View File

@ -86,7 +86,16 @@ public class UpdateUserHomePageSettingsResolver implements DataFetcher<Completab
@Nonnull final CorpUserHomePageSettings settings,
@Nonnull final UpdateUserHomePageSettingsInput input) {
if (input.getPageTemplate() != null) {
Boolean shouldRemoveTemplate = input.getRemovePageTemplate();
if (input.getPageTemplate() != null && Boolean.TRUE.equals(shouldRemoveTemplate)) {
throw new IllegalArgumentException(
"Invalid inputs: Cannot specify both pageTemplate and removePageTemplate.");
}
if (Boolean.TRUE.equals(shouldRemoveTemplate)) {
settings.data().remove("pageTemplate");
} else if (input.getPageTemplate() != null) {
settings.setPageTemplate(UrnUtils.getUrn(input.getPageTemplate()));
}

View File

@ -169,9 +169,11 @@ public class CorpUserMapper {
@Nonnull final com.linkedin.identity.CorpUserHomePageSettings homePageSettings) {
CorpUserHomePageSettings result = new CorpUserHomePageSettings();
if (homePageSettings.hasPageTemplate()) {
if (homePageSettings.getPageTemplate() != null) {
result.setPageTemplate(
(DataHubPageTemplate) UrnToEntityMapper.map(null, homePageSettings.getPageTemplate()));
} else {
result.setPageTemplate(null);
}
if (homePageSettings.hasDismissedAnnouncements()) {

View File

@ -192,6 +192,11 @@ type PlatformPrivileges {
Whether the user can manage platform features.
"""
manageFeatures: Boolean!
"""
Whether the user can manage default home page template.
"""
manageHomePageTemplates: Boolean!
}
"""

View File

@ -12503,6 +12503,11 @@ input UpdateUserHomePageSettingsInput {
The list of urns of announcement posts dismissed by the user.
"""
newDismissedAnnouncements: [String]
"""
Whether to remove the page template for the user.
"""
removePageTemplate: Boolean
}
"""

View File

@ -3741,6 +3741,7 @@ export const mocks = [
viewStructuredPropertiesPage: true,
manageApplications: true,
manageFeatures: true,
manageHomePageTemplates: true,
},
},
},
@ -4027,6 +4028,7 @@ export const platformPrivileges: PlatformPrivileges = {
viewStructuredPropertiesPage: true,
manageApplications: true,
manageFeatures: true,
manageHomePageTemplates: true,
};
export const DomainMock1 = {

View File

@ -89,6 +89,7 @@ describe('handleAccessRoles', () => {
viewStructuredPropertiesPage: true,
manageApplications: true,
manageFeatures: true,
manageHomePageTemplates: true,
__typename: 'PlatformPrivileges',
},
__typename: 'AuthenticatedUser',
@ -174,6 +175,7 @@ describe('handleAccessRoles', () => {
viewStructuredPropertiesPage: true,
manageApplications: true,
manageFeatures: true,
manageHomePageTemplates: true,
__typename: 'PlatformPrivileges',
},
__typename: 'AuthenticatedUser',
@ -267,6 +269,7 @@ describe('handleAccessRoles', () => {
viewStructuredPropertiesPage: true,
manageApplications: true,
manageFeatures: true,
manageHomePageTemplates: true,
__typename: 'PlatformPrivileges',
},
__typename: 'AuthenticatedUser',

View File

@ -1,38 +0,0 @@
import { Button, Tooltip } from '@components';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import analytics, { EventType } from '@app/analytics';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
const ButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-right: 42px;
`;
export default function EditDefaultTemplateButton() {
const { setIsEditingGlobalTemplate, isEditingGlobalTemplate } = usePageTemplateContext();
const onClick = useCallback(() => {
setIsEditingGlobalTemplate(true);
analytics.event({
type: EventType.HomePageTemplateGlobalTemplateEditingStart,
});
}, [setIsEditingGlobalTemplate]);
// TODO: also hide this if you don't have permissions - CH-510
if (isEditingGlobalTemplate) return null;
return (
<ButtonWrapper>
<Tooltip title="Edit the home page that users see by default">
<Button
icon={{ icon: 'PencilSimpleLine', color: 'gray', source: 'phosphor' }}
variant="text"
onClick={onClick}
/>
</Tooltip>
</ButtonWrapper>
);
}

View File

@ -1,8 +1,8 @@
import React from 'react';
import EditDefaultTemplateBar from '@app/homeV3/EditDefaultTemplateBar';
import EditDefaultTemplateButton from '@app/homeV3/EditDefaultTemplateButton';
import { Announcements } from '@app/homeV3/announcements/Announcements';
import EditDefaultTemplateBar from '@app/homeV3/settings/EditDefaultTemplateBar';
import EditHomePageSettingsButton from '@app/homeV3/settings/EditHomePageSettingsButton';
import { CenteredContainer, ContentContainer, ContentDiv } from '@app/homeV3/styledComponents';
import Template from '@app/homeV3/template/Template';
@ -11,7 +11,7 @@ const HomePageContent = () => {
<ContentContainer>
<CenteredContainer>
<ContentDiv>
<EditDefaultTemplateButton />
<EditHomePageSettingsButton />
<Announcements />
<Template />
<EditDefaultTemplateBar />

View File

@ -22,7 +22,8 @@ export const PageTemplateProvider = ({ children }: { children: ReactNode }) => {
} = useTemplateState();
// Template operations
const { updateTemplateWithModule, removeModuleFromTemplate, upsertTemplate } = useTemplateOperations();
const { updateTemplateWithModule, removeModuleFromTemplate, upsertTemplate, resetTemplateToDefault } =
useTemplateOperations(setPersonalTemplate);
// Modal state
const moduleModalState = useModuleModalState();
@ -56,6 +57,7 @@ export const PageTemplateProvider = ({ children }: { children: ReactNode }) => {
upsertModule,
moduleModalState,
moveModule,
resetTemplateToDefault,
}),
[
personalTemplate,
@ -71,6 +73,7 @@ export const PageTemplateProvider = ({ children }: { children: ReactNode }) => {
upsertModule,
moduleModalState,
moveModule,
resetTemplateToDefault,
],
);

View File

@ -92,6 +92,7 @@ const mockMoveModule = vi.fn();
const mockUpdateTemplateWithModule = vi.fn();
const mockRemoveModuleFromTemplate = vi.fn();
const mockUpsertTemplate = vi.fn();
const mockResetTemplateToDefault = vi.fn();
describe('PageTemplateContext', () => {
beforeEach(() => {
@ -113,6 +114,7 @@ describe('PageTemplateContext', () => {
updateTemplateWithModule: mockUpdateTemplateWithModule,
removeModuleFromTemplate: mockRemoveModuleFromTemplate,
upsertTemplate: mockUpsertTemplate,
resetTemplateToDefault: mockResetTemplateToDefault,
});
mockUseModuleOperations.mockReturnValue({

View File

@ -52,6 +52,8 @@ const mockModule: PageModuleFragment = {
},
};
const setPersonalTemplate = vi.fn();
describe('useTemplateOperations', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -61,7 +63,7 @@ describe('useTemplateOperations', () => {
describe('updateTemplateWithModule', () => {
it('should add module to new row when rowIndex is undefined', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: undefined,
@ -77,7 +79,7 @@ describe('useTemplateOperations', () => {
});
it('should add module to existing row on the left side', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: 0,
@ -94,7 +96,7 @@ describe('useTemplateOperations', () => {
});
it('should add module to existing row on the right side', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: 0,
@ -111,7 +113,7 @@ describe('useTemplateOperations', () => {
});
it('should create new row when rowIndex is out of bounds', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: 5,
@ -127,7 +129,7 @@ describe('useTemplateOperations', () => {
});
it('should handle template with no rows', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const templateWithoutRows: PageTemplateFragment = {
urn: 'urn:li:pageTemplate:empty',
@ -162,7 +164,7 @@ describe('useTemplateOperations', () => {
});
it('should return null when template is null', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: 0,
@ -177,7 +179,7 @@ describe('useTemplateOperations', () => {
describe('removeModuleFromTemplate', () => {
it('should remove module by moduleIndex when provided', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const templateWithMultipleModules: PageTemplateFragment = {
...mockTemplate,
@ -231,7 +233,7 @@ describe('useTemplateOperations', () => {
});
it('should handle duplicate URNs correctly with moduleIndex', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const templateWithDuplicateUrns: PageTemplateFragment = {
...mockTemplate,
@ -297,7 +299,7 @@ describe('useTemplateOperations', () => {
});
it('should fall back to URN search when moduleIndex is invalid', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const templateWithMultipleModules: PageTemplateFragment = {
...mockTemplate,
@ -351,7 +353,7 @@ describe('useTemplateOperations', () => {
});
it('should fall back to URN search when moduleIndex URN does not match', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const templateWithMultipleModules: PageTemplateFragment = {
...mockTemplate,
@ -405,7 +407,7 @@ describe('useTemplateOperations', () => {
});
it('should remove entire row when last module is removed', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const templateWithSingleModule: PageTemplateFragment = {
...mockTemplate,
@ -462,7 +464,7 @@ describe('useTemplateOperations', () => {
});
it('should return original template when module is not found', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: 0,
@ -480,7 +482,7 @@ describe('useTemplateOperations', () => {
});
it('should return original template when rowIndex is invalid', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: 5, // Invalid index
@ -498,7 +500,7 @@ describe('useTemplateOperations', () => {
});
it('should return original template when rowIndex is undefined', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: undefined,
@ -516,7 +518,7 @@ describe('useTemplateOperations', () => {
});
it('should return original template when template is null', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const position: ModulePositionInput = {
rowIndex: 0,
@ -530,7 +532,7 @@ describe('useTemplateOperations', () => {
});
it('should handle edge case with no modules in row', () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const templateWithEmptyRow: PageTemplateFragment = {
...mockTemplate,
@ -562,7 +564,7 @@ describe('useTemplateOperations', () => {
describe('upsertTemplate', () => {
it('should upsert personal template with correct input', async () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
mockUpsertPageTemplateMutation.mockResolvedValue({
data: {
@ -593,7 +595,7 @@ describe('useTemplateOperations', () => {
});
it('should upsert existing personal template with URN', async () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
mockUpsertPageTemplateMutation.mockResolvedValue({
data: {
@ -624,7 +626,7 @@ describe('useTemplateOperations', () => {
});
it('should upsert global template with correct input', async () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
mockUpsertPageTemplateMutation.mockResolvedValue({
data: {
@ -655,7 +657,7 @@ describe('useTemplateOperations', () => {
});
it('should update user settings when creating personal template', async () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
mockUpsertPageTemplateMutation.mockResolvedValue({
data: {
@ -679,7 +681,7 @@ describe('useTemplateOperations', () => {
});
it('should not update user settings when updating existing personal template', async () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
mockUpsertPageTemplateMutation.mockResolvedValue({
data: {
@ -697,7 +699,7 @@ describe('useTemplateOperations', () => {
});
it('should handle template with empty rows', async () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const templateWithEmptyRows: PageTemplateFragment = {
urn: 'urn:li:pageTemplate:empty',
@ -742,7 +744,7 @@ describe('useTemplateOperations', () => {
});
it('should handle mutation error', async () => {
const { result } = renderHook(() => useTemplateOperations());
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
const error = new Error('Mutation failed');
mockUpsertPageTemplateMutation.mockRejectedValue(error);
@ -750,4 +752,61 @@ describe('useTemplateOperations', () => {
await expect(result.current.upsertTemplate(mockTemplate, true, null)).rejects.toThrow('Mutation failed');
});
});
describe('resetTemplateToDefault', () => {
it('should call setPersonalTemplate with null', () => {
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
act(() => {
result.current.resetTemplateToDefault();
});
expect(setPersonalTemplate).toHaveBeenCalledWith(null);
});
it('should call updateUserHomePageSettingsMutation with pageTemplate: null', () => {
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
act(() => {
result.current.resetTemplateToDefault();
});
expect(mockUpdateUserHomePageSettings).toHaveBeenCalledWith({
variables: {
input: {
removePageTemplate: true,
},
},
});
});
it('should call setPersonalTemplate and mutation exactly once on multiple calls', () => {
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
act(() => {
result.current.resetTemplateToDefault();
result.current.resetTemplateToDefault();
});
expect(setPersonalTemplate).toHaveBeenCalledTimes(2);
expect(mockUpdateUserHomePageSettings).toHaveBeenCalledTimes(2);
});
it('should handle async mutation call correctly', async () => {
const { result } = renderHook(() => useTemplateOperations(setPersonalTemplate));
await act(async () => {
await result.current.resetTemplateToDefault();
});
expect(setPersonalTemplate).toHaveBeenCalledWith(null);
expect(mockUpdateUserHomePageSettings).toHaveBeenCalledWith({
variables: {
input: {
removePageTemplate: true,
},
},
});
});
});
});

View File

@ -43,7 +43,7 @@ const isValidRemovalPosition = (template: PageTemplateFragment | null, position:
return rowIndex !== undefined && rowIndex >= 0 && rowIndex < rows.length;
};
export function useTemplateOperations() {
export function useTemplateOperations(setPersonalTemplate: (template: PageTemplateFragment | null) => void) {
const [upsertPageTemplateMutation] = useUpsertPageTemplateMutation();
const [updateUserHomePageSettings] = useUpdateUserHomePageSettingsMutation();
@ -194,9 +194,21 @@ export function useTemplateOperations() {
[upsertPageTemplateMutation, updateUserHomePageSettings],
);
const resetTemplateToDefault = () => {
setPersonalTemplate(null);
updateUserHomePageSettings({
variables: {
input: {
removePageTemplate: true,
},
},
});
};
return {
updateTemplateWithModule,
removeModuleFromTemplate,
upsertTemplate,
resetTemplateToDefault,
};
}

View File

@ -60,4 +60,5 @@ export type PageTemplateContextState = {
moduleModalState: ModuleModalState;
removeModule: (input: RemoveModuleInput) => void;
moveModule: (input: MoveModuleInput) => void;
resetTemplateToDefault: () => void;
};

View File

@ -0,0 +1,116 @@
import { Button, Dropdown, colors } from '@components';
import React, { useCallback, useState } from 'react';
import styled from 'styled-components';
import analytics, { EventType } from '@app/analytics';
import { useUserContext } from '@app/context/useUserContext';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import { ConfirmationModal } from '@app/sharedV2/modals/ConfirmationModal';
const ButtonWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
background-color: ${colors.white};
height: 40px;
width: 40px;
position: fixed;
right: 32px;
bottom: 32px;
border-radius: 200px;
box-shadow: 0px 4px 12px 0px rgba(9, 1, 61, 0.12);
`;
const DropdownContainer = styled.div`
border-radius: 12px;
box-shadow: 0px 4px 12px 0px rgba(9, 1, 61, 0.12);
background-color: white;
overflow: hidden; // Cleanly rounds edges
.ant-dropdown-menu-item {
padding: 8px 16px;
}
`;
export default function EditHomePageSettingsButton() {
const user = useUserContext();
const canEditDefaultTemplate = user.platformPrivileges?.manageHomePageTemplates;
const { setIsEditingGlobalTemplate, isEditingGlobalTemplate, resetTemplateToDefault, personalTemplate } =
usePageTemplateContext();
const isOnPersonalTemplate = !!personalTemplate;
const [showConfirmResetModal, setShowConfirmResetModal] = useState(false);
const startGlobalTemplateEdit = useCallback(() => {
setIsEditingGlobalTemplate(true);
analytics.event({
type: EventType.HomePageTemplateGlobalTemplateEditingStart,
});
}, [setIsEditingGlobalTemplate]);
const handleResetToDefault = useCallback(() => {
resetTemplateToDefault();
setShowConfirmResetModal(false);
analytics.event({
type: EventType.HomePageTemplateResetToGlobalTemplate,
});
}, [resetTemplateToDefault]);
if (isEditingGlobalTemplate || (!canEditDefaultTemplate && !isOnPersonalTemplate)) return null;
const menu = {
items: [
...(canEditDefaultTemplate
? [
{
label: 'Edit Organization Default',
key: 'edit-organization-default',
style: {
color: colors.gray[600],
fontSize: '14px',
},
onClick: startGlobalTemplateEdit,
},
]
: []),
...(isOnPersonalTemplate
? [
{
label: 'Reset to Organization Default',
key: 'reset-to-organization-default',
style: {
color: colors.red[1000],
fontSize: '14px',
},
onClick: () => setShowConfirmResetModal(true),
},
]
: []),
],
};
return (
<>
<ButtonWrapper>
<Dropdown
menu={menu}
trigger={['click']}
dropdownRender={(menuNode) => <DropdownContainer>{menuNode}</DropdownContainer>}
>
<Button icon={{ icon: 'Gear', color: 'gray', source: 'phosphor', size: '4xl' }} variant="text" />
</Dropdown>
</ButtonWrapper>
<ConfirmationModal
isOpen={!!showConfirmResetModal}
handleConfirm={handleResetToDefault}
handleClose={() => setShowConfirmResetModal(false)}
modalTitle="Confirm reset to default template"
modalText="Are you sure you want to reset your homepage to the organization's default template? You will lose all your personal modules."
closeButtonText="Cancel"
confirmButtonText="Confirm"
/>
</>
);
}

View File

@ -54,7 +54,7 @@ export const ConfirmationModal = ({
centered
footer={
<ButtonsContainer>
<Button variant="text" onClick={handleClose} data-testid="modal-cancel-button">
<Button variant="text" color="gray" onClick={handleClose} data-testid="modal-cancel-button">
{closeButtonText || 'Cancel'}
</Button>
<Button variant="filled" onClick={handleConfirm} data-testid="modal-confirm-button">

View File

@ -69,6 +69,7 @@ query getMe {
viewStructuredPropertiesPage
manageApplications
manageFeatures
manageHomePageTemplates
}
}
}