Fix customize landing page widget feedbacks (#22543)

* Fix customize landing page feedbacks

* update announcement card color to white

* header and other changes

* update language files

* fix failing unit tests

* minor fix

* remove shadow from add condition

* update styles

* fix dropdown and drag drop

* add empty placeholder widget code

* fix failing test

* address comments

* addressed comments and sonar issues

* fix failing test

* Fix customize landing page widget feedbacks

* add empty widget placeholder

* minor fix

* fix box shadow styling

* update customize my data header

* fix widget placements and failing tests

* minor fix

---------

Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com>
This commit is contained in:
Harshit Shah 2025-07-25 21:06:47 +05:30 committed by GitHub
parent 40d662814c
commit ddd5c8a0e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 961 additions and 322 deletions

View File

@ -13,8 +13,6 @@
@import (reference) '../../styles/variables.less';
@nlp-border-color: #b9e6fe;
.search-container {
border: 1px solid #eaecf5;
border-radius: 12px;

View File

@ -108,8 +108,10 @@ const CustomiseLandingPageHeader = ({
const response = await getActiveAnnouncement();
setAnnouncements(response.data);
setShowAnnouncements(response.data.length > 0);
} catch (error) {
showErrorToast(error as AxiosError);
setShowAnnouncements(false);
} finally {
setIsAnnouncementLoading(false);
}

View File

@ -127,11 +127,9 @@ export const CustomiseSearchBar = ({ disabled }: { disabled?: boolean }) => {
};
const popoverContent = useMemo(() => {
if (isTourOpen || !searchValue || !isSearchBoxOpen) {
return null;
}
return isInPageSearchAllowed(pathname) ? (
return !isTourOpen &&
(searchValue || isNLPActive) &&
isInPageSearchAllowed(pathname) ? (
<SearchOptions
isOpen={isSearchBoxOpen}
options={inPageSearchOptions(pathname)}
@ -195,7 +193,6 @@ export const CustomiseSearchBar = ({ disabled }: { disabled?: boolean }) => {
component={
isNLPActive ? IconSuggestionsActive : IconSuggestionsBlue
}
style={{ fontSize: '20px' }}
/>
}
type="text"

View File

@ -19,6 +19,63 @@
background-size: cover;
background-position: center;
background-repeat: no-repeat;
// Carousel styles
.ant-carousel {
max-height: 125px;
width: 100%;
margin-bottom: @size-xl;
padding-bottom: @padding-xss;
.slick-slide {
vertical-align: top;
padding: 0 @padding-xs;
}
.slick-arrow {
color: @white;
font-size: @size-lg;
z-index: 10;
width: 32px;
height: 32px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 50%;
display: flex !important;
align-items: center;
justify-content: center;
&:hover {
color: @white;
background-color: rgba(0, 0, 0, 0.5);
}
&.slick-prev {
left: -@size-lg;
}
&.slick-next {
right: -@size-lg;
}
&::before {
color: @white;
font-size: @size-lg;
}
}
}
.recently-viewed-data-carousel {
max-height: 125px;
.slick-dots {
bottom: -@size-sm !important;
}
}
.slick-list-center {
.slick-list {
max-height: 125px;
}
}
}
.landing-page-header-bg {
@ -117,50 +174,6 @@
}
}
// Carousel styles
.ant-carousel {
max-height: 125px;
width: 100%;
margin-bottom: @size-xl;
padding-bottom: @padding-xss;
.slick-slide {
vertical-align: top;
padding: 0 @padding-xs;
}
.slick-arrow {
color: @white;
font-size: @size-lg;
z-index: 10;
width: 32px;
height: 32px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 50%;
display: flex !important;
align-items: center;
justify-content: center;
&:hover {
color: @white;
background-color: rgba(0, 0, 0, 0.5);
}
&.slick-prev {
left: -@size-lg;
}
&.slick-next {
right: -@size-lg;
}
&::before {
color: @white;
font-size: @size-lg;
}
}
}
.custom-arrow {
position: absolute;
top: 50%;
@ -191,19 +204,6 @@
transform: translateY(-50%) rotate(180deg);
}
.recently-viewed-data-carousel {
max-height: 125px;
.slick-dots {
bottom: -@size-sm !important;
}
}
.slick-list-center {
.slick-list {
max-height: 125px;
}
}
.customise-recently-viewed-data {
width: 100%;
gap: @size-xl;

View File

@ -27,19 +27,30 @@
}
.nlp-search-button {
background: transparent;
border: none;
color: @grey-4;
transition: all 0.3s ease;
border: 0.5px solid @nlp-border-color !important;
border-radius: @border-rad-xs;
padding: 0 4px;
transition: none;
background-color: @primary-button-background !important;
&.active {
color: @primary-color;
background: @primary-1;
svg {
width: 14px;
height: 14px;
fill: transparent;
}
&:hover {
color: @primary-color;
background: @primary-1;
&.active {
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: none !important;
svg {
width: 24px;
height: 24px;
fill: none;
}
}
}
@ -59,18 +70,22 @@
box-shadow: none;
}
}
}
.customise-search-overlay {
.ant-popover-inner-content {
border-radius: @size-xs;
box-shadow: @box-shadow-base;
max-height: 400px;
width: auto;
overflow-y: auto;
.customise-search-overlay {
.ant-popover-inner-content {
border-radius: @size-xs;
box-shadow: @box-shadow-base;
max-height: 400px;
width: auto;
overflow-y: auto;
}
.ant-popover-arrow {
display: none;
}
}
.ant-popover-arrow {
display: none;
.ant-popover {
left: 0 !important;
}
}

View File

@ -10,7 +10,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CloseOutlined, RedoOutlined, SaveOutlined } from '@ant-design/icons';
import {
CloseOutlined,
PlusOutlined,
RedoOutlined,
SaveOutlined,
} from '@ant-design/icons';
import { Button, Card, Modal, Space, Typography } from 'antd';
import { kebabCase } from 'lodash';
import { useCallback, useMemo, useState } from 'react';
@ -23,15 +28,17 @@ import { Transi18next } from '../../../../utils/CommonUtils';
import { getPersonaDetailsPath } from '../../../../utils/RouterUtils';
export const CustomizablePageHeader = ({
disableSave,
onAddWidget,
onReset,
onSave,
personaName,
disableSave,
}: {
onSave: () => Promise<void>;
onReset: () => void;
personaName: string;
disableSave?: boolean;
onAddWidget?: () => void;
onReset: () => void;
onSave: () => Promise<void>;
personaName: string;
}) => {
const { t } = useTranslation();
const { fqn: personaFqn } = useFqn();
@ -131,13 +138,23 @@ export const CustomizablePageHeader = ({
</Typography.Paragraph>
</div>
<Space>
<Button
data-testid="cancel-button"
disabled={saving}
icon={<CloseOutlined />}
onClick={handleClose}>
{t('label.close')}
</Button>
{isLandingPage ? (
<Button
data-testid="add-widget-button"
icon={<PlusOutlined />}
type="primary"
onClick={onAddWidget}>
{t('label.add-widget-plural')}
</Button>
) : (
<Button
data-testid="cancel-button"
disabled={saving}
icon={<CloseOutlined />}
onClick={handleClose}>
{t('label.close')}
</Button>
)}
<Button
data-testid="reset-button"
disabled={saving}
@ -154,6 +171,14 @@ export const CustomizablePageHeader = ({
onClick={handleSave}>
{t('label.save')}
</Button>
{isLandingPage && (
<Button
data-testid="cancel-button"
disabled={saving}
icon={<CloseOutlined />}
onClick={handleClose}
/>
)}
</Space>
</div>

View File

@ -49,13 +49,13 @@ jest.mock('../CustomiseHomeModal/CustomiseHomeModal', () => {
});
jest.mock(
'../../../MyData/CustomizableComponents/EmptyWidgetPlaceholder/EmptyWidgetPlaceholder',
'../../../MyData/CustomizableComponents/EmptyWidgetPlaceholder/EmptyWidgetPlaceholderV1',
() => {
return jest.fn().mockImplementation(({ handleOpenAddWidgetModal }) => (
<div>
EmptyWidgetPlaceholder{' '}
EmptyWidgetPlaceholderV1{' '}
<button onClick={handleOpenAddWidgetModal}>
handleOpenAddWidgetModal
handleOpenAddWidgetModalV1
</button>
</div>
));
@ -220,12 +220,12 @@ describe('CustomizeMyData component', () => {
expect(screen.queryByTestId('reset-layout-modal')).toBeNull();
});
it('CustomizeMyData should display EmptyWidgetPlaceholder', async () => {
it('CustomizeMyData should display EmptyWidgetPlaceholderV1', async () => {
await act(async () => {
render(<CustomizeMyData {...mockProps} />);
});
expect(screen.getByText('EmptyWidgetPlaceholder')).toBeInTheDocument();
expect(screen.getByText('EmptyWidgetPlaceholderV1')).toBeInTheDocument();
});
it('CustomizeMyData should display CustomiseHomeModal after handleOpenAddWidgetModal is called', async () => {
@ -233,7 +233,7 @@ describe('CustomizeMyData component', () => {
render(<CustomizeMyData {...mockProps} />);
});
const addWidgetButton = screen.getByText('handleOpenAddWidgetModal');
const addWidgetButton = screen.getByText('handleOpenAddWidgetModalV1');
fireEvent.click(addWidgetButton);
@ -245,7 +245,7 @@ describe('CustomizeMyData component', () => {
render(<CustomizeMyData {...mockProps} />);
});
const addWidgetButton = screen.getByText('handleOpenAddWidgetModal');
const addWidgetButton = screen.getByText('handleOpenAddWidgetModalV1');
fireEvent.click(addWidgetButton);

View File

@ -32,8 +32,8 @@ import { WidgetConfig } from '../../../../pages/CustomizablePage/CustomizablePag
import '../../../../pages/MyDataPage/my-data.less';
import {
getAddWidgetHandler,
getLandingPageLayoutWithEmptyWidgetPlaceholder,
getLayoutUpdateHandler,
getLayoutWithEmptyWidgetPlaceholder,
getRemoveWidgetHandler,
getUniqueFilteredLayout,
getWidgetFromKey,
@ -63,11 +63,9 @@ function CustomizeMyData({
const { t } = useTranslation();
const [layout, setLayout] = useState<Array<WidgetConfig>>(
getLayoutWithEmptyWidgetPlaceholder(
getLandingPageLayoutWithEmptyWidgetPlaceholder(
(initialPageData?.layout as WidgetConfig[]) ??
customizeMyDataPageClassBase.defaultLayout,
2,
4
customizeMyDataPageClassBase.defaultLayout
)
);
@ -135,6 +133,11 @@ function CustomizeMyData({
[layout]
);
const emptyWidgetPlaceholder = useMemo(
() => layout.find((widget) => widget.i.endsWith('.EmptyWidgetPlaceholder')),
[layout]
);
const disableSave = useMemo(() => {
const filteredLayout = layout.filter((widget) =>
widget.i.startsWith('KnowledgePanel')
@ -153,13 +156,14 @@ function CustomizeMyData({
layout.map((widget) => (
<div data-grid={widget} id={widget.i} key={widget.i}>
{getWidgetFromKey({
widgetConfig: widget,
currentLayout: layout,
handleLayoutUpdate: handleLayoutUpdate,
handleOpenAddWidgetModal: handleOpenCustomiseHomeModal,
handlePlaceholderWidgetKey: handlePlaceholderWidgetKey,
handleRemoveWidget: handleRemoveWidget,
isEditView: true,
handleLayoutUpdate: handleLayoutUpdate,
currentLayout: layout,
personaName: getEntityName(personaDetails),
widgetConfig: widget,
})}
</div>
)),
@ -188,10 +192,8 @@ function CustomizeMyData({
const handleReset = useCallback(async () => {
// Get default layout with the empty widget added at the end
const newMainPanelLayout = getLayoutWithEmptyWidgetPlaceholder(
customizeMyDataPageClassBase.defaultLayout,
2,
4
const newMainPanelLayout = getLandingPageLayoutWithEmptyWidgetPlaceholder(
customizeMyDataPageClassBase.defaultLayout
);
setLayout(newMainPanelLayout);
await handleBackgroundColorUpdate();
@ -204,13 +206,14 @@ function CustomizeMyData({
return (
<AdvanceSearchProvider isExplorePage={false} updateURL={false}>
<PageLayoutV1
className="p-t-box customise-my-data"
className="p-box customise-my-data"
pageTitle={t('label.customize-entity', {
entity: t('label.landing-page'),
})}>
<CustomizablePageHeader
disableSave={disableSave}
personaName={getEntityName(personaDetails)}
onAddWidget={handleOpenCustomiseHomeModal}
onReset={handleReset}
onSave={handleSave}
/>
@ -231,14 +234,16 @@ function CustomizeMyData({
<ReactGridLayout
useCSSTransforms
verticalCompact
className="grid-container"
className="grid-container layout"
cols={customizeMyDataPageClassBase.landingPageMaxGridSize}
compactType="horizontal"
draggableHandle=".drag-widget-icon"
isResizable={false}
margin={[
customizeMyDataPageClassBase.landingPageWidgetMargin,
customizeMyDataPageClassBase.landingPageWidgetMargin,
]}
maxRows={emptyWidgetPlaceholder?.y}
preventCollision={false}
rowHeight={customizeMyDataPageClassBase.landingPageRowHeight}
onLayoutChange={handleLayoutUpdate}>

View File

@ -12,6 +12,7 @@
*/
export interface EmptyWidgetPlaceholderV1Props {
personaName?: string;
widgetKey: string;
handleOpenAddWidgetModal: () => void;
handlePlaceholderWidgetKey: (key: string) => void;

View File

@ -22,6 +22,7 @@ import { EmptyWidgetPlaceholderV1Props } from './EmptyWidgetPlaceholderV1.interf
function EmptyWidgetPlaceholderV1({
handleOpenAddWidgetModal,
handlePlaceholderWidgetKey,
personaName,
widgetKey,
}: Readonly<EmptyWidgetPlaceholderV1Props>) {
const { t } = useTranslation();
@ -37,7 +38,9 @@ function EmptyWidgetPlaceholderV1({
{t('label.add-new-widget-plural')}
</Typography.Title>
<Typography.Text className="add-widgets-description">
{t('message.tailor-experience-for-persona')}
{t('message.tailor-experience-for-persona', {
persona: personaName,
})}
</Typography.Text>
<Button
className="add-widgets-button"

View File

@ -21,7 +21,7 @@
height: 100%;
padding: @padding-lg;
text-align: center;
gap: @size-md;
gap: @size-lg;
}
.add-widgets-title {
@ -46,13 +46,8 @@
border-color: @primary-color;
border-radius: 6px;
font-weight: @font-semibold;
height: @btn-height-sm;
padding: 0 @padding-lg;
padding: 0;
font-size: @font-size-base;
&:hover {
background-color: @blue-500;
border-color: @blue-500;
}
width: 128px;
}
}

View File

@ -17,9 +17,9 @@
background-color: @white;
border-bottom-right-radius: 12px;
border-bottom-left-radius: 12px;
box-shadow: 0px 0px 24px -8px rgba(10, 13, 18, 0.08),
0px -4px 6px -2px rgba(10, 13, 18, 0.03);
box-shadow: 0px 0px 24px -8px rgba(10, 13, 18, 0.08);
padding: 0;
z-index: 1;
.ant-btn {
border-radius: 6px;

View File

@ -15,6 +15,7 @@
.widget-header {
padding: 12px 20px 12px 20px;
box-shadow: 0px 4px 8px -4px rgba(10, 13, 18, 0.08);
z-index: 1;
.widget-title {
margin: 0;

View File

@ -66,4 +66,8 @@
.action--ADD-RULE {
box-shadow: none !important;
}
.action--DELETE {
color: @grey-400;
}
}

View File

@ -20,6 +20,7 @@ import { Domain } from '../../../generated/entity/domains/domain';
import { EntityReference } from '../../../generated/entity/type';
import { getEntityName } from '../../../utils/EntityUtils';
import Fqn from '../../../utils/Fqn';
import { getVisiblePopupContainer } from '../../../utils/LandingPageWidget/WidgetsUtils';
import { useGenericContext } from '../../Customization/GenericProvider/GenericProvider';
import DomainSelectablTree from '../DomainSelectableTree/DomainSelectableTree';
import { FocusTrapWithContainer } from '../FocusTrap/FocusTrapWithContainer';
@ -125,6 +126,7 @@ const DomainSelectableList = ({
</FocusTrapWithContainer>
)
}
getPopupContainer={getVisiblePopupContainer}
open={popupVisible}
overlayClassName="domain-select-popover w-400"
placement="bottomRight"

View File

@ -59,14 +59,8 @@ export const CustomizablePage = () => {
const { theme } = useApplicationStore();
const [isLoading, setIsLoading] = useState(true);
const [personaDetails, setPersonaDetails] = useState<Persona>();
const {
document,
setDocument,
getNavigation,
currentPage,
getPage,
setCurrentPageType,
} = useCustomizeStore();
const { document, setDocument, currentPage, getPage, setCurrentPageType } =
useCustomizeStore();
const backgroundColor = useMemo(
() =>
@ -323,12 +317,7 @@ export const CustomizablePage = () => {
switch (pageFqn) {
case 'navigation':
return (
<SettingsNavigationPage
currentNavigation={getNavigation()}
onSave={handleNavigationSave}
/>
);
return <SettingsNavigationPage onSave={handleNavigationSave} />;
case PageType.LandingPage:
case 'homepage':

View File

@ -29,8 +29,8 @@ import {
TreeProps,
Typography,
} from 'antd';
import { cloneDeep } from 'lodash';
import { useCallback, useState } from 'react';
import { cloneDeep, isEqual } from 'lodash';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ReactComponent as IconDown } from '../../assets/svg/ic-arrow-down.svg';
@ -41,20 +41,34 @@ import {
getHiddenKeysFromNavigationItems,
getTreeDataForNavigationItems,
} from '../../utils/CustomizaNavigation/CustomizeNavigation';
import { useCustomizeStore } from '../CustomizablePage/CustomizeStore';
import './settings-navigation-page.less';
interface Props {
onSave: (navigationList: NavigationItem[]) => Promise<void>;
currentNavigation?: NavigationItem[];
}
export const SettingsNavigationPage = ({
onSave,
currentNavigation,
}: Props) => {
const getNavigationItems = (
treeData: TreeDataNode[],
hiddenKeys: string[]
): NavigationItem[] => {
return treeData.map((item) => {
return {
id: item.key,
title: item.title,
isHidden: hiddenKeys.includes(item.key as string),
children: getNavigationItems(item.children ?? [], hiddenKeys),
} as NavigationItem;
});
};
export const SettingsNavigationPage = ({ onSave }: Props) => {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const navigate = useNavigate();
const { getNavigation } = useCustomizeStore();
const currentNavigation = getNavigation();
const [hiddenKeys, setHiddenKeys] = useState<string[]>(
getHiddenKeysFromNavigationItems(currentNavigation)
);
@ -62,21 +76,27 @@ export const SettingsNavigationPage = ({
currentNavigation ? getTreeDataForNavigationItems(currentNavigation) : []
);
const disableSave = useMemo(() => {
// Get the initial hidden keys from the current navigation
const initialHiddenKeys =
getHiddenKeysFromNavigationItems(currentNavigation);
// Get the current navigation items from the tree data
const currentNavigationItems =
getNavigationItems(treeData, hiddenKeys) || [];
// Get the current hidden keys from the current navigation items
const currentHiddenKeys =
getHiddenKeysFromNavigationItems(currentNavigationItems) || [];
// Check if the initial hidden keys are the same as the current hidden keys
return isEqual(initialHiddenKeys, currentHiddenKeys);
}, [currentNavigation, treeData, hiddenKeys]);
const handleSave = async () => {
setSaving(true);
const getNavigationItems = (treeData: TreeDataNode[]): NavigationItem[] => {
return treeData.map((item) => {
return {
id: item.key,
title: item.title,
isHidden: hiddenKeys.includes(item.key as string),
children: getNavigationItems(item.children ?? []),
} as NavigationItem;
});
};
const navigationItems = getNavigationItems(treeData);
const navigationItems = getNavigationItems(treeData, hiddenKeys);
await onSave(navigationItems);
setSaving(false);
@ -192,6 +212,7 @@ export const SettingsNavigationPage = ({
</Button>
<Button
data-testid="save-button"
disabled={disableSave}
icon={<SaveOutlined />}
loading={saving}
type="primary"

View File

@ -200,6 +200,7 @@
@border-color-5: #dde3ea;
@border-color-6: #dfdfdf;
@global-border: 1px solid @border-color;
@nlp-border-color: #b9e6fe;
@active-color: #e8f4ff;
@rdg-cell-range-selections-background-color: #e9effd;
@background-color: #ffffff;

View File

@ -11,43 +11,253 @@
* limitations under the License.
*/
import { mockWidget } from '../mocks/AddWidgetTabContent.mock';
import { mockCurrentAddWidget } from '../mocks/CustomizablePage.mock';
import {
mockAddWidgetReturnValues,
mockAddWidgetReturnValues2,
mockCurrentAddWidget,
} from '../mocks/CustomizablePage.mock';
import { getAddWidgetHandler } from './CustomizableLandingPageUtils';
getAddWidgetHandler,
getLandingPageLayoutWithEmptyWidgetPlaceholder,
getLayoutUpdateHandler,
getLayoutWithEmptyWidgetPlaceholder,
getNewWidgetPlacement,
getRemoveWidgetHandler,
getUniqueFilteredLayout,
getWidgetWidthLabelFromKey,
} from './CustomizableLandingPageUtils';
describe('getAddWidgetHandler function', () => {
it('should add new widget at the bottom if not fit in the grid row', () => {
const result = getAddWidgetHandler(
mockWidget,
'ExtraWidget.EmptyWidgetPlaceholder',
1,
3
)(mockCurrentAddWidget);
describe('CustomizableLandingPageUtils', () => {
describe('getNewWidgetPlacement', () => {
it('should place widget in same row if space available', () => {
const currentLayout = [
{ w: 1, h: 3, x: 0, y: 0, i: 'widget1', static: false },
{ w: 1, h: 3, x: 1, y: 0, i: 'widget2', static: false },
];
expect(result).toEqual(mockAddWidgetReturnValues);
const result = getNewWidgetPlacement(currentLayout, 1);
expect(result).toEqual({ x: 2, y: 0 });
});
it('should place widget in next row if no space in current row', () => {
const currentLayout = [
{ w: 1, h: 3, x: 0, y: 0, i: 'widget1', static: false },
{ w: 1, h: 3, x: 1, y: 0, i: 'widget2', static: false },
{ w: 1, h: 3, x: 2, y: 0, i: 'widget3', static: false },
];
const result = getNewWidgetPlacement(currentLayout, 1);
expect(result).toEqual({ x: 0, y: 1 });
});
it('should handle empty layout', () => {
const result = getNewWidgetPlacement([], 1);
expect(result).toEqual({ x: 0, y: 0 });
});
});
it('should add new widget at the same line if new widget can fit', () => {
const result = getAddWidgetHandler(
mockWidget,
'ExtraWidget.EmptyWidgetPlaceholder',
1,
3
)([
...mockCurrentAddWidget,
{
h: 3,
i: 'KnowledgePanel.dataAsset',
w: 1,
x: 0,
y: 4,
static: false,
},
]);
describe('getAddWidgetHandler', () => {
it('should handle empty layout', () => {
const result = getAddWidgetHandler(
mockWidget,
'ExtraWidget.EmptyWidgetPlaceholder',
1,
3
)([]);
expect(result).toEqual(mockAddWidgetReturnValues2);
expect(result).toHaveLength(2);
expect(result[0].i).toContain('KnowledgePanel.Following');
expect(result[1].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
});
it('should handle null widget data', () => {
const result = getAddWidgetHandler(
null as any,
'ExtraWidget.EmptyWidgetPlaceholder',
1,
3
)(mockCurrentAddWidget);
expect(result).toHaveLength(1);
expect(result[0].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
});
it('should replace placeholder with new widget', () => {
const result = getAddWidgetHandler(
mockWidget,
'ExtraWidget.EmptyWidgetPlaceholder',
1,
3
)(mockCurrentAddWidget);
expect(result).toHaveLength(4);
expect(result[0].i).toBe('KnowledgePanel.ActivityFeed');
expect(result[1].i).toBe('KnowledgePanel.Following-2');
expect(result[2].i).toBe('KnowledgePanel.RecentlyViewed');
expect(result[3].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
});
});
describe('getLayoutUpdateHandler', () => {
it('should handle empty updated layout', () => {
const result = getLayoutUpdateHandler([])(mockCurrentAddWidget);
expect(result).toHaveLength(1);
expect(result[0].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
});
it('should preserve widget properties during update', () => {
const currentLayout = [
{ w: 2, h: 3, x: 0, y: 0, i: 'widget1', static: false },
];
const updatedLayout = [
{ w: 2, h: 3, x: 1, y: 0, i: 'widget1', static: false },
];
const result = getLayoutUpdateHandler(updatedLayout)(currentLayout);
expect(result).toHaveLength(2); // widget + placeholder
expect(result[0].w).toBe(2);
expect(result[0].h).toBe(3);
});
it('should handle layout with placeholder widgets', () => {
const currentLayout = [
{ w: 1, h: 3, x: 0, y: 0, i: 'widget1', static: false },
{
w: 1,
h: 3,
x: 0,
y: 3,
i: 'ExtraWidget.EmptyWidgetPlaceholder',
static: false,
},
];
const updatedLayout = [
{ w: 1, h: 3, x: 1, y: 0, i: 'widget1', static: false },
{
w: 1,
h: 3,
x: 0,
y: 3,
i: 'ExtraWidget.EmptyWidgetPlaceholder',
static: false,
},
];
const result = getLayoutUpdateHandler(updatedLayout)(currentLayout);
expect(result).toHaveLength(2);
expect(result[0].i).toBe('widget1');
expect(result[1].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
});
});
describe('getRemoveWidgetHandler', () => {
it('should remove specified widget from layout', () => {
const currentLayout = [
{ w: 1, h: 3, x: 0, y: 0, i: 'widget1', static: false },
{ w: 1, h: 3, x: 1, y: 0, i: 'widget2', static: false },
];
const result = getRemoveWidgetHandler('widget1')(currentLayout);
expect(result).toHaveLength(2); // 1 widget + placeholder
expect(result[0].i).toBe('widget2');
});
it('should handle empty layout', () => {
const result = getRemoveWidgetHandler('widget1')([]);
expect(result).toHaveLength(1);
expect(result[0].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
});
});
describe('getWidgetWidthLabelFromKey', () => {
it('should return correct label for large size', () => {
const result = getWidgetWidthLabelFromKey('large');
expect(result).toBe('label.large');
});
it('should return correct label for medium size', () => {
const result = getWidgetWidthLabelFromKey('medium');
expect(result).toBe('label.medium');
});
it('should return correct label for small size', () => {
const result = getWidgetWidthLabelFromKey('small');
expect(result).toBe('label.small');
});
});
describe('getUniqueFilteredLayout', () => {
it('should filter out non-knowledge panel widgets', () => {
const layout = [
{ w: 1, h: 3, x: 0, y: 0, i: 'KnowledgePanel.widget1', static: false },
{ w: 1, h: 3, x: 1, y: 0, i: 'OtherWidget.widget2', static: false },
{ w: 1, h: 3, x: 2, y: 0, i: 'KnowledgePanel.widget3', static: false },
];
const result = getUniqueFilteredLayout(layout);
expect(result).toHaveLength(2);
expect(result[0].i).toBe('KnowledgePanel.widget1');
expect(result[1].i).toBe('KnowledgePanel.widget3');
});
it('should remove duplicate widgets', () => {
const layout = [
{ w: 1, h: 3, x: 0, y: 0, i: 'KnowledgePanel.widget1', static: false },
{ w: 1, h: 3, x: 1, y: 0, i: 'KnowledgePanel.widget1', static: false },
];
const result = getUniqueFilteredLayout(layout);
expect(result).toHaveLength(1);
expect(result[0].i).toBe('KnowledgePanel.widget1');
});
});
describe('getLayoutWithEmptyWidgetPlaceholder', () => {
it('should add placeholder to empty layout', () => {
const result = getLayoutWithEmptyWidgetPlaceholder([]);
expect(result).toHaveLength(1);
expect(result[0].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
expect(result[0].h).toBe(4);
});
it('should add placeholder to existing layout', () => {
const layout = [{ w: 1, h: 3, x: 0, y: 0, i: 'widget1', static: false }];
const result = getLayoutWithEmptyWidgetPlaceholder(layout);
expect(result).toHaveLength(2);
expect(result[1].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
});
});
describe('getLandingPageLayoutWithEmptyWidgetPlaceholder', () => {
it('should add placeholder to empty layout', () => {
const result = getLandingPageLayoutWithEmptyWidgetPlaceholder([]);
expect(result).toHaveLength(1);
expect(result[0].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
expect(result[0].h).toBe(3);
});
it('should add placeholder to existing layout', () => {
const layout = [{ w: 1, h: 3, x: 0, y: 0, i: 'widget1', static: false }];
const result = getLandingPageLayoutWithEmptyWidgetPlaceholder(layout);
expect(result).toHaveLength(2);
expect(result[1].i).toBe('ExtraWidget.EmptyWidgetPlaceholder');
});
});
});

View File

@ -13,23 +13,12 @@
import Icon from '@ant-design/icons';
import i18next from 'i18next';
import {
capitalize,
isEmpty,
isUndefined,
max,
uniqBy,
uniqueId,
} from 'lodash';
import { capitalize, isUndefined, uniqBy, uniqueId } from 'lodash';
import { DOMAttributes } from 'react';
import { Layout } from 'react-grid-layout';
import { ReactComponent as ArrowRightIcon } from '../assets/svg/arrow-right.svg';
import EmptyWidgetPlaceholder from '../components/MyData/CustomizableComponents/EmptyWidgetPlaceholder/EmptyWidgetPlaceholder';
import { SIZE } from '../enums/common.enum';
import {
LandingPageWidgetKeys,
WidgetWidths,
} from '../enums/CustomizablePage.enum';
import EmptyWidgetPlaceholderV1 from '../components/MyData/CustomizableComponents/EmptyWidgetPlaceholder/EmptyWidgetPlaceholderV1';
import { LandingPageWidgetKeys } from '../enums/CustomizablePage.enum';
import { Document } from '../generated/entity/docStore/document';
import { WidgetConfig } from '../pages/CustomizablePage/CustomizablePage.interface';
import customizeMyDataPageClassBase from './CustomizeMyDataPageClassBase';
@ -75,6 +64,192 @@ export const getNewWidgetPlacement = (
};
};
/**
* Creates a placeholder widget
*/
const createPlaceholderWidget = (x = 0, y = 0) => ({
h: customizeMyDataPageClassBase.defaultWidgetHeight,
i: LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER,
w: 1,
x,
y,
static: false,
isDraggable: false,
});
/**
* Separates regular widgets from placeholder widgets
*/
const separateWidgets = (layout: WidgetConfig[]) => {
const regularWidgets = layout.filter(
(widget) => !widget.i.endsWith('.EmptyWidgetPlaceholder')
);
const placeholderWidget = layout.find((widget) =>
widget.i.endsWith('.EmptyWidgetPlaceholder')
);
return { regularWidgets, placeholderWidget };
};
/**
* Packs widgets tightly without gaps
* - Widgets are arranged left to right, top to bottom
* - No empty spaces between widgets
* - Ensures tight coupling
* - Excludes placeholder widgets from packing
*/
const packWidgetsTightly = (widgets: WidgetConfig[]): WidgetConfig[] => {
if (widgets.length === 0) {
return [];
}
// Filter out placeholder widgets and sort by current position
const regularWidgets = widgets.filter(
(widget) => !widget.i.endsWith('.EmptyWidgetPlaceholder')
);
const sortedWidgets = [...regularWidgets].sort((a, b) => {
if (a.y !== b.y) {
return a.y - b.y;
}
return a.x - b.x;
});
const packedWidgets: WidgetConfig[] = [];
let currentX = 0;
let currentY = 0;
let maxHeightInRow = 0;
sortedWidgets.forEach((widget) => {
// Check if widget fits in current row
if (
currentX + widget.w >
customizeMyDataPageClassBase.landingPageMaxGridSize
) {
// Move to next row
currentX = 0;
currentY += maxHeightInRow;
maxHeightInRow = 0;
}
// Position the widget
const positionedWidget = {
...widget,
x: currentX,
y: currentY,
static: false,
};
packedWidgets.push(positionedWidget);
// Update position for next widget
currentX += widget.w;
maxHeightInRow = Math.max(maxHeightInRow, widget.h);
});
return packedWidgets;
};
/**
* Ensures the placeholder widget is always at the end with tight coupling
* - Widgets are packed tightly without gaps
* - Placeholder is positioned after the last widget
* - No widgets can be placed after the placeholder
*/
export const ensurePlaceholderAtEnd = (
layout: WidgetConfig[]
): WidgetConfig[] => {
if (!layout || layout.length === 0) {
return [createPlaceholderWidget()];
}
const { regularWidgets } = separateWidgets(layout);
if (regularWidgets.length === 0) {
return [createPlaceholderWidget()];
}
// Pack widgets tightly first
const packedWidgets = packWidgetsTightly(regularWidgets);
// Find the rightmost and bottommost position after packing
let maxX = 0;
let maxY = 0;
packedWidgets.forEach((widget) => {
const widgetEndX = widget.x + widget.w;
const widgetEndY = widget.y + widget.h;
maxX = Math.max(maxX, widgetEndX);
maxY = Math.max(maxY, widgetEndY);
});
// Find available space in the last row
const widgetsInLastRow = packedWidgets.filter(
(widget) =>
widget.y === maxY - customizeMyDataPageClassBase.defaultWidgetHeight
);
// Calculate the rightmost position in the last row
let rightmostInLastRow = 0;
widgetsInLastRow.forEach((widget) => {
const widgetEndX = widget.x + widget.w;
rightmostInLastRow = Math.max(rightmostInLastRow, widgetEndX);
});
// Check if there's space in the last row
const canFitInLastRow =
rightmostInLastRow + 1 <=
customizeMyDataPageClassBase.landingPageMaxGridSize;
const placeholderWidget = createPlaceholderWidget(
canFitInLastRow ? rightmostInLastRow : 0,
canFitInLastRow
? maxY - customizeMyDataPageClassBase.defaultWidgetHeight
: maxY
);
return [...packedWidgets, placeholderWidget];
};
/**
* Layout update handler with tight coupling
* - Ensures widgets are packed tightly after drag operations
* - Placeholder is always positioned correctly
* - No gaps between widgets
*/
export const getLayoutUpdateHandler =
(updatedLayout: Layout[]) => (currentLayout: Array<WidgetConfig>) => {
if (!updatedLayout || updatedLayout.length === 0) {
return [createPlaceholderWidget()];
}
// Apply basic layout update from React Grid Layout
const basicUpdatedLayout = updatedLayout.map((widget) => {
const widgetData = currentLayout.find(
(a: WidgetConfig) => a.i === widget.i
);
return {
...(!widgetData ? {} : widgetData),
...widget,
static: false,
};
});
// Separate regular widgets and pack them tightly
const { regularWidgets } = separateWidgets(basicUpdatedLayout);
if (regularWidgets.length === 0) {
return [createPlaceholderWidget()];
}
// Pack widgets tightly and ensure placeholder at end
return ensurePlaceholderAtEnd(basicUpdatedLayout);
};
/**
* Adds a new widget to the layout
*/
export const getAddWidgetHandler =
(
newWidgetData: Document,
@ -83,87 +258,88 @@ export const getAddWidgetHandler =
maxGridSize: number
) =>
(currentLayout: Array<WidgetConfig>) => {
const widgetFQN = uniqueId(`${newWidgetData.fullyQualifiedName}-`);
if (!newWidgetData) {
return [createPlaceholderWidget()];
}
const widgetFQN = uniqueId(
`${newWidgetData.fullyQualifiedName || 'widget'}-`
);
const widgetHeight = customizeMyDataPageClassBase.getWidgetHeight(
newWidgetData.name
);
// The widget with key "ExtraWidget.EmptyWidgetPlaceholder" will always remain in the bottom
// and is not meant to be replaced hence
// if placeholderWidgetKey is "ExtraWidget.EmptyWidgetPlaceholder"
// append the new widget in the array
// else replace the new widget with other placeholder widgets
if (
placeholderWidgetKey === LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER
) {
if (!currentLayout || currentLayout.length === 0) {
return [
...moveEmptyWidgetToTheEnd(currentLayout),
{
w: widgetWidth,
h: widgetHeight,
i: widgetFQN,
static: false,
...getNewWidgetPlacement(currentLayout, widgetWidth),
x: 0,
y: 0,
},
createPlaceholderWidget(widgetWidth, 0),
];
} else {
return currentLayout.map((widget: WidgetConfig) => {
const widgetX =
widget.x + widgetWidth <= maxGridSize
? widget.x
: maxGridSize - widgetWidth;
return widget.i === placeholderWidgetKey
? {
...widget,
i: widgetFQN,
h: widgetHeight,
w: widgetWidth,
x: widgetX,
}
: widget;
});
}
const { regularWidgets } = separateWidgets(currentLayout);
// If adding to placeholder, append and repack
if (
placeholderWidgetKey === LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER
) {
const newWidget = {
w: widgetWidth,
h: widgetHeight,
i: widgetFQN,
static: false,
x: 0,
y: 0,
};
return ensurePlaceholderAtEnd([...regularWidgets, newWidget]);
}
// Replace specific placeholder
const updatedWidgets = currentLayout.map((widget: WidgetConfig) => {
if (widget.i === placeholderWidgetKey) {
return {
...widget,
i: widgetFQN,
h: widgetHeight,
w: widgetWidth,
x: Math.min(widget.x, maxGridSize - widgetWidth),
static: false,
};
}
return widget;
});
return ensurePlaceholderAtEnd(updatedWidgets);
};
export const moveEmptyWidgetToTheEnd = (layout: Array<WidgetConfig>) =>
layout.map((widget) =>
widget.i === LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER
? { ...widget, y: 100 }
: widget
);
/**
* Removes a widget and repacks the layout
*/
export const getRemoveWidgetHandler =
(widgetKey: string) => (currentLayout: Array<WidgetConfig>) => {
return currentLayout.filter(
if (!currentLayout || currentLayout.length === 0) {
return [createPlaceholderWidget()];
}
const filteredLayout = currentLayout.filter(
(widget: WidgetConfig) => widget.i !== widgetKey
);
return ensurePlaceholderAtEnd(filteredLayout);
};
export const getLayoutUpdateHandler =
(updatedLayout: Layout[]) => (currentLayout: Array<WidgetConfig>) => {
return updatedLayout.map((widget) => {
const widgetData = currentLayout.find(
(a: WidgetConfig) => a.i === widget.i
);
return {
...(!isEmpty(widgetData) ? widgetData : {}),
...widget,
};
});
};
export const getWidgetWidth = (widget: Document) => {
const gridSizes = widget.data.gridSizes;
const widgetSize = max(
gridSizes.map((size: WidgetWidths) => WidgetWidths[size])
);
return widgetSize as number;
};
export const getWidgetWidthLabelFromKey = (widgetKey: string) => {
/**
* Gets human-readable widget width label
*/
export const getWidgetWidthLabelFromKey = (widgetKey: string): string => {
switch (widgetKey) {
case 'large':
return i18next.t('label.large');
@ -176,31 +352,17 @@ export const getWidgetWidthLabelFromKey = (widgetKey: string) => {
}
};
const getAllWidgetsArray = (layout: WidgetConfig[]) => {
const widgetsArray: WidgetConfig[] = [];
layout.forEach((widget) => {
if (widget.i.startsWith('KnowledgePanel.')) {
widgetsArray.push(widget);
}
const childLayout = widget.data?.page.layout;
if (!isUndefined(childLayout)) {
widgetsArray.push(...getAllWidgetsArray(childLayout));
}
});
return widgetsArray;
};
/**
* Renders widget component based on configuration
*/
export const getWidgetFromKey = ({
currentLayout,
handleLayoutUpdate,
handleOpenAddWidgetModal,
handlePlaceholderWidgetKey,
handleRemoveWidget,
iconHeight,
iconWidth,
isEditView,
personaName,
widgetConfig,
}: {
currentLayout?: Array<WidgetConfig>;
@ -208,25 +370,20 @@ export const getWidgetFromKey = ({
handleOpenAddWidgetModal?: () => void;
handlePlaceholderWidgetKey?: (key: string) => void;
handleRemoveWidget?: (key: string) => void;
iconHeight?: SIZE;
iconWidth?: SIZE;
isEditView?: boolean;
personaName?: string;
widgetConfig: WidgetConfig;
}) => {
if (
widgetConfig.i.endsWith('.EmptyWidgetPlaceholder') &&
!isUndefined(handleOpenAddWidgetModal) &&
!isUndefined(handlePlaceholderWidgetKey) &&
!isUndefined(handleRemoveWidget)
!isUndefined(handlePlaceholderWidgetKey)
) {
return (
<EmptyWidgetPlaceholder
<EmptyWidgetPlaceholderV1
handleOpenAddWidgetModal={handleOpenAddWidgetModal}
handlePlaceholderWidgetKey={handlePlaceholderWidgetKey}
handleRemoveWidget={handleRemoveWidget}
iconHeight={iconHeight}
iconWidth={iconWidth}
isEditable={widgetConfig.isDraggable}
personaName={personaName}
widgetKey={widgetConfig.i}
/>
);
@ -246,33 +403,9 @@ export const getWidgetFromKey = ({
);
};
export const getLayoutWithEmptyWidgetPlaceholder = (
layout: WidgetConfig[],
emptyWidgetHeight = 2,
emptyWidgetWidth = 1
) => [
...layout,
{
h: emptyWidgetHeight,
i: LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER,
w: emptyWidgetWidth,
x: 0,
y: 1000,
isDraggable: false,
},
];
// Function to filter out empty widget placeholders and only keep knowledge panels
export const getUniqueFilteredLayout = (layout: WidgetConfig[]) =>
uniqBy(
layout.filter(
(widget) =>
widget.i.startsWith('KnowledgePanel') &&
!widget.i.endsWith('.EmptyWidgetPlaceholder')
),
'i'
);
/**
* Custom arrow components for carousel navigation
*/
export const CustomNextArrow = (props: DOMAttributes<HTMLDivElement>) => (
<Icon
className="custom-arrow right-arrow"
@ -288,3 +421,71 @@ export const CustomPrevArrow = (props: DOMAttributes<HTMLDivElement>) => (
onClick={props.onClick}
/>
);
/**
* Creates a layout with empty widget placeholder at the end
*/
export const getLayoutWithEmptyWidgetPlaceholder = (
layout: WidgetConfig[],
emptyWidgetHeight = 4,
emptyWidgetWidth = 1
) => {
// Handle empty or null layout
if (!layout || layout.length === 0) {
return [
{
h: emptyWidgetHeight,
i: LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER,
w: emptyWidgetWidth,
x: 0,
y: 0,
isDraggable: false,
},
];
}
return ensurePlaceholderAtEnd(layout);
};
/**
* Creates a landing page layout with empty widget placeholder
*/
export const getLandingPageLayoutWithEmptyWidgetPlaceholder = (
layout: WidgetConfig[],
emptyWidgetHeight = 3,
emptyWidgetWidth = 1
) => {
if (!layout || layout.length === 0) {
return [
{
h: emptyWidgetHeight,
i: LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER,
w: emptyWidgetWidth,
x: 0,
y: 0,
isDraggable: false,
},
];
}
return ensurePlaceholderAtEnd(layout);
};
/**
* Filters out empty widget placeholders and only keeps knowledge panels
*/
export const getUniqueFilteredLayout = (layout: WidgetConfig[]) => {
// Handle empty or null layout
if (!layout || layout.length === 0) {
return [];
}
return uniqBy(
layout.filter(
(widget) =>
widget.i.startsWith('KnowledgePanel') &&
!widget.i.endsWith('.EmptyWidgetPlaceholder')
),
'i'
);
};

View File

@ -0,0 +1,140 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TreeDataNode } from 'antd';
import { getNavigationItems } from './SettingsNavigationPageUtils';
describe('SettingsNavigationPageUtils', () => {
describe('getNavigationItems', () => {
it('should convert empty tree data to empty navigation items', () => {
const treeData: TreeDataNode[] = [];
const hiddenKeys: string[] = [];
const result = getNavigationItems(treeData, hiddenKeys);
expect(result).toEqual([]);
});
it('should convert single tree node to navigation item with correct hidden state', () => {
const treeData: TreeDataNode[] = [
{
key: 'test-key',
title: 'Test Title',
},
];
const hiddenKeys: string[] = ['test-key'];
const result = getNavigationItems(treeData, hiddenKeys);
expect(result).toEqual([
{
id: 'test-key',
title: 'Test Title',
isHidden: true,
children: [],
},
]);
});
it('should handle tree data with children and nested hidden states', () => {
const treeData: TreeDataNode[] = [
{
key: 'parent-key',
title: 'Parent Title',
children: [
{
key: 'child-key',
title: 'Child Title',
},
],
},
];
const hiddenKeys: string[] = ['child-key'];
const result = getNavigationItems(treeData, hiddenKeys);
expect(result).toEqual([
{
id: 'parent-key',
title: 'Parent Title',
isHidden: false,
children: [
{
id: 'child-key',
title: 'Child Title',
isHidden: true,
children: [],
},
],
},
]);
});
it('should handle complex tree structure with multiple levels and mixed hidden states', () => {
const treeData: TreeDataNode[] = [
{
key: 'root',
title: 'Root',
children: [
{
key: 'child1',
title: 'Child 1',
},
{
key: 'child2',
title: 'Child 2',
children: [
{
key: 'grandchild',
title: 'Grandchild',
},
],
},
],
},
];
const hiddenKeys: string[] = ['child1', 'grandchild'];
const result = getNavigationItems(treeData, hiddenKeys);
expect(result).toEqual([
{
id: 'root',
title: 'Root',
isHidden: false,
children: [
{
id: 'child1',
title: 'Child 1',
isHidden: true,
children: [],
},
{
id: 'child2',
title: 'Child 2',
isHidden: false,
children: [
{
id: 'grandchild',
title: 'Grandchild',
isHidden: true,
children: [],
},
],
},
],
},
]);
});
});
});

View File

@ -0,0 +1,29 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TreeDataNode } from 'antd';
import { NavigationItem } from '../generated/system/ui/uiCustomization';
export const getNavigationItems = (
treeData: TreeDataNode[],
hiddenKeys: string[]
): NavigationItem[] => {
return treeData.map((item) => {
return {
id: item.key,
title: item.title,
isHidden: hiddenKeys.includes(item.key as string),
children: getNavigationItems(item.children ?? [], hiddenKeys),
} as NavigationItem;
});
};