diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index d2a6dab48ee5..6ba78b450a64 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -143,6 +143,7 @@ "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts", "playwright/e2e/Features/DataQuality/DataQualityPermissions.spec.ts", "playwright/e2e/Features/DataQuality/Dimensionality.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", @@ -749,6 +750,7 @@ "playwright/e2e/Features/AppMode/AppModeUrlHandling.spec.ts", "playwright/e2e/Features/ApplicationsSettingsModal.spec.ts", "playwright/e2e/Features/CustomMetric.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/RunTestCase.spec.ts", @@ -802,6 +804,7 @@ "playwright/e2e/Features/DataQuality/AddTestCaseNewFlow.spec.ts", "playwright/e2e/Features/DataQuality/BundleSuiteBulkOperations.spec.ts", "playwright/e2e/Features/DataQuality/DataQuality.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", @@ -1685,6 +1688,7 @@ "playwright/e2e/Features/DataQuality/DataQualityListAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/DataQualityPermissions.spec.ts", "playwright/e2e/Features/DataQuality/Dimensionality.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterOwnerChange.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterSoftDelete.spec.ts", @@ -3347,6 +3351,7 @@ "openmetadata-ui/src/main/resources/ui/playwright/utils/codeEditor.ts" ], "specs": [ + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", "playwright/e2e/Features/Permission.spec.ts", @@ -3448,6 +3453,7 @@ "playwright/e2e/Features/DataQuality/DataQualityListAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/DataQualityPermissions.spec.ts", "playwright/e2e/Features/DataQuality/Dimensionality.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterOwnerChange.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterSoftDelete.spec.ts", @@ -3880,6 +3886,7 @@ "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts", "playwright/e2e/Features/DataQuality/DataQualityDashboardAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/Dimensionality.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/TestSuiteMultiPipeline.spec.ts", @@ -5120,6 +5127,7 @@ "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts", "playwright/e2e/Features/DataQuality/DataQualityPermissions.spec.ts", "playwright/e2e/Features/DataQuality/Dimensionality.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/RunTestCase.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", @@ -6384,6 +6392,7 @@ "playwright/e2e/Features/DataQuality/ColumnLevelTests.spec.ts", "playwright/e2e/Features/DataQuality/DataQuality.spec.ts", "playwright/e2e/Features/DataQuality/Dimensionality.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", "playwright/e2e/Pages/DataContracts.spec.ts", "playwright/e2e/VersionPages/TestCaseVersionPage.spec.ts" @@ -6524,6 +6533,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/RunDetailsCard/RunDetailsCard.tsx" ], "specs": [ + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/TestCaseDetailsPage.spec.ts" ] }, @@ -6798,6 +6808,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaEditor/CodeEditor.tsx" ], "specs": [ + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", "playwright/e2e/Features/QueryEntity.spec.ts", "playwright/e2e/Pages/DataContracts.spec.ts" @@ -6808,6 +6819,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaEditor/SchemaEditor.tsx" ], "specs": [ + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", "playwright/e2e/Features/QueryEntity.spec.ts", "playwright/e2e/Pages/DataContracts.spec.ts" @@ -6979,7 +6991,9 @@ "openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityExportModalProvider/EntityExportModal.component.tsx" ], "specs": [ - "playwright/e2e/Features/LineageExportPNGSnapshot.spec.ts" + "playwright/e2e/Features/LineageExportPNGSnapshot.spec.ts", + "playwright/e2e/Pages/TestSuite.spec.ts", + "playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts" ] }, { @@ -11320,6 +11334,14 @@ "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/observability/data-quality/ThresholdPreview/ThresholdPreview.tsx" + ], + "specs": [ + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/platform/ai-shell/AINotFoundPage/AINotFoundPage.tsx" @@ -11876,6 +11898,7 @@ "playwright/e2e/Features/DataQuality/DataQualityListAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/DataQualityPermissions.spec.ts", "playwright/e2e/Features/DataQuality/Dimensionality.spec.ts", + "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterOwnerChange.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterSoftDelete.spec.ts", @@ -12751,6 +12774,16 @@ "playwright/e2e/Pages/SearchSettings.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/pages/ForgotPassword/ForgotPassword.component.tsx" + ], + "specs": [ + "playwright/e2e/Features/LineageExportPNGSnapshot.spec.ts", + "playwright/e2e/Pages/TestSuite.spec.ts", + "playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/pages/GlobalSettingPage/GlobalSettingCategory/GlobalSettingCategoryPage.tsx" @@ -12989,6 +13022,16 @@ "playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/pages/ResetPassword/ResetPassword.component.tsx" + ], + "specs": [ + "playwright/e2e/Features/LineageExportPNGSnapshot.spec.ts", + "playwright/e2e/Pages/TestSuite.spec.ts", + "playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddAttributeModal/AddAttributeModal.tsx" diff --git a/.github/playwright/impact-map.json b/.github/playwright/impact-map.json index 0ccddc73ffe8..2f7bb77e2f7e 100644 --- a/.github/playwright/impact-map.json +++ b/.github/playwright/impact-map.json @@ -736,7 +736,7 @@ "_comment": "Metric page, custom-metric page, bulk-edit grid, and the CSV import/export machinery all funnel through this cluster. MetricBulkImportExportEdit + BulkImport + ClassificationImportExport have been ejecting PRs from the merge queue because none of them named these paths — PR CI skipped them entirely and the queue was the first place they ran. The two playwright helpers are included so a wait/selector fix in importUtils.ts also routes back to the specs that consume it.", "sources": [ "openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/**", - "openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/**", + "openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/**", "openmetadata-ui/src/main/resources/ui/src/components/Metric/**", "openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/**", "openmetadata-ui/src/main/resources/ui/src/rest/metricsAPI.ts", diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CustomMetric.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CustomMetric.spec.ts index 231aaf6505dd..25dd732e5e91 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CustomMetric.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CustomMetric.spec.ts @@ -17,6 +17,8 @@ import { getApiContext, redirectToHomePage, uuid } from '../../utils/common'; import { createCustomMetric, deleteCustomMetric, + editCustomMetric, + openCustomMetricDrawer, } from '../../utils/customMetric'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { disableAiAppMode, stubUserPreferencesAppMode } from '../Utils/appMode'; @@ -59,6 +61,16 @@ test('Table custom metric', async ({ page }) => { }); }); + await test.step('Edit', async () => { + await editCustomMetric({ + page, + metric: { + ...TABLE_CUSTOM_METRIC, + expression: `SELECT COUNT(*) FROM ${table.entity.name}`, + }, + }); + }); + await test.step('Delete', async () => { await deleteCustomMetric({ page, @@ -99,6 +111,17 @@ test('Column custom metric', async ({ page }) => { }); }); + await test.step('Edit', async () => { + await editCustomMetric({ + page, + isColumnMetric: true, + metric: { + ...COLUMN_CUSTOM_METRIC, + expression: `SELECT COUNT(*) FROM ${table.entity.name}`, + }, + }); + }); + await test.step('Delete', async () => { await deleteCustomMetric({ page, @@ -125,9 +148,13 @@ test('Large column Select preserves keyboard navigation and fully reveals the fo try { await table.create(apiContext); - await page.goto( - `/add-custom-metric/column/${table.entityResponseData.fullyQualifiedName}` + const profilerResponse = page.waitForResponse( + `/api/v1/tables/${table.entityResponseData?.['fullyQualifiedName']}/tableProfile/latest?includeColumnProfile=false` ); + await table.visitEntityPage(page); + await page.click('[data-testid="profiler"]'); + await profilerResponse; + await openCustomMetricDrawer(page, true); const trigger = page .getByTestId('custom-metric-column') .getByRole('button'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts index 812f0fd1c7d6..f102661d270a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts @@ -16,7 +16,7 @@ import { NAME_MAX_LENGTH_VALIDATION_ERROR, NAME_VALIDATION_ERROR, } from '../constant/common'; -import { clickCodeEditor } from './codeEditor'; +import { clickCodeEditor, fillCodeEditor } from './codeEditor'; import { toastNotification } from './common'; import { ObservabilityFeature, @@ -78,46 +78,54 @@ const validateForm = async (page: Page, isColumnMetric = false) => { await page.getByTestId('custom-metric-name').clear(); }; -export const createCustomMetric = async ({ - page, - isColumnMetric = false, - metric, -}: CustomMetricDetails) => { +export const openCustomMetricDrawer = async ( + page: Page, + isColumnMetric = false +) => { await page .getByRole('tab', { name: isColumnMetric ? 'Column Profile' : 'Table Profile', }) .click(); - await page.locator('[data-testid="profiler-add-table-test-btn"]').click(); + await page.getByTestId('profiler-add-table-test-btn').click(); await selectAddObservabilityFeature(page, ObservabilityFeature.CUSTOM_METRIC); - const customMetricResponse = page.waitForResponse( - '/api/v1/tables/name/*?fields=customMetrics%2Ccolumns&include=all*' - ); + const drawer = page.getByTestId('custom-metric-form-drawer'); + + await expect(drawer).toBeVisible(); + + return drawer; +}; + +export const createCustomMetric = async ({ + page, + isColumnMetric = false, + metric, +}: CustomMetricDetails) => { + const drawer = await openCustomMetricDrawer(page, isColumnMetric); - // validate redirection and cancel button - await expect(page.locator('[data-testid="heading"]')).toBeVisible(); + // the drawer keeps the profile preview beside the form await expect( - page - .locator( - `[data-testid="${ - isColumnMetric - ? 'profiler-tab-container' - : 'table-profiler-chart-container' - }"]` - ) - .filter({ visible: true }) - ).not.toHaveCount(0); - - await page.locator('[data-testid="cancel-button"]').click(); - await customMetricResponse; + drawer.getByText( + isColumnMetric ? 'Add Column metric' : 'Add Table metric', + { exact: true } + ) + ).toBeVisible(); + await expect( + drawer.getByTestId( + isColumnMetric + ? 'profiler-tab-container' + : 'table-profiler-chart-container' + ) + ).toBeVisible(); + + await drawer.getByTestId('cancel-button').click(); + await expect(drawer).toBeHidden(); await expect(page).toHaveURL(/profiler/); - // Click on create custom metric button - await page.click('[data-testid="profiler-add-table-test-btn"]'); - await page.getByRole('menuitemradio', { name: 'Custom metric' }).click(); - await page.click('[data-testid="submit-button"]'); + await openCustomMetricDrawer(page, isColumnMetric); + await drawer.getByTestId('submit-button').click(); await validateForm(page, isColumnMetric); @@ -136,7 +144,7 @@ export const createCustomMetric = async ({ const createMetricResponse = page.waitForResponse( '/api/v1/tables/*/customMetric' ); - await page.click('[data-testid="submit-button"]'); + await drawer.getByTestId('submit-button').click(); await createMetricResponse; await toastNotification( @@ -144,12 +152,53 @@ export const createCustomMetric = async ({ new RegExp(`${metric.name} created successfully.`) ); - // verify the created custom metric - await expect(page).toHaveURL(/profiler/); - + await expect(drawer).toBeHidden(); await expect(page.getByTestId(`${metric.name}-title`)).toBeVisible(); }; +export const editCustomMetric = async ({ + page, + metric, +}: CustomMetricDetails) => { + await page.getByTestId(`${metric.name}-title`).scrollIntoViewIfNeeded(); + await page.getByTestId(`${metric.name}-custom-metrics-menu`).click(); + await page.getByRole('menuitemradio', { name: 'Edit' }).click(); + + const drawer = page.getByTestId('custom-metric-form-drawer'); + + await expect(drawer).toBeVisible(); + await expect(drawer.getByTestId('custom-metric-name')).toHaveValue( + metric.name + ); + await expect(drawer.getByTestId('custom-metric-name')).toBeDisabled(); + + await fillCodeEditor(page, drawer, metric.expression); + + const updateMetricResponse = page.waitForResponse( + (response) => + response.url().includes('/customMetric') && + response.request().method() === 'PUT' + ); + await drawer.getByTestId('submit-button').click(); + const response = await updateMetricResponse; + const { customMetrics = [], columns = [] } = await response.json(); + const savedMetric = [ + ...customMetrics, + ...columns.flatMap( + (column: { customMetrics?: unknown[] }) => column.customMetrics ?? [] + ), + ].find((saved: { name: string }) => saved.name === metric.name); + + expect(savedMetric.expression).toBe(metric.expression); + + await toastNotification( + page, + new RegExp(`${metric.name} updated successfully.`) + ); + + await expect(drawer).toBeHidden(); +}; + export const deleteCustomMetric = async ({ page, metric, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx index 79912df0b594..d2eef728e1e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx @@ -28,11 +28,6 @@ import AdminProtectedRoute from './AdminProtectedRoute'; import { withPageSuspenseFallback } from './withSuspenseFallback'; // Previously statically imported — lazify so they stay out of the main chunk -const AddCustomMetricPage = withPageSuspenseFallback( - React.lazy( - () => import('../../pages/AddCustomMetricPage/AddCustomMetricPage') - ) -); const CustomizablePage = withPageSuspenseFallback( React.lazy(() => @@ -503,23 +498,6 @@ const AuthenticatedAppRouter: FunctionComponent = () => { } path={ROUTES.USER_PROFILE_WITH_TAB} /> } path={ROUTES.USER_PROFILE} /> - - - - } - path={ROUTES.ADD_CUSTOM_METRIC} - /> import('../../Database/SchemaEditor/SchemaEditor')) +const CodeEditor = withSuspenseFallback( + lazy(() => import('../../Database/SchemaEditor/CodeEditor')) ); const CustomMetricForm = ({ @@ -45,7 +43,6 @@ const CustomMetricForm = ({ isEditMode = false, }: CustomMetricFormProps) => { const { t } = useTranslation(); - const location = useCustomLocation(); const localForm = useForm({ defaultValues: { name: '', expression: '', ...initialValues }, }); @@ -64,19 +61,15 @@ const CustomMetricForm = ({ [queryLabel, queryError] ); - const { activeColumnFqn } = useMemo(() => { - const param = location.search; - - return QueryString.parse( - param.startsWith('?') ? param.substring(1) : param - ) as { activeColumnFqn: string }; - }, [location.search]); + const columnName = useWatch({ + control: activeForm.control, + name: 'columnName', + }); const { metricNames, columnOptions } = useMemo(() => { const customMetrics = isColumnMetric - ? table?.columns?.find( - (column) => column.fullyQualifiedName === activeColumnFqn - )?.customMetrics ?? [] + ? table?.columns?.find((column) => column.name === columnName) + ?.customMetrics ?? [] : table?.customMetrics ?? []; return { @@ -87,7 +80,7 @@ const CustomMetricForm = ({ label: getEntityName(column), })) ?? [], }; - }, [activeColumnFqn, isColumnMetric, table]); + }, [columnName, isColumnMetric, table]); useEffect(() => { if (initialValues) { @@ -189,11 +182,11 @@ const CustomMetricForm = ({ {({ field }) => ( - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.test.tsx new file mode 100644 index 000000000000..948546ceb359 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.test.tsx @@ -0,0 +1,278 @@ +/* + * Copyright 2026 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, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Table } from '../../../generated/entity/data/table'; +import { MOCK_TABLE } from '../../../mocks/TableData.mock'; +import { putCustomMetric } from '../../../rest/customMetricAPI'; +import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; +import CustomMetricFormDrawer from './CustomMetricFormDrawer'; + +const mockLocation = { search: '' }; +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); + +jest.mock('../../../hooks/useCustomLocation/useCustomLocation', () => + jest.fn(() => mockLocation) +); + +jest.mock('../../../rest/customMetricAPI', () => ({ + putCustomMetric: jest.fn(), +})); + +jest.mock('../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), + showSuccessToast: jest.fn(), +})); + +jest.mock( + '../../Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart', + () => + jest.fn(({ entityFqn }: { entityFqn: string }) => ( +
{entityFqn}
+ )) +); + +jest.mock('../../Database/Profiler/TableProfiler/SingleColumnProfile', () => + jest.fn(({ activeColumnFqn }: { activeColumnFqn: string }) => ( +
{activeColumnFqn}
+ )) +); + +const TABLE = { + ...MOCK_TABLE, + customMetrics: [{ name: 'existing_metric', expression: 'SELECT 1' }], +} as Table; +const [FIRST_COLUMN, SECOND_COLUMN] = TABLE.columns; + +const typeQuery = async ( + user: ReturnType, + query: string +) => { + const editor = within( + await screen.findByTestId('code-mirror-container') + ).getByRole('textbox'); + await user.click(editor); + await user.keyboard(`{Control>}a{/Control}${query}`); +}; + +describe('CustomMetricFormDrawer', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLocation.search = ''; + }); + + it('creates a table metric, refreshes the host and closes', async () => { + const updatedTable = { ...TABLE, customMetrics: [] } as Table; + (putCustomMetric as jest.Mock).mockResolvedValue({ data: updatedTable }); + const onClose = jest.fn(); + const onSuccess = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + + expect(await screen.findByText('label.add-entity-metric')).toBeVisible(); + expect(screen.getByTestId('table-profile-preview')).toHaveTextContent( + TABLE.fullyQualifiedName ?? '' + ); + + await user.type(screen.getByTestId('custom-metric-name'), 'row_metric'); + await typeQuery(user, 'SELECT 10'); + await user.click(screen.getByTestId('submit-button')); + + await waitFor(() => + expect(putCustomMetric).toHaveBeenCalledWith(TABLE.id, { + name: 'row_metric', + expression: 'SELECT 10', + }) + ); + + expect(showSuccessToast).toHaveBeenCalledWith( + 'server.create-entity-success' + ); + expect(onSuccess).toHaveBeenCalledWith(updatedTable); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it('preselects the active profiler column and previews the selected column', async () => { + mockLocation.search = `?activeColumnFqn=${SECOND_COLUMN.fullyQualifiedName}`; + render( + + ); + + expect( + within(await screen.findByTestId('custom-metric-column')).getByRole( + 'button' + ) + ).toHaveTextContent(SECOND_COLUMN.name); + expect(screen.getByTestId('column-profile-preview')).toHaveTextContent( + SECOND_COLUMN.fullyQualifiedName ?? '' + ); + }); + + it("opens the new column metric's column profile after creating it", async () => { + mockLocation.search = '?startTs=1&endTs=2'; + (putCustomMetric as jest.Mock).mockResolvedValue({ data: TABLE }); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + + await user.type( + await screen.findByTestId('custom-metric-name'), + 'column_metric' + ); + await user.click( + within(screen.getByTestId('custom-metric-column')).getByRole('button') + ); + await user.click( + await screen.findByRole('option', { name: SECOND_COLUMN.name }) + ); + await typeQuery(user, 'SELECT 1'); + await user.click(screen.getByTestId('submit-button')); + + await waitFor(() => + expect(putCustomMetric).toHaveBeenCalledWith(TABLE.id, { + name: 'column_metric', + expression: 'SELECT 1', + columnName: SECOND_COLUMN.name, + }) + ); + + expect(mockNavigate).toHaveBeenCalledWith({ + search: `startTs=1&endTs=2&activeColumnFqn=${encodeURIComponent( + SECOND_COLUMN.fullyQualifiedName ?? '' + )}`, + }); + }); + + it('edits a column metric, keeping its persisted fields but not its id', async () => { + (putCustomMetric as jest.Mock).mockResolvedValue({ data: TABLE }); + const metric = { + id: 'metric-id', + name: 'column_metric', + expression: 'SELECT 1', + columnName: FIRST_COLUMN.name, + description: 'kept on update', + }; + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + + expect(await screen.findByText('label.edit-entity')).toBeVisible(); + expect(screen.getByTestId('custom-metric-name')).toHaveValue(metric.name); + expect(screen.getByTestId('custom-metric-name')).toBeDisabled(); + + await typeQuery(user, 'SELECT 2'); + await user.click(screen.getByTestId('submit-button')); + + await waitFor(() => + expect(putCustomMetric).toHaveBeenCalledWith(TABLE.id, { + name: metric.name, + expression: 'SELECT 2', + columnName: FIRST_COLUMN.name, + description: metric.description, + }) + ); + + expect(showSuccessToast).toHaveBeenCalledWith( + 'server.update-entity-success' + ); + }); + + it('keeps the drawer open when saving fails', async () => { + const error = new Error('boom'); + (putCustomMetric as jest.Mock).mockRejectedValue(error); + const onClose = jest.fn(); + const onSuccess = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + + await user.type( + await screen.findByTestId('custom-metric-name'), + 'row_metric' + ); + await typeQuery(user, 'SELECT 10'); + await user.click(screen.getByTestId('submit-button')); + + await waitFor(() => expect(showErrorToast).toHaveBeenCalledWith(error)); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByTestId('custom-metric-form')).toBeInTheDocument(); + }); + + it('rejects a name already used by a table metric', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + + await user.type( + await screen.findByTestId('custom-metric-name'), + 'existing_metric' + ); + await typeQuery(user, 'SELECT 10'); + await user.click(screen.getByTestId('submit-button')); + + expect( + await screen.findByText('message.entity-already-exists') + ).toBeVisible(); + expect(putCustomMetric).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.tsx new file mode 100644 index 000000000000..3728ded21142 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.tsx @@ -0,0 +1,231 @@ +/* + * Copyright 2026 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 { Grid } from '@openmetadata/ui-core-components'; +import { AxiosError } from 'axios'; +import { omit } from 'lodash'; +import QueryString from 'qs'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { CustomMetric, Table } from '../../../generated/entity/data/table'; +import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; +import { putCustomMetric } from '../../../rest/customMetricAPI'; +import { submitAndClose } from '../../../utils/FormDrawerUtils'; +import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; +import { useFormDrawerWithHook } from '../../common/atoms/drawer/useFormDrawer'; +import SingleColumnProfile from '../../Database/Profiler/TableProfiler/SingleColumnProfile'; +import TableProfilerChart from '../../Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart'; +import CustomMetricForm from './CustomMetricForm.component'; + +export interface CustomMetricFormDrawerProps { + open: boolean; + isColumnMetric: boolean; + /** The metric being edited; omit to add a new one. */ + metric?: CustomMetric; + table?: Table; + onClose: () => void; + onSuccess: (table: Table) => void; +} + +const CustomMetricFormDrawer = ({ + open, + isColumnMetric, + metric, + table, + onClose, + onSuccess, +}: CustomMetricFormDrawerProps) => { + const { t } = useTranslation(); + const location = useCustomLocation(); + const navigate = useNavigate(); + const isEditMode = Boolean(metric); + const form = useForm({ + defaultValues: { name: '', expression: '' }, + }); + const columnName = useWatch({ control: form.control, name: 'columnName' }); + + const searchParams = useMemo(() => { + const param = location.search; + + return QueryString.parse( + param.startsWith('?') ? param.substring(1) : param + ); + }, [location.search]); + const activeColumnFqn = searchParams.activeColumnFqn as string | undefined; + + const selectedColumnFqn = useMemo( + () => + table?.columns.find((column) => column.name === columnName) + ?.fullyQualifiedName, + [table, columnName] + ); + + // The host stays mounted between opens, so seed the form on every open: the + // edited metric, or the profiler's active column for a new column metric. + useEffect(() => { + if (!open) { + return; + } + form.reset({ + name: metric?.name ?? '', + expression: metric?.expression ?? '', + columnName: + metric?.columnName ?? + table?.columns.find( + (column) => column.fullyQualifiedName === activeColumnFqn + )?.name, + }); + // Seed only on open/metric change: re-seeding when the host refreshes + // `table` would wipe what the user has already typed. + }, [open, metric]); + + const handleSubmit = useCallback( + async ({ name, expression, columnName }: CustomMetric) => { + if (!table) { + return; + } + const values = { + name, + expression, + ...(isColumnMetric ? { columnName } : {}), + }; + try { + const { data } = await putCustomMetric( + table.id, + metric ? { ...omit(metric, ['id']), ...values } : values + ); + showSuccessToast( + t( + isEditMode + ? 'server.update-entity-success' + : 'server.create-entity-success', + { entity: name } + ) + ); + onSuccess(data); + // Land on the metric's column so its new graph is in view, as the + // former add page did when it navigated back. + if ( + isColumnMetric && + selectedColumnFqn && + selectedColumnFqn !== activeColumnFqn + ) { + navigate({ + search: QueryString.stringify({ + ...searchParams, + activeColumnFqn: selectedColumnFqn, + }), + }); + } + } catch (error) { + showErrorToast(error as AxiosError); + + // Rethrow so submitAndClose keeps the drawer open for a retry. + throw error; + } + }, + [ + table, + metric, + isColumnMetric, + isEditMode, + selectedColumnFqn, + activeColumnFqn, + searchParams, + navigate, + onSuccess, + t, + ] + ); + + const closeDrawerRef = useRef<() => void>(() => undefined); + const submit = (values: CustomMetric) => + submitAndClose(values, handleSubmit, () => closeDrawerRef.current()); + + const handleDismiss = useCallback(() => { + form.reset(); + onClose(); + }, [form, onClose]); + + const profilePreview = isColumnMetric ? ( + + ) : ( + + ); + + const { formDrawer, openDrawer, closeDrawer, isOpen } = + useFormDrawerWithHook({ + // The core SlideoutMenu overlay has no z-index, so positioned page + // elements (the profiler tabs) paint through it; 40 matches the test case + // drawer and stays below select popovers (z-50). + className: 'custom-metric-form-drawer tw:z-40', + testId: 'custom-metric-form-drawer', + title: isEditMode + ? t('label.edit-entity', { entity: metric?.name }) + : t('label.add-entity-metric', { + entity: isColumnMetric ? t('label.column') : t('label.table'), + }), + hookForm: form, + width: '80vw', + // Columns scroll on their own: drop Content's padding, gap and stacking. + body: { className: 'tw:flex-row tw:gap-0 tw:px-0 tw:md:px-0' }, + form: ( + + + + + + {profilePreview} + + + ), + submitLabel: isEditMode ? t('label.save') : t('label.create'), + submitTestId: 'submit-button', + cancelTestId: 'cancel-button', + onClose: handleDismiss, + onSubmit: submit, + }); + + closeDrawerRef.current = closeDrawer; + + useEffect(() => { + if (open) { + openDrawer(); + } else if (isOpen) { + closeDrawer(); + } + }, [open, isOpen, openDrawer, closeDrawer]); + + return formDrawer; +}; + +export default CustomMetricFormDrawer; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/TabFilters/TabFilters.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/TabFilters/TabFilters.test.tsx index 55957e867f89..5adad788db57 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/TabFilters/TabFilters.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/TabFilters/TabFilters.test.tsx @@ -123,6 +123,7 @@ jest.mock('../../../../common/DatePickerMenu/DatePickerMenu.component', () => { const mockNavigate = jest.fn(); const mockOnSettingButtonClick = jest.fn(); const mockOnTestCaseDrawerOpen = jest.fn(); +const mockOnCustomMetricDrawerOpen = jest.fn(); const mockUseParams = jest.fn(() => ({ subTab: 'table-profile' })); const mockUseCustomLocation = jest.fn(() => ({ search: '?startTs=1711065600000&endTs=1711670399000&key=last7days', @@ -162,6 +163,7 @@ const mockUseTableProfiler = jest.fn(() => ({ isTableDeleted: false, onSettingButtonClick: mockOnSettingButtonClick, onTestCaseDrawerOpen: mockOnTestCaseDrawerOpen, + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, table: { columns: mockColumns, }, @@ -213,7 +215,6 @@ jest.mock('../../TableProfiler/ProfilerClassBase', () => ({ })); jest.mock('../../../../../utils/RouterUtils', () => ({ - getAddCustomMetricPath: jest.fn(() => '/custom-metric-path'), getEntityDetailsPath: jest.fn(() => '/entity-details-path'), })); @@ -277,6 +278,7 @@ describe('TabFilters', () => { isTableDeleted: false, onSettingButtonClick: mockOnSettingButtonClick, onTestCaseDrawerOpen: mockOnTestCaseDrawerOpen, + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, table: { columns: mockColumns, }, @@ -446,6 +448,7 @@ describe('TabFilters', () => { isTableDeleted: false, onSettingButtonClick: mockOnSettingButtonClick, onTestCaseDrawerOpen: mockOnTestCaseDrawerOpen, + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, table: { columns: [] }, }); @@ -465,6 +468,7 @@ describe('TabFilters', () => { isTableDeleted: false, onSettingButtonClick: mockOnSettingButtonClick, onTestCaseDrawerOpen: mockOnTestCaseDrawerOpen, + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, table: { columns: [] }, }); @@ -484,6 +488,7 @@ describe('TabFilters', () => { isTableDeleted: true, onSettingButtonClick: mockOnSettingButtonClick, onTestCaseDrawerOpen: mockOnTestCaseDrawerOpen, + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, table: { columns: [] }, }); @@ -619,6 +624,25 @@ describe('TabFilters', () => { expect(mockOnSettingButtonClick).toHaveBeenCalledTimes(1); }); + + it('opens the custom metric drawer for a table metric on the table profile tab', () => { + renderComponent(); + + fireEvent.click(screen.getByTestId('dropdown-item-custom-metric')); + + expect(mockOnCustomMetricDrawerOpen).toHaveBeenCalledWith(false); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('opens the custom metric drawer for a column metric on the column profile tab', () => { + mockUseParams.mockReturnValue({ subTab: 'column-profile' }); + + renderComponent(); + + fireEvent.click(screen.getByTestId('dropdown-item-custom-metric')); + + expect(mockOnCustomMetricDrawerOpen).toHaveBeenCalledWith(true); + }); }); describe('Translations', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/TabFilters/TabFilters.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/TabFilters/TabFilters.tsx index 2162ff03bc06..5e38dbec8687 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/TabFilters/TabFilters.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/TabFilters/TabFilters.tsx @@ -26,16 +26,12 @@ import { import { usePermissionProvider } from '../../../../../context/PermissionProvider/PermissionProvider'; import { useTourProvider } from '../../../../../context/TourProvider/TourProvider'; import { EntityTabs, EntityType } from '../../../../../enums/entity.enum'; -import { ProfilerDashboardType } from '../../../../../enums/table.enum'; import { Operation } from '../../../../../generated/entity/policies/policy'; import LimitWrapper from '../../../../../hoc/LimitWrapper'; import useCustomLocation from '../../../../../hooks/useCustomLocation/useCustomLocation'; import { useFqn } from '../../../../../hooks/useFqn'; import { getDerivedPermissionFlags } from '../../../../../utils/PermissionDerivation'; -import { - getAddCustomMetricPath, - getEntityDetailsPath, -} from '../../../../../utils/RouterUtils'; +import { getEntityDetailsPath } from '../../../../../utils/RouterUtils'; import DatePickerMenu from '../../../../common/DatePickerMenu/DatePickerMenu.component'; import { TestLevel } from '../../../../DataQuality/AddDataQualityTest/components/TestCaseFormV1.interface'; import { ProfilerTabPath } from '../../ProfilerDashboard/profilerDashboard.interface'; @@ -76,7 +72,7 @@ const TabFilters = () => { } as DateRangeObject, } as { activeColumnFqn: string; - formType: TestLevel | ProfilerDashboardType; + formType: TestLevel; dateRangeObject: DateRangeObject; }; }, [location.search, activeTab, isTourOpen]); @@ -88,6 +84,7 @@ const TabFilters = () => { isTableDeleted = false, onSettingButtonClick, onTestCaseDrawerOpen, + onCustomMetricDrawerOpen, table, } = useTableProfiler(); @@ -109,14 +106,12 @@ const TabFilters = () => { ); const handleTestCaseClick = () => { - onTestCaseDrawerOpen(formType as TestLevel); + onTestCaseDrawerOpen(formType); setIsMenuOpen(false); }; const handleCustomMetricClick = () => { - navigate( - getAddCustomMetricPath(formType as ProfilerDashboardType, datasetFQN) - ); + onCustomMetricDrawerOpen(formType === TestLevel.COLUMN); setIsMenuOpen(false); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx index eff9237447be..70eee155a26a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Skeleton } from '@openmetadata/ui-core-components'; +import { Box, Card, Skeleton } from '@openmetadata/ui-core-components'; import { AreaChart, LineChart, @@ -96,44 +96,43 @@ const ProfilerDetailsCard: React.FC = ({ } return ( -
- {title && ( -
-

{title}

-
- )} -
+ {title &&

{title}

} + {/* overflow-visible: chart tooltips near the edge must not be clipped. */} + -
- + + + - {data.length > 0 ? ( -
- 0 ? ( +
+ +
+ ) : ( + -
- ) : ( - - )} -
-
-
+ )} +
+ + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerLatestValue/ProfilerLatestValue.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerLatestValue/ProfilerLatestValue.tsx index d9c383847fda..fc01323336f9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerLatestValue/ProfilerLatestValue.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerLatestValue/ProfilerLatestValue.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Card } from '@openmetadata/ui-core-components'; +import { Box, Card } from '@openmetadata/ui-core-components'; import { chartColor, useChartPalette, @@ -41,43 +41,45 @@ const ProfilerLatestValue = ({ }; return ( - -
- {information.map((info, index) => ( -
-

- {info.title} -

-

- {getLatestValue(info.latestValue)} -

- {info.extra && ( - <> -
+ + + + + {information.map((info, index) => ( +

- {info.extra} + className="tw:m-0 tw:mb-1 tw:break-all tw:pl-2 tw:text-[11px] tw:font-semibold tw:leading-3 tw:text-secondary" + data-testid="title" + style={{ + borderLeft: `4px solid ${chartColor( + palette, + index, + info.status + )}`, + }}> + {info.title}

- - )} -
- ))} -
- {extra &&
{extra}
} +

+ {getLatestValue(info.latestValue)} +

+ {info.extra && ( + <> +
+

+ {info.extra} +

+ + )} +
+ ))} + + {extra && {extra}} + +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.component.tsx index 5f359af644b0..ba5cdbba15c5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.component.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Skeleton } from '@openmetadata/ui-core-components'; +import { Box, Card, Skeleton } from '@openmetadata/ui-core-components'; import ProfilerLatestValue from '../ProfilerLatestValue/ProfilerLatestValue'; import { ProfilerStateWrapperProps } from './ProfilerStateWrapper.interface'; @@ -26,25 +26,28 @@ const ProfilerStateWrapper = ({ } return ( -
+ {title && (

{title}

)} -
-
- {profilerLatestValueProps && ( - - )} -
{children}
-
-
-
+ + + {profilerLatestValueProps && ( + + )} +
{children}
+
+
+ + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.test.tsx index 43904159ced3..1d88d808aae4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.test.tsx @@ -14,6 +14,7 @@ import { render, screen } from '@testing-library/react'; import ProfilerStateWrapper from './ProfilerStateWrapper.component'; jest.mock('@openmetadata/ui-core-components', () => ({ + ...jest.requireActual('@openmetadata/ui-core-components'), Skeleton: () =>
Loading...
, })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnSummary.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnSummary.tsx index 0d6be672397d..9a32314e8e78 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnSummary.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnSummary.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Badge } from '@openmetadata/ui-core-components'; +import { Badge, Box, Card, Grid } from '@openmetadata/ui-core-components'; import { FC, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Column } from '../../../../generated/entity/data/container'; @@ -21,9 +21,10 @@ import TagsViewer from '../../../Tag/TagsViewer/TagsViewer'; interface ColumnSummaryProps { column: Column; + className?: string; } -const ColumnSummary: FC = ({ column }) => { +const ColumnSummary: FC = ({ column, className }) => { const { t } = useTranslation(); const { Classification, Glossary } = useMemo(() => { @@ -31,39 +32,46 @@ const ColumnSummary: FC = ({ column }) => { }, [column.tags]); return ( -
-
-

- {getEntityName(column)} -

- - {column.dataType} - -
-
-
- -
-
-
- {t('label.glossary-term-plural')} -
-
- -
-
- {t('label.tag-plural')} -
-
- -
-
-
-
+ + + {getEntityName(column)} + + {column.dataType} + + + } + /> + + + +
+ + + {t('label.glossary-term-plural')} + + + + + + {t('label.tag-plural')} + + + + + +
+
+
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx index 9dc5da339d8b..50afa96aa9a3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx @@ -10,13 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Button, - Dialog, - Dropdown, - Modal, - ModalOverlay, -} from '@openmetadata/ui-core-components'; +import { Button, Dropdown } from '@openmetadata/ui-core-components'; import { AreaChart, type ChartTooltipRenderProps, @@ -24,15 +18,12 @@ import { } from '@openmetadata/ui-core-components/charts'; import { DotsVertical } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; -import { isEmpty, isUndefined, last, omit, toPairs } from 'lodash'; +import { isEmpty, isUndefined, last, toPairs } from 'lodash'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CustomMetric } from '../../../../../generated/entity/data/table'; import { Operation } from '../../../../../generated/entity/policies/policy'; -import { - deleteCustomMetric, - putCustomMetric, -} from '../../../../../rest/customMetricAPI'; +import { deleteCustomMetric } from '../../../../../rest/customMetricAPI'; import { axisTickFormatter, tooltipFormatter, @@ -49,7 +40,6 @@ import { } from '../../../../../utils/ToastUtils'; import DeleteModal from '../../../../common/DeleteModal/DeleteModal'; import ErrorPlaceHolder from '../../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; -import CustomMetricForm from '../../../../DataQuality/CustomMetricForm/CustomMetricForm.component'; import { MetricChartType } from '../../ProfilerDashboard/profilerDashboard.interface'; import ProfilerStateWrapper from '../../ProfilerStateWrapper/ProfilerStateWrapper.component'; import { useTableProfiler } from '../TableProfilerProvider'; @@ -90,6 +80,7 @@ const CustomMetricGraphs = ({ permissions, customMetric: tableDetails, onCustomMetricUpdate, + onCustomMetricDrawerOpen, } = useTableProfiler(); const editPermission = permissions && @@ -98,7 +89,6 @@ const CustomMetricGraphs = ({ const [selectedMetrics, setSelectedMetrics] = useState(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); - const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false); const [openMenuKey, setOpenMenuKey] = useState(null); @@ -131,7 +121,6 @@ const CustomMetricGraphs = ({ const handleModalCancel = () => { setIsDeleteModalVisible(false); - setIsEditModalVisible(false); setSelectedMetrics(undefined); }; @@ -159,43 +148,19 @@ const CustomMetricGraphs = ({ } }; - const handleEditFormSubmit = async (values: CustomMetric) => { - if (tableDetails) { - setIsActionLoading(true); - const updatedMetric = { - ...omit(selectedMetrics, ['id']), - ...values, - }; - - try { - const { data } = await putCustomMetric(tableDetails.id, updatedMetric); - showSuccessToast( - t('server.update-entity-success', { - entity: selectedMetrics?.name, - }) - ); - onCustomMetricUpdate(data); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - handleModalCancel(); - setIsActionLoading(false); - } - } - }; - const handleMenuClick = (key: string, metricName: string) => { - setSelectedMetrics( - customMetrics?.find((metric) => metric.name === metricName) - ); + const metric = customMetrics?.find((metric) => metric.name === metricName); setOpenMenuKey(null); switch (key) { case MenuOptions.EDIT: - setIsEditModalVisible(true); + if (metric) { + onCustomMetricDrawerOpen(!isUndefined(metric.columnName), metric); + } break; case MenuOptions.DELETE: + setSelectedMetrics(metric); setIsDeleteModalVisible(true); break; @@ -290,44 +255,6 @@ const CustomMetricGraphs = ({ onCancel={handleModalCancel} onDelete={handleDeleteClick} /> - {isEditModalVisible && !isUndefined(selectedMetrics) && ( - !open && handleModalCancel()}> - - - - - - - - - - - - - )} ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.test.tsx index 80ce2fc79813..e3069b14b2ee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.test.tsx @@ -15,6 +15,8 @@ import { type CartesianChartProps, } from '@openmetadata/ui-core-components/charts'; import { queryByAttribute, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; import { useTableProfiler } from '../TableProfilerProvider'; import CustomMetricGraphs from './CustomMetricGraphs.component'; @@ -65,14 +67,13 @@ jest.mock('../../../../../utils/DataInsightUtils', () => { return
CustomTooltip
; }); }); -jest.mock( - '../../../../DataQuality/CustomMetricForm/CustomMetricForm.component', - () => { - return jest.fn().mockImplementation(() =>
CustomMetricForm
); - } -); jest.mock('../../../Profiler/ProfilerLatestValue/ProfilerLatestValue', () => { - return jest.fn().mockImplementation(() =>
ProfilerLatestValue
); + return jest.fn().mockImplementation(({ extra }: { extra?: ReactNode }) => ( +
+ ProfilerLatestValue + {extra} +
+ )); }); jest.mock('../../../../common/DeleteWidget/DeleteEntityModal', () => { return jest.fn().mockImplementation(() =>
DeleteEntityModal
); @@ -80,11 +81,13 @@ jest.mock('../../../../common/DeleteWidget/DeleteEntityModal', () => { jest.mock('../../../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => { return jest.fn().mockImplementation(() =>
ErrorPlaceHolder
); }); +const mockOnCustomMetricDrawerOpen = jest.fn(); jest.mock('../TableProfilerProvider', () => { return { useTableProfiler: jest.fn().mockImplementation(() => ({ permissions: { EditAll: true, Delete: true }, onCustomMetricUpdate: jest.fn(), + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, })), }; }); @@ -166,12 +169,19 @@ describe('CustomMetricGraphs', () => { expect(await screen.findByText(name)).toBeInTheDocument(); }); - it('Edit action', async () => { + it('Edit action opens the shared custom metric drawer with the metric', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const [metric] = mockProps.customMetrics; render(); - expect( - await screen.findByTestId('custom-metric-graph-container') - ).toBeInTheDocument(); + await user.click( + await screen.findByTestId(`${metric.name}-custom-metrics-menu`) + ); + await user.click( + await screen.findByRole('menuitemradio', { name: 'label.edit' }) + ); + + expect(mockOnCustomMetricDrawerOpen).toHaveBeenCalledWith(false, metric); }); it('Delete action', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.test.tsx index d67ca839f8e5..3384be0742c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.test.tsx @@ -326,6 +326,7 @@ const defaultTableProfilerContext = { }, isTestCaseDrawerOpen: false, onTestCaseDrawerOpen: jest.fn(), + onCustomMetricDrawerOpen: jest.fn(), testCaseSummary: { 'db.schema.test_table.test_column': { success: 5, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx index a4b06eb6e757..8fa10f6ccf3d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx @@ -10,7 +10,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Tooltip, Typography } from '@openmetadata/ui-core-components'; +import { + Box, + Card, + Tooltip, + Typography, +} from '@openmetadata/ui-core-components'; import { chartColor, PieChart, @@ -126,7 +131,9 @@ const SingleColumnProfile: FC = ({ return isProfilingEnabled ? ( t('message.profiler-is-enabled-but-no-data-available') ) : ( - + // tw:w-auto overrides the trigger's w-max so the sentence wraps in + // narrow containers such as the custom metric drawer preview. + = ({ }, [activeColumnFqn, dateRangeObject]); return ( -
+ {selectedColumn && ( -
-
- -
-
-
-
-

- {t('label.data-quality-test-plural')} -

-
-
-
-
-
- - - {activeColumnTests?.total ?? 0} - - - {t('label.total-test-plural')} - -
- } - data={columnTestData} - height={PIE_SIZE} - innerRadius="62%" - legend={LEGEND_HIDDEN} - outerRadius="88%" - /> -
+ // Side by side on the page; wraps to a stack in narrow columns such as + // the custom metric drawer preview (each card needs at least 20rem). + + + + + + +
+ + + {activeColumnTests?.total ?? 0} + + + {t('label.total-test-plural')} + + + } + data={columnTestData} + height={PIE_SIZE} + innerRadius="62%" + legend={LEGEND_HIDDEN} + outerRadius="88%" + />
- -
-
- {columnTestData.map((item, index) => ( -
- - {item.name} - - - {formatNumberWithComma(item.value)} - -
- ))} -
+
+ {columnTestData.map((item, index) => ( + + + {item.name} + + + {formatNumberWithComma(item.value)} + + + ))}
-
-
-
-
+ + + + )} = ({ customMetricsGraphData={columnCustomMetrics} isLoading={isLoading || isProfilerDataLoading} /> -
+
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfiler.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfiler.interface.ts index c480136f1a6e..36bf76f86876 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfiler.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfiler.interface.ts @@ -16,6 +16,7 @@ import { OperationPermission } from '../../../../context/PermissionProvider/Perm import { Column, ColumnProfilerConfig, + CustomMetric, PartitionProfilerConfig, ProfileSampleType, Table, @@ -40,7 +41,8 @@ export interface TableProfilerProviderProps extends TableProfilerProps { export interface TableProfilerContextInterface { isTableDeleted?: boolean; testCaseSummary?: Record; - permissions: OperationPermission; + /** Unset in read-only previews (the custom metric drawer), which hides edit actions. */ + permissions?: OperationPermission; isTestsLoading: boolean; isProfilerDataLoading: boolean; tableProfiler?: Table; @@ -57,6 +59,10 @@ export interface TableProfilerContextInterface { table?: Table; isTestCaseDrawerOpen: boolean; onTestCaseDrawerOpen: (type: TestLevel) => void; + onCustomMetricDrawerOpen: ( + isColumnMetric: boolean, + metric?: CustomMetric + ) => void; } export type TableTestsType = { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart.test.tsx index 09192d8b6ee7..ae070c753c26 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart.test.tsx @@ -88,7 +88,6 @@ jest.mock('../../../../../rest/tableAPI', () => ({ })); jest.mock('../../../../../utils/RouterUtils', () => ({ - getAddCustomMetricPath: jest.fn(), getAddDataQualityTableTestPath: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart.tsx index ad04c486c931..97a5c4df80bd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart.tsx @@ -112,7 +112,9 @@ const TableProfilerChart = ({ return isProfilingEnabled ? ( t('message.profiler-is-enabled-but-no-data-available') ) : ( - + // tw:w-auto overrides the trigger's w-max so the sentence wraps in + // narrow containers such as the custom metric drawer preview. + jest.fn().mockReturnValue(null) ); +jest.mock('../../../DataQuality/CustomMetricForm/CustomMetricFormDrawer', () => + jest.fn( + ({ + open, + isColumnMetric, + metric, + onClose, + }: { + open: boolean; + isColumnMetric: boolean; + metric?: { name: string }; + onClose: () => void; + }) => { + const { TableProfilerContext: ProfilerContext } = jest.requireActual( + './TableProfilerProvider' + ); + const profilerContext = jest + .requireActual('react') + .useContext(ProfilerContext); + + return open ? ( +
+ + {String(isColumnMetric)} + + {metric?.name ?? 'new'} + + {String(profilerContext.permissions)} + + + {String(profilerContext.isProfilingEnabled)} + + + {String( + profilerContext.testCaseSummary?.[ + 'sample.db.schema.table.address_id' + ]?.total + )} + + +
+ ) : null; + } + ) +); +jest.mock('../../../../rest/dataQualityDashboardAPI', () => ({ + fetchTestCaseResultByTestSuiteId: jest.fn().mockResolvedValue({ + data: [ + { + document_count: '2', + entityFQN: 'sample.db.schema.table.address_id', + 'testCaseResult.testCaseStatus': 'success', + }, + { + document_count: '1', + entityFQN: 'sample.db.schema.table.address_id', + 'testCaseResult.testCaseStatus': 'failed', + }, + ], + }), +})); jest.mock('../../../../constants/constants', () => ({ PAGE_SIZE: 10, ROUTES: { @@ -255,4 +322,86 @@ describe('TableProfilerProvider', () => { }); }); }); + + describe('Custom metric drawer', () => { + const DrawerOpener = () => { + const { onCustomMetricDrawerOpen } = useContext(TableProfilerContext); + + return ( + <> + + + + ); + }; + + const renderProvider = async () => { + await act(async () => { + render( + + + + ); + }); + }; + + it('opens one shared drawer for adding and editing, with a read-only profiler context', async () => { + await renderProvider(); + + expect( + screen.queryByTestId('custom-metric-drawer') + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('add table metric')); + + expect( + await screen.findByTestId('drawer-column-metric') + ).toHaveTextContent('false'); + expect(screen.getByTestId('drawer-metric')).toHaveTextContent('new'); + + // The preview reads the profiler's data (test summary, profiling state)… + await waitFor(() => + expect(screen.getByTestId('drawer-column-tests')).toHaveTextContent('3') + ); + + expect(screen.getByTestId('drawer-profiling-enabled')).toHaveTextContent( + 'false' + ); + // …but not its permissions, so its metric graphs offer no edit/delete. + expect(screen.getByTestId('drawer-permissions')).toHaveTextContent( + 'undefined' + ); + + fireEvent.click(screen.getByText('edit column metric')); + + expect(screen.getByTestId('drawer-column-metric')).toHaveTextContent( + 'true' + ); + expect(screen.getByTestId('drawer-metric')).toHaveTextContent( + 'column_metric' + ); + + fireEvent.click(screen.getByText('close drawer')); + + expect( + screen.queryByTestId('custom-metric-drawer') + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx index 1dfbf72b9472..1a1408e9f321 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx @@ -32,7 +32,7 @@ import { ReactComponent as TotalSizeIcon } from '../../../../assets/svg/data-obs import { DEFAULT_SORT_ORDER } from '../../../../constants/profiler.constant'; import { useTourProvider } from '../../../../context/TourProvider/TourProvider'; import { TabSpecificField } from '../../../../enums/entity.enum'; -import { Table } from '../../../../generated/entity/data/table'; +import { CustomMetric, Table } from '../../../../generated/entity/data/table'; import { ResourcePermission } from '../../../../generated/entity/policies/accessControl/resourcePermission'; import { ProfileSampleType } from '../../../../generated/metadataIngestion/databaseServiceProfilerPipeline'; import { TestCase } from '../../../../generated/tests/testCase'; @@ -72,6 +72,11 @@ const TestCaseFormDrawer = withSuspenseFallback( ) ) ); +const CustomMetricFormDrawer = withSuspenseFallback( + lazy( + () => import('../../../DataQuality/CustomMetricForm/CustomMetricFormDrawer') + ) +); export const TableProfilerContext = createContext( @@ -102,6 +107,10 @@ export const TableProfilerProvider = ({ const [settingModalVisible, setSettingModalVisible] = useState(false); const [isTestCaseDrawerOpen, setIsTestCaseDrawerOpen] = useState(false); const [testLevel, setTestLevel] = useState(); + const [customMetricDrawer, setCustomMetricDrawer] = useState<{ + isColumnMetric: boolean; + metric?: CustomMetric; + }>(); const [table, setTable] = useState(tableEntity); const [testCaseSummary, setTestCaseSummary] = useState>(); @@ -236,6 +245,17 @@ export const TableProfilerProvider = ({ setCustomMetric(customMetric); }; + const handleOpenCustomMetricDrawer = useCallback( + (isColumnMetric: boolean, metric?: CustomMetric) => + setCustomMetricDrawer({ isColumnMetric, metric }), + [] + ); + + const handleCloseCustomMetricDrawer = useCallback( + () => setCustomMetricDrawer(undefined), + [] + ); + const onTestCaseSubmit = (testCase: TestCase) => { setTable((prev) => { if (!prev) { @@ -392,6 +412,7 @@ export const TableProfilerProvider = ({ testCaseSummary, onTestCaseDrawerOpen: handleOpenTestCaseDrawer, isTestCaseDrawerOpen, + onCustomMetricDrawerOpen: handleOpenCustomMetricDrawer, }; }, [ isTestsLoading, @@ -409,27 +430,48 @@ export const TableProfilerProvider = ({ testCaseSummary, isTestCaseDrawerOpen, handleOpenTestCaseDrawer, + handleOpenCustomMetricDrawer, ]); + const readOnlyProfilerContext = useMemo( + () => ({ ...tableProfilerPropsData, permissions: undefined }), + [tableProfilerPropsData] + ); + return ( - - {children} - {settingModalVisible && ( - + + {children} + {settingModalVisible && ( + + )} + + + {/* The profile preview inside the drawer reads the profiler's data but + gets no permissions, so its custom metric graphs offer no edit/delete + actions that would reopen this drawer. */} + + - )} - - + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts index c3ad9ec96bb6..f0ddf6fa8f89 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts @@ -122,7 +122,6 @@ export const PLACEHOLDER_SETTING_CATEGORY = ':settingCategory'; export const PLACEHOLDER_USER_BOT = ':bot'; export const PLACEHOLDER_WEBHOOK_TYPE = ':webhookType'; export const PLACEHOLDER_RULE_NAME = ':ruleName'; -export const PLACEHOLDER_DASHBOARD_TYPE = ':dashboardType'; export const PLACEHOLDER_ACTION = ':action'; export const PLACEHOLDER_ROUTE_DIMENSION_KEY = ':dimensionKey'; @@ -314,7 +313,6 @@ export const ROUTES = { CUSTOMIZE_PAGE: `/customize-page/${PLACEHOLDER_ROUTE_FQN}/:pageFqn`, - ADD_CUSTOM_METRIC: `/add-custom-metric/${PLACEHOLDER_DASHBOARD_TYPE}/${PLACEHOLDER_ROUTE_FQN}`, CHART_DETAILS: `/chart/${PLACEHOLDER_ROUTE_FQN}`, CHART_DETAILS_WITH_TAB: `/chart/${PLACEHOLDER_ROUTE_FQN}/${PLACEHOLDER_ROUTE_TAB}`, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.test.tsx deleted file mode 100644 index 91e08fa27f09..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2023 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 { MemoryRouter } from 'react-router-dom'; -import { MOCK_TABLE } from '../../mocks/TableData.mock'; -import { getTableDetailsByFQN } from '../../rest/tableAPI'; -import AddCustomMetricPage from './AddCustomMetricPage'; -const mockUseParams = { - fqn: 'sample_data.ecommerce_db.shopify.dim_address', - dashboardType: 'table', -}; -jest.mock( - '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component', - () => { - return jest.fn().mockImplementation(() =>
TitleBreadcrumb
); - } -); -jest.mock( - '../../components/DataQuality/CustomMetricForm/CustomMetricForm.component', - () => { - return jest.fn().mockImplementation(() =>
CustomMetricForm
); - } -); -jest.mock( - '../../components/Database/Profiler/TableProfiler/SingleColumnProfile', - () => { - return jest.fn().mockImplementation(() =>
SingleColumnProfile
); - } -); -jest.mock( - '../../components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart', - () => { - return jest.fn().mockImplementation(() =>
TableProfilerChart
); - } -); -jest.mock('../../components/common/Loader/Loader', () => { - return jest.fn().mockImplementation(() =>
Loader
); -}); -jest.mock('../../hooks/useCustomLocation/useCustomLocation', () => { - return jest.fn().mockImplementation(() => ({ search: '' })); -}); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: jest.fn().mockImplementation(() => mockUseParams), -})); -jest.mock('../../rest/tableAPI', () => ({ - getTableDetailsByFQN: jest - .fn() - .mockImplementation(() => Promise.resolve(MOCK_TABLE)), -})); -jest.mock('../../rest/customMetricAPI', () => ({ - putCustomMetric: jest.fn(), -})); -jest.mock('../../hoc/withPageLayout', () => ({ - withPageLayout: jest.fn().mockImplementation((Component) => Component), -})); -jest.mock('../../components/common/ResizablePanels/ResizablePanels', () => - jest.fn().mockImplementation(({ firstPanel, secondPanel }) => ( - <> -
{firstPanel.children}
-
{secondPanel.children}
- - )) -); - -const mockProps = { - pageTitle: 'add-custom-metric', -}; - -describe('AddCustomMetricPage', () => { - it('should render component', async () => { - render(, { - wrapper: MemoryRouter, - }); - - expect(getTableDetailsByFQN).toHaveBeenCalled(); - - expect( - await screen.findByTestId('add-custom-metric-page-container') - ).toBeInTheDocument(); - expect(await screen.findByTestId('heading')).toBeInTheDocument(); - expect(await screen.findByTestId('cancel-button')).toBeInTheDocument(); - expect(await screen.findByTestId('submit-button')).toBeInTheDocument(); - expect(await screen.findByText('TitleBreadcrumb')).toBeInTheDocument(); - expect(await screen.findByText('CustomMetricForm')).toBeInTheDocument(); - expect(await screen.findByText('TableProfilerChart')).toBeInTheDocument(); - }); - - it("should render column profiler if dashboardType is 'column'", async () => { - mockUseParams.dashboardType = 'column'; - render(, { - wrapper: MemoryRouter, - }); - - expect(await screen.findByText('SingleColumnProfile')).toBeInTheDocument(); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.tsx deleted file mode 100644 index a738c2fa1d0b..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.tsx +++ /dev/null @@ -1,295 +0,0 @@ -/* - * Copyright 2023 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 { - Box, - Button, - Grid, - Typography, -} from '@openmetadata/ui-core-components'; -import { AxiosError } from 'axios'; -import QueryString from 'qs'; -import { useEffect, useMemo, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import Loader from '../../components/common/Loader/Loader'; -import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels'; -import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; -import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; -import { ProfilerTabPath } from '../../components/Database/Profiler/ProfilerDashboard/profilerDashboard.interface'; -import SingleColumnProfile from '../../components/Database/Profiler/TableProfiler/SingleColumnProfile'; -import TableProfilerChart from '../../components/Database/Profiler/TableProfiler/TableProfilerChart/TableProfilerChart'; -import RightPanel from '../../components/DataQuality/AddDataQualityTest/components/RightPanel'; -import CustomMetricForm from '../../components/DataQuality/CustomMetricForm/CustomMetricForm.component'; -import { DEFAULT_RANGE_DATA } from '../../constants/profiler.constant'; -import { - EntityTabs, - EntityType, - TabSpecificField, -} from '../../enums/entity.enum'; -import { ProfilerDashboardType } from '../../enums/table.enum'; -import { CustomMetric, Table } from '../../generated/entity/data/table'; -import { withPageLayout } from '../../hoc/withPageLayout'; -import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; -import { useFqn } from '../../hooks/useFqn'; -import { putCustomMetric } from '../../rest/customMetricAPI'; -import { getTableDetailsByFQN } from '../../rest/tableAPI'; -import { getEntityBreadcrumbs } from '../../utils/EntityBreadcrumbPureUtils'; -import { getEntityName } from '../../utils/EntityNameUtils'; -import { getNameFromFQN } from '../../utils/FqnUtils'; -import { getEntityDetailsPath } from '../../utils/RouterUtils'; -import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; -import { useRequiredParams } from '../../utils/useRequiredParams'; - -const AddCustomMetricPage = () => { - const { dashboardType } = useRequiredParams<{ - dashboardType: ProfilerDashboardType; - }>(); - const { fqn } = useFqn(); - const { t } = useTranslation(); - - const navigate = useNavigate(); - const location = useCustomLocation(); - const isColumnMetric = dashboardType === ProfilerDashboardType.COLUMN; - const form = useForm({ - defaultValues: { name: '', expression: '' }, - }); - const [table, setTable] = useState
(); - const [isLoading, setIsLoading] = useState(true); - const [isActionLoading, setIsActionLoading] = useState(false); - const columnName = useWatch({ control: form.control, name: 'columnName' }); - const entityFqn = useMemo(() => table?.fullyQualifiedName ?? '', [table]); - - const breadcrumb = useMemo(() => { - const data: TitleBreadcrumbProps['titleLinks'] = table - ? [ - ...getEntityBreadcrumbs(table, EntityType.TABLE), - { - name: getEntityName(table), - url: getEntityDetailsPath( - EntityType.TABLE, - entityFqn, - EntityTabs.PROFILER - ), - }, - { - name: t('label.add-entity-metric', { - entity: isColumnMetric ? t('label.column') : t('label.table'), - }), - url: '', - activeTitle: true, - }, - ] - : []; - - return data; - }, [table, isColumnMetric]); - - const { activeColumnFqn } = useMemo(() => { - const param = location.search; - const searchData = QueryString.parse( - param.startsWith('?') ? param.substring(1) : param - ); - - return searchData as { activeColumnFqn: string }; - }, [location.search]); - - const initialValues = useMemo( - () => - activeColumnFqn - ? { columnName: getNameFromFQN(activeColumnFqn) } - : undefined, - [activeColumnFqn] - ); - - const handleBackClick = () => { - navigate({ - pathname: getEntityDetailsPath( - EntityType.TABLE, - entityFqn, - EntityTabs.PROFILER, - isColumnMetric - ? ProfilerTabPath.COLUMN_PROFILE - : ProfilerTabPath.TABLE_PROFILE - ), - search: QueryString.stringify({ - activeColumnFqn, - }), - }); - }; - - const handleFormSubmit = async (values: CustomMetric) => { - if (table) { - setIsActionLoading(true); - try { - await putCustomMetric(table.id, values); - showSuccessToast( - t('server.create-entity-success', { - entity: values.name, - }) - ); - handleBackClick(); - } catch (error) { - showErrorToast(error as AxiosError); - setIsActionLoading(false); - } - } - }; - - const fetchTableData = async () => { - setIsLoading(true); - try { - const table = await getTableDetailsByFQN(fqn, { - fields: [ - TabSpecificField.TESTSUITE, - TabSpecificField.CUSTOM_METRICS, - TabSpecificField.COLUMNS, - ], - }); - setTable(table); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchTableData(); - }, [fqn]); - - useEffect(() => { - const selectedColumn = table?.columns.find( - (column) => column.name === columnName - ); - if (selectedColumn) { - navigate({ - search: QueryString.stringify({ - activeColumnFqn: selectedColumn?.fullyQualifiedName, - }), - }); - } - }, [columnName]); - - const secondPanel = ( - <> - - {isColumnMetric ? ( - - ) : ( - - )} - - ); - - if (isLoading) { - return ; - } - - return ( - - - - - - - - {t('label.add-entity-metric', { - entity: isColumnMetric - ? t('label.column') - : t('label.table'), - })} - - - - - - - - - - - - ), - minWidth: 700, - flex: 0.5, - }} - pageTitle={t('label.add-entity', { - entity: t('label.custom-metric'), - })} - secondPanel={{ - children: secondPanel, - className: 'content-resizable-panel-container', - flex: 0.5, - minWidth: 400, - }} - /> - ); -}; - -export default withPageLayout(AddCustomMetricPage); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts index 9af22f3a4806..36db6e840dbf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts @@ -17,7 +17,6 @@ import QueryString from 'qs'; import { IN_PAGE_SEARCH_ROUTES, PLACEHOLDER_ACTION, - PLACEHOLDER_DASHBOARD_TYPE, PLACEHOLDER_ROUTE_DIMENSION_KEY, PLACEHOLDER_ROUTE_ENTITY_TYPE, PLACEHOLDER_ROUTE_FQN, @@ -43,7 +42,6 @@ import { arrServiceTypes } from '../constants/Services.constant'; import { AlertDetailTabs } from '../enums/Alerts.enum'; import { EntityTabs, EntityType } from '../enums/entity.enum'; import { ServiceAgentSubTabs } from '../enums/service.enum'; -import { ProfilerDashboardType } from '../enums/table.enum'; import { PipelineType } from '../generated/api/services/ingestionPipelines/createIngestionPipeline'; import { useMarketplaceStore } from '../hooks/useMarketplaceStore'; import type { DataQualityPageTabs } from '../pages/DataQuality/DataQualityPage.interface'; @@ -373,19 +371,6 @@ export const getTagPath = (fqn?: string) => { return path; }; -export const getAddCustomMetricPath = ( - dashboardType: ProfilerDashboardType, - fqn: string -) => { - let path = ROUTES.ADD_CUSTOM_METRIC; - - path = path - .replace(PLACEHOLDER_DASHBOARD_TYPE, dashboardType) - .replace(PLACEHOLDER_ROUTE_FQN, getEncodedFqn(fqn)); - - return path; -}; - export const getChartDetailsPath = (fqn: string, tab?: string) => { let path = tab ? ROUTES.CHART_DETAILS_WITH_TAB : ROUTES.CHART_DETAILS; path = path.replace(PLACEHOLDER_ROUTE_FQN, getEncodedFqn(fqn));