Add alert bar (#19047)

* add alert bar

* add mock for PageLayotuV1

* fix sonar issues

* update glossary page

* add PageLayout in Pages

* update test locator

* update as per tests

* add mock for page layout v1

* update alert styles

* remove pagelayout from pipeline details

* update layouting for ErrorPlaceholder

* update to remove render function

* update as per comments

* updated local files

* update as per comments

* update tests

* update pages to remove pagelayout v1

* updated locales

* update test

* fix sonar cloud issue

* updated as per comments

* add visible state to alert

---------

Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com>
This commit is contained in:
Sweta Agarwalla 2025-02-03 10:18:16 +05:30 committed by GitHub
parent a39ee72b6b
commit ca3fa6dcea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 1341 additions and 758 deletions

View File

@ -12,7 +12,12 @@
*/
import { expect, test } from '@playwright/test';
import { GlobalSettingOptions } from '../../constant/settings';
import { descriptionBox, redirectToHomePage, uuid } from '../../utils/common';
import {
descriptionBox,
redirectToHomePage,
toastNotification,
uuid,
} from '../../utils/common';
import { settingClick } from '../../utils/sidebar';
const apiServiceConfig = {
@ -89,10 +94,6 @@ test.describe('API service', () => {
await deleteResponse;
await expect(page.locator('.Toastify__toast-body')).toHaveText(
/deleted successfully!/
);
await page.click('.Toastify__close-button');
await toastNotification(page, /deleted successfully!/);
});
});

View File

