From 2a61e045fd1c5b86ad208be0b18dd8e8765a290d Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Tue, 13 Oct 2026 14:54:43 +0530 Subject: [PATCH 01/13] feat(ui): add and edit custom metrics in one drawer Replace the standalone /add-custom-metric page and the edit modal with a single CustomMetricFormDrawer hosted by TableProfilerProvider. The profiler Add menu opens it for a new table/column metric and the metric graph's Edit action opens it prefilled. The drawer keeps the page's profile preview (table profile charts, or the selected column's profile) beside the form, rendered outside the profiler context so its metric graphs stay read-only. Duplicate-name validation now follows the selected column instead of the URL. --- .../e2e/Features/CustomMetric.spec.ts | 31 +- .../ui/playwright/utils/customMetric.ts | 115 +++++-- .../AppRouter/AuthenticatedAppRouter.tsx | 22 -- .../CustomMetricForm.component.tsx | 23 +- .../CustomMetricFormDrawer.test.tsx | 231 ++++++++++++++ .../CustomMetricFormDrawer.tsx | 198 ++++++++++++ .../TabFilters/TabFilters.test.tsx | 23 +- .../TabFilters/TabFilters.tsx | 15 +- .../CustomMetricGraphs.component.tsx | 91 +----- .../CustomMetricGraphs.test.tsx | 32 +- .../SingleColumnProfile.test.tsx | 1 + .../TableProfiler/TableProfiler.interface.ts | 5 + .../TableProfilerChart.test.tsx | 1 - .../TableProfilerProvider.test.tsx | 113 ++++++- .../TableProfiler/TableProfilerProvider.tsx | 69 +++- .../resources/ui/src/constants/constants.ts | 2 - .../AddCustomMetricPage.test.tsx | 107 ------- .../AddCustomMetricPage.tsx | 295 ------------------ .../resources/ui/src/utils/RouterUtils.ts | 15 - 19 files changed, 775 insertions(+), 614 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.test.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.tsx 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} - /> { 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) { 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..1aa937cea41f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.test.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 { 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: '' }; + +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('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..601e46b95769 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricFormDrawer.tsx @@ -0,0 +1,198 @@ +/* + * 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 { 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 { 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 isEditMode = Boolean(metric); + const form = useForm({ + defaultValues: { name: '', expression: '' }, + }); + const columnName = useWatch({ control: form.control, name: 'columnName' }); + + const activeColumnFqn = useMemo(() => { + const param = location.search; + + return QueryString.parse(param.startsWith('?') ? param.substring(1) : param) + .activeColumnFqn as string | undefined; + }, [location.search]); + + 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); + } catch (error) { + showErrorToast(error as AxiosError); + + // Rethrow so submitAndClose keeps the drawer open for a retry. + throw error; + } + }, + [table, metric, isColumnMetric, isEditMode, 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..7a1fc5d6df6c 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, }, @@ -619,6 +621,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/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/TableProfiler.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfiler.interface.ts index c480136f1a6e..16305921c349 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, @@ -57,6 +58,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/TableProfilerProvider.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.test.tsx index 20605f329382..90f222e5fa59 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.test.tsx @@ -10,7 +10,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { useContext } from 'react'; import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface'; import { MOCK_TABLE } from '../../../../mocks/TableData.mock'; @@ -99,6 +105,41 @@ jest.mock( '../../../DataQuality/AddDataQualityTest/components/TestCaseFormDrawer', () => 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 hasProfilerContext = Boolean( + jest.requireActual('react').useContext(ProfilerContext).permissions + ); + + return open ? ( +
+ + {String(isColumnMetric)} + + {metric?.name ?? 'new'} + + {String(hasProfilerContext)} + + +
+ ) : null; + } + ) +); jest.mock('../../../../constants/constants', () => ({ PAGE_SIZE: 10, ROUTES: { @@ -255,4 +296,74 @@ 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, outside the 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 graphs inside must not see the profiler's edit permissions. + expect(screen.getByTestId('drawer-has-context')).toHaveTextContent( + 'false' + ); + + 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..6268c5201bd8 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,41 @@ export const TableProfilerProvider = ({ testCaseSummary, isTestCaseDrawerOpen, handleOpenTestCaseDrawer, + handleOpenCustomMetricDrawer, ]); return ( - - {children} - {settingModalVisible && ( - + + {children} + {settingModalVisible && ( + + )} + - )} - + {/* Outside the provider on purpose: the profile preview inside the + drawer renders the custom metric graphs, which must stay read-only + instead of offering edit/delete actions that 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 c48c52a097a2..1ffe454a4f76 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts @@ -119,7 +119,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'; @@ -311,7 +310,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)); From eca83e960bdf9d59ff39e2f69a704d3e4279be6a Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Tue, 13 Oct 2026 15:05:56 +0530 Subject: [PATCH 02/13] fix(ui): open the column profile of a newly created column metric The former add page navigated back to the metric's column; without that the drawer left users on the column list, where the new graph is not shown. --- .../CustomMetricFormDrawer.test.tsx | 47 +++++++++++++++++++ .../CustomMetricFormDrawer.tsx | 37 +++++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) 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 index 1aa937cea41f..948546ceb359 100644 --- 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 @@ -19,6 +19,11 @@ 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) @@ -132,6 +137,48 @@ describe('CustomMetricFormDrawer', () => { ); }); + 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 = { 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 index 601e46b95769..ec2c642bb013 100644 --- 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 @@ -16,6 +16,7 @@ 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'; @@ -46,18 +47,21 @@ const CustomMetricFormDrawer = ({ }: 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 activeColumnFqn = useMemo(() => { + const searchParams = useMemo(() => { const param = location.search; - return QueryString.parse(param.startsWith('?') ? param.substring(1) : param) - .activeColumnFqn as string | undefined; + return QueryString.parse( + param.startsWith('?') ? param.substring(1) : param + ); }, [location.search]); + const activeColumnFqn = searchParams.activeColumnFqn as string | undefined; const selectedColumnFqn = useMemo( () => @@ -109,6 +113,20 @@ const CustomMetricFormDrawer = ({ ) ); 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); @@ -116,7 +134,18 @@ const CustomMetricFormDrawer = ({ throw error; } }, - [table, metric, isColumnMetric, isEditMode, onSuccess, t] + [ + table, + metric, + isColumnMetric, + isEditMode, + selectedColumnFqn, + activeColumnFqn, + searchParams, + navigate, + onSuccess, + t, + ] ); const closeDrawerRef = useRef<() => void>(() => undefined); From ca44e7751c7732f662dec604b538f913a47ed488 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Tue, 13 Oct 2026 15:07:52 +0530 Subject: [PATCH 03/13] test(ui): include the custom metric drawer opener in every TabFilters profiler mock --- .../Profiler/DataObservability/TabFilters/TabFilters.test.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 7a1fc5d6df6c..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 @@ -448,6 +448,7 @@ describe('TabFilters', () => { isTableDeleted: false, onSettingButtonClick: mockOnSettingButtonClick, onTestCaseDrawerOpen: mockOnTestCaseDrawerOpen, + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, table: { columns: [] }, }); @@ -467,6 +468,7 @@ describe('TabFilters', () => { isTableDeleted: false, onSettingButtonClick: mockOnSettingButtonClick, onTestCaseDrawerOpen: mockOnTestCaseDrawerOpen, + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, table: { columns: [] }, }); @@ -486,6 +488,7 @@ describe('TabFilters', () => { isTableDeleted: true, onSettingButtonClick: mockOnSettingButtonClick, onTestCaseDrawerOpen: mockOnTestCaseDrawerOpen, + onCustomMetricDrawerOpen: mockOnCustomMetricDrawerOpen, table: { columns: [] }, }); From 2a4e53f008401c4fbebc35daa55bf82cc0d47981 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 15 Oct 2026 14:53:31 +0530 Subject: [PATCH 04/13] fix(ui): fit the custom metric drawer's profile preview to a narrow column Shrink the preview to two fifths of the drawer and let the profile widgets reflow there: the column summary and Data Quality Tests cards stack when their container is narrow (container query, so the page layout is unchanged), latest-value metrics wrap instead of breaking labels per character, and the no-profiler message wraps instead of overflowing. --- .../CustomMetricForm/CustomMetricFormDrawer.tsx | 2 +- .../ProfilerLatestValue/ProfilerLatestValue.tsx | 2 +- .../Profiler/TableProfiler/SingleColumnProfile.tsx | 12 ++++++++---- .../TableProfilerChart/TableProfilerChart.tsx | 4 +++- 4 files changed, 13 insertions(+), 7 deletions(-) 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 index ec2c642bb013..d3e60c9d641f 100644 --- 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 @@ -198,7 +198,7 @@ const CustomMetricFormDrawer = ({ />
{profilePreview}
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..797cec2b67ae 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 @@ -44,7 +44,7 @@ const ProfilerLatestValue = ({ -
+
{information.map((info, index) => (

= ({ 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. + = ({ return (

{selectedColumn && (
-
+ {/* Stack the cards when the profile is rendered in a narrow column + (e.g. the custom metric drawer preview) instead of the page. */} +
-
+

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. + Date: Thu, 15 Oct 2026 14:57:34 +0530 Subject: [PATCH 05/13] fix(ui): give profiler cards their own surface background The column summary, Data Quality Tests, profiler details and state wrapper cards had no background and borrowed the page's white card. On the custom metric drawer's grey preview column they turned grey; tw:bg-surface keeps them white there and follows the card surface in dark mode. --- .../Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx | 2 +- .../ProfilerStateWrapper/ProfilerStateWrapper.component.tsx | 2 +- .../Database/Profiler/TableProfiler/ColumnSummary.tsx | 2 +- .../Database/Profiler/TableProfiler/SingleColumnProfile.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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..024b1c5b066c 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 @@ -103,7 +103,7 @@ const ProfilerDetailsCard: React.FC = ({

)}
)}
{profilerLatestValueProps && ( 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..bfce86c633e1 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 @@ -31,7 +31,7 @@ const ColumnSummary: FC = ({ column }) => { }, [column.tags]); return ( -
+

{getEntityName(column)} 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 1a633b74a1e1..9d1b3703b5f2 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 @@ -238,7 +238,7 @@ const SingleColumnProfile: FC = ({

-
+

{t('label.data-quality-test-plural')} From eb42b3a7acd1eec8f7fe87df93efa9d2b7cfbf66 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 15 Oct 2026 15:11:42 +0530 Subject: [PATCH 06/13] fix(ui): stop the stacked column profile cards overflowing the drawer preview A 24-column grid's 23 gaps (460px) are wider than the drawer's preview column, so the stacked cards spilled past it and the panel scrolled sideways. Use a single-column grid until the container is wide enough for the 24-column side-by-side layout. --- .../Database/Profiler/TableProfiler/SingleColumnProfile.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 9d1b3703b5f2..d02be85d1fb8 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 @@ -231,13 +231,13 @@ const SingleColumnProfile: FC = ({ className="tw:@container tw:mb-lg tw:flex tw:flex-col tw:gap-8" data-testid="profiler-tab-container"> {selectedColumn && ( -

+
{/* Stack the cards when the profile is rendered in a narrow column (e.g. the custom metric drawer preview) instead of the page. */} -
+
-
+

From 7124aeb1116340d0af4734b8f5c8ef5a34c36c0f Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 15 Oct 2026 15:38:29 +0530 Subject: [PATCH 07/13] feat(ui): use the test case form's SQL editor for custom metric queries Swap SchemaEditor for CodeEditor so the custom metric SQL Query field matches the test case drawer's SQL Expression box: copy button header, no line-number gutter. --- .../CustomMetricForm/CustomMetricForm.component.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.component.tsx index 03045729c3ef..01f9165c1b29 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.component.tsx @@ -30,8 +30,8 @@ import { getEntityName } from '../../../utils/EntityNameUtils'; import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import { CustomMetricFormProps } from './CustomMetricForm.interface'; -const SchemaEditor = withSuspenseFallback( - lazy(() => import('../../Database/SchemaEditor/SchemaEditor')) +const CodeEditor = withSuspenseFallback( + lazy(() => import('../../Database/SchemaEditor/CodeEditor')) ); const CustomMetricForm = ({ @@ -182,11 +182,11 @@ const CustomMetricForm = ({ {({ field }) => ( - From 4d73c82b3f847edf113668cfeefe745ae212fc0e Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 15 Oct 2026 15:46:22 +0530 Subject: [PATCH 08/13] chore(playwright): map the custom metric drawer instead of the removed add page The impact map still pointed at pages/AddCustomMetricPage/**, which no longer exists, so changes to the drawer would not select CustomMetric.spec.ts. --- .github/playwright/impact-map.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/playwright/impact-map.json b/.github/playwright/impact-map.json index 9b7b26ec1295..5c4214551f9e 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", From 3e41e5fed72db3f4ef582259ab6e1b9da1b0ac69 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 15 Oct 2026 16:50:36 +0530 Subject: [PATCH 09/13] refactor(ui): build the column profile cards and drawer columns from core components Per review: the column summary and Data Quality Tests cards now use the core Card (default style, Card.Header/Card.Content) instead of hand-rolled bordered divs, their row is a wrapping core Box that stacks when the container is too narrow for two 20rem cards (replacing the container query), and the custom metric drawer lays out its form and preview with core Grid/Grid.Item. --- .../CustomMetricFormDrawer.tsx | 20 ++- .../Profiler/TableProfiler/ColumnSummary.tsx | 78 +++++---- .../TableProfiler/SingleColumnProfile.tsx | 158 +++++++++--------- 3 files changed, 134 insertions(+), 122 deletions(-) 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 index d3e60c9d641f..3728ded21142 100644 --- 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 @@ -10,6 +10,7 @@ * 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'; @@ -187,8 +188,10 @@ const CustomMetricFormDrawer = ({ // 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', 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 bfce86c633e1..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/SingleColumnProfile.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx index d02be85d1fb8..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, @@ -227,86 +232,81 @@ const SingleColumnProfile: FC = ({ }, [activeColumnFqn, dateRangeObject]); return ( -
+ {selectedColumn && ( -
- {/* Stack the cards when the profile is rendered in a narrow column - (e.g. the custom metric drawer preview) instead of the page. */} -
- -
-
-
-
-

- {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} /> -
+
); }; From 46c635adaaca26c25e24c1b476e88be46a152bed Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 15 Oct 2026 17:05:49 +0530 Subject: [PATCH 10/13] refactor(ui): render profiler chart cards with the core Card Per review: ProfilerDetailsCard and ProfilerStateWrapper drop their hand-rolled bordered divs (custom 10px radius) for the core Card with Card.Content, so the profiler cards share the design system's radius, border and surface. The cards stay overflow-visible so chart tooltips near the edge are not clipped. --- .../ProfilerDetailsCard.tsx | 71 +++++++++---------- .../ProfilerStateWrapper.component.tsx | 29 ++++---- .../ProfilerStateWrapper.test.tsx | 1 + 3 files changed, 52 insertions(+), 49 deletions(-) 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 024b1c5b066c..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/ProfilerStateWrapper/ProfilerStateWrapper.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerStateWrapper/ProfilerStateWrapper.component.tsx index b93bdcf5177f..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...
, })); From 8de14077cde326a7564befe9e448aed637693b1e Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 15 Oct 2026 17:10:25 +0530 Subject: [PATCH 11/13] refactor(ui): lay out the profiler latest-value card with core Card.Content and Box Drop the custom padding and flex classes on the latest-value card for Card.Content's default padding and core Box layout, matching the other profiler cards. --- .../ProfilerLatestValue.tsx | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) 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 797cec2b67ae..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}} + +
); }; From 094711e61eb571d247c31cafe316398ca5dc9dc9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 15 Oct 2026 11:52:03 +0000 Subject: [PATCH 12/13] chore(playwright): auto-refresh impact-map.generated.json --- .github/playwright/impact-map.generated.json | 45 +++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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" From 3579d983d4b57164e3e7f814e6171676751bd071 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 15 Oct 2026 17:40:40 +0530 Subject: [PATCH 13/13] fix(ui): give the custom metric preview a read-only profiler context The drawer rendered outside TableProfilerContext, so its profile preview read an empty context: the column's Data Quality Tests showed 0/0/0 and the charts showed the set-up-profiler message even when profiling was enabled. Wrap the drawer in a copy of the profiler context with permissions cleared. The preview now reads testCaseSummary, isProfilingEnabled and the custom metrics, while CustomMetricGraphs (Edit/Delete only when permissions is set) and TableProfilerChart (blocks only when permissions lack ViewDataProfile) stay read-only. permissions becomes optional on the context interface; every consumer already guards it. --- .../TableProfiler/TableProfiler.interface.ts | 3 +- .../TableProfilerProvider.test.tsx | 56 ++++++++++++++++--- .../TableProfiler/TableProfilerProvider.tsx | 29 ++++++---- 3 files changed, 67 insertions(+), 21 deletions(-) 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 16305921c349..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 @@ -41,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; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.test.tsx index 90f222e5fa59..121c745a8bba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.test.tsx @@ -121,9 +121,9 @@ jest.mock('../../../DataQuality/CustomMetricForm/CustomMetricFormDrawer', () => const { TableProfilerContext: ProfilerContext } = jest.requireActual( './TableProfilerProvider' ); - const hasProfilerContext = Boolean( - jest.requireActual('react').useContext(ProfilerContext).permissions - ); + const profilerContext = jest + .requireActual('react') + .useContext(ProfilerContext); return open ? (
@@ -131,8 +131,18 @@ jest.mock('../../../DataQuality/CustomMetricForm/CustomMetricFormDrawer', () => {String(isColumnMetric)} {metric?.name ?? 'new'} - - {String(hasProfilerContext)} + + {String(profilerContext.permissions)} + + + {String(profilerContext.isProfilingEnabled)} + + + {String( + profilerContext.testCaseSummary?.[ + 'sample.db.schema.table.address_id' + ]?.total + )}
@@ -140,6 +150,22 @@ jest.mock('../../../DataQuality/CustomMetricForm/CustomMetricFormDrawer', () => } ) ); +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: { @@ -325,14 +351,17 @@ describe('TableProfilerProvider', () => { render( + table={{ + ...MOCK_TABLE, + testSuite: { id: 'suite-id', type: 'testSuite' }, + }}> ); }); }; - it('opens one shared drawer for adding and editing, outside the profiler context', async () => { + it('opens one shared drawer for adding and editing, with a read-only profiler context', async () => { await renderProvider(); expect( @@ -345,10 +374,19 @@ describe('TableProfilerProvider', () => { await screen.findByTestId('drawer-column-metric') ).toHaveTextContent('false'); expect(screen.getByTestId('drawer-metric')).toHaveTextContent('new'); - // The preview graphs inside must not see the profiler's edit permissions. - expect(screen.getByTestId('drawer-has-context')).toHaveTextContent( + + // 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')); 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 6268c5201bd8..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 @@ -433,6 +433,11 @@ export const TableProfilerProvider = ({ handleOpenCustomMetricDrawer, ]); + const readOnlyProfilerContext = useMemo( + () => ({ ...tableProfilerPropsData, permissions: undefined }), + [tableProfilerPropsData] + ); + return ( <> @@ -453,17 +458,19 @@ export const TableProfilerProvider = ({ onFormSubmit={onTestCaseSubmit} /> - {/* Outside the provider on purpose: the profile preview inside the - drawer renders the custom metric graphs, which must stay read-only - instead of offering edit/delete actions that reopen this drawer. */} - + {/* 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. */} + + + ); };