MINOR: move the export png logic to utils to work independently (#20601)

* move the export png logic to utils to work independently

* added support for manage button in dataInsightHeader

* fix unit test issue

* added common class for the export selector

* optimize the code and remove unwanted thing
This commit is contained in:
Ashish Gupta 2025-04-04 11:25:37 +05:30 committed by GitHub
parent 5ca4db0849
commit 286ccfeba2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 286 additions and 94 deletions

View File

@ -92,7 +92,7 @@ const DailyActiveUsersChart: FC<Props> = ({ chartFilter, selectedDays }) => {
return (
<Card
className="data-insight-card"
className="data-insight-card data-insight-card-chart"
data-testid="entity-active-user-card"
id={DataInsightChartType.DailyActiveUsers}
loading={isLoading}

View File

@ -478,7 +478,10 @@ export const DataInsightChartCard = ({
}
return (
<Card className="data-insight-card" data-testid={`${type}-graph`} id={type}>
<Card
className="data-insight-card data-insight-card-chart"
data-testid={`${type}-graph`}
id={type}>
<Row gutter={DI_STRUCTURE.rowContainerGutter}>
<Col span={DI_STRUCTURE.leftContainerSpan}>
<PageHeader

View File

@ -227,7 +227,7 @@ const KPIChart: FC<Props> = ({
return (
<Card
className="data-insight-card"
className="data-insight-card data-insight-card-chart"
data-testid="kpi-card"
id="kpi-charts"
loading={isLoading || isKpiLoading}

View File

@ -121,7 +121,7 @@ const PageViewsByEntitiesChart: FC<Props> = ({ chartFilter, selectedDays }) => {
return (
<Card
className="data-insight-card"
className="data-insight-card data-insight-card-chart"
data-testid="entity-page-views-card"
id={DataInsightChartType.PageViewsByEntities}
loading={isLoading}>

View File

@ -27,7 +27,6 @@ import { useLocation } from 'react-router-dom';
import { ExportTypes } from '../../../constants/Export.constants';
import { getCurrentISODate } from '../../../utils/date-time/DateTimeUtils';
import { isBulkEditRoute } from '../../../utils/EntityBulkEdit/EntityBulkEditUtils';
import { handleExportFile } from '../../../utils/EntityUtils';
import exportUtilClassBase from '../../../utils/ExportUtilClassBase';
import { showErrorToast } from '../../../utils/ToastUtils';
import Banner from '../../common/Banner/Banner';
@ -123,7 +122,10 @@ export const EntityExportModalProvider = ({
setDownloading(true);
if (exportType !== ExportTypes.CSV) {
await handleExportFile(exportType, exportData);
await exportUtilClassBase.exportMethodBasedOnType({
exportType,
exportData,
});
handleCancel();
setDownloading(false);

View File

@ -156,7 +156,7 @@ const TotalDataAssetsWidget = ({
return (
<Card
className="total-data-insight-card"
className="total-data-insight-card data-insight-card-chart"
data-testid="total-assets-widget"
id={SystemChartType.TotalDataAssets}
loading={isLoading}>

View File

@ -13,6 +13,7 @@
import { Col, Row, Typography } from 'antd';
import classNames from 'classnames';
import { noop } from 'lodash';
import React from 'react';
import { MangeButtonItemLabelProps } from './ManageButtonItemLabel.interface';
@ -34,7 +35,7 @@ export const ManageButtonItemLabel = ({
'opacity-50': disabled,
})}
data-testid={id}
onClick={onClick}>
onClick={disabled ? noop : onClick}>
<Col className="self-center" data-testid={`${id}-icon`} span={3}>
<Icon width="18px" />
</Col>

View File

@ -10,6 +10,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ItemType } from 'antd/lib/menu/hooks/useItems';
import { ReactComponent as AppAnalyticsIcon } from '../../assets/svg/app-analytics.svg';
import { ReactComponent as DataAssetsIcon } from '../../assets/svg/data-asset.svg';
import { ReactComponent as KPIIcon } from '../../assets/svg/kpi.svg';
@ -81,6 +83,10 @@ class DataInsightClassBase {
},
];
}
public getManageExtraOptions(): ItemType[] {
return [];
}
}
const dataInsightClassBase = new DataInsightClassBase();

View File

@ -11,21 +11,25 @@
* limitations under the License.
*/
import { Button, Col, Row, Space, Typography } from 'antd';
import { isEmpty } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory, useParams } from 'react-router-dom';
import DatePickerMenu from '../../../components/common/DatePickerMenu/DatePickerMenu.component';
import ManageButton from '../../../components/common/EntityPageInfos/ManageButton/ManageButton';
import DataInsightSummary from '../../../components/DataInsight/DataInsightSummary';
import KPIChart from '../../../components/DataInsight/KPIChart';
import SearchDropdown from '../../../components/SearchDropdown/SearchDropdown';
import { ROUTES } from '../../../constants/constants';
import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider';
import { ResourceEntity } from '../../../context/PermissionProvider/PermissionProvider.interface';
import { EntityType } from '../../../enums/entity.enum';
import { Operation } from '../../../generated/entity/policies/policy';
import { DataInsightTabs } from '../../../interface/data-insight.interface';
import { getOptionalDataInsightTabFlag } from '../../../utils/DataInsightUtils';
import { formatDate } from '../../../utils/date-time/DateTimeUtils';
import { checkPermission } from '../../../utils/PermissionsUtils';
import dataInsightClassBase from '../DataInsightClassBase';
import { useDataInsightProvider } from '../DataInsightProvider';
import { DataInsightHeaderProps } from './DataInsightHeader.interface';
@ -56,6 +60,11 @@ const DataInsightHeader = ({ onScrollToChart }: DataInsightHeaderProps) => {
[permissions]
);
const extraDropdownContent = useMemo(
() => dataInsightClassBase.getManageExtraOptions(),
[]
);
const handleAddKPI = () => {
history.push(ROUTES.ADD_KPI);
};
@ -73,16 +82,26 @@ const DataInsightHeader = ({ onScrollToChart }: DataInsightHeaderProps) => {
</Typography.Text>
</div>
{createKPIPermission && (
<Button
data-testid="add-kpi-btn"
type="primary"
onClick={handleAddKPI}>
{t('label.add-entity', {
entity: t('label.kpi-uppercase'),
})}
</Button>
)}
<div className="d-flex gap-2">
{createKPIPermission && (
<Button
data-testid="add-kpi-btn"
type="primary"
onClick={handleAddKPI}>
{t('label.add-entity', {
entity: t('label.kpi-uppercase'),
})}
</Button>
)}
{!isEmpty(extraDropdownContent) ? (
<ManageButton
entityName={EntityType.KPI}
entityType={EntityType.KPI}
extraDropdownContent={extraDropdownContent}
/>
) : null}
</div>
</Space>
</Col>
<Col span={24}>

View File

@ -73,6 +73,15 @@ jest.mock('../../../constants/constants', () => ({
ROUTES: {},
}));
jest.mock(
'../../../components/common/EntityPageInfos/ManageButton/ManageButton',
() => jest.fn(() => <div>ManageButton</div>)
);
jest.mock('../DataInsightClassBase', () => ({
getManageExtraOptions: jest.fn().mockReturnValue([]),
}));
const mockProps = {
onScrollToChart: jest.fn(),
};

View File

@ -69,6 +69,17 @@ jest.mock('./ServiceUtils', () => ({
getServiceRouteFromServiceType: jest.fn(),
}));
jest.mock('./ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
jest.mock('./ExportUtilClassBase', () => ({
__esModule: true,
default: {
exportMethodBasedOnType: jest.fn(),
},
}));
describe('EntityUtils unit tests', () => {
describe('highlightEntityNameAndDescription method', () => {
it('highlightEntityNameAndDescription method should return the entity with highlighted name and description', () => {

View File

@ -12,8 +12,6 @@
*/
import { Popover, Space, Typography } from 'antd';
import { AxiosError } from 'axios';
import { toPng } from 'html-to-image';
import i18next, { t } from 'i18next';
import {
isEmpty,
@ -35,7 +33,6 @@ import { DataAssetsWithoutServiceField } from '../components/DataAssets/DataAsse
import { DataAssetSummaryPanelProps } from '../components/DataAssetSummaryPanel/DataAssetSummaryPanel.interface';
import { TableProfilerTab } from '../components/Database/Profiler/ProfilerDashboard/profilerDashboard.interface';
import { QueryVoteType } from '../components/Database/TableQueries/TableQueries.interface';
import { ExportData } from '../components/Entity/EntityExportModalProvider/EntityExportModalProvider.interface';
import {
EntityServiceUnion,
EntityWithServices,
@ -52,7 +49,6 @@ import {
PLACEHOLDER_ROUTE_FQN,
ROUTES,
} from '../constants/constants';
import { ExportTypes } from '../constants/Export.constants';
import {
GlobalSettingOptions,
GlobalSettingsMenuCategory,
@ -119,9 +115,7 @@ import {
import { getDataInsightPathWithFqn } from './DataInsightUtils';
import EntityLink from './EntityLink';
import { BasicEntityOverviewInfo } from './EntityUtils.interface';
import exportUtilClassBase from './ExportUtilClassBase';
import Fqn from './Fqn';
import i18n from './i18next/LocalUtil';
import {
getApplicationDetailsPath,
getBotsPagePath,
@ -154,7 +148,6 @@ import {
getUsagePercentile,
} from './TableUtils';
import { getTableTags } from './TagsUtils';
import { showErrorToast } from './ToastUtils';
export enum DRAWER_NAVIGATION_OPTIONS {
explore = 'Explore',
@ -2548,65 +2541,3 @@ export const updateNodeType = (
return node;
};
export const handleExportFile = async (
exportType: ExportTypes,
exportData: ExportData
) => {
const { name: fileName, documentSelector = '', viewport } = exportData;
try {
const exportElement = document.querySelector(documentSelector);
if (!exportElement) {
throw new Error(
i18n.t('message.error-generating-export-type', {
exportType,
})
);
}
// Minimum width and height for the image
const minWidth = 1000;
const minHeight = 800;
const padding = 20;
const imageWidth = Math.max(minWidth, exportElement.scrollWidth);
const imageHeight = Math.max(minHeight, exportElement.scrollHeight);
await toPng(exportElement as HTMLElement, {
backgroundColor: '#ffffff',
width: imageWidth + padding * 2,
height: imageHeight + padding * 2,
style: {
width: imageWidth.toString(),
height: imageHeight.toString(),
margin: `${padding}px`,
minWidth: `${minWidth}px`,
minHeight: `${minHeight}px`,
...(!isUndefined(viewport)
? {
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
}
: {}),
},
})
.then((base64Image: string) => {
exportUtilClassBase.exportMethodBasedOnType({
exportType,
base64Image,
fileName,
exportData,
});
})
.catch((error) => {
throw error;
});
} catch (error) {
showErrorToast(
error as AxiosError,
i18n.t('message.error-generating-export-type', {
exportType,
})
);
}
};

View File

@ -0,0 +1,156 @@
/*
* 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 { toPng } from 'html-to-image';
import { ExportData } from '../../components/Entity/EntityExportModalProvider/EntityExportModalProvider.interface';
import { ExportTypes } from '../../constants/Export.constants';
import { showErrorToast } from '../ToastUtils';
import {
downloadImageFromBase64,
exportPNGImageFromElement,
} from './ExportUtils';
jest.mock('html-to-image', () => ({
toPng: jest.fn(),
}));
jest.mock('../ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
describe('ExportUtils', () => {
describe('downloadImageFromBase64', () => {
let mockCreateElement: jest.SpyInstance;
let mockSetAttribute: jest.Mock;
let mockClick: jest.Mock;
beforeEach(() => {
mockSetAttribute = jest.fn();
mockClick = jest.fn();
mockCreateElement = jest
.spyOn(document, 'createElement')
.mockReturnValue({
setAttribute: mockSetAttribute,
click: mockClick,
} as unknown as HTMLAnchorElement);
});
afterEach(() => {
mockCreateElement.mockRestore();
});
it('should create and trigger download with correct attributes', () => {
const dataUrl = 'data:image/png;base64,test';
const fileName = 'test-image';
const exportType = ExportTypes.PNG;
downloadImageFromBase64(dataUrl, fileName, exportType);
expect(mockCreateElement).toHaveBeenCalledWith('a');
expect(mockSetAttribute).toHaveBeenCalledWith(
'download',
'test-image.png'
);
expect(mockSetAttribute).toHaveBeenCalledWith('href', dataUrl);
expect(mockClick).toHaveBeenCalled();
});
});
describe('exportPNGImageFromElement', () => {
const mockExportData: ExportData = {
name: 'test-export',
documentSelector: '#test-element',
exportTypes: [ExportTypes.PNG],
onExport: jest.fn(),
};
const mockElement = {
scrollWidth: 1200,
scrollHeight: 900,
};
beforeEach(() => {
// Mock document.querySelector
document.querySelector = jest.fn().mockReturnValue(mockElement);
// Mock toPng
(toPng as jest.Mock).mockResolvedValue('data:image/png;base64,test');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should successfully export PNG image when element exists', async () => {
await exportPNGImageFromElement(mockExportData);
expect(document.querySelector).toHaveBeenCalledWith('#test-element');
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
backgroundColor: '#ffffff',
width: 1240, // 1200 + (20 * 2) padding
height: 940, // 900 + (20 * 2) padding
style: expect.objectContaining({
width: '1200',
height: '900',
margin: '20px',
minWidth: '1000px',
minHeight: '800px',
}),
})
);
});
it('should throw error when element is not found', async () => {
document.querySelector = jest.fn().mockReturnValue(null);
await expect(exportPNGImageFromElement(mockExportData)).rejects.toThrow(
'message.error-generating-export-type'
);
});
it('should handle viewport transformation when provided', async () => {
const exportDataWithViewport = {
...mockExportData,
viewport: {
x: 100,
y: 200,
zoom: 1.5,
},
};
await exportPNGImageFromElement(exportDataWithViewport);
expect(toPng).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
style: expect.objectContaining({
transform: 'translate(100px, 200px) scale(1.5)',
}),
})
);
});
it('should handle toPng error', async () => {
const error = new Error('PNG generation failed');
(toPng as jest.Mock).mockRejectedValue(error);
await exportPNGImageFromElement(mockExportData);
expect(showErrorToast).toHaveBeenCalledWith(
error,
'message.error-generating-export-type'
);
});
});
});

View File

@ -10,8 +10,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { lowerCase } from 'lodash';
import { AxiosError } from 'axios';
import { toPng } from 'html-to-image';
import { isUndefined, lowerCase } from 'lodash';
import { ExportData } from '../../components/Entity/EntityExportModalProvider/EntityExportModalProvider.interface';
import { ExportTypes } from '../../constants/Export.constants';
import i18n from '../i18next/LocalUtil';
import { showErrorToast } from '../ToastUtils';
export const downloadImageFromBase64 = (
dataUrl: string,
@ -23,3 +28,54 @@ export const downloadImageFromBase64 = (
a.setAttribute('href', dataUrl);
a.click();
};
export const exportPNGImageFromElement = async (exportData: ExportData) => {
const { name, documentSelector = '', viewport } = exportData;
const exportElement = document.querySelector(documentSelector);
if (!exportElement) {
throw new Error(
i18n.t('message.error-generating-export-type', {
exportType: ExportTypes.PNG,
})
);
}
// Minimum width and height for the image
const minWidth = 1000;
const minHeight = 800;
const padding = 20;
const imageWidth = Math.max(minWidth, exportElement.scrollWidth);
const imageHeight = Math.max(minHeight, exportElement.scrollHeight);
await toPng(exportElement as HTMLElement, {
backgroundColor: '#ffffff',
width: imageWidth + padding * 2,
height: imageHeight + padding * 2,
style: {
width: imageWidth.toString(),
height: imageHeight.toString(),
margin: `${padding}px`,
minWidth: `${minWidth}px`,
minHeight: `${minHeight}px`,
...(!isUndefined(viewport)
? {
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
}
: {}),
},
})
.then((base64Image: string) => {
downloadImageFromBase64(base64Image, name, ExportTypes.PNG);
})
.catch((error) => {
showErrorToast(
error as AxiosError,
i18n.t('message.error-generating-export-type', {
exportType: ExportTypes.PNG,
})
);
});
};

View File

@ -13,7 +13,7 @@
import { ExportData } from '../components/Entity/EntityExportModalProvider/EntityExportModalProvider.interface';
import { ExportTypes } from '../constants/Export.constants';
import { downloadImageFromBase64 } from './Export/ExportUtils';
import { exportPNGImageFromElement } from './Export/ExportUtils';
class ExportUtilClassBase {
public getExportTypeOptions(): {
@ -28,13 +28,11 @@ class ExportUtilClassBase {
public exportMethodBasedOnType(data: {
exportType: ExportTypes;
base64Image: string;
fileName: string;
exportData?: ExportData;
exportData: ExportData;
}) {
const { exportType, base64Image, fileName } = data;
const { exportType, exportData } = data;
if (exportType === ExportTypes.PNG) {
return downloadImageFromBase64(base64Image, fileName, exportType);
return exportPNGImageFromElement(exportData);
}
return null;