@ -25,7 +25,11 @@ import {
UPDATED_RULE_NAME,
} from '../../constant/permission';
import { GlobalSettingOptions } from '../../constant/settings';
import { descriptionBox, redirectToHomePage } from '../../utils/common';
import {
descriptionBox,
redirectToHomePage,
toastNotification,
} from '../../utils/common';
import { validateFormNameFieldInput } from '../../utils/form';
import { settingClick } from '../../utils/sidebar';
@ -258,7 +262,8 @@ test.describe('Policy page should work properly', () => {
await page.locator('[data-testid="delete-rule"]').click();
// Validate the error message
await expect(page.locator('.Toastify__toast-body')).toContainText(
await toastNotification(
page,
ERROR_MESSAGE_VALIDATION.lastRuleCannotBeRemoved
);
});

View File

@ -12,7 +12,12 @@
*/
import { expect, test } from '@playwright/test';
import { GlobalSettingOptions } from '../../constant/settings';
import { descriptionBox, redirectToHomePage, uuid } from '../../utils/common';
import {
descriptionBox,
redirectToHomePage,
toastNotification,
uuid,
} from '../../utils/common';
import { removePolicyFromRole } from '../../utils/roles';
import { settingClick } from '../../utils/sidebar';
@ -201,9 +206,11 @@ test('Roles page should work properly', async ({ page }) => {
// Removing the last policy and validating the error message
await removePolicyFromRole(page, policies.dataConsumerPolicy, roleName);
await expect(page.locator('.Toastify__toast-body')).toContainText(
await toastNotification(
page,
errorMessageValidation.lastPolicyCannotBeRemoved
);
await expect(page.locator('.ant-table-row')).toContainText(
policies.dataConsumerPolicy
);

View File

@ -26,6 +26,7 @@ import {
createNewPage,
descriptionBoxReadOnly,
redirectToHomePage,
toastNotification,
} from '../../utils/common';
import { addMultiOwner, assignTier } from '../../utils/entity';
@ -237,11 +238,7 @@ entities.forEach((EntityClass) => {
await deleteResponse;
await expect(page.locator('.Toastify__toast-body')).toHaveText(
/deleted successfully!/
);
await page.click('.Toastify__close-button');
await toastNotification(page, /deleted successfully!/);
await page.reload();

View File

@ -27,6 +27,7 @@ import {
createNewPage,
descriptionBoxReadOnly,
redirectToHomePage,
toastNotification,
} from '../../utils/common';
import { addMultiOwner, assignTier } from '../../utils/entity';
@ -202,11 +203,7 @@ entities.forEach((EntityClass) => {
await deleteResponse;
await expect(page.locator('.Toastify__toast-body')).toHaveText(
/deleted successfully!/
);
await page.click('.Toastify__close-button');
await toastNotification(page, /deleted successfully!/);
await page.reload();

View File

@ -116,15 +116,13 @@ export const toastNotification = async (
page: Page,
message: string | RegExp
) => {
await expect(
page.locator('.Toastify__toast-body[role="alert"]').first()
).toHaveText(message);
await page.waitForSelector('[data-testid="alert-bar"]', { state: 'visible' });
await page
.locator('.Toastify__toast')
.getByLabel('close', { exact: true })
.first()
.click();
await expect(page.getByTestId('alert-bar')).toHaveText(message);
await expect(page.getByTestId('alert-icon')).toBeVisible();
await expect(page.getByTestId('alert-icon-close')).toBeVisible();
};
export const clickOutside = async (page: Page) => {
@ -268,13 +266,9 @@ export const replaceAllSpacialCharWith_ = (text: string) => {
// This error toast blocks the buttons at the top
// Below logic closes the alert if it's present to avoid flakiness in tests
export const closeFirstPopupAlert = async (page: Page) => {
const toastLocator = '.Toastify__toast-body[role="alert"]';
const toastElement = await page.$(toastLocator);
if (toastElement) {
await page
.locator('.Toastify__toast')
.getByLabel('close', { exact: true })
.first()
.click();
const toastElement = page.getByTestId('alert-bar');
if ((await toastElement.count()) > 0) {
await page.getByTestId('alert-icon-close').first().click();
}
};

View File

@ -16,6 +16,7 @@ import {
NAME_MAX_LENGTH_VALIDATION_ERROR,
NAME_VALIDATION_ERROR,
} from '../constant/common';
import { toastNotification } from './common';
type CustomMetricDetails = {
page: Page;
@ -118,12 +119,11 @@ export const createCustomMetric = async ({
await page.click('[data-testid="submit-button"]');
await createMetricResponse;
await expect(page.locator('.Toastify__toast-body')).toHaveText(
await toastNotification(
page,
new RegExp(`${metric.name} created successfully.`)
);
await page.locator('.Toastify__close-button').click();
// verify the created custom metric
await expect(page).toHaveURL(/profiler/);
await expect(

View File

@ -24,7 +24,12 @@ import {
} from '../constant/delete';
import { ES_RESERVED_CHARACTERS } from '../constant/entity';
import { EntityTypeEndpoint } from '../support/entity/Entity.interface';
import { clickOutside, descriptionBox, redirectToHomePage } from './common';
import {
clickOutside,
descriptionBox,
redirectToHomePage,
toastNotification,
} from './common';
export const visitEntityPage = async (data: {
page: Page;
@ -792,7 +797,8 @@ const announcementForm = async (
);
await page.click('#announcement-submit');
await announcementSubmit;
await page.click('.Toastify__close-button');
await page.click('[data-testid="announcement-close"]');
await page.click('[data-testid="alert-icon-close"]');
};
export const createAnnouncement = async (
@ -1181,11 +1187,7 @@ export const restoreEntity = async (page: Page) => {
await page.click('[data-testid="restore-button"]');
await page.click('button:has-text("Restore")');
await expect(page.locator('.Toastify__toast-body')).toHaveText(
/restored successfully/
);
await page.click('.Toastify__close-button');
await toastNotification(page, /restored successfully/);
const exists = await page
.locator('[data-testid="deleted-badge"]')
@ -1224,11 +1226,7 @@ export const softDeleteEntity = async (
await deleteResponse;
await expect(page.locator('.Toastify__toast-body')).toHaveText(
/deleted successfully!/
);
await page.click('.Toastify__close-button');
await toastNotification(page, /deleted successfully!/);
await page.reload();
@ -1293,11 +1291,7 @@ export const hardDeleteEntity = async (
await page.click('[data-testid="confirm-button"]');
await deleteResponse;
await expect(page.locator('.Toastify__toast-body')).toHaveText(
/deleted successfully!/
);
await page.click('.Toastify__close-button');
await toastNotification(page, /deleted successfully!/);
};
export const checkDataAssetWidget = async (page: Page, serviceType: string) => {

View File

@ -156,11 +156,7 @@ export const softDeleteUserProfilePage = async (
await deleteResponse;
await expect(page.locator('.Toastify__toast-body')).toHaveText(
/deleted successfully!/
);
await page.click('.Toastify__close-button');
await toastNotification(page, /deleted successfully!/);
await deletedUserChecks(page);
};

View File

@ -16,15 +16,12 @@ import React, { FC, useEffect } from 'react';
import { HelmetProvider } from 'react-helmet-async';
import { I18nextProvider } from 'react-i18next';
import { Router } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.min.css';
import AppRouter from './components/AppRouter/AppRouter';
import { AuthProvider } from './components/Auth/AuthProviders/AuthProvider';
import ErrorBoundary from './components/common/ErrorBoundary/ErrorBoundary';
import { EntityExportModalProvider } from './components/Entity/EntityExportModalProvider/EntityExportModalProvider.component';
import ApplicationsProvider from './components/Settings/Applications/ApplicationsProvider/ApplicationsProvider';
import WebAnalyticsProvider from './components/WebAnalytics/WebAnalyticsProvider';
import { TOAST_OPTIONS } from './constants/Toasts.constants';
import AntDConfigProvider from './context/AntDConfigProvider/AntDConfigProvider';
import PermissionProvider from './context/PermissionProvider/PermissionProvider';
import TourProvider from './context/TourProvider/TourProvider';
@ -100,7 +97,6 @@ const App: FC = () => {
</ErrorBoundary>
</I18nextProvider>
</Router>
<ToastContainer {...TOAST_OPTIONS} newestOnTop />
</div>
</div>
);

View File

@ -0,0 +1,3 @@
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 1.91L12.59 0.5L7 6.09L1.41 0.5L0 1.91L5.59 7.5L0 13.09L1.41 14.5L7 8.91L12.59 14.5L14 13.09L8.41 7.5L14 1.91Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@ -0,0 +1,12 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6713_202785)">
<path d="M6.28843 0L0 6.28843V18.7116L6.28843 25H18.7116L25 18.7116V6.28843L18.7116 0L6.28843 0ZM23.5352 18.1048L18.1048 23.5352H6.89517L1.46484 18.1048V6.89517L6.89517 1.46484H18.1048L23.5352 6.89517V18.1048Z" fill="currentColor"/>
<path d="M12.5 14.428C11.9606 14.428 11.5234 13.9908 11.5234 13.4514V7.59204C11.5234 7.05269 11.9606 6.61548 12.5 6.61548C13.0394 6.61548 13.4766 7.05269 13.4766 7.59204V13.4514C13.4766 13.9908 13.0394 14.428 12.5 14.428Z" fill="currentColor"/>
<path d="M12.5 18.3342C13.0393 18.3342 13.4766 17.897 13.4766 17.3577C13.4766 16.8183 13.0393 16.3811 12.5 16.3811C11.9607 16.3811 11.5234 16.8183 11.5234 17.3577C11.5234 17.897 11.9607 18.3342 12.5 18.3342Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_6713_202785">
<rect width="25" height="25" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 962 B

View File

@ -1,5 +1,5 @@
/*
* Copyright 2022 Collate.
* Copyright 2024 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
@ -10,12 +10,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AlertProps } from 'antd';
import { ToastOptions } from 'react-toastify';
export const TOAST_OPTIONS: ToastOptions = {
autoClose: false,
hideProgressBar: true,
draggable: false,
closeOnClick: false,
};
export interface AlertBarProps {
type: AlertProps['type'];
message: string | JSX.Element;
}

View File

@ -0,0 +1,75 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { ReactComponent as CrossIcon } from '../../assets/svg/ic-cross.svg';
import * as ToastUtils from '../../utils/ToastUtils';
import AlertBar from './AlertBar';
const mockResetAlert = jest.fn();
jest.mock('../../hooks/useAlertStore', () => ({
useAlertStore: jest.fn().mockImplementation(() => ({
resetAlert: mockResetAlert,
animationClass: 'test-animation-class',
})),
}));
jest.mock('../../utils/ToastUtils', () => ({
getIconAndClassName: jest.fn(),
}));
describe('AlertBar', () => {
(ToastUtils.getIconAndClassName as jest.Mock).mockReturnValue({
icon: CrossIcon,
className: 'test-class',
});
it('should render AlertBar with the correct type and message', () => {
const message = 'Test message';
const type = 'success';
render(<AlertBar message={message} type={type} />);
const alertElement = screen.getByTestId('alert-bar');
expect(alertElement).toBeInTheDocument();
expect(alertElement).toHaveClass(
'alert-container test-class test-animation-class'
);
expect(screen.getByTestId('alert-icon')).toBeInTheDocument();
expect(screen.getByText(message)).toBeInTheDocument();
});
it('should render the CrossIcon as the close button', () => {
const message = 'Test message';
const type = 'info';
render(<AlertBar message={message} type={type} />);
const closeIcon = screen.getByTestId('alert-icon-close');
expect(closeIcon).toBeInTheDocument();
});
it('should apply the correct animation class', () => {
const message = 'Test message';
const type = 'warning';
render(<AlertBar message={message} type={type} />);
const alertElement = screen.getByTestId('alert-bar');
expect(alertElement).toHaveClass('test-animation-class');
});
});

View File

@ -0,0 +1,67 @@
/*
* Copyright 2024 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 { Alert } from 'antd';
import classNames from 'classnames';
import React, { useMemo, useState } from 'react';
import { ReactComponent as CrossIcon } from '../../assets/svg/ic-cross.svg';
import { useAlertStore } from '../../hooks/useAlertStore';
import { getIconAndClassName } from '../../utils/ToastUtils';
import './alert-bar.style.less';
import { AlertBarProps } from './AlertBar.interface';
const AlertBar = ({ type, message }: AlertBarProps): JSX.Element => {
const { resetAlert, animationClass } = useAlertStore();
const [expanded, setExpanded] = useState(false);
const { icon: AlertIcon, className } = useMemo(() => {
return getIconAndClassName(type);
}, [type]);
return (
<Alert
closable
showIcon
afterClose={resetAlert}
className={classNames('alert-container', className, animationClass)}
closeIcon={
<CrossIcon
className="alert-close-icon"
color="currentColor"
data-testid="alert-icon-close"
/>
}
data-testid="alert-bar"
description={
<>
<span
className={classNames('alert-message', { expanded })}
data-testid="alert-message">
{message}
</span>
{typeof message === 'string' && message.length > 400 && (
<button
className="alert-toggle-btn"
data-testid="alert-toggle-btn"
onClick={() => setExpanded(!expanded)}>
{expanded ? 'Show Less' : 'Show More'}
</button>
)}
</>
}
icon={AlertIcon && <AlertIcon data-testid="alert-icon" />}
type={type}
/>
);
};
export default AlertBar;

View File

@ -0,0 +1,113 @@
/*
* Copyright 2024 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 (reference) url('../../styles/variables.less');
@keyframes resize-show-animation {
from {
height: 0px;
padding: 0 20px;
}
to {
height: max-content;
padding: 16px 20px;
}
}
@keyframes resize-hide-animation {
from {
height: max-content;
padding: 16px 20px;
}
to {
height: 0px;
padding: 0 20px;
}
}
.alert-container {
backdrop-filter: blur(500px);
height: 0px;
.alert-message {
display: -webkit-box;
-webkit-line-clamp: 2; /* Limits to 2 lines */
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
max-height: 3em; /* Approx height for 2 lines */
transition: max-height 0.3s ease;
}
.alert-message.expanded {
-webkit-line-clamp: unset;
max-height: none;
}
.alert-toggle-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
font-size: 14px;
text-decoration: underline;
}
&.show-alert {
animation: resize-show-animation 0.3s ease-in-out forwards;
}
&.hide-alert {
animation: resize-hide-animation 0.2s ease-in-out forwards;
}
&.info {
background-color: @info-bg-color;
color: @info-color;
border: none;
border-radius: 0px;
}
&.success {
background-color: @success-bg-color;
color: @success-color;
border: none;
border-radius: 0px;
}
&.warning {
background-color: @warning-bg-color;
color: @warning-color;
border: none;
border-radius: 0px;
}
&.error {
background-color: @error-bg-color;
color: @error-color;
border: none;
border-radius: 0px;
}
.ant-alert-description {
font-size: 14px;
}
.alert-close-icon {
margin-right: 6px;
display: flex;
align-items: center;
margin-top: 3px;
width: 14px;
height: 14px;
}
}

View File

@ -24,13 +24,13 @@ import React, {
useEffect,
useImperativeHandle,
} from 'react';
import { toast } from 'react-toastify';
import {
msalLoginRequest,
parseMSALResponse,
} from '../../../utils/AuthProvider.util';
import { getPopupSettingLink } from '../../../utils/BrowserUtils';
import { Transi18next } from '../../../utils/CommonUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import Loader from '../../common/Loader/Loader';
import {
AuthenticatorRef,
@ -96,7 +96,7 @@ const MsalAuthenticator = forwardRef<AuthenticatorRef, Props>(
// eslint-disable-next-line no-console
console.error(e);
if (e?.message?.includes('popup_window_error')) {
toast.error(
showErrorToast(
<Transi18next
i18nKey="message.popup-block-message"
renderElement={

View File

@ -25,6 +25,7 @@ import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
import { TabSpecificField } from '../../enums/entity.enum';
import { Domain } from '../../generated/entity/domains/domain';
import { Operation } from '../../generated/entity/policies/policy';
import { withPageLayout } from '../../hoc/withPageLayout';
import { useDomainStore } from '../../hooks/useDomainStore';
import { useFqn } from '../../hooks/useFqn';
import {
@ -82,9 +83,9 @@ const DomainPage = () => {
];
}, [permissions]);
const handleAddDomainClick = () => {
const handleAddDomainClick = useCallback(() => {
history.push(ROUTES.ADD_DOMAIN);
};
}, [history]);
const handleDomainUpdate = async (updatedData: Domain) => {
if (activeDomain) {
@ -182,49 +183,55 @@ const DomainPage = () => {
if (!(viewBasicDomainPermission || viewAllDomainPermission)) {
return (
<ErrorPlaceHolder
className="mt-0-important"
type={ERROR_PLACEHOLDER_TYPE.PERMISSION}
/>
<div className="d-flex justify-center items-center full-height">
<ErrorPlaceHolder
className="mt-0-important"
type={ERROR_PLACEHOLDER_TYPE.PERMISSION}
/>
</div>
);
}
if (isEmpty(rootDomains)) {
return (
<ErrorPlaceHolder
buttonId="add-domain"
className="mt-0-important"
heading={t('label.domain')}
permission={createDomainPermission}
type={
createDomainPermission
? ERROR_PLACEHOLDER_TYPE.CREATE
: ERROR_PLACEHOLDER_TYPE.CUSTOM
}
onClick={handleAddDomainClick}>
{t('message.domains-not-configured')}
</ErrorPlaceHolder>
<div className="d-flex justify-center items-center full-height">
<ErrorPlaceHolder
buttonId="add-domain"
className="mt-0-important"
heading={t('label.domain')}
permission={createDomainPermission}
type={
createDomainPermission
? ERROR_PLACEHOLDER_TYPE.CREATE
: ERROR_PLACEHOLDER_TYPE.CUSTOM
}
onClick={handleAddDomainClick}>
{t('message.domains-not-configured')}
</ErrorPlaceHolder>
</div>
);
}
return (
<ResizableLeftPanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
minWidth: 280,
flex: 0.13,
children: <DomainsLeftPanel domains={rootDomains} />,
}}
pageTitle={t('label.domain')}
secondPanel={{
children: domainPageRender,
className: 'content-resizable-panel-container p-t-sm',
minWidth: 800,
flex: 0.87,
}}
/>
<div className="m--t-sm">
<ResizableLeftPanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
minWidth: 280,
flex: 0.13,
children: <DomainsLeftPanel domains={rootDomains} />,
}}
pageTitle={t('label.domain')}
secondPanel={{
children: domainPageRender,
className: 'content-resizable-panel-container p-t-sm',
minWidth: 800,
flex: 0.87,
}}
/>
</div>
);
};
export default DomainPage;
export default withPageLayout('domain')(DomainPage);

View File

@ -21,6 +21,8 @@ import React, {
ReactNode,
useMemo,
} from 'react';
import { useAlertStore } from '../../hooks/useAlertStore';
import AlertBar from '../AlertBar/AlertBar';
import DocumentTitle from '../common/DocumentTitle/DocumentTitle';
import './../../styles/layout/page-layout.less';
@ -60,6 +62,8 @@ const PageLayoutV1: FC<PageLayoutProp> = ({
mainContainerClassName = '',
pageContainerStyle = {},
}: PageLayoutProp) => {
const { alert } = useAlertStore();
const contentWidth = useMemo(() => {
if (leftPanel && rightPanel) {
return `calc(100% - ${leftPanelWidth + rightPanelWidth}px)`;
@ -99,27 +103,40 @@ const PageLayoutV1: FC<PageLayoutProp> = ({
{leftPanel}
</Col>
)}
<Col
className={classNames(
'page-layout-v1-center p-t-sm page-layout-v1-vertical-scroll',
{
'flex justify-center': center,
},
mainContainerClassName
)}
flex={contentWidth}
offset={center ? 3 : 0}
span={center ? 18 : 24}>
{children}
</Col>
{rightPanel && (
<Col span={24}>
<Col
className="page-layout-rightpanel page-layout-v1-vertical-scroll"
flex={rightPanelWidth + 'px'}
id="right-panelV1">
{rightPanel}
className={classNames(
`page-layout-v1-center page-layout-v1-vertical-scroll ${
!alert && 'p-t-sm'
}`,
{
'flex justify-center': center,
},
mainContainerClassName
)}
flex={contentWidth}
offset={center ? 3 : 0}
span={center ? 18 : 24}>
<Row gutter={0}>
<Col id={`${alert && 'page-alert'}`} span={24}>
{alert && (
<AlertBar message={alert.message} type={alert.type} />
)}
</Col>
<Col className={`${alert && 'p-t-sm'}`} span={24}>
{children}
</Col>
</Row>
</Col>
)}
{rightPanel && (
<Col
className="page-layout-rightpanel page-layout-v1-vertical-scroll"
flex={rightPanelWidth + 'px'}
id="right-panelV1">
{rightPanel}
</Col>
)}
</Col>
</Row>
</Fragment>
);

View File

@ -57,7 +57,7 @@ const AnnouncementDrawer: FC<Props> = ({
<Typography.Text className="font-medium break-all">
{t('label.announcement-plural')}
</Typography.Text>
<CloseOutlined onClick={onClose} />
<CloseOutlined data-testid="announcement-close" onClick={onClose} />
</Space>
);

View File

@ -1,5 +1,5 @@
/*
* Copyright 2024 Collate.
* 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
@ -10,9 +10,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
/**
* This schema defines Document. A Generic entity to capture any kind of Json Payload.
*/
export interface CreateDocument {

View File

@ -1,5 +1,5 @@
/*
* Copyright 2024 Collate.
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright 2024 Collate.
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright 2024 Collate.
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright 2024 Collate.
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright 2024 Collate.
* 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

View File

@ -0,0 +1,31 @@
/*
* 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 React, { FC } from 'react';
import { useTranslation } from 'react-i18next';
import PageLayoutV1 from '../components/PageLayoutV1/PageLayoutV1';
export const withPageLayout =
<P,>(pageTitleKey: string) =>
(Component: FC<P>) => {
const WrappedComponent: FC<P> = (props) => {
const { t } = useTranslation();
return (
<PageLayoutV1 pageTitle={t(`label.${pageTitleKey}`)}>
<Component {...props} />
</PageLayoutV1>
);
};
return WrappedComponent;
};

View File

@ -0,0 +1,56 @@
/*
* 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 { act } from '@testing-library/react';
import { AlertType, useAlertStore } from './useAlertStore';
describe('useAlertStore', () => {
it('should update the alert state when addAlert is called', () => {
const { alert, animationClass, addAlert } = useAlertStore.getState();
expect(alert).toBeUndefined();
expect(animationClass).toBe('');
const testAlert: AlertType = {
type: 'error',
message: 'Test error message',
};
act(() => {
addAlert(testAlert);
});
expect(useAlertStore.getState().alert).toEqual(testAlert);
expect(useAlertStore.getState().animationClass).toBe('show-alert');
});
it('should reset the alert state when resetAlert is called', () => {
const { resetAlert, addAlert } = useAlertStore.getState();
const testAlert: AlertType = {
type: 'info',
message: 'Test info message',
};
act(() => {
addAlert(testAlert);
});
expect(useAlertStore.getState().alert).toEqual(testAlert);
act(() => {
resetAlert();
});
expect(useAlertStore.getState().alert).toBeUndefined();
});
});

View File

@ -0,0 +1,45 @@
/*
* Copyright 2024 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 { AlertProps } from 'antd';
import { create } from 'zustand';
export type AlertType = {
type: AlertProps['type'];
message: string | JSX.Element;
};
interface AlertStore {
alert: AlertType | undefined;
animationClass: string;
addAlert: (alert: AlertType, timer?: number) => void;
resetAlert: VoidFunction;
}
export const useAlertStore = create<AlertStore>()((set) => ({
alert: undefined,
animationClass: '',
addAlert: (alert: AlertType, timer?: number) => {
set({ alert, animationClass: 'show-alert' });
const autoCloseTimer = timer ?? (alert.type === 'error' ? Infinity : 5000);
if (autoCloseTimer !== Infinity) {
setTimeout(() => {
set({ animationClass: 'hide-alert', alert: undefined });
}, autoCloseTimer);
}
},
resetAlert: () => {
set({ alert: undefined });
},
}));

View File

@ -56,6 +56,7 @@
"aggregate": "Aggregate",
"airflow-config-plural": "Airflow-Konfigurationen",
"alert": "Warnung",
"alert-detail-plural": "Waarschuwingsdetails",
"alert-lowercase": "warnung",
"alert-lowercase-plural": "warnungen",
"alert-plural": "Warnungen",

View File

@ -56,6 +56,7 @@
"aggregate": "Aggregate",
"airflow-config-plural": "airflow configs",
"alert": "Alert",
"alert-detail-plural": "Alert Details",
"alert-lowercase": "alert",
"alert-lowercase-plural": "alerts",
"alert-plural": "Alerts",

View File

@ -56,6 +56,7 @@
"aggregate": "Agregar",
"airflow-config-plural": "Configuraciones de airflow",
"alert": "Alerta",
"alert-detail-plural": "Detalles de la alerta",
"alert-lowercase": "alerta",
"alert-lowercase-plural": "alertas",
"alert-plural": "Alertas",

View File

@ -56,6 +56,7 @@
"aggregate": "Aggregate",
"airflow-config-plural": "Configurations Airflow",
"alert": "Alerte",
"alert-detail-plural": "Détails de l'alerte",
"alert-lowercase": "alerte",
"alert-lowercase-plural": "alertes",
"alert-plural": "Alertes",

View File

@ -56,6 +56,7 @@
"aggregate": "Agrupar",
"airflow-config-plural": "configuracións de Airflow",
"alert": "Alerta",
"alert-detail-plural": "Detalles de la alerta",
"alert-lowercase": "alerta",
"alert-lowercase-plural": "alertas",
"alert-plural": "Alertas",

View File

@ -56,6 +56,7 @@
"aggregate": "כלול",
"airflow-config-plural": "תצורות airflow",
"alert": "התראה",
"alert-detail-plural": "פרטי התראה",
"alert-lowercase": "התראה",
"alert-lowercase-plural": "התראות",
"alert-plural": "התראות",

View File

@ -56,6 +56,7 @@
"aggregate": "Aggregate",
"airflow-config-plural": "Airflowの設定",
"alert": "アラート",
"alert-detail-plural": "アラートの詳細",
"alert-lowercase": "alert",
"alert-lowercase-plural": "alerts",
"alert-plural": "アラート",

View File

@ -56,6 +56,7 @@
"aggregate": "एकूण",
"airflow-config-plural": "एअरफ्लो संरचना",
"alert": "सूचना",
"alert-detail-plural": "सतर्कतेचा तपशील",
"alert-lowercase": "सूचना",
"alert-lowercase-plural": "सूचना",
"alert-plural": "सूचना",

View File

@ -56,6 +56,7 @@
"aggregate": "Agregaat",
"airflow-config-plural": "Airflowconfiguraties",
"alert": "Alert",
"alert-detail-plural": "Alert Details",
"alert-lowercase": "alert",
"alert-lowercase-plural": "alerts",
"alert-plural": "Alerts",

View File

@ -56,6 +56,7 @@
"aggregate": "تجمیع",
"airflow-config-plural": "پیکربندی‌های ایر‌فلو",
"alert": "هشدار",
"alert-detail-plural": "جزئیات هشدار",
"alert-lowercase": "هشدار",
"alert-lowercase-plural": "هشدارها",
"alert-plural": "هشدارها",

View File

@ -56,6 +56,7 @@
"aggregate": "Agregado",
"airflow-config-plural": "configs do airflow",
"alert": "Alerta",
"alert-detail-plural": "Detalhes do Alerta",
"alert-lowercase": "alerta",
"alert-lowercase-plural": "alertas",
"alert-plural": "Alertas",

View File

@ -56,6 +56,7 @@
"aggregate": "Agregado",
"airflow-config-plural": "configs do airflow",
"alert": "Alerta",
"alert-detail-plural": "Detalhes do Alerta",
"alert-lowercase": "alerta",
"alert-lowercase-plural": "alertas",
"alert-plural": "Alertas",

View File

@ -56,6 +56,7 @@
"aggregate": "Aggregate",
"airflow-config-plural": "конфиги airflow",
"alert": "Предупреждение",
"alert-detail-plural": "Детали оповещения",
"alert-lowercase": "предупреждение",
"alert-lowercase-plural": "предупреждения",
"alert-plural": "Предупреждения",

View File

@ -56,6 +56,7 @@
"aggregate": "รวม",
"airflow-config-plural": "การกำหนดค่าของ airflow",
"alert": "การแจ้งเตือน",
"alert-detail-plural": "รายละเอียดการแจ้งเตือน",
"alert-lowercase": "การแจ้งเตือน",
"alert-lowercase-plural": "การแจ้งเตือนหลายอย่าง",
"alert-plural": "การแจ้งเตือนหลายอย่าง",

View File

@ -56,6 +56,7 @@
"aggregate": "聚合",
"airflow-config-plural": "Airflow 配置",
"alert": "提醒",
"alert-detail-plural": "警报详情",
"alert-lowercase": "提醒",
"alert-lowercase-plural": "提醒",
"alert-plural": "提醒",

View File

@ -84,6 +84,19 @@ jest.mock('../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
jest.mock('../../hoc/withPageLayout', () => ({
withPageLayout: jest.fn().mockImplementation(
() =>
(Component: React.FC) =>
(
props: JSX.IntrinsicAttributes & {
children?: React.ReactNode | undefined;
}
) =>
<Component {...props} />
),
}));
jest.mock(
'../../components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails',
() => jest.fn().mockImplementation(() => <div>AlertConfigDetails</div>)

View File

@ -46,6 +46,7 @@ import {
EventSubscription,
ProviderType,
} from '../../generated/events/eventSubscription';
import { withPageLayout } from '../../hoc/withPageLayout';
import { useFqn } from '../../hooks/useFqn';
import { updateNotificationAlert } from '../../rest/alertsAPI';
import {
@ -450,7 +451,9 @@ function AlertDetailsPage({
minWidth: 700,
flex: 0.7,
}}
pageTitle={t('label.entity-detail-plural', { entity: t('label.alert') })}
pageTitle={t('label.entity-detail-plural', {
entity: t('label.alert'),
})}
secondPanel={{
children: <></>,
minWidth: 0,
@ -460,4 +463,6 @@ function AlertDetailsPage({
);
}
export default AlertDetailsPage;
export default withPageLayout<AlertDetailsPageProps>('alert-detail-plural')(
AlertDetailsPage
);

View File

@ -13,7 +13,7 @@
import { Col, Row } from 'antd';
import { t } from 'i18next';
import React, { useLayoutEffect, useMemo, useState } from 'react';
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import {
Redirect,
Route,
@ -30,6 +30,7 @@ import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvi
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
import { DataInsightChartType } from '../../generated/dataInsight/dataInsightChartResult';
import { Operation } from '../../generated/entity/policies/policy';
import { withPageLayout } from '../../hoc/withPageLayout';
import { DataInsightTabs } from '../../interface/data-insight.interface';
import { SystemChartType } from '../../rest/DataInsightAPI';
import { getDataInsightPathWithFqn } from '../../utils/DataInsightUtils';
@ -75,16 +76,17 @@ const DataInsightPage = () => {
SystemChartType | DataInsightChartType
>();
const handleScrollToChart = (
chartType: SystemChartType | DataInsightChartType
) => {
if (ENTITIES_CHARTS.includes(chartType as SystemChartType)) {
history.push(getDataInsightPathWithFqn(DataInsightTabs.DATA_ASSETS));
} else {
history.push(getDataInsightPathWithFqn(DataInsightTabs.APP_ANALYTICS));
}
setSelectedChart(chartType);
};
const handleScrollToChart = useCallback(
(chartType: SystemChartType | DataInsightChartType) => {
if (ENTITIES_CHARTS.includes(chartType as SystemChartType)) {
history.push(getDataInsightPathWithFqn(DataInsightTabs.DATA_ASSETS));
} else {
history.push(getDataInsightPathWithFqn(DataInsightTabs.APP_ANALYTICS));
}
setSelectedChart(chartType);
},
[history]
);
useLayoutEffect(() => {
if (selectedChart) {
@ -125,52 +127,53 @@ const DataInsightPage = () => {
}
return (
<ResizableLeftPanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
minWidth: 280,
flex: 0.13,
children: <LeftPanel />,
}}
pageTitle={t('label.data-insight')}
secondPanel={{
children: (
<DataInsightProvider>
<Row
className="page-container"
data-testid="data-insight-container"
gutter={[16, 16]}>
{isHeaderVisible && (
<div className="m--t-sm">
<ResizableLeftPanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
minWidth: 280,
flex: 0.13,
children: <LeftPanel />,
}}
pageTitle={t('label.data-insight')}
secondPanel={{
children: (
<DataInsightProvider>
<Row
className="page-container"
data-testid="data-insight-container"
gutter={[16, 16]}>
{isHeaderVisible && (
<Col span={24}>
<DataInsightHeader onScrollToChart={handleScrollToChart} />
</Col>
)}
<Col span={24}>
<DataInsightHeader onScrollToChart={handleScrollToChart} />
<Switch>
{dataInsightTabs.map((tab) => (
<Route
exact
component={tab.component}
key={tab.key}
path={tab.path}
/>
))}
<Route exact path={ROUTES.DATA_INSIGHT}>
<Redirect to={getDataInsightPathWithFqn()} />
</Route>
</Switch>
</Col>
)}
<Col span={24}>
<Switch>
{dataInsightTabs.map((tab) => (
<Route
exact
component={tab.component}
key={tab.key}
path={tab.path}
/>
))}
<Route exact path={ROUTES.DATA_INSIGHT}>
<Redirect to={getDataInsightPathWithFqn()} />
</Route>
</Switch>
</Col>
</Row>
</DataInsightProvider>
),
className: 'content-resizable-panel-container p-t-sm',
minWidth: 800,
flex: 0.87,
}}
/>
</Row>
</DataInsightProvider>
),
className: 'content-resizable-panel-container p-t-sm',
minWidth: 800,
flex: 0.87,
}}
/>
</div>
);
};
export default DataInsightPage;
export default withPageLayout('data-insight')(DataInsightPage);

View File

@ -40,6 +40,19 @@ jest.mock('../../components/common/ResizablePanels/ResizableLeftPanels', () => {
));
});
jest.mock('../../hoc/withPageLayout', () => ({
withPageLayout: jest.fn().mockImplementation(
() =>
(Component: React.FC) =>
(
props: JSX.IntrinsicAttributes & {
children?: React.ReactNode | undefined;
}
) =>
<Component {...props} />
),
}));
jest.mock('../../utils/DataInsightUtils', () => ({
getDataInsightPathWithFqn: jest.fn().mockReturnValue('/'),
}));

View File

@ -63,6 +63,19 @@ jest.mock('../../components/common/ResizablePanels/ResizableLeftPanels', () => {
));
});
jest.mock('../../hoc/withPageLayout', () => ({
withPageLayout: jest.fn().mockImplementation(
() =>
(Component: React.FC) =>
(
props: JSX.IntrinsicAttributes & {
children?: React.ReactNode | undefined;
}
) =>
<Component {...props} />
),
}));
jest.mock('react-router-dom', () => {
return {
...jest.requireActual('react-router-dom'),

View File

@ -25,6 +25,7 @@ import LeftPanelCard from '../../components/common/LeftPanelCard/LeftPanelCard';
import ResizableLeftPanels from '../../components/common/ResizablePanels/ResizableLeftPanels';
import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component';
import { ROUTES } from '../../constants/constants';
import { withPageLayout } from '../../hoc/withPageLayout';
import { getDataQualityPagePath } from '../../utils/RouterUtils';
import './data-quality-page.less';
import DataQualityClassBase from './DataQualityClassBase';
@ -68,77 +69,79 @@ const DataQualityPage = () => {
};
return (
<ResizableLeftPanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
minWidth: 280,
flex: 0.13,
children: (
<LeftPanelCard id="data-quality">
<Menu
className="custom-menu custom-menu-with-description data-quality-page-left-panel-menu"
data-testid="tabs"
items={menuItems}
mode="inline"
selectedKeys={[
activeTab ?? DataQualityClassBase.getDefaultActiveTab(),
]}
onClick={handleTabChange}
/>
</LeftPanelCard>
),
}}
pageTitle="Quality"
secondPanel={{
children: (
<DataQualityProvider>
<Row
className="page-container"
data-testid="data-insight-container"
gutter={[16, 16]}>
<Col span={24}>
<Typography.Title
className="m-b-md p-x-md"
data-testid="page-title"
level={5}>
{t('label.data-quality')}
</Typography.Title>
<Typography.Paragraph
className="text-grey-muted p-x-md"
data-testid="page-sub-title">
{t('message.page-sub-header-for-data-quality')}
</Typography.Paragraph>
</Col>
<Col span={24}>
<Switch>
{tabDetailsComponent.map((tab) => (
<Route
exact
component={tab.component}
key={tab.key}
path={tab.path}
/>
))}
<div className="m--t-sm">
<ResizableLeftPanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
minWidth: 280,
flex: 0.13,
children: (
<LeftPanelCard id="data-quality">
<Menu
className="custom-menu custom-menu-with-description data-quality-page-left-panel-menu"
data-testid="tabs"
items={menuItems}
mode="inline"
selectedKeys={[
activeTab ?? DataQualityClassBase.getDefaultActiveTab(),
]}
onClick={handleTabChange}
/>
</LeftPanelCard>
),
}}
pageTitle="Quality"
secondPanel={{
children: (
<DataQualityProvider>
<Row
className="page-container"
data-testid="data-insight-container"
gutter={[16, 16]}>
<Col span={24}>
<Typography.Title
className="m-b-md p-x-md"
data-testid="page-title"
level={5}>
{t('label.data-quality')}
</Typography.Title>
<Typography.Paragraph
className="text-grey-muted p-x-md"
data-testid="page-sub-title">
{t('message.page-sub-header-for-data-quality')}
</Typography.Paragraph>
</Col>
<Col span={24}>
<Switch>
{tabDetailsComponent.map((tab) => (
<Route
exact
component={tab.component}
key={tab.key}
path={tab.path}
/>
))}
<Route exact path={ROUTES.DATA_QUALITY}>
<Redirect
to={getDataQualityPagePath(
DataQualityClassBase.getDefaultActiveTab()
)}
/>
</Route>
</Switch>
</Col>
</Row>
</DataQualityProvider>
),
className: 'content-resizable-panel-container p-t-sm',
minWidth: 800,
flex: 0.87,
}}
/>
<Route exact path={ROUTES.DATA_QUALITY}>
<Redirect
to={getDataQualityPagePath(
DataQualityClassBase.getDefaultActiveTab()
)}
/>
</Route>
</Switch>
</Col>
</Row>
</DataQualityProvider>
),
className: 'content-resizable-panel-container p-t-sm',
minWidth: 800,
flex: 0.87,
}}
/>
</div>
);
};
export default DataQualityPage;
export default withPageLayout('quality')(DataQualityPage);

View File

@ -46,6 +46,7 @@ import { EntityAction, TabSpecificField } from '../../../enums/entity.enum';
import { Glossary } from '../../../generated/entity/data/glossary';
import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm';
import { Operation } from '../../../generated/entity/policies/policy';
import { withPageLayout } from '../../../hoc/withPageLayout';
import { usePaging } from '../../../hooks/paging/usePaging';
import { useElementInView } from '../../../hooks/useElementInView';
import { useFqn } from '../../../hooks/useFqn';
@ -146,9 +147,9 @@ const GlossaryPage = () => {
[permissions, isGlossaryActive]
);
const handleAddGlossaryClick = () => {
const handleAddGlossaryClick = useCallback(() => {
history.push(ROUTES.ADD_GLOSSARY);
};
}, [history]);
const fetchGlossaryList = useCallback(async () => {
try {
@ -236,7 +237,7 @@ const GlossaryPage = () => {
}
}, [paging, isInView, isMoreGlossaryLoading, pageSize]);
const fetchGlossaryTermDetails = async () => {
const fetchGlossaryTermDetails = useCallback(async () => {
setIsRightPanelLoading(true);
try {
const response = await getGlossaryTermByFQN(glossaryFqn, {
@ -257,7 +258,7 @@ const GlossaryPage = () => {
} finally {
setIsRightPanelLoading(false);
}
};
}, [glossaryFqn]);
useEffect(() => {
setIsRightPanelLoading(true);
if (glossaries.length) {
@ -277,22 +278,25 @@ const GlossaryPage = () => {
}
}, [isGlossaryActive, glossaryFqn, glossaries]);
const updateGlossary = async (updatedData: Glossary) => {
const jsonPatch = compare(activeGlossary as Glossary, updatedData);
const updateGlossary = useCallback(
async (updatedData: Glossary) => {
const jsonPatch = compare(activeGlossary as Glossary, updatedData);
try {
const response = await patchGlossaries(activeGlossary?.id, jsonPatch);
try {
const response = await patchGlossaries(activeGlossary?.id, jsonPatch);
updateActiveGlossary({ ...updatedData, ...response });
updateActiveGlossary({ ...updatedData, ...response });
if (activeGlossary?.name !== updatedData.name) {
history.push(getGlossaryPath(response.fullyQualifiedName));
fetchGlossaryList();
if (activeGlossary?.name !== updatedData.name) {
history.push(getGlossaryPath(response.fullyQualifiedName));
fetchGlossaryList();
}
} catch (error) {
showErrorToast(error as AxiosError);
}
} catch (error) {
showErrorToast(error as AxiosError);
}
};
},
[activeGlossary, updateActiveGlossary, history, fetchGlossaryList]
);
const updateVote = useCallback(
async (data: VotingDataProps) => {
@ -318,35 +322,38 @@ const GlossaryPage = () => {
[updateActiveGlossary, activeGlossary]
);
const handleGlossaryDelete = async (id: string) => {
try {
await deleteGlossary(id);
showSuccessToast(
t('server.entity-deleted-successfully', {
entity: t('label.glossary'),
})
);
setIsLoading(true);
// check if the glossary available
const updatedGlossaries = glossaries.filter((item) => item.id !== id);
setGlossaries(updatedGlossaries);
const glossaryPath =
updatedGlossaries.length > 0
? getGlossaryPath(updatedGlossaries[0].fullyQualifiedName)
: getGlossaryPath();
const handleGlossaryDelete = useCallback(
async (id: string) => {
try {
await deleteGlossary(id);
showSuccessToast(
t('server.entity-deleted-successfully', {
entity: t('label.glossary'),
})
);
setIsLoading(true);
// check if the glossary is available
const updatedGlossaries = glossaries.filter((item) => item.id !== id);
setGlossaries(updatedGlossaries);
const glossaryPath =
updatedGlossaries.length > 0
? getGlossaryPath(updatedGlossaries[0].fullyQualifiedName)
: getGlossaryPath();
history.push(glossaryPath);
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.delete-entity-error', {
entity: t('label.glossary'),
})
);
} finally {
setIsLoading(false);
}
};
history.push(glossaryPath);
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.delete-entity-error', {
entity: t('label.glossary'),
})
);
} finally {
setIsLoading(false);
}
},
[glossaries, history]
);
const handleGlossaryTermUpdate = useCallback(
async (updatedData: GlossaryTerm) => {
@ -380,33 +387,38 @@ const GlossaryPage = () => {
[activeGlossary]
);
const handleGlossaryTermDelete = async (id: string) => {
try {
await deleteGlossaryTerm(id);
const handleGlossaryTermDelete = useCallback(
async (id: string) => {
try {
await deleteGlossaryTerm(id);
showSuccessToast(
t('server.entity-deleted-successfully', {
entity: t('label.glossary-term'),
})
);
let fqn;
if (glossaryFqn) {
const fqnArr = Fqn.split(glossaryFqn);
fqnArr.pop();
fqn = fqnArr.join(FQN_SEPARATOR_CHAR);
showSuccessToast(
t('server.entity-deleted-successfully', {
entity: t('label.glossary-term'),
})
);
let fqn;
if (glossaryFqn) {
const fqnArr = Fqn.split(glossaryFqn);
fqnArr.pop();
fqn = fqnArr.join(FQN_SEPARATOR_CHAR);
}
setIsLoading(true);
history.push(getGlossaryPath(fqn));
fetchGlossaryList();
} catch (err) {
showErrorToast(
err as AxiosError,
t('server.delete-entity-error', {
entity: t('label.glossary-term'),
})
);
}
setIsLoading(true);
history.push(getGlossaryPath(fqn));
fetchGlossaryList();
} catch (err) {
showErrorToast(
err as AxiosError,
t('server.delete-entity-error', {
entity: t('label.glossary-term'),
})
);
}
};
},
[glossaryFqn, history, fetchGlossaryList]
);
const handleAssetClick = useCallback(
(asset?: EntityDetailsObjectInterface) => {
@ -420,24 +432,30 @@ const GlossaryPage = () => {
}
if (!(viewBasicGlossaryPermission || viewAllGlossaryPermission)) {
return <ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />;
return (
<div className="d-flex justify-center items-center">
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
</div>
);
}
if (glossaries.length === 0 && !isLoading) {
return (
<ErrorPlaceHolder
buttonId="add-glossary"
className="mt-0-important"
doc={GLOSSARIES_DOCS}
heading={t('label.glossary')}
permission={createGlossaryPermission}
type={
createGlossaryPermission
? ERROR_PLACEHOLDER_TYPE.CREATE
: ERROR_PLACEHOLDER_TYPE.NO_DATA
}
onClick={handleAddGlossaryClick}
/>
<div className="d-flex justify-center items-center full-height">
<ErrorPlaceHolder
buttonId="add-glossary"
className="mt-0-important"
doc={GLOSSARIES_DOCS}
heading={t('label.glossary')}
permission={createGlossaryPermission}
type={
createGlossaryPermission
? ERROR_PLACEHOLDER_TYPE.CREATE
: ERROR_PLACEHOLDER_TYPE.NO_DATA
}
onClick={handleAddGlossaryClick}
/>
</div>
);
}
@ -518,7 +536,7 @@ const GlossaryPage = () => {
/>
);
return <>{resizableLayout}</>;
return <div className="m--t-sm">{resizableLayout}</div>;
};
export default GlossaryPage;
export default withPageLayout('glossary')(GlossaryPage);

View File

@ -53,6 +53,19 @@ jest.mock('../../../context/PermissionProvider/PermissionProvider', () => {
};
});
jest.mock('../../../hoc/withPageLayout', () => ({
withPageLayout: jest.fn().mockImplementation(
() =>
(Component: React.FC) =>
(
props: JSX.IntrinsicAttributes & {
children?: React.ReactNode | undefined;
}
) =>
<Component {...props} />
),
}));
jest.mock('../../../components/Glossary/GlossaryV1.component', () => {
return jest.fn().mockImplementation((props) => (
<div>

View File

@ -33,6 +33,7 @@ import {
LineageSettings,
} from '../../generated/configuration/lineageSettings';
import { Settings, SettingType } from '../../generated/settings/settings';
import { withPageLayout } from '../../hoc/withPageLayout';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import {
getSettingsByType,
@ -76,7 +77,7 @@ const LineageConfigPage = () => {
setActiveField(event.target.id);
}, []);
const handleSave = async (values: LineageSettings) => {
const handleSave = useCallback(async (values: LineageSettings) => {
try {
setIsUpdating(true);
@ -88,6 +89,7 @@ const LineageConfigPage = () => {
lineageLayer: values.lineageLayer,
},
};
const { data } = await updateSettingsConfig(configData as Settings);
showSuccessToast(
t('server.update-entity-success', {
@ -105,7 +107,7 @@ const LineageConfigPage = () => {
} finally {
setIsUpdating(false);
}
};
}, []);
useEffect(() => {
fetchSearchConfig();
@ -116,129 +118,131 @@ const LineageConfigPage = () => {
}
return (
<ResizablePanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
children: (
<div
className="max-width-md w-9/10 service-form-container"
data-testid="add-metric-container">
<Row gutter={[16, 16]}>
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumbs} />
</Col>
<div className="m--t-sm">
<ResizablePanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
children: (
<div
className="max-width-md w-9/10 service-form-container"
data-testid="add-metric-container">
<Row gutter={[16, 16]}>
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumbs} />
</Col>
<Col span={24}>
<Typography.Title
className="m-b-0"
data-testid="heading"
level={5}>
{t('label.lineage')}
</Typography.Title>
</Col>
<Col span={24}>
<Form
form={form}
id="lineage-config"
initialValues={lineageConfig}
layout="vertical"
onFinish={handleSave}
onFocus={handleFieldFocus}>
<Form.Item
id="root/upstreamDepth"
label={t('label.upstream-depth')}
name="upstreamDepth"
rules={[
{
required: true,
message: t('message.upstream-depth-message'),
},
]}>
<Input
data-testid="field-upstream"
max={5}
min={1}
type="number"
/>
</Form.Item>
<Col span={24}>
<Typography.Title
className="m-b-0"
data-testid="heading"
level={5}>
{t('label.lineage')}
</Typography.Title>
</Col>
<Col span={24}>
<Form
form={form}
id="lineage-config"
initialValues={lineageConfig}
layout="vertical"
onFinish={handleSave}
onFocus={handleFieldFocus}>
<Form.Item
id="root/upstreamDepth"
label={t('label.upstream-depth')}
name="upstreamDepth"
rules={[
{
required: true,
message: t('message.upstream-depth-message'),
},
]}>
<Input
data-testid="field-upstream"
max={5}
min={1}
type="number"
/>
</Form.Item>
<Form.Item
className="m-t-sm"
id="root/downstreamDepth"
label={t('label.downstream-depth')}
name="downstreamDepth"
rules={[
{
required: true,
message: t('message.downstream-depth-message'),
},
]}>
<Input
data-testid="field-downstream"
max={5}
min={1}
type="number"
/>
</Form.Item>
<Form.Item
className="m-t-sm"
id="root/downstreamDepth"
label={t('label.downstream-depth')}
name="downstreamDepth"
rules={[
{
required: true,
message: t('message.downstream-depth-message'),
},
]}>
<Input
data-testid="field-downstream"
max={5}
min={1}
type="number"
/>
</Form.Item>
<Form.Item
className="m-t-sm"
id="root/lineageLayer"
label={t('label.lineage-layer')}
name="lineageLayer">
<Select data-testid="field-lineage-layer">
<Select.Option value={LineageLayer.EntityLineage}>
{t('label.entity-lineage')}
</Select.Option>
<Select.Option value={LineageLayer.ColumnLevelLineage}>
{t('label.column-level-lineage')}
</Select.Option>
<Select.Option value={LineageLayer.DataObservability}>
{t('label.data-observability')}
</Select.Option>
</Select>
</Form.Item>
</Form>
<Row className="m-b-xl" justify="end">
<Col className="d-flex justify-end gap-2" span={24}>
<Button
data-testid="cancel-button"
onClick={() => history.goBack()}>
{t('label.cancel')}
</Button>
<Button
data-testid="save-button"
form="lineage-config"
htmlType="submit"
loading={isUpdating}
type="primary">
{t('label.save')}
</Button>
</Col>
</Row>
</Col>
</Row>
</div>
),
minWidth: 700,
flex: 0.7,
}}
pageTitle={t('label.lineage-config')}
secondPanel={{
className: 'service-doc-panel content-resizable-panel-container',
minWidth: 400,
flex: 0.3,
children: (
<ServiceDocPanel
activeField={activeField}
serviceName="LineageConfiguration"
serviceType={OPEN_METADATA}
/>
),
}}
/>
<Form.Item
className="m-t-sm"
id="root/lineageLayer"
label={t('label.lineage-layer')}
name="lineageLayer">
<Select data-testid="field-lineage-layer">
<Select.Option value={LineageLayer.EntityLineage}>
{t('label.entity-lineage')}
</Select.Option>
<Select.Option value={LineageLayer.ColumnLevelLineage}>
{t('label.column-level-lineage')}
</Select.Option>
<Select.Option value={LineageLayer.DataObservability}>
{t('label.data-observability')}
</Select.Option>
</Select>
</Form.Item>
</Form>
<Row className="m-b-xl" justify="end">
<Col className="d-flex justify-end gap-2" span={24}>
<Button
data-testid="cancel-button"
onClick={() => history.goBack()}>
{t('label.cancel')}
</Button>
<Button
data-testid="save-button"
form="lineage-config"
htmlType="submit"
loading={isUpdating}
type="primary">
{t('label.save')}
</Button>
</Col>
</Row>
</Col>
</Row>
</div>
),
minWidth: 700,
flex: 0.7,
}}
pageTitle={t('label.lineage-config')}
secondPanel={{
className: 'service-doc-panel content-resizable-panel-container',
minWidth: 400,
flex: 0.3,
children: (
<ServiceDocPanel
activeField={activeField}
serviceName="LineageConfiguration"
serviceType={OPEN_METADATA}
/>
),
}}
/>
</div>
);
};
export default LineageConfigPage;
export default withPageLayout('lineage-config')(LineageConfigPage);

View File

@ -14,12 +14,21 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { getAllPersonas } from '../../../rest/PersonaAPI';
import { PersonaPage } from './PersonaPage';
jest.mock('../../../components/PageLayoutV1/PageLayoutV1', () => {
return jest.fn().mockImplementation(({ children }) => <div>{children}</div>);
});
jest.mock('../../../components/PageHeader/PageHeader.component', () => {
return jest.fn().mockImplementation(() => <div>PageHeader.component</div>);
});
jest.mock('../../../hoc/withPageLayout', () => ({
withPageLayout: jest.fn().mockImplementation(
() =>
(Component: React.FC) =>
(
props: JSX.IntrinsicAttributes & {
children?: React.ReactNode | undefined;
}
) =>
<Component {...props} />
),
}));
jest.mock(
'../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component',
() => {

View File

@ -23,19 +23,19 @@ import { TitleBreadcrumbProps } from '../../../components/common/TitleBreadcrumb
import { AddEditPersonaForm } from '../../../components/MyData/Persona/AddEditPersona/AddEditPersona.component';
import { PersonaDetailsCard } from '../../../components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard';
import PageHeader from '../../../components/PageHeader/PageHeader.component';
import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1';
import { GlobalSettingsMenuCategory } from '../../../constants/GlobalSettings.constants';
import { PAGE_HEADERS } from '../../../constants/PageHeaders.constant';
import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum';
import { TabSpecificField } from '../../../enums/entity.enum';
import { Persona } from '../../../generated/entity/teams/persona';
import { Paging } from '../../../generated/type/paging';
import { withPageLayout } from '../../../hoc/withPageLayout';
import { useAuth } from '../../../hooks/authHooks';
import { usePaging } from '../../../hooks/paging/usePaging';
import { getAllPersonas } from '../../../rest/PersonaAPI';
import { getSettingPageEntityBreadCrumb } from '../../../utils/GlobalSettingsUtils';
export const PersonaPage = () => {
const PersonaPageLayout = () => {
const { isAdminUser } = useAuth();
const { t } = useTranslation();
@ -82,9 +82,9 @@ export const PersonaPage = () => {
fetchPersonas();
}, [pageSize]);
const handleAddNewPersona = () => {
const handleAddNewPersona = useCallback(() => {
setAddEditPersona({} as Persona);
};
}, []);
const errorPlaceHolder = useMemo(
() => (
@ -101,28 +101,28 @@ export const PersonaPage = () => {
[isAdminUser]
);
const handlePersonalAddEditCancel = () => {
const handlePersonalAddEditCancel = useCallback(() => {
setAddEditPersona(undefined);
};
}, []);
const handlePersonaAddEditSave = () => {
const handlePersonaAddEditSave = useCallback(() => {
handlePersonalAddEditCancel();
fetchPersonas();
};
}, [fetchPersonas]);
const handlePersonaPageChange = ({
currentPage,
cursorType,
}: PagingHandlerParams) => {
handlePageChange(currentPage);
if (cursorType) {
fetchPersonas({ [cursorType]: paging[cursorType] });
}
};
const handlePersonaPageChange = useCallback(
({ currentPage, cursorType }: PagingHandlerParams) => {
handlePageChange(currentPage);
if (cursorType) {
fetchPersonas({ [cursorType]: paging[cursorType] });
}
},
[handlePageChange, fetchPersonas, paging]
);
if (isEmpty(persona) && !isLoading) {
return (
<>
<div className="flex-center full-height">
{errorPlaceHolder}
{Boolean(addEditPersona) && (
<AddEditPersonaForm
@ -131,64 +131,64 @@ export const PersonaPage = () => {
onSave={handlePersonaAddEditSave}
/>
)}
</>
</div>
);
}
return (
<PageLayoutV1 pageTitle={t('label.persona-plural')}>
<Row className="user-listing page-container p-b-md" gutter={[16, 16]}>
<Row className="user-listing page-container p-b-md" gutter={[16, 16]}>
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumbs} />
</Col>
<Col span={18}>
<PageHeader data={PAGE_HEADERS.PERSONAS} />
</Col>
<Col span={6}>
<Space align="center" className="w-full justify-end" size={16}>
<Button
data-testid="add-persona-button"
type="primary"
onClick={handleAddNewPersona}>
{t('label.add-entity', { entity: t('label.persona') })}
</Button>
</Space>
</Col>
{isLoading
? [1, 2, 3].map((key) => (
<Col key={key} span={8}>
<Card>
<Skeleton active paragraph title />
</Card>
</Col>
))
: persona?.map((persona) => (
<Col key={persona.id} span={8}>
<PersonaDetailsCard persona={persona} />
</Col>
))}
{showPagination && (
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumbs} />
</Col>
<Col span={18}>
<PageHeader data={PAGE_HEADERS.PERSONAS} />
</Col>
<Col span={6}>
<Space align="center" className="w-full justify-end" size={16}>
<Button
data-testid="add-persona-button"
type="primary"
onClick={handleAddNewPersona}>
{t('label.add-entity', { entity: t('label.persona') })}
</Button>
</Space>
</Col>
{isLoading
? [1, 2, 3].map((key) => (
<Col key={key} span={8}>
<Card>
<Skeleton active paragraph title />
</Card>
</Col>
))
: persona?.map((persona) => (
<Col key={persona.id} span={8}>
<PersonaDetailsCard persona={persona} />
</Col>
))}
{showPagination && (
<Col span={24}>
<NextPrevious
currentPage={currentPage}
isLoading={isLoading}
pageSize={pageSize}
paging={paging}
pagingHandler={handlePersonaPageChange}
onShowSizeChange={handlePageSizeChange}
/>
</Col>
)}
{Boolean(addEditPersona) && (
<AddEditPersonaForm
persona={addEditPersona}
onCancel={handlePersonalAddEditCancel}
onSave={handlePersonaAddEditSave}
<NextPrevious
currentPage={currentPage}
isLoading={isLoading}
pageSize={pageSize}
paging={paging}
pagingHandler={handlePersonaPageChange}
onShowSizeChange={handlePageSizeChange}
/>
)}
</Row>
</PageLayoutV1>
</Col>
)}
{Boolean(addEditPersona) && (
<AddEditPersonaForm
persona={addEditPersona}
onCancel={handlePersonalAddEditCancel}
onSave={handlePersonaAddEditSave}
/>
)}
</Row>
);
};
export const PersonaPage = withPageLayout('persona-plural')(PersonaPageLayout);

View File

@ -240,6 +240,19 @@ jest.mock('../../components/common/ResizablePanels/ResizableLeftPanels', () =>
))
);
jest.mock('../../hoc/withPageLayout', () => ({
withPageLayout: jest.fn().mockImplementation(
() =>
(Component: React.FC) =>
(
props: JSX.IntrinsicAttributes & {
children?: React.ReactNode | undefined;
}
) =>
<Component {...props} />
),
}));
jest.mock(
'../../components/common/RichTextEditor/RichTextEditorPreviewerV1',
() => {

View File

@ -50,6 +50,7 @@ import {
import { Classification } from '../../generated/entity/classification/classification';
import { Tag } from '../../generated/entity/classification/tag';
import { Operation } from '../../generated/entity/policies/accessControl/rule';
import { withPageLayout } from '../../hoc/withPageLayout';
import { useFqn } from '../../hooks/useFqn';
import {
createClassification,
@ -237,11 +238,11 @@ const TagsPage = () => {
}
};
const handleCancel = () => {
const handleCancel = useCallback(() => {
setEditTag(undefined);
setIsAddingTag(false);
setIsAddingClassification(false);
};
}, []);
const handleAfterDeleteAction = useCallback(() => {
if (!isUndefined(currentClassification)) {
@ -307,72 +308,75 @@ const TagsPage = () => {
/**
* It redirects to respective function call based on tag/Classification
*/
const handleConfirmClick = async () => {
const handleConfirmClick = useCallback(async () => {
if (deleteTags.data?.id) {
await handleDeleteTag(deleteTags.data.id);
}
};
}, [deleteTags.data?.id, handleDeleteTag]);
const handleUpdateClassification = async (
updatedClassification: Classification
) => {
if (!isUndefined(currentClassification)) {
setIsUpdateLoading(true);
const handleUpdateClassification = useCallback(
async (updatedClassification: Classification) => {
if (!isUndefined(currentClassification)) {
setIsUpdateLoading(true);
const patchData = compare(currentClassification, updatedClassification);
try {
const response = await patchClassification(
currentClassification?.id ?? '',
patchData
);
setClassifications((prev) =>
prev.map((item) => {
if (
item.fullyQualifiedName ===
currentClassification.fullyQualifiedName
) {
return {
...item,
...response,
};
}
const patchData = compare(currentClassification, updatedClassification);
try {
const response = await patchClassification(
currentClassification?.id ?? '',
patchData
);
return item;
})
);
setCurrentClassification((prev) => ({ ...prev, ...response }));
if (
currentClassification?.fullyQualifiedName !==
updatedClassification.fullyQualifiedName ||
currentClassification?.name !== updatedClassification.name
) {
history.push(getTagPath(response.fullyQualifiedName));
}
} catch (error) {
if (
(error as AxiosError).response?.status === HTTP_STATUS_CODE.CONFLICT
) {
showErrorToast(
t('server.entity-already-exist', {
entity: t('label.classification'),
entityPlural: t('label.classification-lowercase-plural'),
name: updatedClassification.name,
})
);
} else {
showErrorToast(
error as AxiosError,
t('server.entity-updating-error', {
entity: t('label.classification-lowercase'),
setClassifications((prev) =>
prev.map((item) => {
if (
item.fullyQualifiedName ===
currentClassification.fullyQualifiedName
) {
return {
...item,
...response,
};
}
return item;
})
);
setCurrentClassification((prev) => ({ ...prev, ...response }));
if (
currentClassification?.fullyQualifiedName !==
updatedClassification.fullyQualifiedName ||
currentClassification?.name !== updatedClassification.name
) {
history.push(getTagPath(response.fullyQualifiedName));
}
} catch (error) {
if (
(error as AxiosError).response?.status === HTTP_STATUS_CODE.CONFLICT
) {
showErrorToast(
t('server.entity-already-exist', {
entity: t('label.classification'),
entityPlural: t('label.classification-lowercase-plural'),
name: updatedClassification.name,
})
);
} else {
showErrorToast(
error as AxiosError,
t('server.entity-updating-error', {
entity: t('label.classification-lowercase'),
})
);
}
} finally {
setIsEditClassification(false);
setIsUpdateLoading(false);
}
} finally {
setIsEditClassification(false);
setIsUpdateLoading(false);
}
}
};
},
[currentClassification, history]
);
const handleCreatePrimaryTag = async (data: CreateTag) => {
try {
@ -454,37 +458,40 @@ const TagsPage = () => {
}
};
const handleActionDeleteTag = (record: Tag) => {
if (currentClassification) {
setDeleteTags({
data: {
id: record.id as string,
name: record.name,
categoryName: currentClassification?.fullyQualifiedName,
isCategory: false,
status: 'waiting',
},
state: true,
});
}
};
const handleActionDeleteTag = useCallback(
(record: Tag) => {
if (currentClassification) {
setDeleteTags({
data: {
id: record.id as string,
name: record.name,
categoryName: currentClassification?.fullyQualifiedName,
isCategory: false,
status: 'waiting',
},
state: true,
});
}
},
[currentClassification]
);
const handleEditTagClick = (selectedTag: Tag) => {
const handleEditTagClick = useCallback((selectedTag: Tag) => {
setIsAddingTag(true);
setEditTag(selectedTag);
};
}, []);
const handleAddNewTagClick = () => {
const handleAddNewTagClick = useCallback(() => {
setIsAddingTag(true);
};
}, []);
const handleEditDescriptionClick = () => {
const handleEditDescriptionClick = useCallback(() => {
setIsEditClassification(true);
};
}, []);
const handleCancelEditDescription = () => {
const handleCancelEditDescription = useCallback(() => {
setIsEditClassification(false);
};
}, []);
useEffect(() => {
if (currentClassification) {
@ -516,21 +523,26 @@ const TagsPage = () => {
history.push(getTagPath(category.fullyQualifiedName));
};
const handleAddTagSubmit = async (data: SubmitProps) => {
const updatedData = omit(data, 'color', 'iconURL');
const style = {
color: data.color,
iconURL: data.iconURL,
};
if (editTag) {
await handleUpdatePrimaryTag({ ...editTag, ...updatedData, style });
} else {
await handleCreatePrimaryTag({ ...updatedData, style });
}
};
const handleAddTagSubmit = useCallback(
async (data: SubmitProps) => {
const updatedData = omit(data, 'color', 'iconURL');
const style = {
color: data.color,
iconURL: data.iconURL,
};
const handleCancelClassificationDelete = () =>
if (editTag) {
await handleUpdatePrimaryTag({ ...editTag, ...updatedData, style });
} else {
await handleCreatePrimaryTag({ ...updatedData, style });
}
},
[editTag, handleUpdatePrimaryTag, handleCreatePrimaryTag]
);
const handleCancelClassificationDelete = useCallback(() => {
setDeleteTags({ data: undefined, state: false });
}, []);
const leftPanelLayout = useMemo(
() => (
@ -701,87 +713,92 @@ const TagsPage = () => {
}
return (
<ResizableLeftPanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
minWidth: 280,
flex: 0.13,
children: leftPanelLayout,
}}
pageTitle={t('label.tag-plural')}
secondPanel={{
children: (
<>
{isUpdateLoading ? (
<Loader />
) : (
<ClassificationDetails
classificationPermissions={classificationPermissions}
currentClassification={currentClassification}
deleteTags={deleteTags}
disableEditButton={disableEditButton}
handleActionDeleteTag={handleActionDeleteTag}
handleAddNewTagClick={handleAddNewTagClick}
handleAfterDeleteAction={handleAfterDeleteAction}
handleCancelEditDescription={handleCancelEditDescription}
handleEditDescriptionClick={handleEditDescriptionClick}
handleEditTagClick={handleEditTagClick}
handleUpdateClassification={handleUpdateClassification}
isAddingTag={isAddingTag}
isEditClassification={isEditClassification}
ref={classificationDetailsRef}
/>
)}
<div className="m--t-sm">
<ResizableLeftPanels
className="content-height-with-resizable-panel"
firstPanel={{
className: 'content-resizable-panel-container',
minWidth: 280,
flex: 0.13,
children: leftPanelLayout,
}}
pageTitle={t('label.tag-plural')}
secondPanel={{
children: (
<>
{isUpdateLoading ? (
<Loader />
) : (
<ClassificationDetails
classificationPermissions={classificationPermissions}
currentClassification={currentClassification}
deleteTags={deleteTags}
disableEditButton={disableEditButton}
handleActionDeleteTag={handleActionDeleteTag}
handleAddNewTagClick={handleAddNewTagClick}
handleAfterDeleteAction={handleAfterDeleteAction}
handleCancelEditDescription={handleCancelEditDescription}
handleEditDescriptionClick={handleEditDescriptionClick}
handleEditTagClick={handleEditTagClick}
handleUpdateClassification={handleUpdateClassification}
isAddingTag={isAddingTag}
isEditClassification={isEditClassification}
ref={classificationDetailsRef}
/>
)}
{/* Classification Form */}
{isAddingClassification && (
<TagsForm
isClassification
showMutuallyExclusive
data={classifications}
header={t('label.adding-new-classification')}
isEditing={false}
isLoading={isButtonLoading}
isTier={isTier}
visible={isAddingClassification}
onCancel={handleCancel}
onSubmit={handleCreateClassification}
/>
)}
{/* Classification Form */}
{isAddingClassification && (
<TagsForm
isClassification
showMutuallyExclusive
data={classifications}
header={t('label.adding-new-classification')}
isEditing={false}
isLoading={isButtonLoading}
isTier={isTier}
visible={isAddingClassification}
onCancel={handleCancel}
onSubmit={handleCreateClassification}
/>
)}
{/* Tags Form */}
{isAddingTag && (
<TagsForm
header={tagsFormHeader}
initialValues={editTag}
isEditing={!isUndefined(editTag)}
isLoading={isButtonLoading}
isSystemTag={editTag?.provider === ProviderType.System}
isTier={isTier}
permissions={tagsFormPermissions}
visible={isAddingTag}
onCancel={handleCancel}
onSubmit={handleAddTagSubmit}
/>
)}
{/* Tags Form */}
{isAddingTag && (
<TagsForm
header={tagsFormHeader}
initialValues={editTag}
isEditing={!isUndefined(editTag)}
isLoading={isButtonLoading}
isSystemTag={editTag?.provider === ProviderType.System}
isTier={isTier}
permissions={tagsFormPermissions}
visible={isAddingTag}
onCancel={handleCancel}
onSubmit={handleAddTagSubmit}
/>
)}
<EntityDeleteModal
bodyText={getEntityDeleteMessage(deleteTags.data?.name ?? '', '')}
entityName={deleteTags.data?.name ?? ''}
entityType={t('label.classification')}
visible={deleteTags.state}
onCancel={handleCancelClassificationDelete}
onConfirm={handleConfirmClick}
/>
</>
),
className: 'content-resizable-panel-container',
minWidth: 800,
flex: 0.87,
}}
/>
<EntityDeleteModal
bodyText={getEntityDeleteMessage(
deleteTags.data?.name ?? '',
''
)}
entityName={deleteTags.data?.name ?? ''}
entityType={t('label.classification')}
visible={deleteTags.state}
onCancel={handleCancelClassificationDelete}
onConfirm={handleConfirmClick}
/>
</>
),
className: 'content-resizable-panel-container',
minWidth: 800,
flex: 0.87,
}}
/>
</div>
);
};
export default TagsPage;
export default withPageLayout('tag-plural')(TagsPage);

View File

@ -735,22 +735,6 @@ a[href].link-text-grey,
background-color: @body-bg-color;
}
/* react toastify */
.Toastify__toast-container {
width: 450px;
word-break: break-word;
}
.Toastify__toast {
overflow-y: auto;
max-height: 80vh;
}
.Toastify__toast-body {
align-items: flex-start;
}
/* ToastUI Editor style */
.ProseMirror .placeholder {
color: @text-grey-muted;

View File

@ -20,6 +20,12 @@
overflow-x: hidden;
}
#page-alert {
position: sticky;
top: 0px;
z-index: 20;
}
.page-layout-v1-left-panel {
border: @global-border;
border-radius: @border-radius-base;

View File

@ -173,6 +173,9 @@
.m--t-xss {
margin-top: -@margin-xss;
}
.m--t-sm {
margin-top: -@margin-sm;
}
.m--t-md {
margin-top: -@margin-md;
}

View File

@ -110,6 +110,10 @@
@team-avatar-bg: #0950c51a;
@om-navbar-height: ~'var(--ant-navbar-height)';
@sidebar-width: 60px;
@error-bg-color: rgb(from @error-color r g b / 0.1);
@success-bg-color: rgb(from @success-color r g b / 0.1);
@warning-bg-color: rgb(from @warning-color r g b / 0.1);
@info-bg-color: rgb(from @info-color r g b / 0.1);
// Sizing
@page-height: calc(100vh - @om-navbar-height);

View File

@ -11,14 +11,55 @@
* limitations under the License.
*/
import { AxiosError, isCancel } from 'axios';
import {
CheckCircleOutlined,
InfoCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { AlertProps } from 'antd';
import { AxiosError } from 'axios';
import { isEmpty, isString } from 'lodash';
import React from 'react';
import { toast } from 'react-toastify';
import { ReactComponent as ErrorIcon } from '../assets/svg/ic-error.svg';
import { ClientErrors } from '../enums/Axios.enum';
import { useAlertStore } from '../hooks/useAlertStore';
import i18n from './i18next/LocalUtil';
import { getErrorText } from './StringsUtils';
export const getIconAndClassName = (type: AlertProps['type']) => {
switch (type) {
case 'info':
return {
icon: InfoCircleOutlined,
className: 'info',
};
case 'success':
return {
icon: CheckCircleOutlined,
className: 'success',
};
case 'warning':
return {
icon: WarningOutlined,
className: 'warning',
};
case 'error':
return {
icon: ErrorIcon,
className: 'error',
};
default:
return {
icon: null,
className: '',
};
}
};
export const hashCode = (str: string) => {
let hash = 0,
i,
@ -42,19 +83,18 @@ export const hashCode = (str: string) => {
* @param autoCloseTimer Set the delay in ms to close the toast automatically.
*/
export const showErrorToast = (
error: AxiosError | string,
error: AxiosError | string | JSX.Element,
fallbackText?: string,
autoCloseTimer?: number,
callback?: (value: React.SetStateAction<string>) => void
callback?: (value: React.SetStateAction<string | JSX.Element>) => void
) => {
if (isCancel(error)) {
return;
}
let errorMessage;
if (isString(error)) {
if (React.isValidElement(error)) {
errorMessage = error;
} else if (isString(error)) {
errorMessage = error.toString();
} else {
const method = (error as AxiosError).config?.method?.toUpperCase();
} else if ('config' in error && 'response' in error) {
const method = error.config?.method?.toUpperCase();
const fallback =
fallbackText && fallbackText.length > 0
? fallbackText
@ -65,19 +105,20 @@ export const showErrorToast = (
// except for principal domain mismatch errors
if (
error &&
((error as AxiosError).response?.status === ClientErrors.UNAUTHORIZED ||
((error as AxiosError).response?.status === ClientErrors.FORBIDDEN &&
(error.response?.status === ClientErrors.UNAUTHORIZED ||
(error.response?.status === ClientErrors.FORBIDDEN &&
method === 'GET')) &&
!errorMessage.includes('principal domain')
) {
return;
}
} else {
errorMessage = fallbackText ?? i18n.t('server.unexpected-error');
}
callback && callback(errorMessage);
toast.error(errorMessage, {
toastId: hashCode(errorMessage),
autoClose: autoCloseTimer,
});
useAlertStore
.getState()
.addAlert({ type: 'error', message: errorMessage }, autoCloseTimer);
};
/**
@ -86,9 +127,9 @@ export const showErrorToast = (
* @param autoCloseTimer Set the delay in ms to close the toast automatically. `Default: 5000`
*/
export const showSuccessToast = (message: string, autoCloseTimer = 5000) => {
toast.success(message, {
autoClose: autoCloseTimer,
});
useAlertStore
.getState()
.addAlert({ type: 'success', message }, autoCloseTimer);
};
/**
@ -97,7 +138,5 @@ export const showSuccessToast = (message: string, autoCloseTimer = 5000) => {
* @param autoCloseTimer Set the delay in ms to close the toast automatically. `Default: 5000`
*/
export const showInfoToast = (message: string, autoCloseTimer = 5000) => {
toast.info(message, {
autoClose: autoCloseTimer,
});
useAlertStore.getState().addAlert({ type: 'info', message }, autoCloseTimer);
};