mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-08-08 00:58:06 +00:00
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:
parent
40d662814c
commit
ddd5c8a0e3
@ -13,8 +13,6 @@
|
||||
|
||||
@import (reference) '../../styles/variables.less';
|
||||
|
||||
@nlp-border-color: #b9e6fe;
|
||||
|
||||
.search-container {
|
||||
border: 1px solid #eaecf5;
|
||||
border-radius: 12px;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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}>
|
||||
|
@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
export interface EmptyWidgetPlaceholderV1Props {
|
||||
personaName?: string;
|
||||
widgetKey: string;
|
||||
handleOpenAddWidgetModal: () => void;
|
||||
handlePlaceholderWidgetKey: (key: string) => void;
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -66,4 +66,8 @@
|
||||
.action--ADD-RULE {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.action--DELETE {
|
||||
color: @grey-400;
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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':
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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'
|
||||
);
|
||||
};
|
||||
|
@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
});
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user