diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index f5fb2f5ab969..e54aa728c230 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -144,6 +144,7 @@ "playwright/e2e/Features/DataQuality/DataQualityPermissions.spec.ts", "playwright/e2e/Features/DataQuality/Dimensionality.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/RunTestCase.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", @@ -746,13 +747,16 @@ "playwright/e2e/Features/AppMode/AppModeRouteIsolation.spec.ts", "playwright/e2e/Features/AppMode/AppModeUrlHandling.spec.ts", "playwright/e2e/Features/ApplicationsSettingsModal.spec.ts", + "playwright/e2e/Features/CustomMetric.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/RunTestCase.spec.ts", "playwright/e2e/Features/DataQuality/TestCaseDetailsPage.spec.ts", "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Features/ProfilePreferences.spec.ts", - "playwright/e2e/Pages/BotsProfile.spec.ts" + "playwright/e2e/Pages/BotsProfile.spec.ts", + "playwright/e2e/Pages/CustomProperties.spec.ts" ] }, { @@ -796,6 +800,7 @@ "playwright/e2e/Features/DataQuality/BundleSuiteBulkOperations.spec.ts", "playwright/e2e/Features/DataQuality/DataQuality.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts", "playwright/e2e/Features/DataQuality/RunTestCase.spec.ts", @@ -1678,6 +1683,7 @@ "playwright/e2e/Features/DataQuality/IncidentManagerAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterSoftDelete.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/RunTestCase.spec.ts", @@ -3026,6 +3032,7 @@ "playwright/e2e/Features/DataQuality/DataQuality.spec.ts", "playwright/e2e/Features/DataQuality/DataQualityPermissions.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts", "playwright/e2e/Features/DataQuality/RunTestCase.spec.ts", @@ -3249,6 +3256,7 @@ "playwright/e2e/Features/AIMode/ProfileNotificationTab.spec.ts", "playwright/e2e/Features/AdvancedSearch.spec.ts", "playwright/e2e/Features/ContextCenterDashboard.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DomainFilterQueryFilter.spec.ts", "playwright/e2e/Features/LandingPageWidgets/LandingPageContextCenter.spec.ts", "playwright/e2e/Features/LandingPageWidgets/LandingPageDataEstate.spec.ts", @@ -3427,6 +3435,7 @@ "playwright/e2e/Features/DataQuality/IncidentManagerAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterSoftDelete.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts", "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts", @@ -5177,6 +5186,7 @@ "playwright/e2e/Features/DataProductPersonaCustomization.spec.ts", "playwright/e2e/Features/DataQuality/DataQuality.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/TableTestCasePagination.spec.ts", "playwright/e2e/Features/FailedTestCaseSampleData.spec.ts", "playwright/e2e/Features/Glossary/GlossaryAdvancedOperations.spec.ts", @@ -5521,6 +5531,15 @@ "playwright/e2e/Flow/AlertSeveralSources.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.tsx" + ], + "specs": [ + "playwright/e2e/Flow/AlertSeveralSources.spec.ts", + "playwright/e2e/Flow/ObservabilityAlerts.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/Alerts/DestinationFormItem/DestinationFormItem.component.tsx" @@ -5561,27 +5580,6 @@ "playwright/e2e/Pages/Lineage/LineageFilters.spec.ts" ] }, - { - "sources": [ - "openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx" - ], - "specs": [ - "playwright/e2e/Features/AIMode/AiNotificationAlerts.spec.ts", - "playwright/e2e/Features/AIMode/ProfileNotificationTab.spec.ts", - "playwright/e2e/Flow/AlertSeveralSources.spec.ts", - "playwright/e2e/Flow/NotificationAlerts.spec.ts", - "playwright/e2e/Flow/ObservabilityAlerts.spec.ts" - ] - }, - { - "sources": [ - "openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormTriggerItem/ObservabilityFormTriggerItem.tsx" - ], - "specs": [ - "playwright/e2e/Flow/AlertSeveralSources.spec.ts", - "playwright/e2e/Flow/ObservabilityAlerts.spec.ts" - ] - }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/AppBar/Suggestions.tsx" @@ -5670,6 +5668,7 @@ ], "specs": [ "playwright/e2e/Features/AccessControlSettings.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/ExploreQueryBar.spec.ts", "playwright/e2e/Features/Pagination.spec.ts", "playwright/e2e/Flow/ExploreDiscovery.spec.ts", @@ -6368,6 +6367,14 @@ "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.component.tsx" + ], + "specs": [ + "playwright/e2e/Features/CustomMetric.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/DataQuality/DataQualityDashboard/DataQualityDashboard.component.tsx" @@ -6465,16 +6472,10 @@ }, { "sources": [ - "openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/AddSqlQueryFormModal/AddSqlQueryFormModal.component.tsx" + "openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/SqlQueryTab.component.tsx" ], "specs": [ - "playwright/e2e/Features/AccessControlSettings.spec.ts", - "playwright/e2e/Features/ExploreQueryBar.spec.ts", - "playwright/e2e/Features/Pagination.spec.ts", - "playwright/e2e/Flow/ExploreDiscovery.spec.ts", - "playwright/e2e/Pages/ClassificationConditionalRendering.spec.ts", - "playwright/e2e/Pages/ExploreTree.spec.ts", - "playwright/e2e/Pages/Tags.spec.ts" + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts" ] }, { @@ -6656,15 +6657,6 @@ "playwright/e2e/Pages/Tags.spec.ts" ] }, - { - "sources": [ - "openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx" - ], - "specs": [ - "playwright/e2e/Features/DataQuality/Profiler.spec.ts", - "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts" - ] - }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerSettings.tsx" @@ -9871,8 +9863,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx" ], "specs": [ - "playwright/e2e/Features/DataQuality/Profiler.spec.ts", - "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts" + "playwright/e2e/Features/DataQuality/Profiler.spec.ts" ] }, { @@ -11819,6 +11810,7 @@ "playwright/e2e/Features/DataQuality/IncidentManagerAfterReindex.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerAfterSoftDelete.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts", "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts", @@ -13310,15 +13302,6 @@ "playwright/e2e/Features/PersonaAppLayout.spec.ts" ] }, - { - "sources": [ - "openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx" - ], - "specs": [ - "playwright/e2e/Flow/AlertSeveralSources.spec.ts", - "playwright/e2e/Flow/ObservabilityAlerts.spec.ts" - ] - }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx" diff --git a/docs/decisions/2026-10-10-classic-alert-widgets-update-controlled-values.md b/docs/decisions/2026-10-10-classic-alert-widgets-update-controlled-values.md new file mode 100644 index 000000000000..0a80b8ab6e6d --- /dev/null +++ b/docs/decisions/2026-10-10-classic-alert-widgets-update-controlled-values.md @@ -0,0 +1,39 @@ +# Classic alert widgets update controlled values through a callback + +- **Status:** Accepted +- **Revisions:** v1 2026-10-10 (initial) +- **Deciders:** Shailesh Parmar +- **Guard:** `AlertFormSourceItemSeveralSources.test.tsx`, Collate `TemplateFormItem.test.tsx`, and paired build review +- **Related:** ADR:2026-10-10-alert-widgets-use-values-and-oss-merges-before-collate + +## Context + +Classic Observability now uses React Hook Form and core components. Collate's notification +template widget previously wrote directly into an Ant Design form instance. The shared +notification form still has legacy form state, so exposing a particular form library would +couple downstream widgets to incompatible implementations. + +## Decision + +`AddAlertFormWidgetProps.values` contains the current editable subscription. A widget that +changes fields calls `onValuesChange` with a partial subscription. The owning form merges +those changes into its latest values. Read-only widgets consume `values` without invoking +the callback. `formRef` remains optional for legacy callers, and migrated widgets do not +require it. + +A custom notification template is an editing draft containing `displayName`, `templateSubject` +and `templateBody`. It has no persisted template identity until the create API succeeds. +Do not require or fabricate a template `id` or `name` when updating this draft. The parent +continues to own template creation and subscription submission. + +Validate OSS and Collate together. Merge the OSS Classic migration first, then its Collate +companion immediately afterward. Keep Collate's OpenMetadata submodule on main and verify +its build against merged OSS before completing the migration. Collate requires the new +callback, draft type, profiler state and core component exports. + +## Consequences + +Widgets share a controlled contract across form libraries. Collate's companion change is +required before its Classic template picker can write into the migrated OSS form; the +interval between merges is a coordinated integration window. This does not change the +notification template API or persisted subscription schema. diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/popover/popover.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/popover/popover.tsx index e65a8830842f..8b5660322c46 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/popover/popover.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/popover/popover.tsx @@ -98,7 +98,10 @@ export const Popover = ({ )} - + {children} diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.test.tsx index 685476632302..853a99f037fc 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.test.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.test.tsx @@ -37,6 +37,20 @@ const renderAutocomplete = (onItemInserted: (id: unknown) => void) => ); describe('Autocomplete – selection commit', () => { + it('provides a named remove button and the full selected label', () => { + const label = 'service.database.schema.' + 'long_table_name_'.repeat(20); + render( + + {(item) => ( + {item.label} + )} + + ); + + expect(screen.getByRole('button', { name: /remove/i })).toBeVisible(); + expect(screen.getByTitle(label)).toHaveTextContent(label); + }); + it('inserts the focused option on Enter', async () => { const onItemInserted = vi.fn(); renderAutocomplete(onItemInserted); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx index aeb52a559025..009feba5fb45 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx @@ -10,6 +10,7 @@ import { } from '@/components/base/select/select'; import { Typography } from '@/components/foundations/typography'; import { useResizeObserver } from '@/hooks/use-resize-observer'; +import { useCoreTranslation } from '@/i18n/useCoreTranslation'; import { cx } from '@/utils/cx'; import { isReactComponent } from '@/utils/is-react-component'; import { Search } from '../../../icons'; @@ -155,6 +156,7 @@ const InnerAutocomplete = ({ isDisabled?: boolean; placeholder?: string; }) => { + const { t } = useCoreTranslation(); const focusManager = useFocusManager(); const context = useContext(AutocompleteContext); const comboBoxStateContext = useContext(ComboBoxStateContext); @@ -264,11 +266,14 @@ const InnerAutocomplete = ({ context.renderTag(item, () => context.onRemove(new Set([item.id]))) ) : ( context.onRemove(new Set([item.id]))} onButtonKeyDown={(e) => handleTagKeyDown(e, item.id)}> diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/badges/badges.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/badges/badges.tsx index 142faeab6b71..f56f50b9222c 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/badges/badges.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/badges/badges.tsx @@ -589,6 +589,7 @@ export const BadgeWithButton = ( buttonTestId, children, isDisabled, + onButtonClick, onButtonKeyDown, tooltip, tooltipPlacement = 'top', @@ -656,7 +657,7 @@ export const BadgeWithButton = ( data-testid={buttonTestId} disabled={isDisabled} type="button" - onClick={props.onButtonClick} + onClick={onButtonClick} onKeyDown={onButtonKeyDown}> diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/number-input.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/number-input.test.tsx new file mode 100644 index 000000000000..7b6ac1bd87e2 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/number-input.test.tsx @@ -0,0 +1,92 @@ +/* + * 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 } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef, useState } from 'react'; +import { describe, expect, it } from 'vitest'; +import { NumberInput } from './number-input'; + +const PercentageField = () => { + const [value, setValue] = useState(50); + + return ( + + ); +}; + +describe('NumberInput', () => { + it('parses a percentage without scaling its value and allows clearing it', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText(/Sample/, { selector: 'input' }); + expect(input).toHaveValue('50%'); + + await user.clear(input); + await user.type(input, '75%'); + await user.tab(); + expect(input).toHaveValue('75%'); + + await user.clear(input); + await user.tab(); + expect(input).toHaveValue(''); + }); + + it('steps with the keyboard and mouse while respecting its bounds', async () => { + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/Rows/, { selector: 'input' }); + await user.click(input); + await user.keyboard('{ArrowUp}{ArrowUp}'); + expect(input).toHaveValue('3'); + expect( + screen.getByRole('button', { name: /increase|increment/i }) + ).toBeDisabled(); + await user.click( + screen.getByRole('button', { name: /decrease|decrement/i }) + ); + expect(input).toHaveValue('2'); + await user.click(input); + await user.keyboard('{Home}{ArrowDown}'); + expect(input).toHaveValue('1'); + expect( + screen.getByRole('button', { name: /decrease|decrement/i }) + ).toBeDisabled(); + }); + + it('forwards its ref and exposes validation and disabled state on the input', () => { + const ref = createRef(); + render( + + ); + const input = screen.getByLabelText(/Count/, { selector: 'input' }); + expect(ref.current).toBe(input); + expect(input).toBeDisabled(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByText('Invalid count')).toBeVisible(); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/number-input.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/number-input.tsx new file mode 100644 index 000000000000..57e423de438f --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/number-input.tsx @@ -0,0 +1,98 @@ +/* + * 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 { forwardRef, type ReactNode } from 'react'; +import { NumberField, type NumberFieldProps } from 'react-aria-components'; +import { Minus, Plus } from '../../../icons'; +import { cx } from '@/utils/cx'; +import { Box } from '../box/box'; +import { Button } from '../buttons/button'; +import { HintText } from './hint-text'; +import { InputBase, type InputBaseProps } from './input'; +import { Label } from './label'; + +export interface NumberInputProps + extends Omit, + Pick< + InputBaseProps, + 'size' | 'inputDataTestId' | 'inputClassName' | 'placeholder' + > { + label?: ReactNode; + hint?: ReactNode; + showSteppers?: boolean; +} + +export const NumberInput = forwardRef( + function NumberInput( + { + label, + hint, + size = 'sm', + inputDataTestId, + inputClassName, + placeholder, + showSteppers = true, + className, + ...props + }, + ref + ) { + return ( + + cx( + 'tw:group tw:flex tw:w-full tw:flex-col tw:gap-1.5', + typeof className === 'function' ? className(state) : className + ) + }> + {({ isDisabled, isInvalid, state }) => ( + <> + {label && } + + + {isOpen && + createPortal( + + {(item) => {item.label}} + , + document.body + )} + + ); + }; + const user = userEvent.setup(); + render( + + + + + + + + + ); + await user.click(screen.getByRole('button', { name: 'Open editor' })); + await user.click(screen.getByRole('button', { name: 'Show options' })); + + // Role queries skip aria-hidden subtrees. + expect( + await screen.findByRole('option', { name: 'Accuracy' }) + ).toBeInTheDocument(); + }); }); describe('Select dismissal', () => { @@ -180,3 +233,181 @@ describe('Select with no items', () => { expect(await screen.findByText('Nothing to map')).toBeInTheDocument(); }); }); + +describe('Select with a large collection', () => { + beforeEach(() => { + vi.stubEnv('VIRT_ON', 'true'); + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => 40, + }); + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { + configurable: true, + get: () => 320, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get: () => 256, + }); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => 320, + }); + }); + afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight'); + Reflect.deleteProperty(HTMLElement.prototype, 'scrollWidth'); + Reflect.deleteProperty(HTMLElement.prototype, 'clientHeight'); + Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth'); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + it('windows the options and selects the last item with the keyboard', async () => { + const user = userEvent.setup(); + const onSelectionChange = vi.fn(); + const items = Array.from({ length: 250 }, (_, index) => ({ + id: `column-${index}`, + label: `Column ${index}`, + })); + render( + + ); + await user.click(screen.getByRole('button', { name: /Columns/ })); + await screen.findByRole('listbox'); + expect(screen.getAllByRole('option').length).toBeLessThan(items.length); + await user.keyboard('{End}{Enter}'); + await waitFor(() => + expect(onSelectionChange).toHaveBeenCalledWith('column-249') + ); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('navigates a windowed ComboBox with arrows and PageDown and commits', async () => { + const user = userEvent.setup(); + const onSelectionChange = vi.fn(); + const items = Array.from({ length: 250 }, (_, index) => ({ + id: `column-${index}`, + label: `Column ${index}`, + })); + render( + + {(item) => {item.label}} + + ); + const input = screen.getByRole('combobox', { name: /Columns/ }); + await user.click(input); + await screen.findByRole('listbox'); + expect(screen.getAllByRole('option').length).toBeLessThan(items.length); + + await user.keyboard('{ArrowDown}{ArrowDown}'); + await waitFor(() => + expect(input.getAttribute('aria-activedescendant')).toBeTruthy() + ); + const activeText = () => + document.getElementById(input.getAttribute('aria-activedescendant') ?? '') + ?.textContent; + await waitFor(() => expect(activeText()).toBe('Column 1')); + + for (let i = 0; i < 60; i++) { + await user.keyboard('{PageDown}'); + } + await waitFor(() => expect(activeText()).toBe('Column 249')); + await user.keyboard('{Enter}'); + + await waitFor(() => + expect(onSelectionChange).toHaveBeenCalledWith('column-249') + ); + }); + + describe('keyboard navigation', () => { + const items = Array.from({ length: 250 }, (_, index) => ({ + id: `column-${index}`, + label: `Column ${index}`, + })); + const openSelect = async (onSelectionChange = vi.fn()) => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('button', { name: /Columns/ })); + await screen.findByRole('listbox'); + + return { user, onSelectionChange }; + }; + const activeLabel = () => document.activeElement?.textContent; + + it('moves the active option with arrows, Home, End and PageDown', async () => { + const { user } = await openSelect(); + await user.keyboard('{Home}'); + await waitFor(() => expect(activeLabel()).toBe('Column 0')); + + await user.keyboard('{ArrowDown}{ArrowDown}'); + await waitFor(() => expect(activeLabel()).toBe('Column 2')); + + await user.keyboard('{ArrowUp}'); + await waitFor(() => expect(activeLabel()).toBe('Column 1')); + + await user.keyboard('{End}'); + await waitFor(() => expect(activeLabel()).toBe('Column 249')); + + await user.keyboard('{Home}{PageDown}'); + await waitFor(() => { + const label = activeLabel() ?? ''; + expect(Number(label.replace('Column ', ''))).toBeGreaterThan(1); + }); + }); + + it('reaches options past the 100th by repeated PageDown and commits', async () => { + const { user, onSelectionChange } = await openSelect(); + await user.keyboard('{Home}'); + await waitFor(() => expect(activeLabel()).toBe('Column 0')); + + for (let i = 0; i < 60; i++) { + await user.keyboard('{PageDown}'); + } + await waitFor(() => expect(activeLabel()).toBe('Column 249')); + await user.keyboard('{Enter}'); + + await waitFor(() => + expect(onSelectionChange).toHaveBeenCalledWith('column-249') + ); + }); + + it('jumps to a late option by typeahead and commits it', async () => { + const { user, onSelectionChange } = await openSelect(); + await user.keyboard('Column 17'); + await waitFor(() => expect(activeLabel()).toBe('Column 17')); + await user.keyboard('{Enter}'); + + await waitFor(() => + expect(onSelectionChange).toHaveBeenCalledWith('column-17') + ); + }); + + it('closes on Escape without committing and returns focus to the trigger', async () => { + const { user, onSelectionChange } = await openSelect(); + await user.keyboard('{End}{Escape}'); + + await waitFor(() => + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + ); + expect(onSelectionChange).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.getByRole('button', { name: /Columns/ })).toHaveFocus() + ); + }); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/select/select.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/select/select.tsx index 94955e79d338..4928b615611a 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/select/select.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/select/select.tsx @@ -11,11 +11,14 @@ import { createContext, forwardRef, isValidElement } from 'react'; import type { SelectProps as AriaSelectProps } from 'react-aria-components'; import { Button as AriaButton, - ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue, } from 'react-aria-components'; import { ComboBox } from './combobox'; +import { + SELECT_VIRTUALIZATION_THRESHOLD, + SelectListBox, +} from './select-list-box'; import { Popover } from './popover'; import { SelectItem } from './select-item'; @@ -198,6 +201,9 @@ const Select = forwardRef>( }, ref ) { + const isVirtualized = + (items?.length ?? 0) > SELECT_VIRTUALIZATION_THRESHOLD; + return ( >( icon={icon} /> - - + ( - )}> + )} + size={size} + virtualize={isVirtualized}> {children} - + {hint && {hint}} diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/index.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/components/index.ts index 4c7861b05ac4..6eb5b1f891d5 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/index.ts +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/index.ts @@ -64,6 +64,7 @@ export * from './base/form/hook-form'; export * from './base/grid/grid'; export * from './base/input/hint-text'; export * from './base/input/input'; +export * from './base/input/number-input'; export * from './base/input/input-group'; export * from './base/input/label'; export * from './base/input/password-input'; diff --git a/openmetadata-ui/src/main/resources/ui/eslint-rules/openmetadata-antd.test.mjs b/openmetadata-ui/src/main/resources/ui/eslint-rules/openmetadata-antd.test.mjs index 90408344dbd7..9475d6506ab7 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-rules/openmetadata-antd.test.mjs +++ b/openmetadata-ui/src/main/resources/ui/eslint-rules/openmetadata-antd.test.mjs @@ -163,7 +163,7 @@ test('the app config never suppresses a migrated component', async () => { // Exact, so re-running --suppress-rule cannot quietly absorb new antd usage. // Lower this when a PR removes antd imports and prunes the baseline. - const EXPECTED_SUPPRESSED_ANTD_SYMBOLS = 497; + const EXPECTED_SUPPRESSED_ANTD_SYMBOLS = 409; const suppressedSymbols = Object.values(suppressions).reduce( (total, rules) => total + (rules['openmetadata-antd/no-antd-import']?.count ?? 0), diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index a4d2fdd6efd6..40e64d7969b0 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -127,11 +127,6 @@ "count": 2 } }, - "playwright/e2e/Features/DataQuality/DataQuality.spec.ts": { - "om-playwright/no-positional-locator": { - "count": 1 - } - }, "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts": { "om-playwright/no-positional-locator": { "count": 1 @@ -142,11 +137,6 @@ "count": 1 } }, - "playwright/e2e/Features/DataQuality/Profiler.spec.ts": { - "om-playwright/no-positional-locator": { - "count": 2 - } - }, "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts": { "om-playwright/no-positional-locator": { "count": 2 @@ -547,11 +537,6 @@ "count": 10 } }, - "playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts": { - "om-playwright/no-positional-locator": { - "count": 2 - } - }, "playwright/e2e/Pages/Roles.spec.ts": { "om-playwright/no-positional-locator": { "count": 2 @@ -833,56 +818,6 @@ "count": 3 } }, - "src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/components/Alerts/AlertDetails/AlertDiagnosticInfo/AlertDiagnosticInfoTab.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.test.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/Alerts/FQNListSelect/FQNListSelect.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.test.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, - "src/components/Alerts/ObservabilityFormTriggerItem/ObservabilityFormTriggerItem.test.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/components/Alerts/ObservabilityFormTriggerItem/ObservabilityFormTriggerItem.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, "src/components/AppContainer/AppContainer.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -928,56 +863,6 @@ "count": 1 } }, - "src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.test.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/DataQuality/AddDataQualityTest/components/ParameterForm.test.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/DataQuality/AddDataQualityTest/components/ParameterForm.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 10 - } - }, - "src/components/DataQuality/CustomMetricForm/CustomMetricForm.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, - "src/components/DataQuality/CustomMetricForm/CustomMetricForm.interface.ts": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/DataQuality/CustomMetricForm/CustomMetricForm.test.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/DataQuality/IncidentManager/SqlQueryTab/AddSqlQueryFormModal/AddSqlQueryFormModal.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, - "src/components/DataQuality/TestCases/TestCases.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/components/DataQuality/TestSuite/TestSuiteList/TestSuites.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/components/Database/ColumnDetailPanel/ColumnDetailPanel.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -988,31 +873,6 @@ "count": 3 } }, - "src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/components/Database/Profiler/ProfilerSettings/ProfilerObjectFieldTemplate.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 9 - } - }, - "src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, "src/components/Database/SampleDataTable/SampleDataTable.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1483,11 +1343,6 @@ "count": 2 } }, - "src/components/common/DatePickerMenu/DatePickerMenu.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, "src/components/common/DraggableTabs/DraggableTabs.tsx": { "openmetadata-antd/no-antd-import": { "count": 4 @@ -1533,26 +1388,6 @@ "count": 1 } }, - "src/components/common/Form/JSONSchema/JsonSchemaWidgets/PasswordWidget.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/common/Form/JSONSchema/JsonSchemaWidgets/SelectWidget.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/components/common/FormBuilder/FormBuilder.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/common/InheritedRolesCard/InheritedRolesCard.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1588,11 +1423,6 @@ "count": 2 } }, - "src/components/common/SliderWithInput/SliderWithInput.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/components/common/SummaryCard/SummaryCard.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1638,46 +1468,16 @@ "count": 3 } }, - "src/pages/AddCustomMetricPage/AddCustomMetricPage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/pages/AddNotificationPage/AddNotificationPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 3 } }, - "src/pages/AddObservabilityPage/components/ObservabilityAlertForm.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/pages/AddObservabilityPage/components/ObservabilityAlertFormFields.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/pages/AddObservabilityPage/hooks/useObservabilityAlertForm.ts": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.ts": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/pages/AddQueryPage/AddQueryPage.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 3 } }, - "src/pages/AlertDetailsPage/components/AlertDetailsContent.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/pages/AppearanceConfigSettingsPage/AppearanceConfigSettingsPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 3 @@ -1808,11 +1608,6 @@ "count": 6 } }, - "src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 6 - } - }, "src/pages/RolesPage/AddAttributeModal/AddAttributeModal.tsx": { "openmetadata-antd/no-antd-import": { "count": 3 @@ -1873,11 +1668,6 @@ "count": 5 } }, - "src/utils/Alerts/AlertsUtil.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 6 - } - }, "src/utils/Alerts/AlertsUtilPure.ts": { "openmetadata-antd/no-antd-import": { "count": 1 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 5be1d01a4b9a..231aaf6505dd 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 @@ -10,7 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import test from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DataType } from '../../../src/generated/entity/data/table'; import { TableClass } from '../../support/entity/TableClass'; import { getApiContext, redirectToHomePage, uuid } from '../../utils/common'; import { @@ -18,10 +19,16 @@ import { deleteCustomMetric, } from '../../utils/customMetric'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; +import { disableAiAppMode, stubUserPreferencesAppMode } from '../Utils/appMode'; // use the admin user to login test.use({ storageState: 'playwright/.auth/admin.json' }); +test.beforeEach(async ({ page }) => { + await disableAiAppMode(page); + await stubUserPreferencesAppMode(page, 'classic'); +}); + test('Table custom metric', async ({ page }) => { const table = new TableClass(); await redirectToHomePage(page); @@ -103,3 +110,91 @@ test('Column custom metric', async ({ page }) => { await table.delete(apiContext); await afterAction(); }); + +test('Large column Select preserves keyboard navigation and fully reveals the focused option', async ({ + page, +}) => { + const table = new TableClass(); + table.entity.columns = Array.from({ length: 250 }, (_, index) => ({ + name: `col_${String(index).padStart(3, '0')}`, + dataType: DataType.Varchar, + dataLength: 50, + })); + await redirectToHomePage(page); + const { apiContext, afterAction } = await getApiContext(page); + + try { + await table.create(apiContext); + await page.goto( + `/add-custom-metric/column/${table.entityResponseData.fullyQualifiedName}` + ); + const trigger = page + .getByTestId('custom-metric-column') + .getByRole('button'); + + await test.step('Window and reveal keyboard destinations', async () => { + await trigger.click(); + await expect(page.getByRole('listbox')).toBeVisible(); + expect(await page.getByRole('option').count()).toBeLessThan(250); + + await page.keyboard.press('ArrowDown'); + await expect( + page.getByRole('option', { name: 'col_000', exact: true }) + ).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect( + page.getByRole('option', { name: 'col_001', exact: true }) + ).toBeFocused(); + await page.keyboard.press('ArrowUp'); + await expect( + page.getByRole('option', { name: 'col_000', exact: true }) + ).toBeFocused(); + await page.keyboard.press('End'); + const lastOption = page.getByRole('option', { + name: 'col_249', + exact: true, + }); + await expect(lastOption).toBeFocused(); + await expect(lastOption).toBeInViewport({ ratio: 1 }); + + await page.keyboard.press('Home'); + await expect( + page.getByRole('option', { name: 'col_000', exact: true }) + ).toBeFocused(); + await page.keyboard.press('PageDown'); + await page.keyboard.press('Enter'); + await expect(page.getByRole('listbox')).toHaveCount(0); + await expect(trigger).not.toContainText('col_000'); + }); + + await test.step('Commit typeahead and cancel a subsequent selection', async () => { + await trigger.click(); + await expect(page.getByRole('listbox')).toBeVisible(); + await page.keyboard.press('Home'); + await page.keyboard.type('col_177'); + const matchingOption = page.getByRole('option', { + name: 'col_177', + exact: true, + }); + await expect(matchingOption).toBeFocused(); + await expect(matchingOption).toBeInViewport({ ratio: 1 }); + await page.keyboard.press('Enter'); + await expect(trigger).toContainText('col_177'); + await expect(page.getByRole('listbox')).toHaveCount(0); + + await trigger.click(); + await expect(page.getByRole('listbox')).toBeVisible(); + await page.keyboard.press('End'); + await expect( + page.getByRole('option', { name: 'col_249', exact: true }) + ).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('listbox')).toHaveCount(0); + await expect(trigger).toContainText('col_177'); + await expect(trigger).toBeFocused(); + }); + } finally { + await table.delete(apiContext); + await afterAction(); + } +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts index 13222cf633ce..3227b6b84955 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts @@ -1173,29 +1173,30 @@ test.describe( const serviceResponse = page.waitForResponse( '/api/v1/search/query?q=*index=databaseService*' ); - await page.fill('#serviceName', filterTable1.service.name); + await page + .getByTestId('service-select-filter') + .getByRole('combobox') + .fill(filterTable1.service.name); await serviceResponse; const testCaseByServiceName = page.waitForResponse( `/api/v1/dataQuality/testCases/search/list?*serviceName=${filterTable1.service.name}*` ); await page - .locator('.ant-select-dropdown') - .filter({ - hasNot: page.locator('.ant-select-dropdown-hidden'), - has: page.locator(`[data-testid="${filterTable1.service.name}"]`), - }) + .getByRole('listbox') + .getByRole('option', { name: filterTable1.service.name, exact: true }) .click(); await testCaseByServiceName; await verifyFilterTestCase(page); await verifyFilter2TestCase(page); // remove service filter - await page.click('[data-testid="advanced-filter"]'); const getTestCase = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.getByTestId('advanced-filter-option-serviceName').click(); + await page + .getByRole('button', { name: 'Clear Service', exact: true }) + .click(); await getTestCase; // Test case filter by Tags @@ -1204,69 +1205,80 @@ test.describe( ); await page .getByTestId('tags-select-filter') - .locator('div') - .filter({ hasText: 'Tags' }) - .click(); - await page.fill('#tags', 'PII.None'); + .getByRole('combobox') + .fill('PII.None'); await tagResponse; const getTestCaseByTag = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*tags=PII.None*' ); await page - .locator('.ant-select-dropdown') - .filter({ - hasNot: page.locator('.ant-select-dropdown-hidden'), - has: page.locator(`[data-testid="PII.None"]`), - }) + .getByRole('listbox') + .getByRole('option', { name: 'PII.None', exact: true }) .click(); await getTestCaseByTag; + await page.keyboard.press('Escape'); await verifyFilterTestCase(page); await verifyFilter2TestCase(page, true); // remove tags filter - await page.click('[data-testid="advanced-filter"]'); const getTestCaseWithoutTag = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.getByTestId('advanced-filter-option-tags').click(); + await page + .getByRole('button', { name: 'Clear Tags', exact: true }) + .click(); await getTestCaseWithoutTag; // Test case filter by Tier - await page.click('#tier'); - await page.fill('#tier', 'Tier2'); - await page.waitForLoadState('domcontentloaded'); + await page + .getByTestId('tier-select-filter') + .getByRole('combobox') + .fill('Tier2'); const getTestCaseByTier = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*tier=Tier.Tier2*' ); - await page.getByTestId('Tier.Tier2').getByText('Tier.Tier2').click(); + await page + .getByRole('listbox') + .getByRole('option') + .filter({ has: page.getByText('Tier.Tier2', { exact: true }) }) + .click(); await getTestCaseByTier; await verifyFilterTestCase(page); await verifyFilter2TestCase(page, true); // remove tier filter - await page.click('[data-testid="advanced-filter"]'); const getTestCaseWithoutTier = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.getByTestId('advanced-filter-option-tier').click(); + await page + .getByRole('button', { name: 'Clear Tier', exact: true }) + .click(); await getTestCaseWithoutTier; // Test case filter by table name const tableSearchResponse = page.waitForResponse( `/api/v1/search/query?q=*index=table*` ); - await page.fill('#tableFqn', filterTable1.entity.name); + await page + .getByTestId('table-select-filter') + .getByRole('combobox') + .fill(filterTable1.entity.name); await tableSearchResponse; const getTestCaseByTable = page.waitForResponse( `/api/v1/dataQuality/testCases/search/list?*entityLink=*${filterTable1.entity.name}*` ); await page - .getByTestId( - filterTable1.entityResponseData?.['fullyQualifiedName'] || '' - ) + .getByRole('listbox') + .getByRole('option') + .filter({ + has: page.getByText( + filterTable1.entityResponseData.fullyQualifiedName!, + { exact: true } + ), + }) .click(); await getTestCaseByTable; await verifyFilterTestCase(page); @@ -1277,7 +1289,10 @@ test.describe( `/api/v1/dataQuality/testCases/search/list?*testCaseType=column*` ); await page.getByTestId('test-case-type-select-filter').click(); - await page.getByTitle('Column', { exact: true }).click(); + await page + .getByRole('listbox') + .getByRole('option', { name: 'Column', exact: true }) + .click(); await testCaseTypeByColumn; await expect( @@ -1290,12 +1305,8 @@ test.describe( ); await page.getByTestId('test-case-type-select-filter').click(); await page - .locator('.ant-select-dropdown') - .filter({ - hasNot: page.locator('.ant-select-dropdown-hidden'), - has: page.locator(`[title="Table"]`), - hasText: 'Table', - }) + .getByRole('listbox') + .getByRole('option', { name: 'Table', exact: true }) .click(); await testCaseTypeByTable; await verifyFilterTestCase(page); @@ -1305,7 +1316,10 @@ test.describe( `/api/v1/dataQuality/testCases/search/list?*testCaseType=all*` ); await page.getByTestId('test-case-type-select-filter').click(); - await page.getByTitle('All').nth(1).click(); + await page + .getByRole('listbox') + .getByRole('option', { name: 'All', exact: true }) + .click(); await testCaseTypeByAll; // Test case filter by status @@ -1320,8 +1334,8 @@ test.describe( const statusFilter = page.getByTestId('status-select-filter'); await statusFilter.getByRole('combobox').click(); await page - .locator('.ant-select-dropdown:visible') - .getByTitle('Success', { exact: true }) + .getByRole('listbox') + .getByRole('option', { name: 'Success', exact: true }) .click(); await testCaseStatusBySuccess; @@ -1342,10 +1356,11 @@ test.describe( ); await statusFilter.getByRole('combobox').click(); await page - .locator('.ant-select-dropdown:visible') - .getByTitle('Failed', { exact: true }) + .getByRole('listbox') + .getByRole('option', { name: 'Failed', exact: true }) .click(); await testCaseStatusesBySuccessAndFailed; + await page.keyboard.press('Escape'); await verifyFilterTestCase(page); await verifyFilter2TestCase(page, true); @@ -1354,8 +1369,12 @@ test.describe( `/api/v1/dataQuality/testCases/search/list?*testPlatforms=dbt*` ); await page.getByTestId('platform-select-filter').click(); - await page.getByTitle('DBT').click(); + await page + .getByRole('listbox') + .getByRole('option', { name: 'dbt', exact: true }) + .click(); await testCasePlatformByDBT; + await page.keyboard.press('Escape'); await expect( page.locator('[data-testid="empty-placeholder"]') @@ -1365,15 +1384,17 @@ test.describe( '/api/v1/dataQuality/testCases/search/list?*' ); await page - .getByTestId('platform-select-filter') - .locator('.ant-select-clear') + .getByRole('button', { name: 'Clear Platform', exact: true }) .click(); await getTestCaseWithoutPlatform; const testCasePlatformByOpenMetadata = page.waitForResponse( `/api/v1/dataQuality/testCases/search/list?*testPlatforms=OpenMetadata*` ); await page.getByTestId('platform-select-filter').click(); - await page.getByTitle('OpenMetadata').click(); + await page + .getByRole('listbox') + .getByRole('option', { name: 'OpenMetadata', exact: true }) + .click(); await testCasePlatformByOpenMetadata; await clickOutside(page); await verifyFilterTestCase(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts index 45db3d001187..684301d54c53 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts @@ -1156,7 +1156,9 @@ test.describe( }); await test.step('Select data product and verify API carries dataProductFqn', async () => { - const dataProductInput = page.locator('#dataProductFqn'); + const dataProductInput = page + .getByTestId('data-product-select-filter') + .getByRole('combobox'); const dataProductOptionsRes = page.waitForResponse( (r) => r.url().includes('/api/v1/search/query') && @@ -1173,8 +1175,8 @@ test.describe( r.url().includes('dataProductFqn') ); await page - .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)') - .getByText(dataProductDisplayName) + .getByRole('listbox') + .getByRole('option', { name: dataProductDisplayName, exact: true }) .click(); await filterApiRes; }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts new file mode 100644 index 000000000000..0e91a3b2581f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts @@ -0,0 +1,170 @@ +/* + * 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 { expect } from '@playwright/test'; +import { DOMAIN_TAGS } from '../../../constant/config'; +import { TableClass } from '../../../support/entity/TableClass'; +import { performAdminLogin } from '../../../utils/admin'; +import { deleteFixtureEntity, okJson } from '../../../utils/apiResponse'; +import { + clickIgnoringToasts, + fillDescriptionBox, + uuid, +} from '../../../utils/common'; +import { waitForResponseWithStatus } from '../../../utils/waitHelpers'; +import { test } from '../../fixtures/pages'; +import { + disableAiAppMode, + stubUserPreferencesAppMode, +} from '../../Utils/appMode'; + +interface SavedQuery { + id: string; + query: string; + description?: string; + queryUsedIn: Array<{ id: string }>; + owners: Array<{ id: string; name: string }>; +} + +test.describe( + 'Classic Incident Manager SQL query', + { tag: `${DOMAIN_TAGS.OBSERVABILITY}:Incident_Manager` }, + () => { + let table: TableClass; + let testCaseFqn: string; + let tableFqn: string; + let initialQuery: string; + let savedQueryId: string | undefined; + + test.beforeAll(async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + table = new TableClass(); + savedQueryId = undefined; + try { + await table.create(apiContext); + const fqn = table.entityResponseData.fullyQualifiedName; + if (!fqn) { + throw new Error('Created table must have a fully qualified name'); + } + tableFqn = fqn; + const testCase = await table.createTestCase(apiContext); + testCaseFqn = testCase.fullyQualifiedName; + initialQuery = `SELECT * FROM test_table /* ${uuid()} */`; + const response = await apiContext.put( + `/api/v1/dataQuality/testCases/${testCase.id}/inspectionQuery`, + { + data: JSON.stringify(initialQuery), + headers: { 'Content-Type': 'application/json' }, + } + ); + expect(response.status()).toBe(200); + } finally { + await afterAction(); + } + }); + + test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + if (savedQueryId) { + await deleteFixtureEntity( + apiContext, + `/api/v1/queries/${savedQueryId}?hardDelete=true` + ); + } + await table.delete(apiContext); + } finally { + await afterAction(); + } + }); + + test.beforeEach(async ({ page }) => { + await disableAiAppMode(page); + await stubUserPreferencesAppMode(page, 'classic'); + await page.goto( + `/test-case/${encodeURIComponent(testCaseFqn)}/sql-query`, + { + waitUntil: 'domcontentloaded', + } + ); + await expect(page.getByTestId('add-to-table-button')).toBeVisible(); + }); + + test('validates a required query and saves the edited SQL for its table', async ({ + page, + browser, + }) => { + const dialog = page.getByRole('dialog', { name: 'Add New Query' }); + const editor = dialog + .getByTestId('sql-editor-container') + .getByRole('textbox'); + const editedQuery = `${initialQuery}\nWHERE id > 0`; + const description = 'Inspection query saved from the incident'; + + await test.step('Show the inspection query and reject an empty query', async () => { + await page.getByTestId('add-to-table-button').click(); + await expect(dialog).toBeVisible(); + await expect(editor).toContainText(initialQuery); + await expect(dialog.getByTestId('table')).toBeDisabled(); + await expect(dialog.getByTestId('table')).toHaveValue(tableFqn); + await editor.fill(''); + await clickIgnoringToasts( + dialog.getByRole('button', { name: 'Save', exact: true }) + ); + await expect(dialog.getByText(/SQL Query.*required/i)).toBeVisible(); + }); + + await test.step('Save and verify the persisted query, owner and table', async () => { + await editor.fill(editedQuery); + await fillDescriptionBox(dialog, description); + const responsePromise = waitForResponseWithStatus( + page, + (response) => + new URL(response.url()).pathname === '/api/v1/queries' && + response.request().method() === 'POST', + 201 + ); + await clickIgnoringToasts( + dialog.getByRole('button', { name: 'Save', exact: true }) + ); + const response = await responsePromise; + const savedQuery: SavedQuery = await response.json(); + savedQueryId = savedQuery.id; + expect(savedQuery.query).toBe(editedQuery); + expect(savedQuery.description).toContain(description); + expect(savedQuery.queryUsedIn).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: table.entityResponseData.id }), + ]) + ); + expect(savedQuery.owners).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'admin' })]) + ); + await expect(dialog).not.toBeVisible(); + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + const persisted = await okJson( + await apiContext.get( + `/api/v1/queries/${savedQueryId}?fields=queryUsedIn,owners` + ), + 'Read saved inspection query' + ); + expect(persisted.query).toBe(editedQuery); + expect(persisted.description).toContain(description); + } finally { + await afterAction(); + } + }); + }); + } +); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/Profiler.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/Profiler.spec.ts index 998034148bbd..9bf6bf4061bd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/Profiler.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/Profiler.spec.ts @@ -288,38 +288,52 @@ test.describe( await page .locator('[data-testid="sample-data-count-input"]') .fill(profilerSetting.sampleDataCount); - await page.locator('[data-testid="exclude-column-select"]').click(); - await page.keyboard.type(`${profilerSetting.excludeColumns}`); - await page.keyboard.press('Enter'); + await page + .getByTestId('exclude-column-select') + .getByRole('combobox') + .fill(profilerSetting.excludeColumns); + await page + .getByRole('listbox') + .getByRole('option', { + name: profilerSetting.excludeColumns, + exact: true, + }) + .click(); + await page.keyboard.press('Escape'); await clickCodeEditor(page); await page.keyboard.type(profilerSetting.profileQuery); - await page.locator('[data-testid="include-column-select"]').click(); await page - .locator('.ant-select-dropdown') - .locator( - `[title="${profilerSetting.includeColumns}"]:not(.ant-select-dropdown-hidden)` - ) - .last() + .getByTestId('include-column-select') + .getByRole('combobox') + .fill(profilerSetting.includeColumns); + await page + .getByRole('listbox') + .getByRole('option', { + name: profilerSetting.includeColumns, + exact: true, + }) .click(); await page.locator('[data-testid="enable-partition-switch"]').click(); await page.locator('[data-testid="interval-type"]').click(); await page - .locator('.ant-select-dropdown') - .locator( - `[title="${profilerSetting.partitionIntervalType}"]:not(.ant-select-dropdown-hidden)` - ) + .getByRole('listbox') + .getByRole('option', { + name: profilerSetting.partitionIntervalType, + exact: true, + }) .click(); await page - .locator('#includeColumnsProfiler_partitionColumnName') - .click(); + .getByTestId('column-name') + .getByRole('combobox') + .fill(profilerSetting.partitionColumnName); await page - .locator('.ant-select-dropdown') - .locator( - `[title="${profilerSetting.partitionColumnName}"]:not(.ant-select-dropdown-hidden)` - ) - .last() + .getByRole('listbox') + .getByRole('option', { + name: profilerSetting.partitionColumnName, + exact: true, + }) .click(); await page .locator('[data-testid="partition-value"]') @@ -333,29 +347,27 @@ test.describe( ); await page.getByRole('button', { name: 'Save' }).click(); const updateResponse = await updateTableProfilerConfigResponse; - const requestBody = await updateResponse.request().postData(); - - expect(requestBody).toEqual( - JSON.stringify({ - excludeColumns: [table.entity?.columns[0].name], - profileQuery: 'select * from table', - profileSampleConfig: { - sampleConfigType: 'STATIC', - config: { - profileSample: 60, - profileSampleType: 'PERCENTAGE', - }, - }, - includeColumns: [{ columnName: table.entity?.columns[1].name }], - partitioning: { - partitionColumnName: table.entity?.columns[2].name, - partitionIntervalType: 'COLUMN-VALUE', - partitionValues: ['test'], - enablePartitioning: true, + const requestBody = updateResponse.request().postDataJSON(); + + expect(requestBody).toEqual({ + excludeColumns: [table.entity?.columns[0].name], + profileQuery: 'select * from table', + profileSampleConfig: { + sampleConfigType: 'STATIC', + config: { + profileSample: 60, + profileSampleType: 'PERCENTAGE', }, - sampleDataCount: 100, - }) - ); + }, + includeColumns: [{ columnName: table.entity?.columns[1].name }], + partitioning: { + partitionColumnName: table.entity?.columns[2].name, + partitionIntervalType: 'COLUMN-VALUE', + partitionValues: ['test'], + enablePartitioning: true, + }, + sampleDataCount: 100, + }); }); await test.step('Reset profile sample type', async () => { @@ -378,22 +390,20 @@ test.describe( ); await page.getByRole('button', { name: 'Save' }).click(); const updateResponse = await updateTableProfilerConfigResponse; - const requestBody = await updateResponse.request().postData(); - - expect(requestBody).toEqual( - JSON.stringify({ - excludeColumns: [table.entity?.columns[0].name], - profileQuery: 'select * from table', - includeColumns: [{ columnName: table.entity?.columns[1].name }], - partitioning: { - partitionColumnName: table.entity?.columns[2].name, - partitionIntervalType: 'COLUMN-VALUE', - partitionValues: ['test'], - enablePartitioning: true, - }, - sampleDataCount: 100, - }) - ); + const requestBody = updateResponse.request().postDataJSON(); + + expect(requestBody).toEqual({ + excludeColumns: [table.entity?.columns[0].name], + profileQuery: 'select * from table', + includeColumns: [{ columnName: table.entity?.columns[1].name }], + partitioning: { + partitionColumnName: table.entity?.columns[2].name, + partitionIntervalType: 'COLUMN-VALUE', + partitionValues: ['test'], + enablePartitioning: true, + }, + sampleDataCount: 100, + }); await page.getByTestId('profiler-settings-modal').waitFor({ state: 'detached', @@ -410,7 +420,7 @@ test.describe( page.locator('[data-testid="slider-input"]') ).not.toBeVisible(); await expect( - page.getByTestId('profile-sample').locator('div') + page.getByTestId('profile-sample').getByRole('button') ).toBeVisible(); }); @@ -433,25 +443,23 @@ test.describe( ); await page.getByRole('button', { name: 'Save' }).click(); const updateResponse = await updateTableProfilerConfigResponse; - const requestBody = await updateResponse.request().postData(); + const requestBody = updateResponse.request().postDataJSON(); // Saving without touching anything must round-trip the stored // partitioning block. The backend replaces this extension wholesale, // so any field missing from the payload is erased on the server. - expect(requestBody).toEqual( - JSON.stringify({ - excludeColumns: [table.entity?.columns[0].name], - profileQuery: 'select * from table', - includeColumns: [{ columnName: table.entity?.columns[1].name }], - partitioning: { - partitionColumnName: table.entity?.columns[2].name, - partitionIntervalType: 'COLUMN-VALUE', - partitionValues: ['test'], - enablePartitioning: true, - }, - sampleDataCount: 100, - }) - ); + expect(requestBody).toEqual({ + excludeColumns: [table.entity?.columns[0].name], + profileQuery: 'select * from table', + includeColumns: [{ columnName: table.entity?.columns[1].name }], + partitioning: { + partitionColumnName: table.entity?.columns[2].name, + partitionIntervalType: 'COLUMN-VALUE', + partitionValues: ['test'], + enablePartitioning: true, + }, + sampleDataCount: 100, + }); }); }); } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts index 4fa94050ca42..19a6ddaf3174 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts @@ -48,22 +48,21 @@ test('Profiler settings survive a table reindex', async ({ browser }) => { await page.getByTestId('profiler-setting-btn').click(); await page.getByTestId('profiler-settings-modal').waitFor(); - // Body-portaled options are hidden by the modal's accessibility boundary. - // Check accessible options and keyboard selection inside the drawer. const settings = page.getByRole('dialog', { name: 'Settings' }); - const sampleType = settings.getByRole('combobox', { - name: 'Profile Sample type', - }); - await settings.getByTestId('profile-sample').click(); - await expect( - settings.getByRole('option', { name: 'Row Count', exact: true }) - ).toBeAttached(); - await sampleType.press('ArrowDown'); - await sampleType.press('Enter'); + const sampleType = settings + .getByTestId('profile-sample') + .getByRole('button'); + await sampleType.click(); + await page + .getByRole('listbox') + .getByRole('option', { name: 'Row Count', exact: true }) + .click(); await expect(settings.getByTestId('metric-number-input')).toBeVisible(); - await settings.getByTestId('profile-sample').click(); - await sampleType.press('ArrowUp'); - await sampleType.press('Enter'); + await sampleType.click(); + await page + .getByRole('listbox') + .getByRole('option', { name: 'Percentage', exact: true }) + .click(); await page.getByTestId('slider-input').clear(); await page.getByTestId('slider-input').fill(PROFILE_SAMPLE); @@ -91,13 +90,10 @@ test('Profiler settings survive a table reindex', async ({ browser }) => { await page.getByTestId('profiler-setting-btn').click(); await page.getByTestId('profiler-settings-modal').waitFor(); - // aria-valuenow, not the input's value: the slider renders its display value with a percent - // suffix ("60%") once it loses focus, so asserting on value compares against formatting - // rather than against the setting that had to survive the rebuild. await expect( page.getByTestId('slider-input'), 'profile sample must survive the rebuild' - ).toHaveAttribute('aria-valuenow', PROFILE_SAMPLE); + ).toHaveValue(`${PROFILE_SAMPLE}%`); await expect( page.getByTestId('sample-data-count-input'), 'sampleDataCount must survive the rebuild' diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Table.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Table.spec.ts index 71d13fc97e52..21b576d66756 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Table.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Table.spec.ts @@ -155,7 +155,10 @@ test.describe('Table pagination sorting search scenarios ', () => { '/api/v1/dataQuality/testCases/search/list?*testCaseStatus=Queued*' ); - await page.getByTitle('Queued').locator('div').click(); + await page + .getByRole('listbox') + .getByRole('option', { name: 'Queued', exact: true }) + .click(); await filteredResults; await waitForAllLoadersToDisappear(page.getByTestId('test-case-container')); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AlertSeveralSources.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AlertSeveralSources.spec.ts index 49344bf2b92a..ac7cfe3b54a6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AlertSeveralSources.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AlertSeveralSources.spec.ts @@ -126,7 +126,7 @@ test.describe('Alerts with several sources', () => { ); await page.click('[data-testid="trigger-select-0"]'); await page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId('Get Schema Changes (Table)-filter-option') .click(); await capabilities; @@ -152,6 +152,7 @@ test.describe('Alerts with several sources', () => { }); expect(created.ok()).toBeTruthy(); const contract: DataContract = await created.json(); + let alertId: string | undefined; // Contract names are read from the database; table names from the search index. await waitForSearchIndexed( apiContext, @@ -173,24 +174,92 @@ test.describe('Alerts with several sources', () => { await page.getByTestId('add-filters').click(); await page.getByTestId('filter-select-0').click(); await page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId('Table Name-filter-option') .click(); const names = page.getByTestId('fqn-list-select').getByRole('combobox'); - const found = page.locator('.ant-select-dropdown:visible'); + const found = page.getByRole('listbox'); await names.click(); await names.fill(tableName); await expect( - found.getByTitle(table.entityResponseData.fullyQualifiedName ?? '', { + found.getByRole('option', { + name: table.entityResponseData.fullyQualifiedName, + exact: true, + }) + ).toBeVisible(); + await expect( + found.getByRole('option', { + name: contract.fullyQualifiedName, exact: true, }) ).toBeVisible(); + + await found + .getByRole('option', { + name: table.entityResponseData.fullyQualifiedName, + exact: true, + }) + .click(); + const field = page.getByTestId('fqn-list-select'); + await names.click(); + await expect(found).toBeVisible(); + + await expect + .poll(async () => { + const fieldBounds = await field.boundingBox(); + const rowBounds = await page.getByTestId('filter-0').boundingBox(); + const popupBounds = await found.boundingBox(); + + return Boolean( + fieldBounds && + rowBounds && + popupBounds && + fieldBounds.x + fieldBounds.width <= + rowBounds.x + rowBounds.width && + Math.abs(popupBounds.width - fieldBounds.width) < 1 + ); + }) + .toBe(true); await expect( - found.getByTitle(contract.fullyQualifiedName ?? '', { exact: true }) + field.getByTitle(table.entityResponseData.fullyQualifiedName) ).toBeVisible(); + await page.setViewportSize({ width: 1024, height: 768 }); + await expect + .poll(async () => { + const fieldBounds = await field.boundingBox(); + const chipBounds = await field + .getByTitle(table.entityResponseData.fullyQualifiedName) + .boundingBox(); + + return Boolean( + fieldBounds && + chipBounds && + chipBounds.x + chipBounds.width <= + fieldBounds.x + fieldBounds.width + ); + }) + .toBe(true); + await page.keyboard.press('Escape'); + await page.getByTestId('add-destination-button').click(); + await addInternalDestination({ + page, + destinationNumber: 0, + category: 'Admins', + type: 'Email', + }); + const alertDetails = await saveAlertAndVerifyResponse(page); + alertId = alertDetails.id; + expect(alertDetails.input.filters).toMatchObject([ + { + arguments: [{ input: [table.entityResponseData.fullyQualifiedName] }], + }, + ]); } finally { + if (alertId) { + await apiContext.delete(`/api/v1/events/subscriptions/${alertId}`); + } await apiContext.delete( `/api/v1/dataContracts/${contract.id}?hardDelete=true&recursive=true` ); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ObservabilityAlerts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ObservabilityAlerts.spec.ts index bb6f8f2e3733..506ceca5814a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ObservabilityAlerts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ObservabilityAlerts.spec.ts @@ -263,6 +263,29 @@ test('Pipeline Alert', async ({ page }) => { await diagnosticTab.click(); await diagnosticInfoResponse; await waitForAllLoadersToDisappear(page); + + const diagnosticHelp = page.getByRole('button', { name: /latest offset/i }); + await diagnosticTab.focus(); + await page.keyboard.press('Tab'); + await expect(diagnosticHelp).toBeFocused(); + await expect + .poll(() => + diagnosticHelp.evaluate((element) => { + const style = getComputedStyle(element); + + return ( + element.matches(':focus-visible') && + Number.parseFloat(style.outlineWidth) >= 2 && + style.outlineStyle !== 'none' && + !/rgba\([^)]*,\s*0\)|transparent/.test(style.outlineColor) + ); + }) + ) + .toBe(true); + await expect( + page.getByRole('tooltip', { name: /latest offset/i }) + ).toBeVisible(); + await page.keyboard.press('Escape'); }); await test.step('Check created alert details', async () => { @@ -519,7 +542,7 @@ test('Data Contract Name filter lists matching data contracts', async ({ await page.getByTestId('add-filters').click(); await page.getByTestId('filter-select-0').click(); await page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId('Data Contract Name-filter-option') .click(); @@ -528,8 +551,8 @@ test('Data Contract Name filter lists matching data contracts', async ({ await fqnInput.fill(dataContractName); const dataContractOption = page - .locator('.ant-select-dropdown:visible') - .getByTitle(dataContractFqn); + .getByRole('listbox') + .getByRole('option', { name: dataContractFqn, exact: true }); const searchFailureAlert = page .getByTestId('alert-bar') .filter({ hasText: 'Search failed' }) @@ -587,19 +610,15 @@ test('Alert operations for a user with and without permissions', async ({ // Select filter await userWithPermissionsPage.click('[data-testid="filter-select-0"]'); - await userWithPermissionsPage.click( - '.ant-select-dropdown:visible [data-testid="Table Name-filter-option"]' - ); await userWithPermissionsPage - .locator('.ant-select-dropdown:visible') + .getByRole('option', { name: 'Table Name', exact: true }) + .click(); + await userWithPermissionsPage + .getByRole('listbox') .waitFor({ state: 'hidden' }); - // The mode="multiple" AsyncSelect renders dropdown options whose `title` - // equals the entity FQN, not the bare name. Search and pick by FQN. const table1Fqn = table1.entityResponseData.fullyQualifiedName ?? ''; - // Focus the combobox first so the search input becomes editable - // (mode="multiple" keeps the input readonly until focused). await userWithPermissionsPage.click( `[data-testid="fqn-list-select"] [role="combobox"]` ); @@ -610,17 +629,14 @@ test('Alert operations for a user with and without permissions', async ({ ); await userWithPermissionsPage.fill( `[data-testid="fqn-list-select"] [role="combobox"]`, - table1Fqn, - { - force: true, // eslint-disable-line playwright/no-force-option -- Ant Select overlay covers combobox input - } + table1Fqn ); await searchOptions; - await userWithPermissionsPage.click( - `.ant-select-dropdown:visible [title="${table1Fqn}"]` - ); + await userWithPermissionsPage + .getByRole('option', { name: table1Fqn, exact: true }) + .click(); // Check if option is selected await test @@ -636,26 +652,22 @@ test('Alert operations for a user with and without permissions', async ({ // Wait for the fqn-list-select dropdown to fully close before opening the trigger dropdown await userWithPermissionsPage - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .waitFor({ state: 'hidden' }); // Select action await userWithPermissionsPage.click('[data-testid="trigger-select-0"]'); // Adding the dropdown visibility check to avoid flakiness here + await userWithPermissionsPage.getByRole('listbox').waitFor({ + state: 'visible', + }); await userWithPermissionsPage - .locator(`.ant-select-dropdown:visible`) - .waitFor({ - state: 'visible', - }); - await userWithPermissionsPage.click( - '.ant-select-dropdown:visible [data-testid="Get Schema Changes-filter-option"]:visible' - ); - await userWithPermissionsPage - .locator(`.ant-select-dropdown:visible`) - .waitFor({ - state: 'hidden', - }); + .getByRole('option', { name: 'Get Schema Changes', exact: true }) + .click(); + await userWithPermissionsPage.getByRole('listbox').waitFor({ + state: 'hidden', + }); await userWithPermissionsPage.click( '[data-testid="add-destination-button"]' diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts index 1c55339bac20..3c89c747fcf2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts @@ -87,6 +87,7 @@ import { getCustomPropertyEditButton, openCustomPropertiesTab, openCustomPropertyEditModal, + removeCustomPropertyViaApi, setValueForProperty, updateCustomPropertyInRightPanel, validateValueForProperty, @@ -119,9 +120,84 @@ import { } from '../../utils/sidebar'; import { CustomPropertiesPageObject } from '../PageObject/Explore/CustomPropertiesPageObject'; import { RightPanelPageObject } from '../PageObject/Explore/RightPanelPageObject'; +import { disableAiAppMode, stubUserPreferencesAppMode } from '../Utils/appMode'; test.use({ storageState: 'playwright/.auth/admin.json' }); +for (const { type, label, suffixes } of [ + { + type: CustomPropertyTypeByName.TIME_INTERVAL, + label: 'Time Interval', + suffixes: [' (Start)', ' (End)'], + }, + { + type: CustomPropertyTypeByName.HYPERLINK_CP, + label: 'Hyperlink', + suffixes: [' URL', ' Display Text'], + }, + { + type: CustomPropertyTypeByName.TABLE_CP, + label: 'Table', + suffixes: [' - pw-column1', ' - pw-column2'], + }, +]) { + test(`Advanced search selects successive long ${label} custom-property fields`, async ({ + page, + }) => { + await disableAiAppMode(page); + await stubUserPreferencesAppMode(page, 'classic'); + await redirectToHomePage(page); + const { apiContext, afterAction } = await getApiContext(page); + let propertyName: string | undefined; + + try { + const { customProperties } = await createCustomPropertyForEntity( + apiContext, + EntityTypeEndpoint.Table, + [type] + ); + propertyName = customProperties[type].property.name; + await page.goto('/explore'); + await waitForAllLoadersToDisappear(page); + await showAdvancedSearchDialog(page); + + const rule = page.getByTestId('query-builder-rule-0'); + await selectOption( + page, + rule.getByTestId('advanced-search-field-select'), + 'Custom Properties', + true + ); + await selectOption( + page, + rule.getByTestId('advanced-search-field-select-1'), + 'Table', + true + ); + + for (const suffix of suffixes) { + await test.step(`Select ${label}${suffix}`, async () => { + const field = rule.getByTestId('advanced-search-field-select-2'); + const optionLabel = `${propertyName}${suffix}`; + await selectOption(page, field, optionLabel, true); + await expect(field.getByRole('combobox')).toHaveValue(optionLabel); + }); + } + + await page.getByTestId('cancel-btn').click(); + await expect(rule).toBeHidden(); + } finally { + try { + if (propertyName) { + await removeCustomPropertyViaApi(apiContext, 'table', propertyName); + } + } finally { + await afterAction(); + } + } + }); +} + type CustomPropertyEntity = (typeof CUSTOM_PROPERTIES_ENTITIES)[keyof typeof CUSTOM_PROPERTIES_ENTITIES]; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts index 601d18072089..74bd027e3404 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts @@ -56,18 +56,33 @@ base.beforeAll(async ({ browser }) => { const removeAllMetricConfigRows = async (page: Page) => { await page.getByTestId('add-fields').waitFor(); - try { + while (await page.getByTestId('remove-filter-0').isVisible()) { + await page.getByTestId('remove-filter-0').click(); + } +}; + +const selectDataType = async (page: Page, index: number, value: string) => { + const input = page + .getByTestId(`profiler-data-type-${index}`) + .getByRole('combobox'); + await input.fill(value); + await page.getByRole('option', { name: value, exact: true }).click(); +}; +const selectMetrics = async (page: Page, index: number, labels: string[]) => { + await page + .getByTestId(`profiler-metrics-${index}`) + .getByRole('textbox') + .click(); + if (labels.some((label) => label !== 'All')) { await page - .locator('[data-testid^="remove-filter-"]') - .first() - .waitFor({ timeout: 1000 }); - const rows = page.locator('[data-testid^="remove-filter-"]'); - while ((await rows.count()) > 0) { - await rows.first().click(); - } - } catch { - // No existing rows to clean up + .getByRole('treegrid') + .getByRole('button', { name: 'Expand All', exact: true }) + .click(); + } + for (const label of labels) { + await page.getByRole('treegrid').getByText(label, { exact: true }).click(); } + await clickOutside(page); }; test.describe('Profiler Configuration Page', () => { @@ -95,11 +110,11 @@ test.describe('Profiler Configuration Page', () => { await adminPage.click('[data-testid="add-fields"]'); await adminPage.click('[data-testid="save-button"]'); await adminPage - .locator('#metricConfiguration_0_dataType_help') + .getByText(/^Data Type is required\.?$/) .waitFor({ state: 'visible' }); await expect( - adminPage.locator('#metricConfiguration_0_dataType_help') + adminPage.getByText(/^Data Type is required\.?$/) ).toHaveText(/Data Type is required/); await adminPage.click('[data-testid="cancel-button"]'); @@ -122,52 +137,37 @@ test.describe('Profiler Configuration Page', () => { await removeAllMetricConfigRows(adminPage); await adminPage.click('[data-testid="add-fields"]'); - await adminPage.click('#metricConfiguration_0_dataType'); - await adminPage.fill('#metricConfiguration_0_dataType', 'AGG_STATE'); - await adminPage.click(`[title="AGG_STATE"]`); - await adminPage.click('#metricConfiguration_0_metrics'); - await adminPage.fill('#metricConfiguration_0_metrics', 'All'); - await adminPage.getByRole('tree').getByText('All').click(); - await clickOutside(adminPage); - - await adminPage.click('[data-testid="add-fields"]'); - await adminPage.click('#metricConfiguration_1_dataType'); - + await selectDataType(adminPage, 0, 'AGG_STATE'); + await selectMetrics(adminPage, 0, ['All']); + await adminPage.getByTestId('add-fields').click(); + await adminPage + .getByTestId('profiler-data-type-1') + .getByRole('combobox') + .click(); await expect( - adminPage.locator(`[title="AGG_STATE"]:has(:visible)`) - ).toHaveClass(/ant-select-item-option-disabled/); - - await adminPage.fill( - '#metricConfiguration_1_dataType', - 'AGGREGATEFUNCTION' - ); - await adminPage.click(`[title="AGGREGATEFUNCTION"]:has(:visible)`); - - await adminPage.click('#metricConfiguration_1_metrics'); - await adminPage.fill('#metricConfiguration_1_metrics', 'column'); - await adminPage.click(`[title="Column Count"]:has(:visible)`); - await adminPage.click(`[title="Column Names"]:has(:visible)`); - await clickOutside(adminPage); - - await adminPage.click('[data-testid="add-fields"]'); - await adminPage.click('#metricConfiguration_2_dataType'); - + adminPage.getByRole('option', { name: 'AGG_STATE', exact: true }) + ).toHaveAttribute('aria-disabled', 'true'); + await selectDataType(adminPage, 1, 'AGGREGATEFUNCTION'); + await selectMetrics(adminPage, 1, ['Column Count', 'Column Names']); + await adminPage.getByTestId('add-fields').click(); + await adminPage + .getByTestId('profiler-data-type-2') + .getByRole('combobox') + .click(); await expect( - adminPage.locator(`[title="AGG_STATE"]:has(:visible)`) - ).toHaveClass(/ant-select-item-option-disabled/); + adminPage.getByRole('option', { name: 'AGG_STATE', exact: true }) + ).toHaveAttribute('aria-disabled', 'true'); await expect( - adminPage.locator(`[title="AGGREGATEFUNCTION"]:has(:visible)`) - ).toHaveClass(/ant-select-item-option-disabled/); - - await adminPage.click(`[title="ARRAY"]:has(:visible)`); - await adminPage.click('#metricConfiguration_2_metrics'); - await adminPage.fill('#metricConfiguration_2_metrics', 'All'); - await adminPage.getByRole('tree').getByText('All').click(); - await clickOutside(adminPage); - - // The visible track covers the hidden switch input, so click its label. + adminPage.getByRole('option', { + name: 'AGGREGATEFUNCTION', + exact: true, + }) + ).toHaveAttribute('aria-disabled', 'true'); + await selectDataType(adminPage, 2, 'ARRAY'); + await selectMetrics(adminPage, 2, ['All']); await adminPage - .locator('label:has(#metricConfiguration_2_disabled)') + .getByTestId('profiler-disabled-2') + .getByTestId('disabled-switch') .click(); const settingRes = adminPage.waitForResponse('/api/v1/system/settings'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts index 536b79bb73ac..124b3e0d0778 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts @@ -148,6 +148,45 @@ test( ); await page.getByTestId('add-test-case-btn').click(); await modalListResponse; + const submit = page + .getByRole('dialog', ADD_TEST_CASES_DIALOG) + .getByTestId('submit'); + await expect(submit).toBeInViewport(); + const cancel = page + .getByRole('dialog', ADD_TEST_CASES_DIALOG) + .getByTestId('cancel'); + await expect + .poll(async () => { + const cancelBounds = await cancel.boundingBox(); + const submitBounds = await submit.boundingBox(); + + return cancelBounds && submitBounds + ? Math.abs( + cancelBounds.y + + cancelBounds.height / 2 - + (submitBounds.y + submitBounds.height / 2) + ) + : Number.POSITIVE_INFINITY; + }) + .toBeLessThan(1); + expect( + await submit.evaluate((button) => { + for ( + let parent = button.parentElement; + parent; + parent = parent.parentElement + ) { + if ( + /auto|scroll/.test(getComputedStyle(parent).overflowY) && + parent.scrollHeight > parent.clientHeight + ) { + return true; + } + } + + return false; + }) + ).toBe(false); await page .getByRole('dialog', ADD_TEST_CASES_DIALOG) .locator( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs index 8bac72648f65..2c31e7baa61e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs +++ b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs @@ -59,8 +59,10 @@ test('the suppressions baseline matches its recorded state exactly', () => { // `.om-block-editor` instead of `.ProseMirror...first()` removes four. The // landing page's FeedWidget, Data Assets and My Tasks suites went with the // widgets they drove, taking six more with them, and the Data Contract - // core-components migration removes three more. - 'om-playwright/no-positional-locator': 578, + // core-components migration removes three more. The Classic Observability + // migration's core-component selectors in the Data Quality, Profiler and + // Profiler Configuration specs remove five more. + 'om-playwright/no-positional-locator': 573, 'om-playwright/require-assertion-per-test': 1, 'playwright/no-skipped-test': 1, 'playwright/no-wait-for-selector': 14, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts index 319ee75b9929..de606811e99f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts @@ -167,13 +167,11 @@ export const commonCleanup = async ({ * not just hidden. This prevents race conditions where multiple dropdowns exist. */ export const ensureNoDropdownVisible = async (page: Page) => { - const dropdownCount = await page - .locator('.ant-select-dropdown:visible') - .count(); + const dropdownCount = await page.getByRole('listbox').count(); if (dropdownCount > 0) { await clickOutside(page); // Wait for ALL visible dropdowns to be gone from DOM - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(0); } }; @@ -318,18 +316,18 @@ export const addOwnerFilter = async ({ await page.click(`[data-testid="filter-select-${filterNumber}"]`); // CRITICAL: Verify EXACTLY one dropdown is visible (fail fast if multiple) - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); // Use :visible selector chain pattern for dropdown option const ownerOption = page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId(`${selectId}-filter-option`); await expect(ownerOption).toBeVisible(); await expect(ownerOption).toBeEnabled(); await ownerOption.click(); // Verify filter dropdown closed before next interaction - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(0); // Ensure no dropdowns visible before opening owner selector await ensureNoDropdownVisible(page); @@ -343,7 +341,7 @@ export const addOwnerFilter = async ({ await ownerInput.click(); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); const getSearchResult = page.waitForResponse('/api/v1/search/query?q=*'); await ownerInput.fill(ownerName); @@ -351,20 +349,20 @@ export const addOwnerFilter = async ({ // Use :visible selector chain pattern for search results const searchResult = page - .locator('.ant-select-dropdown:visible') - .locator(`[title="${ownerName}"]`); + .getByRole('listbox') + .getByRole('option', { name: ownerName, exact: true }); await expect(searchResult).toBeVisible(); await searchResult.click(); // Verify selection is displayed await expect( - page.getByTestId('owner-name-select').getByTitle(ownerName) + page.getByTestId('owner-name-select').getByText(ownerName, { exact: true }) ).toBeVisible(); if (exclude) { const filterSwitch = page.getByTestId(`filter-switch-${filterNumber}`); await expect(filterSwitch).toBeVisible(); - await expect(filterSwitch.getByRole('switch')).toBeEnabled(); + await expect(filterSwitch).toBeEnabled(); await filterSwitch.click(); } }; @@ -389,18 +387,18 @@ export const addEntityFQNFilter = async ({ await page.click(`[data-testid="filter-select-${filterNumber}"]`); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); // Use :visible selector chain pattern const entityFilterOption = page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId(`${selectId}-filter-option`); await expect(entityFilterOption).toBeVisible(); await expect(entityFilterOption).toBeEnabled(); await entityFilterOption.click(); // Verify filter dropdown closed before next interaction - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(0); // Ensure no dropdowns visible before searching await ensureNoDropdownVisible(page); @@ -419,12 +417,12 @@ export const addEntityFQNFilter = async ({ await getSearchResult; // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); // Use :visible selector chain pattern for search result const searchResult = page - .locator('.ant-select-dropdown:visible') - .locator(`[title="${entityFQN}"]`); + .getByRole('listbox') + .getByRole('option', { name: entityFQN, exact: true }); await expect(searchResult).toBeVisible(); await searchResult.click(); @@ -435,7 +433,7 @@ export const addEntityFQNFilter = async ({ if (exclude) { const filterSwitch = page.getByTestId(`filter-switch-${filterNumber}`); await expect(filterSwitch).toBeVisible(); - await expect(filterSwitch.getByRole('switch')).toBeEnabled(); + await expect(filterSwitch).toBeEnabled(); await filterSwitch.click(); } }; @@ -458,18 +456,18 @@ export const addEventTypeFilter = async ({ await page.click(`[data-testid="filter-select-${filterNumber}"]`); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); // Use :visible selector chain pattern for dropdown option const eventTypeOption = page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId('Event Type-filter-option'); await expect(eventTypeOption).toBeVisible(); await expect(eventTypeOption).toBeEnabled(); await eventTypeOption.click(); // Verify filter dropdown closed before next interaction - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(0); for (const eventType of eventTypes) { // Ensure no dropdowns visible before opening event type selector @@ -484,20 +482,22 @@ export const addEventTypeFilter = async ({ await eventTypeInput.click(); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); await eventTypeInput.fill(eventType); // Use :visible selector chain pattern for search results const searchResult = page - .locator('.ant-select-dropdown:visible') - .locator(`[title="${startCase(eventType)}"]`); + .getByRole('listbox') + .getByRole('option', { name: startCase(eventType), exact: true }); await expect(searchResult).toBeVisible(); await searchResult.click(); // Verify selection is displayed await expect( - page.getByTestId('event-type-select').getByTitle(startCase(eventType)) + page + .getByTestId('event-type-select') + .getByText(startCase(eventType), { exact: true }) ).toBeVisible(); } @@ -507,7 +507,7 @@ export const addEventTypeFilter = async ({ if (exclude) { const filterSwitch = page.getByTestId(`filter-switch-${filterNumber}`); await expect(filterSwitch).toBeVisible(); - await expect(filterSwitch.getByRole('switch')).toBeEnabled(); + await expect(filterSwitch).toBeEnabled(); await filterSwitch.click(); } }; @@ -532,18 +532,18 @@ export const addDomainFilter = async ({ await page.click(`[data-testid="filter-select-${filterNumber}"]`); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); // Select Domain filter option - chain :visible selector inline const domainOption = page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId('Domain-filter-option'); await expect(domainOption).toBeVisible(); await expect(domainOption).toBeEnabled(); await domainOption.click(); // Verify filter dropdown closed before next interaction - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(0); // Ensure no dropdowns visible before opening domain selector await ensureNoDropdownVisible(page); @@ -557,7 +557,7 @@ export const addDomainFilter = async ({ await domainInput.click(); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); const awaitResponse = page.waitForResponse( (response) => @@ -571,8 +571,8 @@ export const addDomainFilter = async ({ // Select domain from search results - chain :visible selector inline const searchResult = page - .locator('.ant-select-dropdown:visible') - .locator(`[title="${domainDisplayName}"]`); + .getByRole('listbox') + .getByRole('option', { name: domainDisplayName, exact: true }); await expect(searchResult).toBeVisible(); await searchResult.click(); @@ -581,7 +581,9 @@ export const addDomainFilter = async ({ // Verify domain is selected in UI await expect( - page.getByTestId('domain-select').getByTitle(domainDisplayName) + page + .getByTestId('domain-select') + .getByText(domainDisplayName, { exact: true }) ).toBeVisible(); if (exclude) { @@ -608,23 +610,23 @@ export const addGMEFilter = async ({ await page.click(`[data-testid="filter-select-${filterNumber}"]`); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); // Use :visible selector chain pattern for dropdown option const gmeOption = page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId('General Metadata Events-filter-option'); await expect(gmeOption).toBeVisible(); await expect(gmeOption).toBeEnabled(); await gmeOption.click(); // Verify filter dropdown closed - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(0); if (exclude) { const filterSwitch = page.getByTestId(`filter-switch-${filterNumber}`); await expect(filterSwitch).toBeVisible(); - await expect(filterSwitch.getByRole('switch')).toBeEnabled(); + await expect(filterSwitch).toBeEnabled(); await filterSwitch.click(); } }; @@ -750,23 +752,23 @@ export const addGetSchemaChangesAction = async ({ await page.click(`[data-testid="trigger-select-${filterNumber}"]`); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); // Use :visible selector chain pattern for dropdown option const schemaChangesOption = page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId('Get Schema Changes-filter-option'); await expect(schemaChangesOption).toBeVisible(); await expect(schemaChangesOption).toBeEnabled(); await schemaChangesOption.click(); // Verify trigger dropdown closed - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(0); if (exclude) { const filterSwitch = page.getByTestId(`filter-switch-${filterNumber}`); await expect(filterSwitch).toBeVisible(); - await expect(filterSwitch.getByRole('switch')).toBeEnabled(); + await expect(filterSwitch).toBeEnabled(); await filterSwitch.click(); } }; @@ -789,18 +791,18 @@ export const addPipelineStatusUpdatesAction = async ({ await page.click(`[data-testid="trigger-select-${filterNumber}"]`); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); // Use :visible selector chain pattern for dropdown option const pipelineStatusOption = page - .locator('.ant-select-dropdown:visible') + .getByRole('listbox') .getByTestId('Get Pipeline Status Updates-filter-option'); await expect(pipelineStatusOption).toBeVisible(); await expect(pipelineStatusOption).toBeEnabled(); await pipelineStatusOption.click(); // Verify trigger dropdown closed before next interaction - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(0); // Ensure no dropdowns visible before opening status selector await ensureNoDropdownVisible(page); @@ -814,21 +816,23 @@ export const addPipelineStatusUpdatesAction = async ({ await pipelineStatusInput.click(); // CRITICAL: Verify EXACTLY one dropdown is visible - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); + await expect(page.getByRole('listbox')).toHaveCount(1); await pipelineStatusInput.fill(statusName); // Use :visible selector chain pattern for search results const searchResult = page - .locator('.ant-select-dropdown:visible') - .locator(`[title="${statusName}"]`); + .getByRole('listbox') + .getByRole('option', { name: statusName, exact: true }); await expect(searchResult).toBeVisible(); await searchResult.click(); // Verify selection is displayed - await expect(page.getByTestId('pipeline-status-select')).toHaveText( - statusName - ); + await expect( + page + .getByTestId('pipeline-status-select') + .getByText(statusName, { exact: true }) + ).toBeVisible(); // Ensure dropdown is closed before proceeding await ensureNoDropdownVisible(page); 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 c1795bc9a59c..812f0fd1c7d6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts @@ -35,31 +35,47 @@ type CustomMetricDetails = { const validateForm = async (page: Page, isColumnMetric = false) => { // error messages - await expect(page.locator('#name_help')).toHaveText('Name is required'); + await expect( + page + .getByTestId('custom-metric-form') + .getByText('Name is required', { exact: true }) + ).toHaveText('Name is required'); - await expect(page.locator('#expression_help')).toHaveText( - 'SQL Query is required.' - ); + await expect( + page + .getByTestId('custom-metric-form') + .getByText('SQL Query is required.', { exact: true }) + ).toHaveText('SQL Query is required.'); if (isColumnMetric) { - await expect(page.locator('#columnName_help')).toHaveText( - 'Column is required.' - ); + await expect( + page + .getByTestId('custom-metric-form') + .getByText('Column is required.', { exact: true }) + ).toHaveText('Column is required.'); } // max length validation - await page.locator('#name').fill(INVALID_NAMES.MAX_LENGTH); + await page.getByTestId('custom-metric-name').fill(INVALID_NAMES.MAX_LENGTH); - await expect(page.locator('#name_help')).toHaveText( - NAME_MAX_LENGTH_VALIDATION_ERROR - ); + await expect( + page + .getByTestId('custom-metric-form') + .getByText(NAME_MAX_LENGTH_VALIDATION_ERROR, { exact: true }) + ).toBeVisible(); // with special char validation - await page.locator('#name').fill(INVALID_NAMES.WITH_SPECIAL_CHARS); + await page + .getByTestId('custom-metric-name') + .fill(INVALID_NAMES.WITH_SPECIAL_CHARS); - await expect(page.locator('#name_help')).toHaveText(NAME_VALIDATION_ERROR); + await expect( + page + .getByTestId('custom-metric-form') + .getByText(NAME_VALIDATION_ERROR, { exact: true }) + ).toBeVisible(); - await page.locator('#name').clear(); + await page.getByTestId('custom-metric-name').clear(); }; export const createCustomMetric = async ({ @@ -106,10 +122,12 @@ export const createCustomMetric = async ({ await validateForm(page, isColumnMetric); // fill form and submit - await page.fill('#name', metric.name); + await page.getByTestId('custom-metric-name').fill(metric.name); if (isColumnMetric) { - await page.click('#columnName'); - await page.click(`[title="${metric.column}"]`); + await page.getByTestId('custom-metric-column').getByRole('button').click(); + await page + .getByRole('option', { name: metric.column, exact: true }) + .click(); } if (metric.expression) { await clickCodeEditor(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/notificationAlert.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/notificationAlert.ts index 747adbdd5a97..11c56309a2d7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/notificationAlert.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/notificationAlert.ts @@ -85,17 +85,13 @@ export const addFilterWithUsersListInput = async ({ // Open filter dropdown await page.click(`[data-testid="filter-select-${filterNumber}"]`); - // Select filter option - chain :visible selector inline - // Wait for dropdown animation to complete and element to be actionable - const filterOption = page - .locator('.ant-select-dropdown:visible') - .getByTestId(filterTestId); + const filterOption = page.getByRole('listbox').getByTestId(filterTestId); await expect(filterOption).toBeVisible(); await expect(filterOption).toBeEnabled(); await filterOption.click(); // Verify dropdown closed - await expect(page.locator('.ant-select-dropdown:visible')).not.toBeVisible(); + await expect(page.getByRole('listbox')).toHaveCount(0); // Open user select dropdown const userSelectInput = page.locator( @@ -113,21 +109,22 @@ export const addFilterWithUsersListInput = async ({ await userSelectInput.fill(updaterName); await awaitResponse; - // Select user from search results - chain :visible selector inline const userOption = page - .locator('.ant-select-dropdown:visible') - .locator(`[title="${updaterName}"]`); + .getByRole('listbox') + .getByRole('option', { name: updaterName, exact: true }); await expect(userOption).toBeVisible(); await userOption.click(); // Verify user is selected - await expect(page.getByTestId('user-name-select')).toHaveText(updaterName); + await expect( + page.getByTestId('user-name-select').getByText(updaterName, { exact: true }) + ).toBeVisible(); // Manually close dropdown if it doesn't auto-close await clickOutside(page); // Verify dropdown closed - await expect(page.locator('.ant-select-dropdown:visible')).not.toBeVisible(); + await expect(page.getByRole('listbox')).toHaveCount(0); if (exclude) { // Change filter effect @@ -469,15 +466,23 @@ export const checkAlertConfigDetails = async ({ sourceLabelOf(sourceName) ); - await expect(page.getByTestId('filter-select-0')).toHaveText('Event Type'); await expect( - page.getByTestId('event-type-select').getByTitle('Entity Restored') + page.getByTestId('filter-select-0').getByRole('button') + ).toHaveText('Event Type'); + await expect( + page + .getByTestId('event-type-select') + .getByText('Entity Restored', { exact: true }) ).toBeAttached(); await expect( - page.getByTestId('event-type-select').getByTitle('Entity Soft Deleted') + page + .getByTestId('event-type-select') + .getByText('Entity Soft Deleted', { exact: true }) ).toBeAttached(); - await expect(page.getByTestId('filter-select-1')).toHaveText('Entity FQN'); + await expect( + page.getByTestId('filter-select-1').getByRole('button') + ).toHaveText('Entity FQN'); await expect( page.getByTestId('destination-category-select-0').getByRole('combobox') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/observabilityAlert.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/observabilityAlert.ts index 459739343b23..58856ee7f70c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/observabilityAlert.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/observabilityAlert.ts @@ -598,13 +598,8 @@ export const createCommonObservabilityAlert = async ({ // Select filter await page.click(`[data-testid="filter-select-${filterNumber}"]`); - await page.click( - `.ant-select-dropdown:visible [data-testid="${filter.name}-filter-option"]` - ); + await page.getByRole('option', { name: filter.name, exact: true }).click(); - // Focus the combobox first. Ant Design Select with mode="multiple" renders the - // search input as readonly until focused, which makes page.fill() fail. Click also - // opens the dropdown so the search query fires when we fill. await page.click( `[data-testid="${filter.inputSelector}"] [role="combobox"]` ); @@ -613,28 +608,23 @@ export const createCommonObservabilityAlert = async ({ const searchOptions = page.waitForResponse('/api/v1/search/query?q=*'); await page.fill( `[data-testid="${filter.inputSelector}"] [role="combobox"]`, - filter.inputValue, - { - force: true, // eslint-disable-line playwright/no-force-option -- Ant Select overlay covers combobox input - } + filter.inputValue ); await searchOptions; - await page.click( - `.ant-select-dropdown:visible [title="${ - filter.inputValueId ?? filter.inputValue - }"]` - ); + await page + .getByRole('option', { + name: filter.inputValueId ?? filter.inputValue, + exact: true, + }) + .click(); - // Check if option is selected await expect( - page.locator( - `[data-testid="${filter.inputSelector}"] [title="${ - filter.inputValueId ?? filter.inputValue - }"]` - ) - ).toBeAttached(); + page + .getByTestId(filter.inputSelector) + .getByText(filter.inputValueId ?? filter.inputValue, { exact: true }) + ).toBeVisible(); if (filter.exclude) { // Change filter effect @@ -651,18 +641,12 @@ export const createCommonObservabilityAlert = async ({ // Select action await page.click(`[data-testid="trigger-select-${actionNumber}"]`); - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); - await page.click( - `.ant-select-dropdown:visible [data-testid="${action.name}-filter-option"]:visible` - ); - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + await expect(page.getByRole('listbox')).toHaveCount(1); + await page.getByRole('option', { name: action.name, exact: true }).click(); + await expect(page.getByRole('listbox')).toHaveCount(0); if (action.inputs && action.inputs.length > 0) { for (const input of action.inputs) { - // Focus the combobox first. Ant Design Select with mode="multiple" renders - // the search input as readonly until focused; without the click, page.fill() - // (even with force: true) doesn't trigger AsyncSelect's onSearch handler, so - // the API call never fires and no options appear in the dropdown. await page.click( `[data-testid="${input.inputSelector}"] [role="combobox"]` ); @@ -672,10 +656,7 @@ export const createCommonObservabilityAlert = async ({ ); await page.fill( `[data-testid="${input.inputSelector}"] [role="combobox"]`, - input.inputValue, - { - force: true, // eslint-disable-line playwright/no-force-option -- Ant Select overlay covers combobox input - } + input.inputValue ); if (input.waitForAPI) { await getSearchResult; @@ -683,11 +664,15 @@ export const createCommonObservabilityAlert = async ({ // Click the option whose title is the rendered label. Use inputValueId // when the displayed label differs from the searched value. const optionTitle = input.inputValueId ?? input.inputValue; - await page.click(`[title="${optionTitle}"]:visible`); + await page + .getByRole('option', { name: optionTitle, exact: true }) + .click(); - await expect(page.getByTestId(input.inputSelector)).toHaveText( - optionTitle - ); + await expect( + page + .getByTestId(input.inputSelector) + .getByText(optionTitle, { exact: true }) + ).toBeVisible(); await clickOutside(page); } @@ -742,16 +727,16 @@ export const checkAlertConfigDetails = async ({ sourceLabelOf(sourceName) ); - await expect(page.getByTestId('filter-select-0')).toHaveText('Table Name'); + await expect( + page.getByTestId('filter-select-0').getByRole('button') + ).toHaveText('Table Name'); await expect( page.getByTestId('fqn-list-select').getByTitle(tableName) ).toBeAttached(); await expect( - page - .getByTestId('trigger-select-0') - .getByTestId('Get Schema Changes-filter-option') - ).toBeAttached(); + page.getByTestId('trigger-select-0').getByRole('button') + ).toHaveText('Get Schema Changes'); await expect( page.getByTestId('destination-category-select-0').getByRole('combobox') diff --git a/openmetadata-ui/src/main/resources/ui/specs/components/card.md b/openmetadata-ui/src/main/resources/ui/specs/components/card.md index da469e93e921..5675dce0b56e 100644 --- a/openmetadata-ui/src/main/resources/ui/specs/components/card.md +++ b/openmetadata-ui/src/main/resources/ui/specs/components/card.md @@ -7,7 +7,7 @@ | **Name** | Card | | **Category** | Base / container | | **Status** | Stable | -| **Styles** | [`src/styles/components/card.less`](../../src/styles/components/card.less) | +| **Styles** | [Core Card implementation](../../../../../../../openmetadata-ui-core-components/src/main/resources/ui/src/components/base/card/card.tsx) | | **Component** | `@openmetadata/ui-core-components` → `Card` (`Card.Header` / `Card.Content` / `Card.Footer`) for new work; Ant Design `Card` (legacy) | ## Overview @@ -35,8 +35,8 @@ Parts: **root** (surface, border, radius, optional shadow), **header** ## Tokens used -Real `--om-*` tokens in `card.less` are the medium weight and the expand -transition; surface/border/radius map through the modern core-components Card. +The legacy token mappings below correspond to the semantic styles maintained +in the [core Card implementation](../../../../../../../openmetadata-ui-core-components/src/main/resources/ui/src/components/base/card/card.tsx). | Part | Token | | --- | --- | diff --git a/openmetadata-ui/src/main/resources/ui/specs/tokens/token-reference.md b/openmetadata-ui/src/main/resources/ui/specs/tokens/token-reference.md index 2651169ffb81..26cbfd315aba 100644 --- a/openmetadata-ui/src/main/resources/ui/specs/tokens/token-reference.md +++ b/openmetadata-ui/src/main/resources/ui/specs/tokens/token-reference.md @@ -4,9 +4,9 @@ Master map of every **project (`--om-*`) token** — the tokens components reference. Each references the matching upstream `globals.css` token (or holds a raw value) and resolves to the value shown. Full layering: [../README.md](../README.md). -Total project tokens: **832**. +Total project tokens: **816**. -## Spacing (57) +## Spacing (53) padding / margin / gap. See foundations/spacing.md. @@ -31,12 +31,10 @@ padding / margin / gap. See foundations/spacing.md. | `--om-space-14` | `14px` | | `--om-space-15` | `15px` | | `--om-space-16` | `16px` | -| `--om-space-17` | `17px` | | `--om-space-18` | `18px` | | `--om-space-19` | `19px` | | `--om-space-20` | `20px` | | `--om-space-22` | `22px` | -| `--om-space-23` | `23px` | | `--om-space-24` | `24px` | | `--om-space-26` | `26px` | | `--om-space-28` | `28px` | @@ -55,7 +53,6 @@ padding / margin / gap. See foundations/spacing.md. | `--om-space-56` | `56px` | | `--om-space-60` | `60px` | | `--om-space-64` | `64px` | -| `--om-space-70` | `70px` | | `--om-space-75` | `75px` | | `--om-space-80` | `80px` | | `--om-space-84` | `84px` | @@ -67,7 +64,6 @@ padding / margin / gap. See foundations/spacing.md. | `--om-space-176` | `176px` | | `--om-space-192` | `192px` | | `--om-space-208` | `208px` | -| `--om-space-220` | `220px` | | `--om-space-290` | `290px` | ## Layout (1) @@ -704,11 +700,11 @@ Fixed swatches; do NOT adapt to dark mode. Prefer semantic tokens. -## Legacy colors (191) +## Legacy colors (179) Exact migrated one-offs (migration debt). Do not use in new code; re-express with a semantic token. -
Show all 191 +
Show all 179 | Token | Value | | --- | --- | @@ -723,7 +719,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-0-0-0-0-8` | `rgba(0, 0, 0, 0.8)` | | `--om-legacy-color-0-0-0-0-9` | `rgba(0, 0, 0, 0.9)` | | `--om-legacy-color-0-0-0-0-12` | `rgba(0, 0, 0, 0.12)` | -| `--om-legacy-color-0-0-0-0-13` | `rgba(0, 0, 0, 0.13)` | | `--om-legacy-color-0-0-0-0-15` | `rgba(0, 0, 0, 0.15)` | | `--om-legacy-color-0-0-0-0-25` | `rgba(0, 0, 0, 0.25)` | | `--om-legacy-color-0-0-0-0-102` | `#0000001a` | @@ -746,7 +741,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-10-13-18-0-04` | `rgba(10, 13, 18, 0.04)` | | `--om-legacy-color-10-13-18-0-05` | `rgba(10, 13, 18, 0.05)` | | `--om-legacy-color-10-13-18-0-08` | `rgba(10, 13, 18, 0.08)` | -| `--om-legacy-color-10-13-18-0-18` | `rgba(10, 13, 18, 0.18)` | | `--om-legacy-color-10-13-18-0-051` | `#0a0d120d` | | `--om-legacy-color-10-13-18-0-059` | `#0a0d120f` | | `--om-legacy-color-10-13-18-0-102` | `#0a0d121a` | @@ -770,7 +764,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-52c41a` | `#52c41a` | | `--om-legacy-color-0056b3` | `#0056b3` | | `--om-legacy-color-0066cc` | `#06c` | -| `--om-legacy-color-82-196-26-0-05` | `rgba(82, 196, 26, 0.05)` | | `--om-legacy-color-85-155-238-0-2` | `rgba(85, 155, 238, 0.2)` | | `--om-legacy-color-96-167-251-0-2` | `rgba(96, 167, 251, 0.2)` | | `--om-legacy-color-102a72` | `#102a72` | @@ -778,7 +771,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-113-71-232-0-6` | `#7147e899` | | `--om-legacy-color-113-71-232-0-35` | `rgba(113, 71, 232, 0.35)` | | `--om-legacy-color-113-71-232-0-102` | `#7147e81a` | -| `--om-legacy-color-117-117-117-0-3` | `rgba(117, 117, 117, 0.3)` | | `--om-legacy-color-203-36-49-0-059` | `#cb24310f` | | `--om-legacy-color-209-236-241-0-867` | `#d1ecf1dd` | | `--om-legacy-color-212-237-218-0-867` | `#d4eddadd` | @@ -802,7 +794,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-356fe7` | `rgb(53, 111, 231)` | | `--om-legacy-color-373e44` | `#373e44` | | `--om-legacy-color-721c24` | `#721c24` | -| `--om-legacy-color-0950c5` | `#0950c5` | | `--om-legacy-color-1677ff` | `#1677ff` | | `--om-legacy-color-1890ff` | `#1890ff` | | `--om-legacy-color-2563eb` | `#2563eb` | @@ -813,7 +804,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-52525b` | `#52525b` | | `--om-legacy-color-52607a` | `#52607a` | | `--om-legacy-color-76746f` | `#76746f` | -| `--om-legacy-color-101828` | `#101828` | | `--om-legacy-color-111827` | `#111827` | | `--om-legacy-color-155724` | `#155724` | | `--om-legacy-color-344054` | `#344054` | @@ -833,9 +823,7 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-b93815` | `#b93815` | | `--om-legacy-color-bee5eb` | `#bee5eb` | | `--om-legacy-color-c3e6cb` | `#c3e6cb` | -| `--om-legacy-color-c6b5f6` | `#c6b5f6` | | `--om-legacy-color-c6c6c6` | `#c6c6c6` | -| `--om-legacy-color-c9d7ff` | `#c9d7ff` | | `--om-legacy-color-c45296` | `#c45296` | | `--om-legacy-color-cb2431` | `#cb2431` | | `--om-legacy-color-cccccc` | `#cccccc` | @@ -861,7 +849,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-e8e8e9` | `#e8e8e9` | | `--om-legacy-color-e8ecf5` | `#e8ecf5` | | `--om-legacy-color-e60000` | `#e60000` | -| `--om-legacy-color-eaecf0` | `#eaecf0` | | `--om-legacy-color-ebeef1` | `#ebeef1` | | `--om-legacy-color-ecedee` | `#ecedee` | | `--om-legacy-color-eeeeee` | `#eeeeee` | @@ -869,7 +856,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-efbdc1` | `#efbdc1` | | `--om-legacy-color-f0f0f0` | `#f0f0f0` | | `--om-legacy-color-f0f5ff` | `#f0f5ff` | -| `--om-legacy-color-f1edfd` | `#f1edfd` | | `--om-legacy-color-f1f1f1` | `#f1f1f1` | | `--om-legacy-color-f1f5ff` | `#f1f5ff` | | `--om-legacy-color-f3f7fd` | `#f3f7fd` | @@ -885,10 +871,8 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-f7f9fc` | `#f7f9fc` | | `--om-legacy-color-f7fbf9` | `#f7fbf9` | | `--om-legacy-color-f8f8f8` | `#f8f8f8` | -| `--om-legacy-color-f8f9fa` | `#f8f9fa` | | `--om-legacy-color-f8faff` | `#f8faff` | | `--om-legacy-color-f9dbaf` | `#f9dbaf` | -| `--om-legacy-color-f9f5ff` | `#f9f5ff` | | `--om-legacy-color-f9816c` | `#f9816c` | | `--om-legacy-color-f44336` | `#f44336` | | `--om-legacy-color-fbf8f8` | `#fbf8f8` | diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.test.tsx index 0bb5cf012597..1f6389f7c3af 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.test.tsx @@ -69,7 +69,7 @@ jest.mock( jest.fn().mockImplementation(() =>
ObservabilityFormTriggerItem
) ); -jest.mock('../../DestinationFormItem/DestinationFormItemFormBridge', () => +jest.mock('../../DestinationFormItem/DestinationFormItem.component', () => jest.fn().mockImplementation(() =>
DestinationFormItem
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx index 08b0fea1f155..17377b0796d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx @@ -12,10 +12,9 @@ */ import { Box, Divider } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; -import { useForm } from 'antd/lib/form/Form'; import { isEmpty } from 'lodash'; import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { PAGE_SIZE_LARGE } from '../../../../constants/constants'; import { usePermissionProvider } from '../../../../context/PermissionProvider/PermissionProvider'; @@ -31,20 +30,19 @@ import { AlertSelectionProvider, useAlertSelection, } from '../../../../hooks/useAlertSelection'; -import { ModifiedCreateEventSubscription } from '../../../../pages/AddObservabilityPage/AddObservabilityPage.interface'; import { getResourceFunctions as getNotificationResourceFunctions } from '../../../../rest/alertsAPI'; import { getAllNotificationTemplates } from '../../../../rest/notificationtemplateAPI'; import { getResourceFunctions } from '../../../../rest/observabilityAPI'; +import { getClassicAlertInitialValues } from '../../../../utils/Alerts/ClassicAlertForm.utils'; import alertsClassBase from '../../../../utils/AlertsClassBase'; +import { ModifiedCreateEventSubscription } from '../../../../utils/AlertsClassBase.interface'; import Fqn from '../../../../utils/Fqn'; import { getDerivedPermissionFlags } from '../../../../utils/PermissionDerivation'; import { DEFAULT_ENTITY_PERMISSION } from '../../../../utils/PermissionsUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; import Loader from '../../../common/Loader/Loader'; import AlertFormSourceItem from '../../AlertFormSourceItem/AlertFormSourceItem'; -import DestinationFormItemFormBridge, { - DestinationFormFieldRegistrar, -} from '../../DestinationFormItem/DestinationFormItemFormBridge'; +import DestinationFormItem from '../../DestinationFormItem/DestinationFormItem.component'; import ObservabilityFormFiltersItem from '../../ObservabilityFormFiltersItem/ObservabilityFormFiltersItem'; import ObservabilityFormTriggerItem from '../../ObservabilityFormTriggerItem/ObservabilityFormTriggerItem'; import './alert-config-details.less'; @@ -58,13 +56,20 @@ function AlertConfigDetails({ isNotificationAlert, }: AlertConfigDetailsProps) { const { t } = useTranslation(); - const [form] = useForm(); - const destinations = Form.useWatch('destinations', form); - const timeout = Form.useWatch('timeout', form); - const readTimeout = Form.useWatch('readTimeout', form); const { getResourcePermission } = usePermissionProvider(); - const modifiedAlertData = - alertsClassBase.getModifiedAlertDataForForm(alertDetails); + const modifiedAlertData = useMemo( + () => + getClassicAlertInitialValues( + alertsClassBase.getModifiedAlertDataForForm(alertDetails) + ), + [alertDetails] + ); + const form = useForm({ + defaultValues: modifiedAlertData, + }); + const values = form.watch(); + const { reset } = form; + useEffect(() => reset(modifiedAlertData), [modifiedAlertData, reset]); const [loadingState, setLoadingState] = useState({ templates: false, functions: false, @@ -159,111 +164,80 @@ function AlertConfigDetails({ return ( - - disabled - className="alert-config-details" - form={form} - initialValues={{ - ...modifiedAlertData, - resources: modifiedAlertData?.filteringRules?.resources, - }}> - - - - - {!isEmpty(modifiedAlertData.input?.filters) && ( - <> - - - - - - - - )} - {!isEmpty(modifiedAlertData.input?.actions) && ( - <> - - - - - - - - )} - - - - - ( - - )} - values={{ destinations, readTimeout, timeout }} - onChange={(values) => { - // Keep this adapter replacement-based even in view mode so the - // core form cannot be rehydrated with stale nested config. - Object.entries(values).forEach(([name, value]) => - form.setFieldValue(name, value) - ); - }} - /> + + + + + + + {!isEmpty(modifiedAlertData.input?.filters) && ( + <> + + + + + + + + )} + {!isEmpty(modifiedAlertData.input?.actions) && ( + <> + + + + + + + + )} + + + + + + + {!isEmpty(extraFormWidgets) && ( + <> + {Object.entries(extraFormWidgets).map(([name, Widget]) => ( + + + + + + + + + ))} + + )} - {!isEmpty(extraFormWidgets) && ( - <> - {Object.entries(extraFormWidgets).map(([name, Widget]) => ( - - - - - - - - - ))} - - )} - + ); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertDiagnosticInfo/AlertDiagnosticInfoTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertDiagnosticInfo/AlertDiagnosticInfoTab.tsx index 86dfcc937ca1..dd383506d858 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertDiagnosticInfo/AlertDiagnosticInfoTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertDiagnosticInfo/AlertDiagnosticInfoTab.tsx @@ -11,23 +11,21 @@ * limitations under the License. */ -import { InfoCircleOutlined } from '@ant-design/icons'; import { + ButtonUtility, Card, Grid, SkeletonParagraph, Tooltip, Typography, } from '@openmetadata/ui-core-components'; - +import { InfoCircle } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { GRAYED_OUT_COLOR } from '../../../../constants/constants'; import { EventSubscriptionDiagnosticInfo } from '../../../../generated/events/api/eventSubscriptionDiagnosticInfo'; import { useFqn } from '../../../../hooks/useFqn'; import { getDiagnosticInfo } from '../../../../rest/observabilityAPI'; import { getDiagnosticItems } from '../../../../utils/Alerts/AlertsUtilPure'; -import { getLayoutGutter } from '../../../../utils/common/layout.utils'; import { showErrorToast } from '../../../../utils/ToastUtils'; function AlertDiagnosticInfoTab() { @@ -72,8 +70,8 @@ function AlertDiagnosticInfoTab() { ) : ( + className="layout-row layout-grid w-full tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> {diagnosticItems.map((item) => ( @@ -86,13 +84,13 @@ function AlertDiagnosticInfoTab() { - + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.test.tsx index cb5f0f921bfb..4f30cf0a9621 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.test.tsx @@ -18,12 +18,14 @@ import { screen, waitFor, } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Status } from '../../../../generated/events/api/typedEvent'; import { mockAlertDetails, MOCK_TYPED_EVENT_LIST_RESPONSE, } from '../../../../mocks/Alerts.mock'; import { getAlertEventsFromId } from '../../../../rest/alertsAPI'; +import { getChangeEventDataFromTypedEvent } from '../../../../utils/Alerts/AlertsUtilPure'; import { NextPreviousWithOffsetProps } from '../../../common/NextPreviousWithOffset/NextPreviousWithOffset.interface'; import AlertRecentEventsTab from './AlertRecentEventsTab'; @@ -142,6 +144,38 @@ describe('AlertRecentEventsTab', () => { expect(screen.getByTestId('recent-events-list')).toBeInTheDocument(); }); + it('expands events by keyboard and allows multiple event details to stay open', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const [firstEvent, secondEvent] = MOCK_TYPED_EVENT_LIST_RESPONSE.data; + const firstId = + getChangeEventDataFromTypedEvent(firstEvent).changeEventData.id; + const secondId = + getChangeEventDataFromTypedEvent(secondEvent).changeEventData.id; + + if (!firstId || !secondId) { + throw new Error('Event fixtures must include IDs'); + } + + await act(async () => { + render(); + }); + const firstHeader = screen.getByRole('button', { + name: new RegExp(firstId), + }); + act(() => firstHeader.focus()); + + await user.keyboard('{Enter}'); + + expect(screen.getByTestId(`event-details-${firstId}`)).toBeVisible(); + + await user.click( + screen.getByRole('button', { name: new RegExp(secondId) }) + ); + + expect(screen.getByTestId(`event-details-${firstId}`)).toBeVisible(); + expect(screen.getByTestId(`event-details-${secondId}`)).toBeVisible(); + }); + it('should handle filter change', async () => { await act(async () => { render(); @@ -156,6 +190,9 @@ describe('AlertRecentEventsTab', () => { expect(await screen.findByTestId('applied-filter-text')).toHaveTextContent( ': label.successful' ); + expect(filterButton).toHaveAccessibleName( + 'label.filter-plural : label.successful' + ); }); it('should go back to the first page when the filter changes', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx index ecbbec902d4c..1ace35cf6fb8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx @@ -12,6 +12,10 @@ */ import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, Box, Button, Dropdown, @@ -21,14 +25,15 @@ import { Tooltip, Typography, } from '@openmetadata/ui-core-components'; -import { Bell01 } from '@openmetadata/ui-core-components/icons'; -import { Collapse } from 'antd'; +import { + Bell01, + FilterLines, + NoFilterFunnel, +} from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; import { isEmpty, isUndefined, startCase } from 'lodash'; import { Key, lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as FilterIcon } from '../../../../assets/svg/ic-feeds-filter.svg'; -import { ReactComponent as FilterOffIcon } from '../../../../assets/svg/ic-filter-off.svg'; import { INITIAL_PAGING_VALUE } from '../../../../constants/constants'; import { AlertRecentEventFilters } from '../../../../enums/Alerts.enum'; import { CSMode } from '../../../../enums/codemirror.enum'; @@ -47,7 +52,6 @@ import { getChangeEventDataFromTypedEvent, getLabelsForEventDetails, } from '../../../../utils/Alerts/AlertsUtilPure'; -import { getLayoutGutter } from '../../../../utils/common/layout.utils'; import { formatDateTime } from '../../../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../../../utils/EntityNameUtils'; import searchClassBase from '../../../../utils/SearchClassBase'; @@ -55,7 +59,6 @@ import { showErrorToast } from '../../../../utils/ToastUtils'; import { withSuspenseFallback } from '../../../AppRouter/withSuspenseFallback'; import NextPreviousWithOffset from '../../../common/NextPreviousWithOffset/NextPreviousWithOffset'; import { PagingHandlerParams } from '../../../common/NextPreviousWithOffset/NextPreviousWithOffset.interface'; -import './alert-recent-events-tab.less'; import { AlertEventDetailsToDisplay, AlertRecentEventsTabProps, @@ -65,8 +68,6 @@ const SchemaEditor = withSuspenseFallback( lazy(() => import('../../../Database/SchemaEditor/SchemaEditor')) ); -const { Panel } = Collapse; - function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { const { t } = useTranslation(); const [filter, setFilter] = useState( @@ -156,18 +157,22 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { const recentEventsList = useMemo(() => { if (loading) { return ( - + {Array.from( { length: 5 }, (_, index) => `alert-event-skeleton-${index}` ).map((skeletonKey) => ( - } - key={skeletonKey} - /> + id={skeletonKey} + key={skeletonKey}> + + + + ))} - + ); } @@ -184,7 +189,7 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { )} icon={ isFiltered ? ( - + ) : ( ) @@ -202,24 +207,23 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { return ( + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> - + {alertRecentEvents?.map((typedEvent) => { // Get the change event data from the typedEvent object const { changeEventData, changeEventDataToDisplay } = getChangeEventDataFromTypedEvent(typedEvent); return ( - + {/* Display icon for the status of the alert event */} @@ -237,7 +241,7 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { arrow excludeTriggerFromTabOrder title={startCase(typedEvent.status)} - triggerClassName="tw:inline-flex flex-center"> + triggerClassName="tw:inline-flex"> {getAlertStatusIcon(typedEvent.status)}{' '} @@ -247,7 +251,7 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { arrow excludeTriggerFromTabOrder title={startCase(changeEventData.entityType)} - triggerClassName="tw:inline-flex flex-center"> + triggerClassName="tw:inline-flex"> {searchClassBase.getEntityIcon( changeEventData.entityType ?? '', 'h-4 w-4' @@ -271,85 +275,82 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { - } - key={`${changeEventData.id}-${changeEventData.timestamp}`}> - - - - {Object.entries(changeEventDataToDisplay).map( - ([key, value]) => - isUndefined(value) ? null : ( - - - - - {`${getLabelsForEventDetails( - key as keyof AlertEventDetailsToDisplay - )}:`} - - - - - {value} - - - - - ) - )} - + + + + + + {Object.entries(changeEventDataToDisplay).map( + ([key, value]) => + isUndefined(value) ? null : ( + + + + + {`${getLabelsForEventDetails( + key as keyof AlertEventDetailsToDisplay + )}:`} + + + + + {value} + + + + + ) + )} + + + {!isEmpty(changeEventData.changeDescription) && ( + <> + + + {`${t('label.change-entity', { + entity: t('label.description'), + })}:`} + + + + + + + )} - {!isEmpty(changeEventData.changeDescription) && ( - <> - - - {`${t('label.change-entity', { - entity: t('label.description'), - })}:`} - - - - - - - )} - - + + ); })} - + {showPagination && ( @@ -387,18 +388,23 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { }; }, [currentPage, getAlertRecentEvents, pageSize]); + const appliedFilterText = + filter === AlertRecentEventFilters.ALL + ? '' + : ` : ${getAlertEventsFilterLabels(filter as AlertRecentEventFilters)}`; + return ( + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-8)] tw:-mx-1" + rowGap="2"> - + {`${t('label.description')}:`} @@ -412,16 +418,15 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/alert-recent-events-tab.less b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/alert-recent-events-tab.less deleted file mode 100644 index dad71cf22055..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/alert-recent-events-tab.less +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2024 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -.recent-events-collapse.ant-collapse { - background: none; - .ant-collapse-header { - padding: var(--om-space-8) var(--om-space-40) var(--om-space-8) - var(--om-space-12); - } -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.interface.ts index 484307fd844a..a6018ff2aa54 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.interface.ts @@ -16,4 +16,7 @@ export interface AlertFormSourceItemProps { name?: string; }[]; isViewMode?: boolean; + value?: string[]; + onChange?: (value: string[], previous: string[]) => void; + error?: string; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.test.tsx index 444d505a9510..400eb2922f70 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.test.tsx @@ -18,8 +18,7 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Form } from 'antd'; -import { ReactNode } from 'react'; +import { ReactNode, useState } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { useFqn } from '../../../hooks/useFqn'; import { MOCK_FILTER_RESOURCES } from '../../../test/unit/mocks/observability.mock'; @@ -30,9 +29,7 @@ jest.mock('../../../hooks/useFqn', () => ({ })); const FormWrapper = ({ children }: { children: ReactNode }) => ( - -
{children}
-
+ {children} ); describe('AlertFormSourceItem', () => { @@ -75,76 +72,45 @@ describe('AlertFormSourceItem', () => { expect(screen.getByTestId('drop-down-menu')).toBeInTheDocument(); }); - it('commits a different source and clears incompatible filters and destinations', async () => { + it('keeps the source selection controlled and reports the previous selection on removal', async () => { (useFqn as jest.Mock).mockReturnValue({ fqn: 'existing-alert' }); - const onFinish = jest.fn(); - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - render( - -
+ const changed = jest.fn(); + const SourceForm = () => { + const [value, setValue] = useState(['all']); + + return ( + { + changed(next, previous); + setValue(next); + }} /> - - {(fields) => - fields.map((field) => ( -
- Owner -
- )) - } -
- - {(fields) => - fields.map((field) => ( -
- Email -
- )) - } -
- - -
- ); - - expect(screen.getByTestId('existing-filter')).toBeVisible(); - expect(screen.getByTestId('existing-destination')).toBeVisible(); - - // The source field holds several sources, so switching takes the old one away first. +
+ ); + }; + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); const [removeAll] = within( screen.getByTestId('source-select') ).getAllByRole('button'); await user.click(removeAll); + + expect(changed).toHaveBeenLastCalledWith([], ['all']); + await user.click( within(screen.getByTestId('source-select')).getByRole('combobox') ); await user.click(screen.getByTestId('dashboard-option')); await user.keyboard('{Escape}'); - await waitFor(() => - expect(screen.queryByTestId('existing-filter')).not.toBeInTheDocument() + expect(changed).toHaveBeenLastCalledWith(['dashboard'], []) ); - expect( - screen.queryByTestId('existing-destination') - ).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Save source' })); - await waitFor(() => - expect(onFinish).toHaveBeenCalledWith( - expect.objectContaining({ - resources: ['dashboard'], - input: { filters: undefined }, - destinations: [], - }) - ) + expect(screen.getByTestId('source-select')).toHaveTextContent( + 'label.dashboard' ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.tsx index 12e286bae372..800f357e194b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.tsx @@ -11,8 +11,12 @@ * limitations under the License. */ -import { Button, Dropdown } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; +import { + Box, + Button, + Dropdown, + HintText, +} from '@openmetadata/ui-core-components'; import { Key, ReactNode, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import FormCardSection from '../../../components/common/FormCardSection/FormCardSection'; @@ -35,10 +39,12 @@ interface SourceMenuItem { function AlertFormSourceItem({ filterResources, isViewMode = false, + value = [], + onChange, + error, }: Readonly) { const { t } = useTranslation(); const { capabilities } = useAlertSelectionContext(); - const form = Form.useFormInstance(); const { fqn } = useFqn(); const [isEditMode, setIsEditMode] = useState(false); @@ -58,19 +64,6 @@ function AlertFormSourceItem({ [filterResources] ); - // Filters and triggers depend on the sources, so they start again. A destination is offered - // when any source allows it, so adding a source keeps the destinations, and taking one away - // starts them again, as changing the source always has. - const handleSourcesChange = (values: string[], previous: string[]) => { - const sourceTakenAway = previous.some((source) => !values.includes(source)); - - form.setFieldValue('input', {}); - if (sourceTakenAway) { - form.setFieldValue('destinations', []); - } - form.setFieldValue('resources', values); - }; - // Grouped by kind, as the picker groups them, once the server has said each source's kind. const sourceGroups = useMemo(() => { const labelOf = new Map( @@ -90,10 +83,13 @@ function AlertFormSourceItem({ })); }, [resourcesOptions, sourceNames, capabilities.selection]); - const handleMenuItemClick = useCallback((key: Key) => { - form.setFieldValue(['resources'], [key]); - setIsEditMode(true); - }, []); + const handleMenuItemClick = useCallback( + (key: Key) => { + onChange?.([String(key)], value); + setIsEditMode(true); + }, + [onChange, value] + ); const renderMenuItems = (items: SourceMenuItem[]) => items.map((item) => ( @@ -107,7 +103,8 @@ function AlertFormSourceItem({ isDisabled={isViewMode} selection={capabilities.selection} sources={sourceNames} - onChange={handleSourcesChange} + value={value} + onChange={onChange} /> ); @@ -115,64 +112,49 @@ function AlertFormSourceItem({ -
- - {isEditMode || fqn ? ( - sourceControl - ) : ( - - - -
- - {sourceGroups.map(({ kind, items }) => - kind ? ( - - - {getSourceKindLabel(kind)} - - {renderMenuItems(items)} - - ) : ( - renderMenuItems(items) - ) - )} - -
-
-
- )} -
-
+ + {isEditMode || fqn ? ( + sourceControl + ) : ( + + + + + + {sourceGroups.map(({ kind, items }) => + kind ? ( + + + {getSourceKindLabel(kind)} + + {renderMenuItems(items)} + + ) : ( + renderMenuItems(items) + ) + )} + + + + + )} + {error && {error}} +
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItemSeveralSources.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItemSeveralSources.test.tsx index d71525342e29..7a1a5f5d2db1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItemSeveralSources.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItemSeveralSources.test.tsx @@ -10,84 +10,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { MOCK_FILTER_RESOURCES } from '../../../test/unit/mocks/observability.mock'; -import AlertFormSourceItem from './AlertFormSourceItem'; - -const mockSetFieldValue = jest.fn(); - -jest.mock('../../../hooks/useFqn', () => ({ - useFqn: jest.fn().mockReturnValue({ fqn: 'an-alert' }), -})); - -jest.mock('../AlertSourcePicker/AlertSourcePicker', () => - jest - .fn() - .mockImplementation( - ({ - onChange, - }: { - onChange: (v: string[], previous: string[]) => void; - }) => ( - <> - - - - ) - ) -); - -jest.mock('antd', () => { - const antd = jest.requireActual('antd'); - - return { - ...antd, - Form: { - ...antd.Form, - useFormInstance: jest.fn().mockImplementation(() => ({ - setFieldValue: mockSetFieldValue, - getFieldValue: jest.fn(), - })), - }, - }; -}); - -const renderItem = () => - render(, { - wrapper: MemoryRouter, +import { getAlertSourceChanges } from '../../../utils/Alerts/ClassicAlertForm.utils'; + +describe('Classic alert source transitions', () => { + it('invalidates filters without resetting destinations when a source is added', () => { + expect(getAlertSourceChanges(['table', 'topic'], ['table'])).toEqual({ + resources: ['table', 'topic'], + input: {}, + }); }); -describe('AlertFormSourceItem with several sources', () => { - beforeEach(() => { - mockSetFieldValue.mockClear(); - }); - - it('keeps the destinations when a source is added', () => { - renderItem(); - fireEvent.click(screen.getByTestId('add-topic')); - - expect(mockSetFieldValue).toHaveBeenCalledWith('input', {}); - expect(mockSetFieldValue).toHaveBeenCalledWith('resources', [ - 'table', - 'topic', - ]); - expect(mockSetFieldValue).not.toHaveBeenCalledWith('destinations', []); - }); - - it('starts the destinations again when a source is taken away', () => { - renderItem(); - fireEvent.click(screen.getByTestId('only-topic')); - - expect(mockSetFieldValue).toHaveBeenCalledWith('destinations', []); - expect(mockSetFieldValue).toHaveBeenCalledWith('resources', ['topic']); + it('resets destinations when any source is removed or replaced', () => { + expect(getAlertSourceChanges(['topic'], ['table', 'topic'])).toEqual({ + resources: ['topic'], + input: {}, + destinations: [], + }); + expect(getAlertSourceChanges(['dashboard'], ['table'])).toEqual({ + resources: ['dashboard'], + input: {}, + destinations: [], + }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.test.tsx new file mode 100644 index 000000000000..12b6e66c0d2d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.test.tsx @@ -0,0 +1,296 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { + AlertType, + Effect, + InputType, +} from '../../generated/events/eventSubscription'; +import { EventType } from '../../generated/type/changeEvent'; +import { + AlertSelection, + AlertSelectionProvider, +} from '../../hooks/useAlertSelection'; +import { ModifiedCreateEventSubscription } from '../../utils/AlertsClassBase.interface'; +import { ClassicAlertRuleSection } from './ClassicAlertRuleSection'; + +const mockSearchQuery = jest.fn(); +jest.mock('../../rest/searchAPI', () => ({ + searchQuery: (...args: unknown[]) => mockSearchQuery(...args), +})); + +const selection: AlertSelection = { + sources: ['table'], + loading: false, + capabilities: { loading: false }, + support: { + supportedFilters: [ + { + name: 'domain', + condition: 'true', + effect: Effect.Include, + displayName: 'Domain', + inputType: InputType.Runtime, + arguments: ['domainList'], + }, + { + name: 'events', + condition: 'true', + effect: Effect.Include, + displayName: 'Event type', + inputType: InputType.Runtime, + arguments: ['eventTypeList'], + }, + { + name: 'result', + condition: 'true', + effect: Effect.Include, + displayName: 'Test result', + inputType: InputType.Runtime, + arguments: ['testResultList'], + }, + ], + supportedEventTypes: [EventType.EntityCreated, EventType.EntityUpdated], + }, + search: { + indexes: [], + containerEntities: [], + byName: async () => [], + byId: async () => [], + }, +}; +const initial: ModifiedCreateEventSubscription = { + name: 'draft', + resources: ['table'], + destinations: [], + timeout: 10, + readTimeout: 10, + alertType: AlertType.Observability, + input: { filters: [] }, +}; +const Controlled = ({ + sources = ['table'], + isViewOnly = false, + value = initial, +}: { + sources?: string[]; + isViewOnly?: boolean; + value?: ModifiedCreateEventSubscription; +}) => { + const [data, setData] = useState(value); + + return ( + + + {JSON.stringify(data.input)} + + ); +}; + +describe('Classic alert rules', () => { + it('adds a runtime filter, stores its argument and effect, then clears arguments when its type changes', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + await user.click(screen.getByTestId('add-filters')); + await user.click( + screen.getByTestId('filter-select-0').querySelector('button') ?? + screen.getByTestId('filter-select-0') + ); + await user.click(await screen.findByRole('option', { name: 'Event type' })); + const input = screen + .getByTestId('event-type-select') + .querySelector('input'); + if (!input) { + throw new Error('Event type input missing'); + } + await user.click(input); + await user.type(input, EventType.EntityCreated); + await user.click( + await screen.findByRole('option', { name: 'Entity Created' }) + ); + + expect(screen.getByTestId('payload')).toHaveTextContent( + EventType.EntityCreated + ); + + await user.click(screen.getByTestId('filter-switch-0')); + + expect(screen.getByTestId('payload')).toHaveTextContent(Effect.Exclude); + + await user.click( + screen.getByTestId('filter-select-0').querySelector('button') ?? + screen.getByTestId('filter-select-0') + ); + await user.click( + await screen.findByRole('option', { name: 'Test result' }) + ); + + expect(screen.getByTestId('payload')).not.toHaveTextContent( + EventType.EntityCreated + ); + expect(screen.getByTestId('payload')).toHaveTextContent('testResultList'); + expect(screen.getByTestId('payload')).toHaveTextContent(Effect.Exclude); + }); + + it('keeps a selected domain display name while storing its fully qualified name', async () => { + mockSearchQuery.mockResolvedValue({ + hits: { + hits: [ + { + _source: { + displayName: 'Domain display', + fullyQualifiedName: 'domain.raw', + }, + }, + ], + }, + }); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + await user.click( + within(screen.getByTestId('domain-select')).getByRole('combobox') + ); + await user.click( + await screen.findByRole('option', { name: 'Domain display' }) + ); + + expect( + within(screen.getByTestId('domain-select')).getByText('Domain display') + ).toBeVisible(); + expect(screen.getByTestId('payload')).toHaveTextContent('domain.raw'); + expect(screen.getByTestId('payload')).not.toHaveTextContent( + 'Domain display' + ); + }); + + it('removes a rule without losing the remaining rule arguments', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + await user.click(screen.getByTestId('remove-filter-0')); + + expect(screen.getByTestId('payload')).not.toHaveTextContent('events'); + expect(screen.getByTestId('payload')).toHaveTextContent('Failed'); + expect(screen.getByTestId('payload')).toHaveTextContent(Effect.Exclude); + expect(screen.queryByTestId('filter-1')).not.toBeInTheDocument(); + }); + + it('mounts an added rule row once instead of remounting it after its key is assigned', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const insertedRows: Node[] = []; + const observer = new MutationObserver((records) => + records.forEach((record) => insertedRows.push(...record.addedNodes)) + ); + observer.observe(screen.getByTestId('filters-list'), { childList: true }); + + await user.click(screen.getByTestId('add-filters')); + insertedRows.push( + ...observer.takeRecords().flatMap((record) => [...record.addedNodes]) + ); + observer.disconnect(); + + const addedRows = insertedRows.filter( + (node) => + node instanceof HTMLElement && node.dataset.testid === 'filter-0' + ); + + expect(addedRows).toHaveLength(1); + expect(screen.getByTestId('filter-0')).toBe(addedRows[0]); + }); + + it('disables adding a filter until a source is selected', () => { + render(); + + expect(screen.getByTestId('add-filters')).toBeDisabled(); + }); + + it('keeps saved rules visible and disabled in the configuration view', () => { + render( + + ); + + expect( + screen.getByTestId('filter-select-0').querySelector('button') + ).toBeDisabled(); + expect( + screen.getByTestId('event-type-select').querySelector('input') + ).toBeDisabled(); + expect(screen.queryByTestId('remove-filter-0')).not.toBeInTheDocument(); + expect(screen.queryByTestId('add-filters')).not.toBeInTheDocument(); + expect(screen.getByTestId('payload')).toHaveTextContent( + EventType.EntityCreated + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.tsx new file mode 100644 index 000000000000..6d3a4c428307 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.tsx @@ -0,0 +1,252 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Box, + Button, + Grid, + Select, + Toggle, + Typography, +} from '@openmetadata/ui-core-components'; +import { XClose } from '@openmetadata/ui-core-components/icons'; +import { useTranslation } from 'react-i18next'; +import { Effect } from '../../generated/events/eventSubscription'; +import { useAlertSelectionContext } from '../../hooks/useAlertSelection'; +import FormCardSection from '../common/FormCardSection/FormCardSection'; +import { RuleSectionProps } from '../observability/Alerts/AlertAiFormFields.interface'; +import { + getRuleCopy, + getRulesWithAddedRule, + getRulesWithEffect, + getRulesWithName, + getRulesWithoutIndex, + getRuntimeArguments, + getValidationPath, + updateAlertAiValue, +} from '../observability/Alerts/AlertAiFormFieldsPureUtils'; +import { RuleArgumentField } from '../observability/Alerts/AlertAiRuleSection.component'; +import { getClassicRuleItems } from './ClassicAlertRuleSection.utils'; +import FQNListSelect from './FQNListSelect/FQNListSelect.component'; +import { useAlertRuleKeys } from './useAlertRuleKeys'; + +const ARGUMENT_TEST_IDS: Record = { + fqnList: 'fqn-list-select', + domainList: 'domain-select', + tableNameList: 'table-name-select', + entityNameList: 'entity-name-select', + ownerNameList: 'owner-name-select', + updateByUserList: 'user-name-select', + userList: 'user-name-select', + entityIdList: 'entity-id-select', + testSuiteList: 'test-suite-select', + eventTypeList: 'event-type-select', + pipelineStateList: 'pipeline-status-select', + ingestionPipelineStateList: 'pipeline-status-select', + testStatusList: 'test-status-select', + testResultList: 'test-result-select', + contractStatusList: 'contract-status-select', +}; + +export const ClassicAlertRuleSection = ({ + field, + title, + isViewOnly = false, + value, + onChange, + validationErrors, +}: RuleSectionProps) => { + const { t } = useTranslation(); + const { sources, support, search } = useAlertSelectionContext(); + const supportedRules = + field === 'filters' ? support.supportedFilters : support.supportedTriggers; + const rules = value.input?.[field] ?? []; + const copy = getRuleCopy(field, t); + const items = getClassicRuleItems(supportedRules, rules); + const { keys, removeKey } = useAlertRuleKeys(rules.length); + const prefix = field === 'filters' ? 'filter' : 'trigger'; + const changeRules = (nextRules: typeof rules) => + updateAlertAiValue(value, onChange, ['input', field], nextRules); + + return ( + + + {rules.map((rule, ruleIndex) => { + const error = + validationErrors?.[ + getValidationPath('input', field, ruleIndex, 'name') + ]; + const argumentsToRender = getRuntimeArguments(rule, supportedRules); + + return ( + + + + + + + + {argumentsToRender.map((argument, index) => ( + + {argument === 'fqnList' ? ( + + updateAlertAiValue( + value, + onChange, + [ + 'input', + field, + ruleIndex, + 'arguments', + index, + 'input', + ], + next + ) + } + /> + ) : ( + + )} + + ))} + + + {t('label.include')} + + changeRules( + getRulesWithEffect(rules, ruleIndex, included) + ) + } + /> + + + {!isViewOnly && ( + + + )} + + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.utils.ts new file mode 100644 index 000000000000..caff49d42d5e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ClassicAlertRuleSection.utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ArgumentsInput, + EventFilterRule, +} from '../../generated/events/eventSubscription'; +import { getRuleItems } from '../observability/Alerts/AlertAiFormFieldsPureUtils'; + +export const getClassicRuleItems = ( + supportedRules: EventFilterRule[] | undefined, + rules: ArgumentsInput[] +) => { + const items = getRuleItems(supportedRules, rules); + + return rules.reduce( + (result, rule) => { + if (rule.name && !items.some((item) => item.id === rule.name)) { + result.push({ + id: rule.name, + label: rule.name, + isDisabled: true, + }); + } + + return result; + }, + [...items] + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.component.test.tsx index 9fd6ef02eb97..a9ffe521fc8f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.component.test.tsx @@ -11,32 +11,147 @@ * limitations under the License. */ -import { render, screen, waitFor } from '@testing-library/react'; -import { ReactElement } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { SearchIndex } from '../../../enums/search.enum'; import { searchQuery } from '../../../rest/searchAPI'; import FQNListSelect, { resolveWildcardFqns } from './FQNListSelect.component'; -jest.mock('../../../rest/searchAPI', () => ({ - searchQuery: jest.fn(), -})); - -interface CapturedTagProps { - value: string; - closable: boolean; - onClose: () => void; -} -interface CapturedAsyncSelectProps { - tagRender: (props: CapturedTagProps) => ReactElement; -} -let capturedProps: CapturedAsyncSelectProps | undefined; -jest.mock('../../common/AsyncSelect/AsyncSelect', () => ({ - AsyncSelect: (props: CapturedAsyncSelectProps) => { - capturedProps = props; - - return
; - }, -})); +jest.mock('../../../rest/searchAPI', () => ({ searchQuery: jest.fn() })); +jest.mock('../../../utils/ToastUtils', () => ({ showErrorToast: jest.fn() })); +const api = jest.fn().mockResolvedValue([ + { value: 'service.schema', label: 'service.schema.*' }, + { value: 'service.schema.table', label: 'service.schema.table' }, +]); +const Controlled = () => { + const [value, setValue] = useState([]); + + return ( + <> + + {JSON.stringify(value)} + + ); +}; + +describe('FQNListSelect', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('searches exact typed labels and saves the selected FQN without wildcard decoration', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByRole('combobox', { name: 'FQN' }); + await user.type(input, 'service.schema.*'); + await act(async () => { + jest.advanceTimersByTime(400); + }); + + expect(api).toHaveBeenLastCalledWith('service.schema.*'); + expect(screen.getByTestId('saved-value')).toHaveTextContent('[]'); + + await user.click(screen.getByRole('option', { name: 'service.schema.*' })); + + expect(screen.getByTestId('saved-value')).toHaveTextContent( + '["service.schema"]' + ); + expect(screen.getByTestId('autocomplete-selected-item')).toHaveTextContent( + 'service.schema' + ); + }); + + it('resolves saved ancestors outside the first result page and updates changed form values', async () => { + (searchQuery as jest.Mock).mockResolvedValue({ + hits: { + hits: [ + { + _source: { + entityType: 'databaseSchema', + fullyQualifiedName: 'service.schema', + }, + }, + ], + }, + }); + const { rerender } = render( + + ); + + expect(await screen.findByText('service.schema.*')).toBeInTheDocument(); + + rerender( + + ); + + expect(await screen.findByText('service.schema.table')).toBeInTheDocument(); + expect(screen.queryByText('service.schema.*')).not.toBeInTheDocument(); + }); + + it('does not let a disabled field remove a selected FQN', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onChange = jest.fn(); + render( + + ); + + const removeButton = screen.getByRole('button', { + name: 'label.remove-entity', + }); + + expect(removeButton).toBeDisabled(); + + await user.click(removeButton); + + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByTestId('autocomplete-selected-item')).toBeVisible(); + }); + + it('removes a long selected FQN with the keyboard', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const longFqn = 'service.database.schema.' + 'long_table_name_'.repeat(20); + const onChange = jest.fn(); + render( + + ); + + await user.click(screen.getByRole('combobox', { name: 'FQN' })); + await user.keyboard('{ArrowLeft}{Backspace}'); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); const mockSearchQuery = searchQuery as jest.Mock; @@ -103,87 +218,3 @@ describe('resolveWildcardFqns', () => { ).toEqual([]); }); }); - -describe('FQNListSelect', () => { - beforeEach(() => { - jest.clearAllMocks(); - capturedProps = undefined; - }); - - it('decorates container FQNs with ".*" and renders leaves plain', async () => { - mockSearchQuery.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - fullyQualifiedName: 'svc', - entityType: 'databaseService', - }, - }, - ], - }, - }); - - render( - - ); - - await screen.findByTestId('async-select'); - - await waitFor(() => { - const containerTag = render( - (capturedProps as CapturedAsyncSelectProps).tagRender({ - value: 'svc', - closable: true, - onClose: jest.fn(), - }) - ); - - expect(containerTag.getByText('svc.*')).toBeInTheDocument(); - expect( - containerTag.getByText('svc.*').closest('[title]') - ).toHaveAttribute('title', 'svc.*'); - }); - - const leafTag = render( - (capturedProps as CapturedAsyncSelectProps).tagRender({ - value: 'svc.db.schema.tbl', - closable: true, - onClose: jest.fn(), - }) - ); - - expect(leafTag.getByText('svc.db.schema.tbl')).toBeInTheDocument(); - }); - - it('renders all tags plain when there are no container entities', async () => { - render( - - ); - - await screen.findByTestId('async-select'); - - const tag = render( - (capturedProps as CapturedAsyncSelectProps).tagRender({ - value: 'svc', - closable: true, - onClose: jest.fn(), - }) - ); - - expect(tag.getByText('svc')).toBeInTheDocument(); - expect(mockSearchQuery).not.toHaveBeenCalled(); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.component.tsx index 97913299e4a6..a5bbb170fa22 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.component.tsx @@ -12,128 +12,137 @@ */ import { - Badge, - BadgeWithButton, - Typography, + Autocomplete, + Box, + SelectItemType, } from '@openmetadata/ui-core-components'; -import { SelectProps } from 'antd'; -import { isEmpty } from 'lodash'; -import type { CustomTagProps } from 'rc-select/lib/BaseSelect'; -import React, { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { AxiosError } from 'axios'; +import { debounce, uniqBy } from 'lodash'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { SearchIndex } from '../../../enums/search.enum'; -import { searchQuery } from '../../../rest/searchAPI'; -import { getTermQuery } from '../../../utils/SearchPureUtils'; -import { AsyncSelect } from '../../common/AsyncSelect/AsyncSelect'; -import { AsyncSelectListProps } from '../../common/AsyncSelect/AsyncSelectList.interface'; -interface FQNListSelectProps - extends SelectProps, - Pick { +import { NameSearch } from '../../../utils/Alerts/AlertSourceSearch'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import { resolveWildcardFqns } from './FQNListSelect.utils'; + +export { resolveWildcardFqns } from './FQNListSelect.utils'; + +interface FQNListSelectProps { + api: NameSearch; + value?: string[]; + onChange?: (value: string[]) => void; searchIndex: SearchIndex | SearchIndex[]; containerEntities?: string[]; + placeholder?: string; + 'data-testid'?: string; + isDisabled?: boolean; + hint?: string; + className?: string; } -// Resolves which of the saved FQNs refer to a container (ancestor) entity type for the current -// source. The match rule is identical to authoring time (entityType in containerEntities), so the -// saved-alert view can re-apply the display-only ".*" subtree hint that is not persisted. -export const resolveWildcardFqns = async ( - fqns: string[], - searchIndex: SearchIndex | SearchIndex[], - containerEntities: string[] = [] -): Promise => { - let wildcardFqns: string[] = []; - if (!isEmpty(fqns) && !isEmpty(containerEntities)) { - try { - const response = await searchQuery({ - query: '*', - pageNumber: 1, - pageSize: fqns.length, - searchIndex, - queryFilter: getTermQuery({ fullyQualifiedName: fqns }, 'should', 1), - }); - - wildcardFqns = response.hits.hits - .filter( - (hit) => - !!hit._source.entityType && - containerEntities.includes(hit._source.entityType) - ) - .map((hit) => hit._source.fullyQualifiedName ?? '') - .filter(Boolean); - } catch { - wildcardFqns = []; - } - } - - return wildcardFqns; -}; +const EMPTY_VALUES: string[] = []; -// Alerts-only wrapper around the shared AsyncSelect. It keeps the shared component untouched and -// scopes the ".*" tag decoration to the Entity-FQN filter, so other consumers of AsyncSelect are -// unaffected. const FQNListSelect = ({ - value, + api, + value = EMPTY_VALUES, + onChange, searchIndex, - containerEntities = [], - ...rest + containerEntities = EMPTY_VALUES, + placeholder, + 'data-testid': testId, + isDisabled = false, + hint, + className, }: FQNListSelectProps) => { - const { t } = useTranslation(); - const [wildcardFqns, setWildcardFqns] = useState>(new Set()); - + const [wildcards, setWildcards] = useState([]); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const requestId = useRef(0); useEffect(() => { - const fqns = (value as string[] | undefined) ?? []; - if (isEmpty(fqns) || isEmpty(containerEntities)) { - setWildcardFqns(new Set()); - - return; - } - - let isActive = true; - resolveWildcardFqns(fqns, searchIndex, containerEntities).then((list) => { - if (isActive) { - setWildcardFqns(new Set(list)); + let active = true; + void resolveWildcardFqns(value, searchIndex, containerEntities).then( + (found) => { + if (active) { + setWildcards(found); + } } - }); + ); return () => { - isActive = false; + active = false; }; }, [value, searchIndex, containerEntities]); + const search = useMemo( + () => + debounce(async (text: string) => { + const id = ++requestId.current; + setLoading(true); + try { + const options = await api(text); + if (id === requestId.current) { + setItems( + uniqBy( + options.map((option) => ({ + id: option.value, + label: option.label, + })), + 'id' + ) + ); + } + } catch (error) { + if (id === requestId.current) { + showErrorToast(error as AxiosError); + } + } finally { + if (id === requestId.current) { + setLoading(false); + } + } + }, 400), + [api] + ); + useEffect(() => { + void search(''); - const tagRender = (tagProps: CustomTagProps) => { - const { value: tagValue, closable, onClose } = tagProps; - const fqn = tagValue as string; - const label = wildcardFqns.has(fqn) ? `${fqn}.*` : fqn; - - const onPreventMouseDown = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - }; - - const badgeProps = { - className: 'tw:mr-2 tw:inline-flex tw:max-w-full', - color: 'gray' as const, - 'data-testid': `fqn-tag-${fqn}`, - size: 'sm' as const, - title: label, - type: 'color' as const, - onMouseDown: onPreventMouseDown, + return () => { + search.cancel(); + requestId.current++; }; - const content = {label}; - - return closable ? ( - - {content} - - ) : ( - {content} - ); - }; + }, [search]); + const selectedItems = value.map((id) => ({ + id, + label: wildcards.includes(id) ? id + '.*' : id, + })); - return ; + return ( + + true} + hint={hint} + icon={null} + isDisabled={isDisabled} + isInvalid={Boolean(hint)} + items={items} + placeholder={placeholder} + selectedItems={selectedItems} + onItemCleared={(key) => + onChange?.(value.filter((id) => id !== String(key))) + } + onItemInserted={(key) => + onChange?.([...new Set([...value, String(key)])]) + } + onSearchChange={search}> + {(item) => ( + + {item.label} + + )} + + + ); }; export default FQNListSelect; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.utils.ts new file mode 100644 index 000000000000..7a6069bc3da1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/FQNListSelect/FQNListSelect.utils.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isEmpty, isString } from 'lodash'; +import { SearchIndex } from '../../../enums/search.enum'; +import { searchQuery } from '../../../rest/searchAPI'; +import { getTermQuery } from '../../../utils/SearchPureUtils'; + +const getContainerFqn = ( + source: object, + containerEntities: string[] +): string | undefined => { + if ( + !('entityType' in source) || + !isString(source.entityType) || + !containerEntities.includes(source.entityType) + ) { + return undefined; + } + if ('fullyQualifiedName' in source && isString(source.fullyQualifiedName)) { + return source.fullyQualifiedName || undefined; + } + + return undefined; +}; + +// Resolves which of the saved FQNs refer to a container (ancestor) entity type for the current +// source. The match rule is identical to authoring time (entityType in containerEntities), so the +// saved-alert view can re-apply the display-only ".*" subtree hint that is not persisted. +export const resolveWildcardFqns = async ( + fqns: string[], + searchIndex: SearchIndex | SearchIndex[], + containerEntities: string[] = [] +): Promise => { + let wildcardFqns: string[] = []; + if (!isEmpty(fqns) && !isEmpty(containerEntities)) { + try { + const response = await searchQuery({ + query: '*', + pageNumber: 1, + pageSize: fqns.length, + searchIndex, + queryFilter: getTermQuery({ fullyQualifiedName: fqns }, 'should', 1), + }); + + wildcardFqns = response.hits.hits.flatMap(({ _source }) => { + const fqn = getContainerFqn(_source, containerEntities); + + return fqn ? [fqn] : []; + }); + } catch { + wildcardFqns = []; + } + } + + return wildcardFqns; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.interface.ts index 648d1e49a07e..491b46728892 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.interface.ts @@ -11,6 +11,9 @@ * limitations under the License. */ -export interface ObservabilityFormFiltersItemProps { +import { RuleSectionProps } from '../../observability/Alerts/AlertAiFormFields.interface'; + +export interface ObservabilityFormFiltersItemProps + extends Pick { isViewMode?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.test.tsx index 682c4e90fd8f..498e5f6ce7a0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.test.tsx @@ -10,122 +10,64 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { render, screen } from '@testing-library/react'; -import { Form, FormInstance } from 'antd'; -import { EventFilterRule } from '../../../generated/events/eventSubscription'; +import { AlertType, Effect } from '../../../generated/events/eventSubscription'; import { AlertSelection, AlertSelectionProvider, } from '../../../hooks/useAlertSelection'; -import { MOCK_FILTER_RESOURCES } from '../../../test/unit/mocks/observability.mock'; import ObservabilityFormFiltersItem from './ObservabilityFormFiltersItem'; -jest.mock('../../../utils/Alerts/AlertsUtil', () => ({ - getConditionalField: jest - .fn() - .mockReturnValue(
), - getSupportedFilterOptions: jest.fn().mockReturnValue([]), -})); - -const mockSupportedFilters = MOCK_FILTER_RESOURCES.reduce( - (resource, current) => { - resource.push(...(current.supportedFilters ?? [])); - - return resource; +const selection: AlertSelection = { + sources: ['table'], + support: { + supportedFilters: [ + { + name: 'filter', + displayName: 'Filter', + condition: 'true', + effect: Effect.Include, + }, + ], + supportedTriggers: [ + { + name: 'trigger', + displayName: 'Trigger', + condition: 'true', + effect: Effect.Include, + }, + ], }, - [] as EventFilterRule[] -); - -const selectionOf = (sources: string[]): AlertSelection => ({ - sources, - support: { supportedFilters: mockSupportedFilters }, capabilities: { loading: false }, loading: false, search: { indexes: [], containerEntities: [], - byName: jest.fn(), - byId: jest.fn(), + byName: async () => [], + byId: async () => [], }, -}); - -describe('ObservabilityFormFiltersItem', () => { - it('should renders without crashing', () => { - const setFieldValue = jest.fn(); - const getFieldValue = jest.fn(); - jest.spyOn(Form, 'useFormInstance').mockImplementation( - () => - ({ - setFieldValue, - getFieldValue, - } as unknown as FormInstance) - ); - - const useWatchMock = jest.spyOn(Form, 'useWatch'); - useWatchMock.mockImplementation(() => ['container']); - - render( - - - - ); - - expect(screen.getByText('label.filter-plural')).toBeInTheDocument(); - expect( - screen.getByText('message.alerts-filter-description') - ).toBeInTheDocument(); - - expect(screen.getByTestId('filters-list')).toBeInTheDocument(); - expect(screen.getByTestId('add-filters')).toBeInTheDocument(); - }); - - it('add filter button should be disabled if there is no selected trigger', () => { - const setFieldValue = jest.fn(); - const getFieldValue = jest.fn(); - jest.spyOn(Form, 'useFormInstance').mockImplementation( - () => - ({ - setFieldValue, - getFieldValue, - } as unknown as FormInstance) - ); - - const useWatchMock = jest.spyOn(Form, 'useWatch'); - useWatchMock.mockImplementation(() => []); - - render( - - - - ); - - const addButton = screen.getByTestId('add-filters'); - - expect(addButton).toBeDisabled(); - }); - - it('add filter button should not be disabled if there is selected trigger', () => { - const setFieldValue = jest.fn(); - const getFieldValue = jest.fn(); - jest.spyOn(Form, 'useFormInstance').mockImplementation( - () => - ({ - setFieldValue, - getFieldValue, - } as unknown as FormInstance) - ); - - const useWatchMock = jest.spyOn(Form, 'useWatch'); - useWatchMock.mockImplementation(() => ['container']); - - render( - - - - ); - - const addButton = screen.getByTestId('add-filters'); - - expect(addButton).not.toBeDisabled(); - }); +}; + +it('renders the filter section with its saved rules', () => { + render( + + + + ); + + expect( + screen.getByTestId('filter-select-0').querySelector('button') + ).toBeDisabled(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx index 158ff7a7924f..c0ce919f893c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx @@ -11,173 +11,23 @@ * limitations under the License. */ -import { CloseOutlined } from '@ant-design/icons'; -import { - Button, - Grid, - Toggle, - Typography, -} from '@openmetadata/ui-core-components'; -import { Form, Select } from 'antd'; -import { isEmpty } from 'lodash'; -import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import FormCardSection from '../../../components/common/FormCardSection/FormCardSection'; -import { Effect } from '../../../generated/events/api/createEventSubscription'; -import { EventFilterRule } from '../../../generated/events/eventSubscription'; -import { useAlertSelectionContext } from '../../../hooks/useAlertSelection'; -import { - getConditionalField, - getSupportedFilterOptions, -} from '../../../utils/Alerts/AlertsUtil'; -import { getLayoutGutter } from '../../../utils/common/layout.utils'; +import { ClassicAlertRuleSection } from '../ClassicAlertRuleSection'; import { ObservabilityFormFiltersItemProps } from './ObservabilityFormFiltersItem.interface'; function ObservabilityFormFiltersItem({ isViewMode = false, + ...props }: Readonly) { const { t } = useTranslation(); - const { sources, support, search } = useAlertSelectionContext(); - const { supportedFilters, supportedEventTypes } = support; - - const form = Form.useFormInstance(); - - // Watchers - const selectedFilters = Form.useWatch( - ['input', 'filters'], - form - ); - // Run time values needed for conditional rendering - const filterOptions = useMemo(() => { - return getSupportedFilterOptions(selectedFilters, supportedFilters); - }, [selectedFilters, supportedFilters]); return ( - - - {(fields, { add, remove }, { errors }) => { - const showAddFilterButton = - fields.length < (supportedFilters?.length ?? 1) && !isViewMode; - - return ( - - {fields.map(({ key, name }) => { - const effect = - form.getFieldValue(['input', 'filters', name, 'effect']) ?? - Effect.Include; - - const showConditionalFields = - !isEmpty(selectedFilters) && selectedFilters[name]; - - return ( - -
-
- - - - { - form.setFieldValue( - ['input', 'actions', name, 'arguments'], - [] - ); - }} - /> - - - {showConditionalFields && - getConditionalField( - selectedTriggers[name].name ?? '', - name, - search, - supportedTriggers - )} - -
- {!isViewMode && ( -
- {t('label.include')}} - labelAlign="left" - labelCol={{ span: 6 }} - name={[name, 'effect']} - normalize={(value) => - value ? Effect.Include : Effect.Exclude - }> - - -
- ); - })} - {showAddTriggerButton && ( - - - - )} - {/* Empty error lists must not reserve a grid row and its gutter. */} - - - -
- ); - }} -
-
+ ); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/useAlertRuleKeys.ts b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/useAlertRuleKeys.ts new file mode 100644 index 000000000000..147455f0623b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/useAlertRuleKeys.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useId, useState } from 'react'; + +interface RuleKeys { + ids: number[]; + nextId: number; +} + +const resizeRuleKeys = ({ ids, nextId }: RuleKeys, count: number): RuleKeys => { + if (ids.length >= count) { + return { ids: ids.slice(0, count), nextId }; + } + const added = count - ids.length; + + return { + ids: [...ids, ...Array.from({ length: added }, (_, i) => nextId + i)], + nextId: nextId + added, + }; +}; + +/** + * Stable React keys for a list of rules that has no ids of its own. Keys are + * resized during render (not in an effect) so a newly added row commits with + * its final key once, instead of mounting under a fallback key and remounting. + */ +export const useAlertRuleKeys = (count: number) => { + const prefix = useId(); + const [state, setState] = useState(() => + resizeRuleKeys({ ids: [], nextId: 0 }, count) + ); + if (state.ids.length !== count) { + setState(resizeRuleKeys(state, count)); + } + + return { + keys: state.ids.map((id) => prefix + '-' + id), + removeKey: (index: number) => + setState((current) => ({ + ...current, + ids: current.ids.filter((_, position) => position !== index), + })), + }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/AddDataQualityTest.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/AddDataQualityTest.interface.ts index 5c2e9d1fd310..0378c06f72bf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/AddDataQualityTest.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/AddDataQualityTest.interface.ts @@ -16,7 +16,6 @@ import { CreateTestCase } from '../../../generated/api/tests/createTestCase'; import { Table } from '../../../generated/entity/data/table'; import { IngestionPipeline } from '../../../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { TagLabel } from '../../../generated/tests/testCase'; -import { TestDefinition } from '../../../generated/tests/testDefinition'; import { TestSuite } from '../../../generated/tests/testSuite'; import { ListTestCaseParamsBySearch } from '../../../rest/testAPI'; @@ -71,11 +70,6 @@ export type SelectTestSuiteType = { isNewTestSuite: boolean; }; -export interface ParameterFormProps { - definition: TestDefinition; - table?: Table; -} - export type TestCaseFormType = { testName: string; params: Record; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.test.tsx index edaacc12c517..75f52bfc2a4d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.test.tsx @@ -11,7 +11,6 @@ * limitations under the License. */ import { act, fireEvent, render, screen } from '@testing-library/react'; -import { Form } from 'antd'; import { TestCase } from '../../../../generated/tests/testCase'; import { TestSuite } from '../../../../generated/tests/testSuite'; import { AddTestSuitePipelineProps } from '../AddDataQualityTest.interface'; @@ -47,15 +46,6 @@ jest.mock( () => jest.fn().mockImplementation((props) => mockScheduleInterval(props)) ); -jest.mock('../../../../utils/SchedularUtils', () => ({ - getRaiseOnErrorFormField: () => ({ - name: 'raiseOnError', - label: 'Raise On Error', - type: 'switch', - required: false, - }), -})); - const mockProps: AddTestSuitePipelineProps = { isLoading: false, onSubmit: jest.fn(), @@ -79,11 +69,7 @@ describe('AddTestSuitePipeline', () => { }); it('renders form fields', () => { - render( -
- - - ); + render(); expect(screen.getByTestId('pipeline-name')).toBeInTheDocument(); expect(screen.getByTestId('select-all-test-cases')).toBeInTheDocument(); @@ -92,11 +78,7 @@ describe('AddTestSuitePipeline', () => { }); it('calls onSubmit when submit button is clicked', async () => { - render( -
- - - ); + render(); fireEvent.change(screen.getByTestId('pipeline-name'), { target: { value: 'Test Suite pipeline' }, @@ -116,11 +98,7 @@ describe('AddTestSuitePipeline', () => { it('calls onCancel when cancel button is clicked and onCancel button is provided', async () => { const mockOnCancel = jest.fn(); - render( -
- - - ); + render(); await act(async () => { fireEvent.click(screen.getByTestId('back-button')); @@ -130,11 +108,7 @@ describe('AddTestSuitePipeline', () => { }); it('calls navigate(-1) when cancel button is clicked and onCancel button is not provided', async () => { - render( -
- - - ); + render(); await act(async () => { fireEvent.click(screen.getByTestId('back-button')); @@ -144,11 +118,7 @@ describe('AddTestSuitePipeline', () => { }); it('Hide AddTestCaseList after clicking on select-all-test-cases switch', async () => { - render( -
- - - ); + render(); expect(screen.getByText('AddTestCaseList.component')).toBeInTheDocument(); @@ -163,13 +133,11 @@ describe('AddTestSuitePipeline', () => { it('includes raiseOnError field in form submission', async () => { const mockOnSubmit = jest.fn(); render( -
- - + ); await act(async () => { @@ -191,13 +159,11 @@ describe('AddTestSuitePipeline', () => { }; render( -
- - + ); await act(async () => { @@ -222,19 +188,17 @@ describe('AddTestSuitePipeline', () => { } as TestCase; render( -
- - + ); await act(async () => { @@ -252,13 +216,11 @@ describe('AddTestSuitePipeline', () => { const mockOnSubmit = jest.fn(); render( -
- - + ); await act(async () => { @@ -287,20 +249,18 @@ describe('AddTestSuitePipeline', () => { } as TestCase; render( -
- - + ); await act(async () => { @@ -319,11 +279,7 @@ describe('AddTestSuitePipeline', () => { it('uses testSuiteId from testSuite prop when available', () => { const testSuite = { id: 'prop-test-suite-id' } as TestSuite; - render( -
- - - ); + render(); expect(screen.getByText('AddTestCaseList.component')).toBeInTheDocument(); }); @@ -333,11 +289,7 @@ describe('AddTestSuitePipeline', () => { search: '?testSuiteId=url-test-suite-id', }); - render( -
- - - ); + render(); expect(screen.getByText('AddTestCaseList.component')).toBeInTheDocument(); }); @@ -347,11 +299,7 @@ describe('AddTestSuitePipeline', () => { search: 'testSuiteId=no-question-mark-id', }); - render( -
- - - ); + render(); expect(screen.getByText('AddTestCaseList.component')).toBeInTheDocument(); }); @@ -363,11 +311,7 @@ describe('AddTestSuitePipeline', () => { const testSuite = { id: 'prop-id' } as TestSuite; - render( -
- - - ); + render(); expect(screen.getByText('AddTestCaseList.component')).toBeInTheDocument(); }); @@ -389,13 +333,11 @@ describe('AddTestSuitePipeline', () => { } as TestSuite; render( -
- - + ); expect(screen.getByText('AddTestCaseList.component')).toBeInTheDocument(); @@ -425,13 +367,11 @@ describe('AddTestSuitePipeline', () => { const testSuite = { id: 'logical-suite-id', basic: false } as TestSuite; render( -
- - + ); const lastCall = @@ -453,13 +393,9 @@ describe('AddTestSuitePipeline', () => { describe('Edit mode behavior', () => { it('displays Save button in edit mode', () => { - mockUseFqn.mockReturnValueOnce({ ingestionFQN: 'test-ingestion-fqn' }); + mockUseFqn.mockReturnValue({ ingestionFQN: 'test-ingestion-fqn' }); - render( -
- - - ); + render(); expect( screen.getByRole('button', { name: 'label.save' }) @@ -467,13 +403,9 @@ describe('AddTestSuitePipeline', () => { }); it('displays Create button when not in edit mode', () => { - mockUseFqn.mockReturnValueOnce({ ingestionFQN: '' }); + mockUseFqn.mockReturnValue({ ingestionFQN: '' }); - render( -
- - - ); + render(); expect( screen.getByRole('button', { name: 'label.create' }) @@ -494,13 +426,11 @@ describe('AddTestSuitePipeline', () => { }; render( -
- - + ); await act(async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.tsx index 0521b25964a9..ae297b05defc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/AddTestSuitePipeline.tsx @@ -10,26 +10,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button, Grid } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; +import { + Box, + Button, + Card, + FieldProp, + FieldTypes, + FormField, + FormItemLabel, + FormItemLayout, + getField, + Grid, + HintText, + HookForm, + Input, +} from '@openmetadata/ui-core-components'; import { isEmpty } from 'lodash'; import QueryString from 'qs'; -import { useMemo, useState } from 'react'; +import { Fragment, useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { DEFAULT_SCHEDULE_CRON_DAILY } from '../../../../constants/Schedular.constants'; import useCustomLocation from '../../../../hooks/useCustomLocation/useCustomLocation'; import { useFqn } from '../../../../hooks/useFqn'; -import { - FieldProp, - FieldTypes, - FormItemLayout, -} from '../../../../interface/FormUtils.interface'; import { ListTestCaseParamsBySearch } from '../../../../rest/testAPI'; -import { getLayoutGutter } from '../../../../utils/common/layout.utils'; import { getDefaultScheduleValue } from '../../../../utils/CronExpressionUtils'; -import { generateFormFields } from '../../../../utils/formUtils'; -import { getRaiseOnErrorFormField } from '../../../../utils/SchedularUtils'; import { escapeESReservedCharacters } from '../../../../utils/StringUtils'; import ScheduleInterval from '../../../Settings/Services/AddIngestion/Steps/ScheduleInterval'; import { WorkflowExtraConfig } from '../../../Settings/Services/AddIngestion/Steps/ScheduleInterval.types'; @@ -39,7 +45,6 @@ import { AddTestSuitePipelineProps, TestSuiteIngestionDataType, } from '../AddDataQualityTest.interface'; -import './add-test-suite-pipeline.style.less'; const AddTestSuitePipeline = ({ initialData, @@ -91,13 +96,7 @@ const AddTestSuitePipeline = ({ [testSuiteId, tableFqnForFilters] ); - const [selectAllTestCases, setSelectAllTestCases] = useState( - initialData?.selectAllTestCases - ); const [isSchedulerValid, setIsSchedulerValid] = useState(true); - const [form] = Form.useForm< - WorkflowExtraConfig & TestSuiteIngestionDataType - >(); const isEditMode = !isEmpty(ingestionFQN); const initialCron = isEditMode ? initialData?.cron @@ -107,22 +106,13 @@ const AddTestSuitePipeline = ({ includePeriodOptions, allowNoSchedule: true, }); - - const formFields: FieldProp[] = [ - { - name: 'name', - label: t('label.name'), - type: FieldTypes.TEXT, - required: false, - placeholder: t('label.enter-entity', { - entity: t('label.name'), - }), - props: { - 'data-testid': 'pipeline-name', - }, - id: 'root/name', - }, - ]; + const form = useForm({ + defaultValues: { ...initialData, cron: initialCron }, + }); + const selectAllTestCases = useWatch({ + control: form.control, + name: 'selectAllTestCases', + }); const testCaseFormFields: FieldProp[] = [ { @@ -134,6 +124,11 @@ const AddTestSuitePipeline = ({ required: false, props: { 'data-testid': 'select-all-test-cases', + onChange: (value) => { + if (value === 'true') { + form.setValue('testCases', undefined); + } + }, }, id: 'root/selectAllTestCases', formItemLayout: FormItemLayout.HORIZONTAL, @@ -185,35 +180,39 @@ const AddTestSuitePipeline = ({ }); }; - const handleValuesChange = ( - changedValues: Partial - ) => { - if (!Object.hasOwn(changedValues, 'selectAllTestCases')) { - return; - } - - const value = Boolean(changedValues.selectAllTestCases); - setSelectAllTestCases(value); - if (value) { - form.setFieldsValue({ testCases: undefined }); - } + const raiseOnErrorFormField: FieldProp = { + name: 'raiseOnError', + label: t('label.raise-on-error'), + type: FieldTypes.SWITCH, + required: false, + formItemLayout: FormItemLayout.HORIZONTAL, + id: 'root/raiseOnError', }; - const raiseOnErrorFormField = useMemo(() => getRaiseOnErrorFormField(), []); - return ( -
+ id="schedular-form" + validationBehavior="aria" + onSubmit={form.handleSubmit(onFinish)}> + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> - {generateFormFields(formFields)} + + {({ field }) => ( + + )} + {t('label.schedule-for-entity', { @@ -221,58 +220,79 @@ const AddTestSuitePipeline = ({ })} - - - + + {({ field }) => ( + + )} + - {generateFormFields([debugLogFormField])} + {getField(debugLogFormField)} - {generateFormFields([raiseOnErrorFormField])} + {getField(raiseOnErrorFormField)} - - - {generateFormFields(testCaseFormFields)} - - {!selectAllTestCases && ( - - - - - - )} - + + + + + {testCaseFormFields.map((field) => ( + {getField(field)} + ))} + + {!selectAllTestCases && ( + + + normalizeSelectedTestProp(value).length > 0 || + t('label.field-required', { + field: t('label.test-case'), + }), + }}> + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + + + )} + + +
- ))} - - )} - -); - -const ParameterForm: React.FC = ({ definition, table }) => { - const { t } = useTranslation(); - - const prepareForm = ( - data: TestCaseParameterDefinition, - DynamicField?: ReactElement - ) => { - const label = getEntityName(data); - const ruleValidation: RuleRender = ({ getFieldValue }) => ({ - validator(_, formValue) { - if (data?.validationRule) { - const fieldValue = data.validationRule.parameterField - ? +getFieldValue(['params', data.validationRule.parameterField]) - : undefined; - const value = +formValue; - if (fieldValue && value) { - switch (data.validationRule.rule) { - case Rule.GreaterThanOrEquals: - return validateGreaterThanOrEquals(fieldValue, value); - case Rule.LessThanOrEquals: - return validateLessThanOrEquals(fieldValue, value); - case Rule.Equals: - return validateEquals(fieldValue, value); - case Rule.NotEquals: - return validateNotEquals(fieldValue, value); - } - } - } - - return Promise.resolve(); - }, - }); - - let internalFormItemProps: FormItemProps = {}; - let Field = ( - - ); - if (data.optionValues?.length) { - Field = ( - - ); - } else if (data.dataType === TestDataType.String) { - Field = buildStringField({ data, table, definition, label, t }); - } else if (NUMERIC_DATA_TYPES.includes(data.dataType)) { - Field = ( - - ); - } else if (data.dataType === TestDataType.Boolean) { - Field = ; - internalFormItemProps = { - ...internalFormItemProps, - valuePropName: 'isSelected', - }; - } else if ( - data.dataType === TestDataType.Array || - data.dataType === TestDataType.Set - ) { - return buildArraySetField({ data, label, DynamicField, t }); - } - - const commonFormItemProps = { - 'data-testid': 'parameter', - key: data.name, - - name: data.name, - rules: [ - { - required: data.required, - message: `${t('message.field-text-is-required', { - fieldText: label, - })}`, - }, - ruleValidation, - ], - tooltip: data.description, - ...internalFormItemProps, - }; - - return data.dataType === TestDataType.Boolean ? ( -
- - {Field} - - {label} -
- ) : ( - - {DynamicField ?? Field} - - ); - }; - - const paramsForm: FormListProps['children'] = () => { - switch (definition.fullyQualifiedName) { - case TABLE_DIFF: - return ( - - ); - - default: - return definition.parameterDefinition?.map((data) => prepareForm(data)); - } - }; - - return {paramsForm}; -}; - -export default ParameterForm; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/add-test-suite-pipeline.style.less b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/add-test-suite-pipeline.style.less deleted file mode 100644 index 68feef8397d9..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/add-test-suite-pipeline.style.less +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2024 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@import (reference) '../../../../styles/variables.less'; - -.add-test-case-container { - padding: var(--om-space-16); - border-radius: @border-rad-base; - background-color: @grey-1; - - .form-item-horizontal { - margin-bottom: 0; - } -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx index a8845be9bf01..21f8c623d6fe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Box, Dialog } from '@openmetadata/ui-core-components'; import { fireEvent, render, @@ -293,6 +294,53 @@ describe('AddTestCaseList', () => { } }); + it('submits selected cases from actions outside the scrolling dialog content', async () => { + mockGetListTestCaseBySearch.mockResolvedValue({ + data: [mockTestCases[0]], + paging: { total: 1 }, + }); + const onSubmit = jest.fn(); + await act(async () => + render( + + + ( + <> + + + {content} + + + {actions} + + )} + onSubmit={onSubmit} + /> + + + ) + ); + const checkbox = await screen.findByTestId( + `checkbox-${mockTestCases[0].name}` + ); + fireEvent.click(checkbox); + + expect( + within(screen.getByTestId('test-case-dialog-content')).queryByTestId( + 'submit' + ) + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('submit')); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ includeIds: [mockTestCases[0].id] }) + ) + ); + }); + it('windows large lists and preserves selection when a row leaves the viewport', async () => { const cases = Array.from({ length: 200 }, (_, index) => ({ ...mockTestCases[0], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.tsx index fd39e6a3f53e..d7be384cc193 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.tsx @@ -26,6 +26,7 @@ import { AxiosError } from 'axios'; import { debounce } from 'lodash'; import isEmpty from 'lodash/isEmpty'; import { + ReactNode, UIEventHandler, useCallback, useEffect, @@ -156,6 +157,17 @@ const buildTestCaseSearchParams = ({ return { ...testCaseParams, ...requestParams }; }; +const renderDefaultLayout = (content: ReactNode, actions: ReactNode) => ( + + {content} + {actions && ( + + {actions} + + )} + +); + export const AddTestCaseList = ({ onCancel, onSubmit, @@ -166,6 +178,7 @@ export const AddTestCaseList = ({ existingTest, onChange, showButton = true, + renderLayout = renderDefaultLayout, testCaseParams, hideTableFilter = false, getPopupContainer, @@ -899,8 +912,22 @@ export const AddTestCaseList = ({ ] ); - return ( - + const actions = showButton ? ( + <> + + + + ) : null; + const content = ( + <> )} {renderList} - {showButton && ( - - - - - )} - + ); + + return renderLayout(content, actions); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.interface.ts index c84dd1af3052..5b3320b058d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.interface.ts @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { ReactElement, ReactNode } from 'react'; import { EntityReference, TestCase } from '../../../generated/tests/testCase'; import { AddTestCaseListSubmitPayload, @@ -32,6 +33,7 @@ export interface AddTestCaseModalProps { testCaseParams?: ListTestCaseParamsBySearch; selectedTest?: string[]; showButton?: boolean; + renderLayout?: (content: ReactNode, actions: ReactNode) => ReactElement; hideTableFilter?: boolean; getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement; } 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 1ad7c52fb6e8..80117caac65c 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 @@ -10,18 +10,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Form, Input, Select } from 'antd'; +import { EditorView } from '@codemirror/view'; +import { + Box, + FormField, + FormItemLabel, + HintText, + HookForm, + Input, + Select, +} from '@openmetadata/ui-core-components'; import QueryString from 'qs'; -import { lazy, useEffect, useMemo, useState } from 'react'; +import { lazy, useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { VALIDATION_MESSAGES } from '../../../constants/constants'; -import { NAME_FIELD_RULES } from '../../../constants/Form.constants'; +import { ENTITY_NAME_REGEX } from '../../../constants/regex.constants'; import { CSMode } from '../../../enums/codemirror.enum'; -import { CustomMetric } from '../../../generated/entity/data/table'; +import { CustomMetric } from '../../../generated/tests/customMetric'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import { getEntityName } from '../../../utils/EntityNameUtils'; import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; -import Loader from '../../common/Loader/Loader'; import { CustomMetricFormProps } from './CustomMetricForm.interface'; const SchemaEditor = withSuspenseFallback( @@ -38,116 +46,167 @@ const CustomMetricForm = ({ }: CustomMetricFormProps) => { const { t } = useTranslation(); const location = useCustomLocation(); - const [isLoading, setIsLoading] = useState(true); + const localForm = useForm({ + defaultValues: { name: '', expression: '', ...initialValues }, + }); + const activeForm = form ?? localForm; + const { reset, getValues } = activeForm; + const queryLabel = t('label.sql-uppercase-query'); + const queryError = activeForm.formState.errors.expression?.message; + const queryExtensions = useMemo( + () => [ + EditorView.contentAttributes.of({ + 'aria-label': queryLabel, + 'aria-invalid': String(Boolean(queryError)), + ...(queryError ? { 'aria-describedby': 'metric-query-error' } : {}), + }), + ], + [queryLabel, queryError] + ); const { activeColumnFqn } = useMemo(() => { const param = location.search; - const searchData = QueryString.parse( - param.startsWith('?') ? param.substring(1) : param - ); - return searchData as { activeColumnFqn: string }; + return QueryString.parse( + param.startsWith('?') ? param.substring(1) : param + ) as { activeColumnFqn: string }; }, [location.search]); const { metricNames, columnOptions } = useMemo(() => { - let customMetrics = table?.customMetrics ?? []; - - if (isColumnMetric) { - customMetrics = - table?.columns?.find( + const customMetrics = isColumnMetric + ? table?.columns?.find( (column) => column.fullyQualifiedName === activeColumnFqn - )?.customMetrics ?? []; - } + )?.customMetrics ?? [] + : table?.customMetrics ?? []; return { metricNames: customMetrics.map((metric) => metric.name), - columnOptions: table ? table.columns : [], + columnOptions: + table?.columns.map((column) => ({ + id: column.name, + label: getEntityName(column), + })) ?? [], }; }, [activeColumnFqn, isColumnMetric, table]); useEffect(() => { - if (form && initialValues) { - form.setFieldsValue(initialValues); + if (initialValues) { + reset({ ...getValues(), ...initialValues }); } - setIsLoading(false); - }, [initialValues]); - - if (isLoading) { - return ; - } + }, [initialValues, reset, getValues]); return ( - + - { - if (metricNames.includes(value) && !isEditMode) { - return Promise.reject( - t('message.entity-already-exists', { - entity: t('label.custom-metric'), - }) - ); - } - - return Promise.resolve(); + form={activeForm} + id="custom-metric-form" + validationBehavior="aria" + onSubmit={activeForm.handleSubmit(({ name, expression, columnName }) => + onFinish({ + name, + expression, + ...(isColumnMetric ? { columnName } : {}), + }) + )}> + + - - - {isColumnMetric && ( - - - - )} - - - - + validate: (value) => + isEditMode || + !metricNames.includes(value) || + t('message.entity-already-exists', { + entity: t('label.custom-metric'), + }), + }}> + {({ field, fieldState }) => ( + + )} + + {isColumnMetric && ( + + {({ field, fieldState }) => ( + + )} + + )} + + {({ field }) => ( + + + + {queryError && ( + + {queryError} + + )} + + )} + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.interface.ts index 2d4537707adf..48a38c25bddc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.interface.ts @@ -10,15 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FormInstance } from 'antd'; +import { UseFormReturn } from 'react-hook-form'; import { Table } from '../../../generated/entity/data/table'; import { CustomMetric } from '../../../generated/tests/customMetric'; export interface CustomMetricFormProps { isColumnMetric: boolean; - initialValues?: CustomMetric; + initialValues?: Partial; onFinish: (values: CustomMetric) => void; - form?: FormInstance; + form?: UseFormReturn; table?: Table; isEditMode?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.test.tsx index b9c856a870f7..4068fedc8434 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/CustomMetricForm/CustomMetricForm.test.tsx @@ -10,8 +10,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, screen, within } from '@testing-library/react'; -import { Form } from 'antd'; +import { act, fireEvent, render, screen, 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 CustomMetricForm from './CustomMetricForm.component'; jest.mock('../../../hooks/useCustomLocation/useCustomLocation', () => { @@ -44,9 +46,11 @@ describe('CustomMetricForm', () => { render(); expect(await screen.findByTestId('custom-metric-name')).toBeDisabled(); - expect(await screen.findByTestId('custom-metric-column')).toHaveClass( - 'ant-select-disabled' - ); + expect( + within(await screen.findByTestId('custom-metric-column')).getByRole( + 'button' + ) + ).toBeDisabled(); }); it('initial value is visible if provided', async () => { @@ -55,28 +59,24 @@ describe('CustomMetricForm', () => { expression: 'select * from table', columnName: 'column', }; - const FormWrapper = () => { - const [form] = Form.useForm(); - - return ( - - ); - }; - - render(); + render( + + ); expect(await screen.findByTestId('custom-metric-name')).toHaveValue( initialValues.name ); expect( - await screen.findByText(initialValues.columnName) - ).toBeInTheDocument(); + within(await screen.findByTestId('custom-metric-column')).getByRole( + 'button' + ) + ).toHaveTextContent(initialValues.columnName); // The line-number and fold gutters live inside the container, so read the // expression from the editor's content element instead of the whole box. expect( @@ -85,4 +85,75 @@ describe('CustomMetricForm', () => { ) ).toHaveTextContent(initialValues.expression); }); + + it('blocks empty name and SQL fields without submitting', async () => { + const onFinish = jest.fn(); + render(); + const form = await screen.findByTestId('custom-metric-form'); + await act(async () => { + fireEvent.submit(form); + }); + + expect(onFinish).not.toHaveBeenCalled(); + expect(screen.getByText('label.field-required')).toBeVisible(); + expect(screen.getByText('message.field-text-is-required')).toBeVisible(); + }); + + it('submits edited values while keeping the disabled metric name', async () => { + const onFinish = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + const editor = within( + await screen.findByTestId('code-mirror-container') + ).getByRole('textbox'); + await user.click(editor); + await user.keyboard('{Control>}a{/Control}SELECT 10'); + await act(async () => { + fireEvent.submit(screen.getByTestId('custom-metric-form')); + }); + + expect(onFinish).toHaveBeenCalledWith({ + name: 'row_metric', + expression: 'SELECT 10', + }); + }); + + it('rejects an existing table metric name before submitting', async () => { + const onFinish = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + await user.type( + await screen.findByTestId('custom-metric-name'), + 'duplicate' + ); + await act(async () => { + fireEvent.submit(screen.getByTestId('custom-metric-form')); + }); + + expect(screen.getByText('message.entity-already-exists')).toBeVisible(); + expect(onFinish).not.toHaveBeenCalled(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/DimensionalityHeatmap.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/DimensionalityHeatmap.component.test.tsx index 55672527bb1b..458fcb2b384f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/DimensionalityHeatmap.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/DimensionalityHeatmap.component.test.tsx @@ -11,7 +11,8 @@ * limitations under the License. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { TestCaseStatus } from '../../../../../generated/tests/dimensionResult'; import DimensionalityHeatmap from './DimensionalityHeatmap.component'; @@ -21,19 +22,6 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => ( <>{children} ); -jest.mock('@openmetadata/ui-core-components', () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, - Typography: ({ - as: Component = 'span', - children, - ...props - }: { - as?: React.ElementType; - children: React.ReactNode; - [key: string]: unknown; - }) => {children}, -})); - jest.mock('./useScrollIndicator.hook', () => ({ useScrollIndicator: jest.fn(() => ({ showLeftIndicator: false, @@ -43,12 +31,6 @@ jest.mock('./useScrollIndicator.hook', () => ({ })), })); -jest.mock('./HeatmapCellTooltip.component', () => ({ - HeatmapCellTooltip: jest - .fn() - .mockImplementation(() =>
HeatmapCellTooltip
), -})); - describe('DimensionalityHeatmap Component', () => { const startDate = new Date('2025-01-01').getTime(); const endDate = new Date('2025-01-03').getTime(); @@ -76,6 +58,35 @@ describe('DimensionalityHeatmap Component', () => { jest.clearAllMocks(); }); + it('shows the actual result details on hover and dismisses them on mouse leave', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { container } = render( + + ); + const cell = container.querySelector('.dimensionality-heatmap__cell'); + if (!cell) { + throw new Error('Heatmap cell missing'); + } + fireEvent.mouseMove(document); + await user.hover(cell); + const tooltip = await screen.findByRole('tooltip'); + + expect(tooltip).toHaveTextContent('US'); + expect(tooltip).toHaveTextContent('2025-01-01'); + expect(within(tooltip).getByText('100')).toBeInTheDocument(); + expect(within(tooltip).getByText('label.passed-rows')).toBeInTheDocument(); + expect(within(tooltip).getByText('label.failed-rows')).toBeInTheDocument(); + + await user.unhover(cell); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + describe('Rendering States', () => { it('should render loading state when isLoading is true', () => { render( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/DimensionalityHeatmap.less b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/DimensionalityHeatmap.less index 34e0425ced75..2bd05ac4eb5c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/DimensionalityHeatmap.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/DimensionalityHeatmap.less @@ -13,20 +13,6 @@ @import url('../../../../../styles/variables.less'); -// Rendered inside a core Tooltip whose container is made transparent, so the -// cell card supplies its own surface. -.heatmap-cell-tooltip__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: @size-3xl; -} - -.heatmap-cell-tooltip__label, -.heatmap-cell-tooltip__value { - font-size: @size-sm; -} - .dimensionality-heatmap { width: 100%; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/HeatmapCellTooltip.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/HeatmapCellTooltip.component.tsx index 355d3e7c5a70..d7aa6d8a2ceb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/HeatmapCellTooltip.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/DimensionalityTab/DimensionalityHeatmap/HeatmapCellTooltip.component.tsx @@ -11,7 +11,12 @@ * limitations under the License. */ -import { Typography } from '@openmetadata/ui-core-components'; +import { + Box, + Card, + Divider, + Typography, +} from '@openmetadata/ui-core-components'; import { isUndefined } from 'lodash'; import { FC, ReactNode, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,18 +32,18 @@ interface TooltipRowProps { const TooltipRow: FC = ({ label, value }) => { return ( -
+ + className="tw:whitespace-nowrap" + color="secondary" + size="text-xs"> {label} - + {value} -
+ ); }; @@ -87,35 +92,32 @@ export const HeatmapCellTooltip: FC = ({ cell }) => { ); return ( -
- - {cell.date} - -
-
- {rows.map( - (row) => - !isUndefined(row.value) && ( - - ) - )} - {cell.result?.testResultValue && - cell.result.testResultValue.length > 0 && - cell.result.testResultValue.map((resultValue) => ( - - ))} -
-
+ + + + {cell.date} + + + + {rows.map( + (row) => + !isUndefined(row.value) && ( + + ) + )} + {cell.result?.testResultValue && + cell.result.testResultValue.length > 0 && + cell.result.testResultValue.map((resultValue) => ( + + ))} + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/FailedTestCaseSampleData/FailedTestCaseSampleData.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/FailedTestCaseSampleData/FailedTestCaseSampleData.component.tsx index 3ba35f97d4a9..04e239ad2a84 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/FailedTestCaseSampleData/FailedTestCaseSampleData.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/FailedTestCaseSampleData/FailedTestCaseSampleData.component.tsx @@ -17,6 +17,7 @@ import { Table, Typography, } from '@openmetadata/ui-core-components'; +import { Trash01 } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; import classNames from 'classnames'; import { isUndefined } from 'lodash'; @@ -28,7 +29,6 @@ import { ManageMenuItem, } from '../../../common/EntityPageInfos/ManageButton/ManageMenu'; -import { ReactComponent as IconDelete } from '../../../../assets/svg/ic-delete.svg'; import { usePermissionProvider } from '../../../../context/PermissionProvider/PermissionProvider'; import { ClientErrors } from '../../../../enums/Axios.enum'; import { ResourceEntity } from '../../../../enums/permissions.enum'; @@ -129,7 +129,7 @@ const FailedTestCaseSampleData = ({ description={t('message.delete-entity-type-action-description', { entityType: t('label.sample-data'), })} - icon={IconDelete} + icon={Trash01} id="delete-button" name={t('label.delete')} /> @@ -281,7 +281,7 @@ const FailedTestCaseSampleData = ({ weight="bold"> {t('label.sample-data')} -
+ {testCaseData?.inspectionQuery && !isVersionPage && ( )} @@ -304,7 +305,7 @@ const FailedTestCaseSampleData = ({ onOpenChange={setShowActions} /> )} -
+
{ jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - Link: jest.fn().mockImplementation(({ children, to, ...rest }) => ( - - {children} - - )), useParams: jest.fn().mockReturnValue({}), })); @@ -176,26 +171,20 @@ describe('FailedTestCaseSampleData - observabilityRouterClassBase migration', () }); }); - it('explore-with-query Link should use observabilityRouterClassBase.getTestCaseDetailPagePath with SQL_QUERY tab', async () => { - const { getTestCaseDetailPagePath } = jest.requireMock( - '../../../../utils/RouterUtils' - ); - + it('links to the SQL query tab for this test case', async () => { render(); - const exploreLink = await screen.findByTestId('explore-with-query'); + const link = await screen.findByRole('link', { + name: 'label.explore-with-query', + }); - expect(exploreLink).toHaveAttribute( + expect(link).toHaveAttribute( 'href', observabilityRouterClassBase.getTestCaseDetailPagePath( FQN, TestCasePageTabs.SQL_QUERY ) ); - expect(getTestCaseDetailPagePath).toHaveBeenCalledWith( - FQN, - TestCasePageTabs.SQL_QUERY - ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/AddSqlQueryFormModal/AddSqlQueryFormModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/AddSqlQueryFormModal/AddSqlQueryFormModal.component.tsx index 9aac5e4e3d05..b87f7a6785a8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/AddSqlQueryFormModal/AddSqlQueryFormModal.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/AddSqlQueryFormModal/AddSqlQueryFormModal.component.tsx @@ -11,13 +11,26 @@ * limitations under the License. */ -import { SimpleModal } from '@openmetadata/ui-core-components'; -import { Form, FormProps, Input } from 'antd'; +import { EditorView } from '@codemirror/view'; +import { + Box, + Button, + Dialog, + FormField, + FormItemLabel, + HintText, + HookForm, + Input, + Modal, + ModalOverlay, +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; -import { lazy, useEffect, useState } from 'react'; +import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { HTTP_STATUS_CODE } from '../../../../../constants/Auth.constants'; +import { NO_PERMISSION_FOR_ACTION } from '../../../../../constants/HelperTextUtil'; import { usePermissionProvider } from '../../../../../context/PermissionProvider/PermissionProvider'; import { CSMode } from '../../../../../enums/codemirror.enum'; import { EntityType, FqnPart } from '../../../../../enums/entity.enum'; @@ -43,41 +56,67 @@ const SchemaEditor = withSuspenseFallback( lazy(() => import('../../../../Database/SchemaEditor/SchemaEditor')) ); +interface QueryFormValues { + query: string; + table: string; + description: string; +} + const AddSqlQueryFormModal = ({ open, onCancel, }: AddSqlQueryFormModalProps) => { - const [form] = Form.useForm(); const { t } = useTranslation(); const { permissions } = usePermissionProvider(); const { currentUser } = useApplicationStore(); const { testCase } = useTestCaseStore(); + const form = useForm({ + defaultValues: { + query: testCase?.inspectionQuery ?? '', + table: '', + description: '', + }, + }); + const { setValue } = form; const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [table, setTable] = useState
(); + const queryLabel = t('label.sql-uppercase-query'); + const queryError = form.formState.errors.query?.message; + const queryExtensions = useMemo( + () => [ + EditorView.contentAttributes.of({ + 'aria-label': queryLabel, + 'aria-invalid': String(Boolean(queryError)), + ...(queryError ? { 'aria-describedby': 'query-error' } : {}), + }), + ], + [queryLabel, queryError] + ); - const fetchTableData = async (entityFQN: string) => { - setIsLoading(true); - const tableFQN = getPartialNameFromTableFQN( - entityFQN, - [FqnPart.Service, FqnPart.Database, FqnPart.Schema, FqnPart.Table], - '.' - ); - try { - const response = await getTableDetailsByFQN(tableFQN); - form.setFieldsValue({ - table: response.fullyQualifiedName ?? tableFQN, - }); - setTable(response); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setIsLoading(false); - } - }; + const fetchTableData = useCallback( + async (entityFQN: string) => { + setIsLoading(true); + const tableFQN = getPartialNameFromTableFQN( + entityFQN, + [FqnPart.Service, FqnPart.Database, FqnPart.Schema, FqnPart.Table], + '.' + ); + try { + const response = await getTableDetailsByFQN(tableFQN); + setValue('table', response.fullyQualifiedName ?? tableFQN); + setTable(response); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }, + [setValue] + ); - const handleSubmit: FormProps['onFinish'] = async (values): Promise => { + const handleSubmit = async (values: QueryFormValues): Promise => { setIsSaving(true); const updatedValues: CreateQuery = { description: values.description, @@ -132,68 +171,110 @@ const AddSqlQueryFormModal = ({ useEffect(() => { if (testCase) { fetchTableData(testCase?.entityFQN ?? ''); - form.setFieldsValue({ - query: testCase.inspectionQuery, - }); + setValue('query', testCase.inspectionQuery ?? ''); } - }, [testCase]); + }, [testCase, fetchTableData, setValue]); return ( - - {isLoading ? ( - - ) : ( -
- - - - - - - - - - - )} -
+ onOpenChange={(isOpen) => !isOpen && onCancel()}> + + + + {isLoading ? ( + + ) : ( + + + + {({ field }) => ( + + + + {queryError && ( + + {queryError} + + )} + + )} + + + {({ field }) => ( + + )} + + + {({ field }) => ( + + + + + )} + + + + )} + + + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/SqlQueryTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/SqlQueryTab.component.tsx index a4a5e3f4fe59..f2916fe29768 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/SqlQueryTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/SqlQueryTab/SqlQueryTab.component.tsx @@ -17,7 +17,6 @@ import { lazy, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { ChangeDescription } from '../../../../generated/tests/testCase'; -import { getLayoutGutter } from '../../../../utils/common/layout.utils'; import { usePermissionProvider } from '../../../../context/PermissionProvider/PermissionProvider'; import { useTestCaseStore } from '../../../../pages/IncidentManager/IncidentManagerDetailPage/useTestCase.store'; @@ -80,14 +79,15 @@ const SqlQueryTab = () => { return ( + className="layout-row layout-grid sql-query-tab p-md tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> {permissions.query?.Create && !isVersionPage && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/ClassicTestCaseFilter.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/ClassicTestCaseFilter.test.tsx new file mode 100644 index 000000000000..be70b4a91ab6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/ClassicTestCaseFilter.test.tsx @@ -0,0 +1,209 @@ +/* + * 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 { useState } from 'react'; +import { ClassicTestCaseFilter } from './ClassicTestCaseFilter'; +import { FilterDescriptor, FilterValue } from './FilterChip.interface'; + +const descriptor: FilterDescriptor = { + key: 'testCaseStatus', + paramKey: 'testCaseStatus', + label: 'Status', + controlType: 'multiselect', + searchable: false, + options: [ + { value: 'Success', label: 'Success' }, + { value: 'Queued', label: 'Queued' }, + { value: 'Failed', label: 'Failed' }, + ], + isLoading: false, + onGetInitialOptions: () => undefined, + onChange: () => undefined, +}; +const ControlledFilter = ({ + filter = descriptor, +}: { + filter?: FilterDescriptor; +}) => { + const [value, setValue] = useState(filter.value); + + return ( + + ); +}; + +describe('ClassicTestCaseFilter', () => { + it('filters local dimension options and submits the option value', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onChange = jest.fn(); + render( + + ); + await user.type( + screen.getByRole('combobox', { name: 'Dimension' }), + 'aCCu' + ); + + expect( + screen.queryByRole('option', { name: 'Completeness' }) + ).not.toBeInTheDocument(); + + await user.click(await screen.findByRole('option', { name: 'Accuracy' })); + + expect(onChange).toHaveBeenCalledWith('Accuracy'); + }); + + it('renders a non-searchable descriptor as a plain select regardless of its key', () => { + render( + + ); + + expect( + screen.queryByRole('combobox', { name: 'Tier' }) + ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Tier Tier' })).toBeVisible(); + }); + + it('keeps multiple selections and removes only the selected chip', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByRole('combobox', { name: 'Status' }); + await user.click(input); + await user.click(await screen.findByRole('option', { name: 'Success' })); + await user.click(input); + await user.click(await screen.findByRole('option', { name: 'Queued' })); + await user.keyboard('{Escape}'); + + expect(screen.getAllByTestId('autocomplete-selected-item')).toHaveLength(2); + + const successChip = screen + .getByText('Success') + .closest('[data-testid=autocomplete-selected-item]'); + if (!successChip) { + throw new Error('Success chip missing'); + } + await user.click(within(successChip as HTMLElement).getByRole('button')); + + expect(screen.getAllByTestId('autocomplete-selected-item')).toHaveLength(1); + expect(screen.getByTestId('autocomplete-selected-item')).toHaveTextContent( + 'Queued' + ); + }); + + it('synchronizes a changed URL value and retains options missing from the first server page', async () => { + const { rerender } = render( + + ); + + expect(screen.getByTestId('autocomplete-selected-item')).toHaveTextContent( + 'Success' + ); + + rerender( + + ); + await waitFor(() => + expect(screen.getAllByTestId('autocomplete-selected-item')).toHaveLength( + 2 + ) + ); + + expect(screen.getByTestId('filter')).toHaveTextContent('Queued'); + expect(screen.getByTestId('filter')).toHaveTextContent('Pending'); + expect(screen.getByTestId('filter')).not.toHaveTextContent('Success'); + }); + + it('searches manually typed text equal to a loaded label without selecting it', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onSearch = jest.fn(); + const onChange = jest.fn(); + render( + + ); + await user.type(screen.getByRole('combobox', { name: 'Table' }), 'Orders'); + + expect(onSearch).toHaveBeenLastCalledWith('Orders'); + expect(onChange).not.toHaveBeenCalled(); + + await user.click(await screen.findByRole('option', { name: 'Orders' })); + + expect(onChange).toHaveBeenCalledWith('service.table'); + }); + + it('clears the single selection without choosing a different option', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + ); + await user.click(screen.getByRole('button', { name: 'label.clear Type' })); + + expect(screen.getByRole('button', { name: 'Type Type' })).toHaveTextContent( + 'Type' + ); + expect( + screen.queryByRole('button', { name: 'label.clear Type' }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/ClassicTestCaseFilter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/ClassicTestCaseFilter.tsx new file mode 100644 index 000000000000..01abf4dcdb61 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/ClassicTestCaseFilter.tsx @@ -0,0 +1,208 @@ +/* + * 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 { + Autocomplete, + Box, + Button, + FormSelectItem, + Select, + Tooltip, + Typography, +} from '@openmetadata/ui-core-components'; +import { XClose } from '@openmetadata/ui-core-components/icons'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import DatePickerMenu from '../../common/DatePickerMenu/DatePickerMenu.component'; +import { getClassicFilterItems } from './ClassicTestCaseFilter.utils'; +import { FilterDescriptor } from './FilterChip.interface'; + +interface ClassicTestCaseFilterProps { + filter: FilterDescriptor; + className?: string; + testId: string; +} + +interface ClassicSingleSelectProps + extends Pick { + items: FormSelectItem[]; + selectedKey: string | null; +} + +const ClassicSingleSelect = ({ + filter, + items, + selectedKey, + testId, +}: ClassicSingleSelectProps) => { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + // Select.ComboBox gets controlled `items`, so react-aria skips its defaultFilter; + // local filtering has to happen here when there is no server-side search. + const visibleItems = filter.onSearch + ? items + : items.filter((item) => + (item.label ?? String(item.id)) + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase()) + ); + const handleSearch = (value: string) => { + setSearch(value); + filter.onSearch?.(value); + }; + const handleSelection = (key: string | number | null) => { + if (key !== null) { + filter.onChange(String(key)); + } + }; + const renderItem = (item: FormSelectItem) => ( + + {item.label} + + ); + if (filter.searchable) { + return ( + { + if (isOpen) { + setSearch(''); + } + }}> + {renderItem} + + ); + } + + return ( + + ); +}; + +const ClassicMultipleSelect = ({ + filter, + testId, + items, + selectedItems, + selectedValues, +}: Pick & + ReturnType) => ( + + true : undefined} + icon={null} + items={items} + placeholder={filter.label} + selectedItems={selectedItems} + onItemCleared={(key) => + filter.onChange(selectedValues.filter((value) => value !== String(key))) + } + onItemInserted={(key) => + filter.onChange([...new Set([...selectedValues, String(key)])]) + } + onSearchChange={filter.onSearch}> + {(item) => ( + + {item.label} + + )} + + +); + +export const ClassicTestCaseFilter = ({ + filter, + className, + testId, +}: ClassicTestCaseFilterProps) => { + const { t } = useTranslation(); + const selection = getClassicFilterItems(filter); + const dateValue = + typeof filter.value === 'object' && !Array.isArray(filter.value) + ? filter.value + : undefined; + + return ( + + + {filter.label}: + + {filter.controlType === 'date' ? ( + + ) : ( + + {filter.controlType === 'multiselect' ? ( + + ) : ( + + )} + {selection.selectedValues.length > 0 && ( + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/DataObservabilityTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/DataObservabilityTab.test.tsx index 1c0c4307d218..04197a5221b6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/DataObservabilityTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/DataObservabilityTab.test.tsx @@ -57,7 +57,11 @@ jest.mock('@openmetadata/ui-core-components', () => { ); - return { Tabs, useTabItemState: () => null }; + return { + ...jest.requireActual('@openmetadata/ui-core-components'), + Tabs, + useTabItemState: () => null, + }; }); const mockNavigate = jest.fn(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/DataObservabilityTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/DataObservabilityTab.tsx index f5013c0a9ee0..b06681be9587 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/DataObservabilityTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/DataObservabilityTab.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Tabs } from '@openmetadata/ui-core-components'; +import { Box, Card, Tabs, Typography } from '@openmetadata/ui-core-components'; import { isEmpty } from 'lodash'; import Qs from 'qs'; import { useEffect, useMemo, useState } from 'react'; @@ -164,49 +164,57 @@ const DataObservabilityTab = (props: TableProfilerProps) => { return ( -
-
- {isEmpty(activeColumnFqn) ? ( - handleTabChange(String(key))}> - - {tabOptions.map(({ label, key }) => ( - - - - ))} - - - ) : ( - - - {t(PAGE_HEADERS.COLUMN_PROFILE.header)} - - )} - -
-
- {activeTabComponent} -
-
+ + + {isEmpty(activeColumnFqn) ? ( + handleTabChange(String(key))}> + + {tabOptions.map(({ label, key }) => ( + + + + ))} + + + ) : ( + + + + {t(PAGE_HEADERS.COLUMN_PROFILE.header)} + + + )} + + + + {activeTabComponent} + + +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/data-observability-tab.less b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/data-observability-tab.less index 99e0f5aaeef0..84efd95d6023 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/data-observability-tab.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataObservability/data-observability-tab.less @@ -16,8 +16,6 @@ height: 100%; flex-grow: 1; overflow-y: scroll; - // The entity tab already supplies the outer frame; only its child panels - // need card surfaces, otherwise the profiler becomes a card inside a card. padding: 0; .data-observability-content-panel { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx index df538e450097..474c94dc84eb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx @@ -10,43 +10,48 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Grid } from '@openmetadata/ui-core-components'; +import { Grid, NumberInput, Slider } from '@openmetadata/ui-core-components'; import { WidgetProps } from '@rjsf/utils'; -import { InputNumber, Slider } from 'antd'; -import { getLayoutGutter } from '../../../../utils/common/layout.utils'; +import { isNumber } from 'lodash'; +import { useTranslation } from 'react-i18next'; export const CustomRangeWidget = (props: WidgetProps) => { const min = props.schema.minimum ?? 0; + const { t } = useTranslation(); + const label = props.label || t('label.profile-sample'); return ( - + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-20)] tw:-mx-2.5" + data-testid="percentage-input"> + `${value}%`} + maxValue={100} + minValue={min} + step={1} + value={isNumber(props.value) ? props.value : min} onChange={props.onChange} /> - - `${value}%`} + + + props.onChange(Number.isNaN(value) ? null : value) + } /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomeRangeWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomeRangeWidget.test.tsx index 86deef594bd2..adbaeffa871f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomeRangeWidget.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomeRangeWidget.test.tsx @@ -10,47 +10,69 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { fireEvent, render } from '@testing-library/react'; - import { WidgetProps } from '@rjsf/utils'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { CustomRangeWidget } from './CustomRangeWidget'; -const widgetProps = { - id: 'custom-range-widget', - name: 'custom-range', - value: 50, - schema: {}, - options: {}, - onChange: jest.fn(), - onBlur: jest.fn(), - onFocus: jest.fn(), - registry: {}, -} as unknown as WidgetProps; - -describe('Test Custom Range Widget', () => { - it('renders the CustomRangeWidget with a Slider and InputNumber', () => { - const { getByTestId } = render(); - const slider = getByTestId('percentage-input'); - const inputNumber = getByTestId('slider-input'); - - expect(slider).toBeInTheDocument(); - expect(inputNumber).toBeInTheDocument(); - }); - it('updates the value when Slider is changed', () => { - const { getByTestId } = render(); - const slider = getByTestId('slider-input'); +const Range = () => { + const [value, setValue] = useState(50); + const props = { + id: 'sample', + name: 'sample', + label: 'Sample', + value, + schema: { minimum: 1 }, + options: {}, + onChange: setValue, + onBlur: jest.fn(), + onFocus: jest.fn(), + registry: {}, + } as unknown as WidgetProps; + + return ; +}; - fireEvent.change(slider, { target: { value: 75 } }); +describe('CustomRangeWidget', () => { + it('keeps the slider and input synchronized after editing a percentage', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByTestId('slider-input'); + await user.clear(input); + await user.type(input, '75%'); + await user.tab(); - expect(widgetProps.onChange).toHaveBeenCalledWith(75); + expect(input).toHaveValue('75%'); + expect(screen.getByRole('slider')).toHaveValue('75'); }); - it('updates the value when InputNumber is changed', () => { - const { getByTestId } = render(); - const inputNumber = getByTestId('slider-input'); + it('preserves bounds and keyboard slider changes', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const slider = screen.getByRole('slider'); + await user.tab(); + await user.keyboard('{Home}'); + + expect(screen.getByTestId('slider-input')).toHaveValue('1%'); + + await user.keyboard('{End}'); + + expect(screen.getByTestId('slider-input')).toHaveValue('100%'); + + await user.keyboard('{ArrowRight}'); + + expect(slider).toHaveValue('100'); + }); - fireEvent.change(inputNumber, { target: { value: '30%' } }); + it('allows an empty percentage instead of saving NaN', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByTestId('slider-input'); + await user.clear(input); + await user.tab(); - expect(widgetProps.onChange).toHaveBeenCalledWith(30); + expect(input).toHaveValue(''); + expect(screen.getByRole('slider')).toHaveValue('1'); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerObjectFieldTemplate.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerObjectFieldTemplate.tsx index 12fe3801eeee..79e17ab19ae4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerObjectFieldTemplate.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerObjectFieldTemplate.tsx @@ -10,8 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PlusOutlined } from '@ant-design/icons'; import { Box, Button, Typography } from '@openmetadata/ui-core-components'; +import { Plus } from '@openmetadata/ui-core-components/icons'; import { ObjectFieldTemplateProps } from '@rjsf/utils'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; @@ -33,25 +33,23 @@ export const ProfilerObjectFieldTemplate: FC = ( className="layout-space layout-space-horizontal w-full justify-between" gap={2} itemClassName="layout-space-item"> - {title} - + {schema.additionalProperties && ( - - - } + onVisibilityChange(false)}> - {isLoading ? ( - - ) : ( - - focusOnFirstError - noHtml5Validate - className={classNames('rjsf no-header profiler-settings-form')} - fields={{ - BooleanField: BooleanFieldTemplate, - }} - formData={profilerConfig ?? {}} - id="profiler-setting-form" - idSeparator="/" - schema={profilerSettingsSchema as FormProps['schema']} - showErrorList={false} - templates={{ - DescriptionFieldTemplate: DescriptionFieldTemplate, - FieldErrorTemplate: FieldErrorTemplate, - ObjectFieldTemplate: ProfilerObjectFieldTemplate, - }} - transformErrors={transformErrors} - uiSchema={uiSchema} - validator={validator as ValidatorType} - widgets={{ RangeWidget: CustomRangeWidget }} - onChange={handleOnChange} - onSubmit={handleUpdate} - /> - )} - + onOpenChange={(isOpen) => !isOpen && onVisibilityChange(false)}> + + + + {isLoading ? ( + + ) : ( + + focusOnFirstError + noHtml5Validate + className={classNames('rjsf no-header profiler-settings-form')} + fields={{ + BooleanField: BooleanFieldTemplate, + }} + formData={profilerConfig ?? {}} + id="profiler-setting-form" + idSeparator="/" + schema={profilerSettingsSchema as FormProps['schema']} + showErrorList={false} + templates={{ + DescriptionFieldTemplate: DescriptionFieldTemplate, + FieldErrorTemplate: FieldErrorTemplate, + ObjectFieldTemplate: ProfilerObjectFieldTemplate, + }} + transformErrors={transformErrors} + uiSchema={uiSchema} + validator={validator as ValidatorType} + widgets={{ RangeWidget: CustomRangeWidget }} + onChange={handleOnChange} + onSubmit={handleUpdate} + /> + )} + + + + + + + + ); }; 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 7d63b9b3fc4f..9dc5da339d8b 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 @@ -12,8 +12,10 @@ */ import { Button, + Dialog, Dropdown, - SimpleModal, + Modal, + ModalOverlay, } from '@openmetadata/ui-core-components'; import { AreaChart, @@ -21,7 +23,6 @@ import { type ChartYAxisProps, } from '@openmetadata/ui-core-components/charts'; import { DotsVertical } from '@openmetadata/ui-core-components/icons'; -import { Form } from 'antd'; import { AxiosError } from 'axios'; import { isEmpty, isUndefined, last, omit, toPairs } from 'lodash'; import { useMemo, useState } from 'react'; @@ -47,7 +48,6 @@ import { showSuccessToast, } from '../../../../../utils/ToastUtils'; import DeleteModal from '../../../../common/DeleteModal/DeleteModal'; -import { DrawerPopupContainerProvider } from '../../../../common/DrawerPopupContainerProvider/DrawerPopupContainerProvider'; import ErrorPlaceHolder from '../../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import CustomMetricForm from '../../../../DataQuality/CustomMetricForm/CustomMetricForm.component'; import { MetricChartType } from '../../ProfilerDashboard/profilerDashboard.interface'; @@ -86,7 +86,6 @@ const CustomMetricGraphs = ({ customMetrics, }: CustomMetricGraphsProps) => { const { t } = useTranslation(); - const [form] = Form.useForm(); const { permissions, customMetric: tableDetails, @@ -235,7 +234,6 @@ const CustomMetricGraphs = ({ setOpenMenuKey(isOpen ? key : null) }> + + + + + )} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerColumnSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerColumnSelect.tsx new file mode 100644 index 000000000000..bd26d8386463 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerColumnSelect.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2022 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 { FormSelectItem, Select } from '@openmetadata/ui-core-components'; +import { useState } from 'react'; + +interface ProfilerColumnSelectProps { + items: FormSelectItem[]; + selectedKey: string | null; + label: string; + placeholder: string; + testId: string; + isDisabled: boolean; + isInvalid: boolean; + onBlur: () => void; + onSelectionChange: (key: string | number | null) => void; +} + +export const ProfilerColumnSelect = ({ + items, + selectedKey, + label, + placeholder, + testId, + isDisabled, + isInvalid, + onBlur, + onSelectionChange, +}: ProfilerColumnSelectProps) => { + const [search, setSearch] = useState(''); + const filteredItems = items.filter((item) => + (item.label ?? String(item.id)) + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase()) + ); + + return ( + { + if (isOpen) { + setSearch(''); + } + }}> + {(item) => ( + + {item.label} + + )} + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerMetricSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerMetricSelect.tsx new file mode 100644 index 000000000000..1a5bed7eef24 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerMetricSelect.tsx @@ -0,0 +1,156 @@ +/* + * Copyright 2022 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 { + Badge, + BadgeWithButton, + TreeSelect, + TreeSelectNode, +} from '@openmetadata/ui-core-components'; +import { startCase } from 'lodash'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import profilerMetricsClassBase from '../../../../../utils/ProfilerMetricsClassBase'; + +interface ProfilerMetricSelectProps { + value?: string[]; + onChange: (value: string[]) => void; + testId: string; + options?: string[]; + isDisabled?: boolean; + maxVisible?: number; + label?: string; + placeholder?: string; +} + +export const ProfilerMetricSelect = ({ + value = [], + onChange, + testId, + options, + isDisabled = false, + maxVisible = 2, + label, + placeholder, +}: ProfilerMetricSelectProps) => { + const { t } = useTranslation(); + const nodes = useMemo( + () => [ + { + id: 'all', + value: 'all', + label: t('label.all'), + children: ( + options ?? profilerMetricsClassBase.getProfilerMetricOptions() + ).map((metric) => ({ + id: metric, + value: metric, + label: startCase(metric), + parentId: 'all', + isLeaf: true, + })), + }, + ], + [t, options] + ); + const children = nodes[0].children ?? []; + const allSelected = + value.includes('all') || + (children.length > 0 && + children.every((child) => value.includes(child.id))); + const selected = allSelected + ? [nodes[0], ...children] + : value.map( + (id) => + children.find((node) => node.id === id) ?? { + id, + value: id, + label: startCase(id), + } + ); + const fetchData = useCallback(() => Promise.resolve({ nodes }), [nodes]); + const handleChange = ( + selection: TreeSelectNode | TreeSelectNode[] | null + ) => { + let selectedNodes: TreeSelectNode[] = []; + if (Array.isArray(selection)) { + selectedNodes = selection; + } else if (selection) { + selectedNodes = [selection]; + } + const selectedIds = new Set(selectedNodes.map((node) => node.id)); + onChange( + children.length > 0 && + children.every((child) => selectedIds.has(child.id)) + ? ['all'] + : selectedNodes.reduce((ids, node) => { + if (node.id !== 'all') { + ids.push(node.id); + } + + return ids; + }, []) + ); + }; + const visible = allSelected ? [nodes[0]] : selected; + + return ( + { + const index = visible.findIndex((item) => item.id === node.id); + if (index < 0 || index > maxVisible) { + return null; + } + if (index === maxVisible) { + return ( + + +{visible.length - maxVisible} + + ); + } + + if (isDisabled) { + return ( + + {node.label} + + ); + } + + return ( + + onChange(allSelected ? [] : value.filter((id) => id !== node.id)) + }> + {node.label} + + ); + }} + showIcon={false} + value={selected} + onChange={handleChange} + /> + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.test.tsx index 6264a85ce3b0..e930343bb156 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.test.tsx @@ -20,6 +20,7 @@ import { waitFor, within, } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Column } from '../../../../../generated/entity/data/dashboardDataModel'; import { DataType, @@ -358,6 +359,21 @@ describe('ProfilerSettingsModal partitioning round-trip', () => { expect(payload.partitioning).toBeUndefined(); }); + + it('clears excluded columns without changing included metrics or partitioning', async () => { + const payload = await renderAndSave(mockTableProfilerConfig, () => { + fireEvent.click(screen.getByTestId('clear-excluded-columns')); + }); + + expect( + screen.queryByTestId('clear-excluded-columns') + ).not.toBeInTheDocument(); + expect(payload.excludeColumns).toBeUndefined(); + expect(payload.includeColumns).toEqual( + mockTableProfilerConfig.includeColumns + ); + expect(payload.partitioning).toEqual(mockTableProfilerConfig.partitioning); + }); }); const buildStaticSampleConfig = ( @@ -402,11 +418,13 @@ describe('ProfilerSettingsModal profile-sample round-trip', () => { render(); }); - expect(await screen.findByTestId('slider-input')).toHaveAttribute( - 'aria-valuemin', - '1' - ); - expect(screen.getByRole('slider')).toHaveAttribute('aria-valuemin', '1'); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const input = await screen.findByTestId('slider-input'); + await user.click(input); + await user.keyboard('{Home}{ArrowDown}'); + + expect(input).toHaveValue('1%'); + expect(screen.getByRole('slider')).toHaveAttribute('min', '1'); }); it('should clamp a typed 0 percentage up to 1 on blur', async () => { @@ -429,10 +447,12 @@ describe('ProfilerSettingsModal profile-sample round-trip', () => { render(); }); - expect(await screen.findByTestId('metric-number-input')).toHaveAttribute( - 'aria-valuemin', - '1' - ); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const input = await screen.findByTestId('metric-number-input'); + await user.click(input); + await user.keyboard('{Home}{ArrowDown}'); + + expect(input).toHaveValue('1'); }); // Configs saved before the minimum existed may hold 0; saving the modal @@ -464,170 +484,39 @@ describe('ProfilerSettingsModal profile-sample round-trip', () => { }); }); -describe('ProfilerSettingsModal include-column dropdown popup container', () => { - // The include-column `Select` and metrics `TreeSelect` render inside a - // `h-max-40 overflow-y-auto` wrapper that becomes a 160px clip box once a - // second include row exists. Once the clip box is active, mounting their - // popups via `triggerNode.parentElement` (the helper used for every other - // dropdown) traps them inside that scroll wrapper, so the options get cut - // off at 160px and scroll with the tiny box. The popups must instead mount - // OUTSIDE the clip wrapper but still INSIDE the dialog, to preserve the - // react-aria focus/accessibility boundary. jsdom performs no layout, so the - // visual clipping cannot be asserted directly; DOM ancestry of the open - // popup is what produces the clip in a real browser — a popup whose nearest - // `[data-testid="include-column-container"]` ancestor is null can never be - // clipped by the 160px wrapper. - - beforeEach(() => { - cleanup(); - jest.clearAllMocks(); - }); - - afterEach(() => { - cleanup(); - // antd appends a scroll-lock placeholder and react-aria leaves focus-scope - // markers directly under when a dropdown is open at unmount; these - // are not part of any React root so cleanup() cannot remove them, and they - // cross-contaminate later renders (the form never mounts). Reset . - document.body.removeAttribute('style'); - document.body.removeAttribute('class'); - document - .querySelectorAll( - 'body > [aria-hidden="true"], body > [data-focus-scope-start]' - ) - .forEach((el) => el.remove()); - }); - - const renderModalWithTwoIncludeRows = async () => { +describe('ProfilerSettingsModal include-column dropdowns', () => { + // Two include rows turn the include-column container into a 160px scroll + // box (tw:max-h-40 tw:overflow-y-auto). A dropdown rendered inside it would + // be clipped, so the row pickers must open outside it (#35369). + it('opens the include-column list outside the scroll clip box', async () => { await act(async () => { render(); }); - // Wait for the form to finish loading: `include-column-select` only - // renders once `isDataLoading` flips to false after the config fetch - // resolves. `profiler-settings-modal` (the dialog chrome) is present - // during load too, so it cannot gate this. - await waitFor(() => { - expect(screen.getByTestId('include-column-select')).toBeInTheDocument(); - }); + await waitFor(() => + expect(screen.getByTestId('include-column-select')).toBeInTheDocument() + ); - // DEFAULT_INCLUDE_PROFILE holds a single include row, so the clip box is - // inactive on first paint. Click the include-columns "+" button to add a - // second row: `handleIncludeColumnsProfiler` mirrors it into state, so - // `state.includeCol.length > 1` switches on the `h-max-40 overflow-y-auto` - // clip wrapper. await act(async () => { - fireEvent.click( - within(screen.getByTestId('include-column-section')).getByRole( - 'button', - { name: 'label.add' } - ) - ); + fireEvent.click(screen.getByRole('button', { name: 'label.add-entity' })); }); - await waitFor(() => { - const container = screen.getByTestId('include-column-container'); + const container = screen.getByTestId('include-column-container'); - expect(container.className).toMatch(/h-max-40/); + await waitFor(() => { + expect(container).toHaveClass('tw:max-h-40', 'tw:overflow-y-auto'); expect(screen.getAllByTestId('include-column-select')).toHaveLength(2); - expect(container.querySelectorAll('.ant-tree-select')).toHaveLength(2); }); - }; - - const openDropdown = (testid: string, rowIndex = 0) => { - const trigger = screen.getAllByTestId(testid)[rowIndex]; - const combobox = within(trigger).getByRole('combobox'); - - fireEvent.mouseDown(combobox); - }; - - // Each include row holds two comboboxes: the column Select, then the metrics - // TreeSelect. - const openMetricsDropdown = (rowIndex = 0) => { - const comboboxes = within( - screen.getByTestId('include-column-container') - ).getAllByRole('combobox'); - - fireEvent.mouseDown(comboboxes[rowIndex * 2 + 1]); - }; - - // Dispatch an outside `mousedown` so rc-trigger closes any open antd - // dropdown. This lets antd's scroll-lock effect cleanup restore - // before the next test renders, which a bare cleanup() does not flush. - const closeOpenDropdowns = async () => { - await act(async () => { - fireEvent.mouseDown(document.body); - }); - }; - - // Wait for the dropdown matching `selector` to appear, assertion-check its - // ancestry, and always close it so an open dropdown never leaks into the - // next test. `waitFor` only gates on the dropdown *existing*; the ancestry - // assertions run once and fail fast (so a regression does not hold the - // dropdown open for the full `waitFor` timeout). - const assertDropdownInsideDialogOutsideClipBox = async (selector: string) => { - const dropdown = (await waitFor(() => { - const el = document.querySelector( - `${selector}:not(.ant-select-dropdown-hidden)` - ) as HTMLElement | null; - - expect(el).toBeInTheDocument(); - - return el; - })) as HTMLElement; - - try { - // Stays inside the dialog (focus/accessibility boundary preserved). - expect(dropdown.closest('[data-testid="profiler-settings-modal"]')).toBe( - screen.getByTestId('profiler-settings-modal') - ); - // Is NOT nested inside the 160px include-column scroll clip box. - expect( - dropdown.closest('[data-testid="include-column-container"]') - ).toBeNull(); - } finally { - await closeOpenDropdowns(); - } - }; - it('mounts the include-column Select dropdown outside the scroll clip box but inside the dialog', async () => { - await renderModalWithTwoIncludeRows(); + const [firstRow] = screen.getAllByTestId('include-column-select'); + const [trigger] = within(firstRow).getAllByRole('button'); await act(async () => { - openDropdown('include-column-select'); + fireEvent.click(trigger); }); - // The include-column Select popup must NOT be nested inside the 160px - // scroll clip box. - await assertDropdownInsideDialogOutsideClipBox('.ant-select-dropdown'); - }); - - it('mounts the metrics TreeSelect dropdown outside the scroll clip box but inside the dialog', async () => { - await renderModalWithTwoIncludeRows(); - - await act(async () => { - openMetricsDropdown(); - }); - - // The metrics TreeSelect popup must NOT be nested inside the 160px - // scroll clip box. - await assertDropdownInsideDialogOutsideClipBox('.ant-tree-select-dropdown'); - }); - - it('keeps the non-row dropdowns mounted inside the dialog (unchanged behavior)', async () => { - // Only the two pickers nested in the clip wrapper were moved to the dialog - // root. The other dropdowns keep using `triggerNode.parentElement`, which - // is correct because their trigger's parent is never the scroll clip box. - // This guards against a sloppy fix that ports *every* dropdown to - // document.body and re-opens the original focus-boundary regression. - await renderModalWithTwoIncludeRows(); - - await act(async () => { - openDropdown('profile-sample'); - }); + const listbox = await screen.findByRole('listbox'); - // The profile-sample Select lives outside the include-column wrapper, so - // its popup is never in the clip box; it must still mount inside the dialog. - await assertDropdownInsideDialogOutsideClipBox('.ant-select-dropdown'); + expect(container).not.toContainElement(listbox); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx index 4d24ce5fc927..1528d3225ca4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx @@ -10,43 +10,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { PlusOutlined } from '@ant-design/icons'; -import Icon from '@ant-design/icons/lib/components/Icon'; import { + Autocomplete, Box, Button, + FormField, + FormItemLabel, + FormSelectItem, Grid, + HintText, + HookForm, + Input, + NumberInput, + Select, SlideoutMenu, Toggle, Typography, } from '@openmetadata/ui-core-components'; -import { Input, InputNumber, Select, TreeSelect } from 'antd'; -import Form from 'antd/lib/form'; -import { FormProps, List } from 'antd/lib/form/Form'; -import { getLayoutGutter } from '../../../../../utils/common/layout.utils'; - +import { Plus, Trash01, XClose } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; -import classNames from 'classnames'; -import { isEmpty, isEqual, isNil, isUndefined, pick, startCase } from 'lodash'; -import { - lazy, - Reducer, - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from 'react'; +import { isString } from 'lodash'; +import { lazy, useEffect, useMemo, useState } from 'react'; +import { FieldPath, useFieldArray, useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as IconDelete } from '../../../../../assets/svg/ic-delete.svg'; import { - DEFAULT_INCLUDE_PROFILE, INTERVAL_TYPE_OPTIONS, INTERVAL_UNIT_OPTIONS, MIN_PROFILE_SAMPLE, - PROFILER_MODAL_LABEL_STYLE, PROFILE_SAMPLE_OPTIONS, SUPPORTED_COLUMN_DATA_TYPE_FOR_INTERVAL, TIME_BASED_PARTITION, @@ -55,15 +45,12 @@ import { CSMode } from '../../../../../enums/codemirror.enum'; import { PartitionIntervalTypes, ProfileSampleType, - SampleConfigType, TableProfilerConfig, } from '../../../../../generated/entity/data/table'; import { getTableProfilerConfig, putTableProfileConfig, } from '../../../../../rest/tableAPI'; -import { reducerWithoutAction } from '../../../../../utils/ObjectUtils'; -import profilerMetricsClassBase from '../../../../../utils/ProfilerMetricsClassBase'; import { showErrorToast, showSuccessToast, @@ -72,970 +59,677 @@ import withSuspenseFallback from '../../../../AppRouter/withSuspenseFallback'; import Loader from '../../../../common/Loader/Loader'; import SliderWithInput from '../../../../common/SliderWithInput/SliderWithInput'; import '../table-profiler.less'; +import { ProfilerSettingsModalProps } from '../TableProfiler.interface'; +import { ProfilerColumnSelect } from './ProfilerColumnSelect'; +import { ProfilerMetricSelect } from './ProfilerMetricSelect'; import { - ProfilerForm, - ProfilerSettingModalState, - ProfilerSettingsModalProps, -} from '../TableProfiler.interface'; + DEFAULT_VALUES, + getProfilerSelectItems, + ProfilerSettingsValues, + toFormValues, + toProfilerConfig, +} from './ProfilerSettingsModal.utils'; const SchemaEditor = withSuspenseFallback( lazy(() => import('../../../SchemaEditor/SchemaEditor')) ); -// Legacy dropdowns must stay inside the modal's focus and accessibility boundary. -const getPopupContainer = (triggerNode: HTMLElement) => - triggerNode.parentElement ?? document.body; - -const ProfilerSettingsModal: React.FC = ({ +const ProfilerSettingsModal = ({ tableId, columns, visible, onVisibilityChange, -}) => { +}: ProfilerSettingsModalProps) => { const { t } = useTranslation(); - const [form] = Form.useForm(); - // The include rows sit in a `h-max-40 overflow-y-auto` box once there are two - // or more, and a popup mounted inside it is clipped to 160px. Mount the row - // pickers on the include section instead: it is outside the clip box but - // still inside the drawer's scroll body, so the popup scrolls with its field. - const includeSectionRef = useRef(null); - const getIncludeRowPopupContainer = useCallback( - () => includeSectionRef.current ?? document.body, - [] - ); - + const form = useForm({ + defaultValues: DEFAULT_VALUES, + }); + const { reset } = form; + const [storedConfig, setStoredConfig] = useState(); const [isLoading, setIsLoading] = useState(false); const [isDataLoading, setIsDataLoading] = useState(true); - - const initialState: ProfilerSettingModalState = useMemo( - () => ({ - data: undefined, - sqlQuery: '', - profileSample: 100, - sampleDataCount: 50, - excludeCol: [], - includeCol: DEFAULT_INCLUDE_PROFILE, - enablePartition: false, - selectedProfileSampleType: ProfileSampleType.Percentage, - }), - [] + const values = useWatch({ control: form.control }); + const includeColumns = useFieldArray({ + control: form.control, + name: 'includeColumns', + }); + const partitionValues = useFieldArray({ + control: form.control, + name: 'partitionValues', + }); + const enablePartition = values.enablePartitioning ?? false; + const partitionIntervalType = values.partitionIntervalType; + const sampleType = values.profileSampleType; + const columnItems = useMemo( + () => columns.map(({ name }) => ({ id: name, label: name })), + [columns] ); - const [state, dispatch] = useReducer< - Reducer> - >(reducerWithoutAction, initialState); - - const handleStateChange = useCallback( - (newState: Partial) => { - dispatch(newState); - }, - [] + const columnWithAll = useMemo( + () => [{ id: 'all', label: t('label.all') }, ...columnItems], + [columnItems, t] ); - - const { columnOptions, columnWithAllOption } = useMemo(() => { - const columnOptions = columns.map(({ name }) => ({ - label: name, - value: name, - })); - const columnWithAllOption = [ - { - label: t('label.all'), - value: 'all', - }, - ...columnOptions, - ]; - - return { columnOptions, columnWithAllOption }; - }, [columns]); - const metricsOptions = useMemo(() => { - const profilerMetrics = profilerMetricsClassBase.getProfilerMetricOptions(); - const metricsOptions = [ - { - title: t('label.all'), - value: 'all', - key: 'all', - children: profilerMetrics.map((metric) => ({ - title: startCase(metric), - value: metric, - key: metric, - })), - }, - ]; - - return metricsOptions; - }, [columns]); - - const partitionIntervalType = Form.useWatch(['partitionIntervalType'], form); - - const partitionColumnOptions = useMemo(() => { - const partitionColumnOptions = columns.reduce((result, column) => { - const filter = partitionIntervalType - ? SUPPORTED_COLUMN_DATA_TYPE_FOR_INTERVAL[partitionIntervalType] - : []; - if (filter.includes(column.dataType)) { - return [ - ...result, - { - value: column.name, - label: column.name, - }, - ]; - } - - return result; - }, [] as { value: string; label: string }[]); - - return partitionColumnOptions; - }, [columns, partitionIntervalType]); - - const updateInitialConfig = async ( - tableProfilerConfig: TableProfilerConfig - ) => { - const { - includeColumns, - partitioning, - profileQuery, - excludeColumns, - sampleDataCount, - profileSampleConfig, - } = tableProfilerConfig; - const staticConfig = profileSampleConfig?.config; - const profileSample = staticConfig?.profileSample; - const profileSampleType = staticConfig?.profileSampleType; - - const applyProfileSampleFields = () => { - form.setFieldsValue({ - profileSampleType, - profileSamplePercentage: - !isNil(profileSample) && - profileSampleType === ProfileSampleType.Percentage - ? profileSample - : undefined, - profileSampleRows: - !isNil(profileSample) && profileSampleType === ProfileSampleType.Rows - ? profileSample - : undefined, - }); - }; - - const applyIncludeColumns = () => { - if (!includeColumns || includeColumns.length === 0) { - return; - } - - const includeColValue = includeColumns.map((col) => { + const partitionColumnItems = useMemo( + () => + columns.reduce((items, column) => { if ( - isUndefined(col.metrics) || - (col.metrics && col.metrics.length === 0) + partitionIntervalType && + SUPPORTED_COLUMN_DATA_TYPE_FOR_INTERVAL[ + partitionIntervalType + ].includes(column.dataType) ) { - col.metrics = ['all']; + items.push({ id: column.name, label: column.name }); } - return col; - }); - form.setFieldsValue({ includeColumns: includeColValue }); - handleStateChange({ - includeCol: includeColValue, - }); - }; + return items; + }, []), + [columns, partitionIntervalType] + ); - const applyPartitioning = () => { - if (!partitioning) { - return; + useEffect(() => { + let active = true; + const load = async () => { + setIsDataLoading(true); + try { + const { tableProfilerConfig } = await getTableProfilerConfig(tableId); + if (active) { + setStoredConfig(tableProfilerConfig); + reset( + tableProfilerConfig + ? toFormValues(tableProfilerConfig) + : DEFAULT_VALUES + ); + } + } catch (error) { + if (active) { + showErrorToast( + error as AxiosError, + t('server.fetch-table-profiler-config-error') + ); + } + } finally { + if (active) { + setIsDataLoading(false); + } } - - handleStateChange({ - enablePartition: partitioning.enablePartitioning || false, - }); - - form.setFieldsValue({ - ...partitioning, - }); }; + if (tableId) { + void load(); + } else { + setIsDataLoading(false); + } - handleStateChange({ - sqlQuery: profileQuery ?? '', - profileSample: profileSample, - excludeCol: excludeColumns ?? [], - selectedProfileSampleType: profileSampleType, - sampleDataCount, - }); - form.setFieldsValue({ - sampleDataCount: sampleDataCount ?? initialState.sampleDataCount, - }); + return () => { + active = false; + }; + }, [tableId, reset, t]); - applyProfileSampleFields(); - applyIncludeColumns(); - applyPartitioning(); + const handleCancel = () => { + reset(storedConfig ? toFormValues(storedConfig) : DEFAULT_VALUES); + onVisibilityChange(false); + }; - Promise.resolve(); + const resetPartitionFields = () => { + form.setValue('partitionColumnName', undefined); + form.setValue('partitionIntegerRangeStart', undefined); + form.setValue('partitionIntegerRangeEnd', undefined); + form.setValue('partitionIntervalUnit', undefined); + form.setValue('partitionInterval', undefined); + partitionValues.replace([{ value: '' }]); }; - const fetchProfileConfig = async () => { - setIsDataLoading(true); + const handleSave = async (data: ProfilerSettingsValues) => { + const profileConfig = toProfilerConfig(data); + setIsLoading(true); try { - const response = await getTableProfilerConfig(tableId); - const { tableProfilerConfig } = response; - if (tableProfilerConfig) { - handleStateChange({ - data: tableProfilerConfig, - }); - - await updateInitialConfig(tableProfilerConfig); + const response = await putTableProfileConfig(tableId, profileConfig); + if (!response) { + throw new Error( + t('server.entity-updating-error', { + entity: t('label.profile-config'), + }) + ); } + showSuccessToast( + t('server.update-entity-success', { entity: t('label.profile-config') }) + ); + onVisibilityChange(false); } catch (error) { showErrorToast( error as AxiosError, - t('server.fetch-table-profiler-config-error') + t('server.entity-updating-error', { entity: t('label.profile-config') }) ); } finally { - setIsDataLoading(false); + setIsLoading(false); } }; - const getIncludesColumns = () => { - const includeCols = state.includeCol.filter( - ({ columnName }) => !isUndefined(columnName) - ); - - handleStateChange({ - includeCol: includeCols, - }); - - return includeCols.map((col) => { - if (col.metrics && col.metrics[0] === 'all') { - return { - columnName: col.columnName, - }; - } - - return col; - }); - }; - - const handleSave: FormProps['onFinish'] = useCallback( - async (data: ProfilerForm) => { - const buildPartitioning = (): TableProfilerConfig['partitioning'] => { - if (!state.enablePartition) { - return undefined; - } - - // Read straight from the form: the loaded config is pushed into the - // form with `setFieldsValue`, which never fires `onValuesChange`, so - // any state copy is stale until the user edits a partition field. - const partitionData = pick( - data, - 'partitionColumnName', - 'partitionIntegerRangeEnd', - 'partitionIntegerRangeStart', - 'partitionInterval', - 'partitionIntervalType', - 'partitionIntervalUnit', - 'partitionValues' - ); - - return { - ...partitionData, - partitionValues: - data.partitionIntervalType === PartitionIntervalTypes.ColumnValue - ? partitionData.partitionValues?.filter( - (value) => !isEmpty(value) - ) - : undefined, - enablePartitioning: state.enablePartition, - }; - }; - - const buildProfileConfig = (): TableProfilerConfig => { - const { excludeCol, sqlQuery, includeCol } = state; - const { - profileSamplePercentage, - profileSampleRows, - profileSampleType, - sampleDataCount, - } = data; - - const profileSampleValue = - profileSampleType === ProfileSampleType.Percentage - ? profileSamplePercentage - : profileSampleRows; - const profileSample = profileSampleType - ? profileSampleValue - : undefined; - - return { - excludeColumns: excludeCol.length > 0 ? excludeCol : undefined, - profileQuery: !isEmpty(sqlQuery) ? sqlQuery : undefined, - profileSampleConfig: - !isNil(profileSampleType) && !isNil(profileSample) - ? { - sampleConfigType: SampleConfigType.Static, - config: { - profileSample, - profileSampleType, - }, - } - : undefined, - includeColumns: !isEqual(includeCol, DEFAULT_INCLUDE_PROFILE) - ? getIncludesColumns() - : undefined, - partitioning: buildPartitioning(), - sampleDataCount, - }; - }; - - setIsLoading(true); - const profileConfig = buildProfileConfig(); - try { - const response = await putTableProfileConfig(tableId, profileConfig); - if (response) { - showSuccessToast( - t('server.update-entity-success', { - entity: t('label.profile-config'), - }) + const required = (label: string) => + enablePartition + ? { required: t('message.field-text-is-required', { fieldText: label }) } + : undefined; + const selectField = ({ + name, + label, + items, + testId, + placeholder, + isDisabled = false, + isRequired = false, + searchable = false, + onChange, + }: { + name: FieldPath; + label?: string; + items: { id: string; label: string }[]; + testId: string; + placeholder: string; + isDisabled?: boolean; + isRequired?: boolean; + searchable?: boolean; + onChange?: () => void; + }) => { + const accessibleLabel = label ?? placeholder; + + return ( + + {({ field, fieldState }) => { + const key = isString(field.value) ? field.value : null; + const completeItems = getProfilerSelectItems(items, key); + const handleSelection = (selection: string | number | null) => { + if (selection !== null) { + field.onChange(String(selection)); + onChange?.(); + } + }; + const content = (item: FormSelectItem) => ( + {item.label} ); - onVisibilityChange(false); - } else { - throw t('server.entity-updating-error', { - entity: t('label.profile-config'), - }); - } - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-updating-error', { - entity: t('label.profile-config'), - }) - ); - } finally { - setIsLoading(false); - } - }, - [state, getIncludesColumns] - ); - - const handleCancel = useCallback(() => { - const { data } = state; - data && updateInitialConfig(data); - onVisibilityChange(false); - }, [state]); - - const handleProfileSampleType = useCallback( - (selectedProfileSampleType: ProfileSampleType) => - handleStateChange({ - selectedProfileSampleType, - }), - [] - ); - - const handleProfileSample = useCallback( - (value: number | null) => - handleStateChange({ - profileSample: Number(value), - }), - [] - ); - - const handleCodeMirrorChange = useCallback((value: string) => { - handleStateChange({ - sqlQuery: value, - }); - }, []); - - const handleIncludeColumnsProfiler = useCallback( - (changedValues: Partial, data: ProfilerForm) => { - const { partitionIntervalType, enablePartitioning } = changedValues; - if (partitionIntervalType || !isNil(enablePartitioning)) { - form.setFieldsValue({ - partitionColumnName: undefined, - partitionIntegerRangeStart: undefined, - partitionIntegerRangeEnd: undefined, - partitionIntervalUnit: undefined, - partitionInterval: undefined, - partitionValues: [''], - }); - } - if (!isNil(enablePartitioning)) { - form.setFieldsValue({ - partitionIntervalType: undefined, - }); - } - handleStateChange({ - includeCol: data.includeColumns, - }); - }, - [] - ); - - const handleChange = - (field: keyof ProfilerSettingModalState) => - (value: ProfilerSettingModalState[keyof ProfilerSettingModalState]) => - handleStateChange({ - [field]: value, - }); - - const handleExcludeCol = handleChange('excludeCol'); - - const handleEnablePartition = handleChange('enablePartition'); - - useEffect(() => { - if (tableId) { - fetchProfileConfig(); - } else { - setIsDataLoading(false); - } - }, [tableId]); + return ( + + {label && ( + + )} + + {searchable ? ( + + ) : ( + + )} + {key !== null && !isDisabled && ( + - - - + const numberField = ( + name: + | 'profileSampleRows' + | 'sampleDataCount' + | 'partitionIntegerRangeStart' + | 'partitionIntegerRangeEnd' + | 'partitionInterval', + label: string, + testId: string, + placeholder: string, + min?: number, + partition = false + ) => ( + + {({ field, fieldState }) => ( + + field.onChange(Number.isNaN(value) ? undefined : value) + } + /> + )} + ); - const renderContent = isDataLoading ? ( -
+ const content = isDataLoading ? ( + -
+
) : ( -
+ + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> - - className="profiler-settings-form new-form-style" + - - - - - - - autoComplete="off" - className="new-form-style" - form={form} - id="profiler-setting-form" - initialValues={{ - includeColumns: state?.includeCol, - partitionData: [''], - ...state?.data?.partitioning, - }} - layout="vertical" - name="includeColumnsProfiler" - onFinish={handleSave} - onValuesChange={handleIncludeColumnsProfiler}> - - {(fields, { add, remove }) => ( - -
-

- {`${t('label.include')}:`} -

+ + {t('message.enable-column-profile')} + + {t('label.exclude')}: + + + {({ field }) => ( + + + columnItems.find((item) => item.id === id) ?? { + id, + label: id, + } + )} + onItemCleared={(id) => + field.onChange( + field.value.filter((value) => value !== id) + ) + } + onItemInserted={(id) => + field.onChange([...new Set([...field.value, String(id)])]) + }> + {(item) => ( + + {item.label} + + )} + + {field.value.length > 0 && (
-
1, - })} - data-testid="include-column-container"> - {fields.map(({ key, name, ...restField }) => ( - - - - - - - - - {t('label.column-entity', { - entity: t('label.name'), - })} - - } - labelCol={PROFILER_MODAL_LABEL_STYLE} - name="partitionColumnName" - rules={[ - { - required: state?.enablePartition, - message: t('message.field-text-is-required', { - fieldText: t('label.column-entity', { - entity: t('label.name'), - }), - }), - }, - ]}> - - - - - ) : null} - {PartitionIntervalTypes.IntegerRange === partitionIntervalType ? ( - <> + + + + + + + + {t('label.include')}: + +
- - {fields.map(({ key, name, ...restField }) => ( - - - - - -
+ ); return ( = ({ {t('label.setting-plural')} - - {renderContent} - - {drawerFooter} + {content} + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.utils.ts new file mode 100644 index 000000000000..d06b6207accc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.utils.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2022 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 { isEqual } from 'lodash'; +import { DEFAULT_INCLUDE_PROFILE } from '../../../../../constants/profiler.constant'; +import { + ColumnProfilerConfig, + PartitionIntervalTypes, + PartitionIntervalUnit, + ProfileSampleType, + SampleConfigType, + TableProfilerConfig, +} from '../../../../../generated/entity/data/table'; + +export interface ProfilerSettingsValues { + profileSampleType?: ProfileSampleType; + profileSamplePercentage?: number | null; + profileSampleRows?: number; + sampleDataCount?: number; + profileQuery: string; + excludeColumns: string[]; + includeColumns: ColumnProfilerConfig[]; + enablePartitioning: boolean; + partitionIntervalType?: PartitionIntervalTypes; + partitionColumnName?: string; + partitionIntegerRangeStart?: number; + partitionIntegerRangeEnd?: number; + partitionInterval?: number; + partitionIntervalUnit?: PartitionIntervalUnit; + partitionValues: { value: string }[]; +} + +export const DEFAULT_VALUES: ProfilerSettingsValues = { + profileSampleType: ProfileSampleType.Percentage, + profileSamplePercentage: 100, + sampleDataCount: 50, + profileQuery: '', + excludeColumns: [], + includeColumns: DEFAULT_INCLUDE_PROFILE, + enablePartitioning: false, + partitionValues: [], +}; + +export const toFormValues = ( + config: TableProfilerConfig +): ProfilerSettingsValues => { + const sample = config.profileSampleConfig?.config; + const partitioning = config.partitioning; + + return { + profileSampleType: sample?.profileSampleType, + profileSamplePercentage: + sample?.profileSampleType === ProfileSampleType.Percentage + ? sample.profileSample + : undefined, + profileSampleRows: + sample?.profileSampleType === ProfileSampleType.Rows + ? sample.profileSample + : undefined, + sampleDataCount: config.sampleDataCount ?? DEFAULT_VALUES.sampleDataCount, + profileQuery: config.profileQuery ?? '', + excludeColumns: config.excludeColumns ?? [], + includeColumns: config.includeColumns?.length + ? config.includeColumns.map((column) => ({ + ...column, + metrics: column.metrics?.length ? column.metrics : ['all'], + })) + : DEFAULT_INCLUDE_PROFILE, + enablePartitioning: partitioning?.enablePartitioning ?? false, + partitionIntervalType: partitioning?.partitionIntervalType, + partitionColumnName: partitioning?.partitionColumnName, + partitionIntegerRangeStart: partitioning?.partitionIntegerRangeStart, + partitionIntegerRangeEnd: partitioning?.partitionIntegerRangeEnd, + partitionInterval: partitioning?.partitionInterval, + partitionIntervalUnit: partitioning?.partitionIntervalUnit, + partitionValues: (partitioning?.partitionValues ?? []).map((value) => ({ + value: String(value), + })), + }; +}; + +export const toProfilerConfig = ( + data: ProfilerSettingsValues +): TableProfilerConfig => { + const profileSample = + data.profileSampleType === ProfileSampleType.Percentage + ? data.profileSamplePercentage + : data.profileSampleRows; + + return { + excludeColumns: data.excludeColumns.length + ? data.excludeColumns + : undefined, + profileQuery: data.profileQuery || undefined, + profileSampleConfig: + data.profileSampleType != null && profileSample != null + ? { + sampleConfigType: SampleConfigType.Static, + config: { + profileSample, + profileSampleType: data.profileSampleType, + }, + } + : undefined, + includeColumns: !isEqual(data.includeColumns, DEFAULT_INCLUDE_PROFILE) + ? data.includeColumns.reduce( + (columns, column) => { + if (column.columnName !== undefined) { + columns.push( + column.metrics?.[0] === 'all' + ? { columnName: column.columnName } + : column + ); + } + + return columns; + }, + [] + ) + : undefined, + partitioning: data.enablePartitioning + ? { + enablePartitioning: true, + partitionColumnName: data.partitionColumnName, + partitionIntegerRangeStart: data.partitionIntegerRangeStart, + partitionIntegerRangeEnd: data.partitionIntegerRangeEnd, + partitionInterval: data.partitionInterval, + partitionIntervalType: data.partitionIntervalType, + partitionIntervalUnit: data.partitionIntervalUnit, + partitionValues: + data.partitionIntervalType === PartitionIntervalTypes.ColumnValue + ? data.partitionValues.reduce((values, { value }) => { + if (value.length > 0) { + values.push(value); + } + + return values; + }, []) + : undefined, + } + : undefined, + sampleDataCount: data.sampleDataCount, + }; +}; + +export const getProfilerSelectItems = ( + items: { id: string; label: string }[], + key: string | null +) => { + if (key && !items.some((item) => item.id === key)) { + return [...items, { id: key, label: key }]; + } + + return items; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx index 8d90d1293fa9..7364519bb849 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx @@ -10,9 +10,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Box, Tabs } from '@openmetadata/ui-core-components'; -import { Form, Select } from 'antd'; -import { ItemType } from 'antd/lib/menu/hooks/useItems'; +import { + Box, + Select, + Tabs, + Typography, +} from '@openmetadata/ui-core-components'; import { isEmpty } from 'lodash'; import QueryString from 'qs'; import { useEffect, useMemo, useState } from 'react'; @@ -123,8 +126,9 @@ export const QualityTab = () => { [qualityTab] ); - const [selectedTestCaseStatus, setSelectedTestCaseStatus] = - useState('' as TestCaseStatus); + const [selectedTestCaseStatus, setSelectedTestCaseStatus] = useState< + TestCaseStatus | '' + >(''); const [selectedTestType, setSelectedTestType] = useState(TestCaseType.all); const [searchValue, setSearchValue] = useState(); const [sortOptions, setSortOptions] = @@ -246,7 +250,7 @@ export const QualityTab = () => { : undefined; }, [originBreadcrumb, table]); - const handleTestCaseStatusChange = (value: TestCaseStatus) => { + const handleTestCaseStatusChange = (value: TestCaseStatus | '') => { if (value !== selectedTestCaseStatus) { setSelectedTestCaseStatus(value); fetchAllTests({ @@ -256,7 +260,7 @@ export const QualityTab = () => { } }; - const extraDropdownContent: ItemType[] = useMemo(() => { + const extraDropdownContent = useMemo(() => { const bulkImportExportTestCasePermission = { ViewAll: checkPermission( @@ -424,30 +428,75 @@ export const QualityTab = () => { {isTestCaseTab && ( -
+ - + + + {t('label.type')}: + + + + + {t('label.status')}: + + { isRecursiveDelete={false} /> - + )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx index 1436e3692379..eba1e5121f94 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx @@ -351,7 +351,9 @@ describe('QualityTab', () => { const filterControls = screen.getByTestId('quality-tab-filter-controls'); const filterSpace = filterControls.querySelector('.layout-space'); - const filterItems = filterControls.querySelectorAll('.ant-form-item'); + const filterItems = filterControls.querySelectorAll( + '[data-testid=classic-quality-type-filter], [data-testid=classic-quality-status-filter]' + ); expect(filterControls).toHaveClass('tw:ml-auto', 'tw:shrink-0'); expect(filterSpace).toBeInTheDocument(); @@ -359,7 +361,7 @@ describe('QualityTab', () => { expect(filterItems).toHaveLength(2); filterItems.forEach((item) => { - expect(item).toHaveClass('tw:m-0', 'tw:w-44'); + expect(item).toHaveClass('tw:w-44'); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummary/TestSummary.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummary/TestSummary.test.tsx index c8056d454dea..c29429b15e16 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummary/TestSummary.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummary/TestSummary.test.tsx @@ -25,8 +25,8 @@ import { MOCK_SQL_TEST_CASE, MOCK_TEST_CASE, } from '../../../../mocks/TestSuite.mock'; +import { getPastDaysRange } from '../../../../utils/date-time/calendarDate.utils'; import { showErrorToast } from '../../../../utils/ToastUtils'; -import { getPastDaysRange } from '../../../observability/DataQuality/Dashboard/calendarDate.utils'; import { TestSummaryProps } from '../ProfilerDashboard/profilerDashboard.interface'; import TestSummary from './TestSummary'; @@ -115,14 +115,11 @@ jest.mock('../../../../utils/date-time/DateTimeUtils', () => ({ ...jest.requireActual('../../../../utils/date-time/DateTimeUtils'), formatDate: jest.fn().mockImplementation((val) => `date-${val}`), })); -jest.mock( - '../../../observability/DataQuality/Dashboard/calendarDate.utils', - () => ({ - getPastDaysRange: jest - .fn() - .mockReturnValue({ startTs: 1633948800000, endTs: 1633948800000 }), - }) -); +jest.mock('../../../../utils/date-time/calendarDate.utils', () => ({ + getPastDaysRange: jest + .fn() + .mockReturnValue({ startTs: 1633948800000, endTs: 1633948800000 }), +})); describe('TestSummary component', () => { beforeEach(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummary/TestSummary.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummary/TestSummary.tsx index 53290cf334e3..97cc7a222fb1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummary/TestSummary.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummary/TestSummary.tsx @@ -34,13 +34,13 @@ import { getListTestCaseResults, getTestCaseDimensionResultsByFqn, } from '../../../../rest/testAPI'; +import { getPastDaysRange } from '../../../../utils/date-time/calendarDate.utils'; import { formatDate } from '../../../../utils/date-time/DateTimeUtils'; import { translateWithNestedKeys } from '../../../../utils/i18next/LocalUtil'; import { showErrorToast } from '../../../../utils/ToastUtils'; import { useRequiredParams } from '../../../../utils/useRequiredParams'; import Loader from '../../../common/Loader/Loader'; import RunDetailsCard from '../../../DataQuality/IncidentManager/RunDetailsCard/RunDetailsCard'; -import { getPastDaysRange } from '../../../observability/DataQuality/Dashboard/calendarDate.utils'; import DqDateRangeFilter from '../../../observability/DataQuality/Dashboard/DqDateRangeFilter'; import { TestSummaryProps } from '../ProfilerDashboard/profilerDashboard.interface'; import RunSummaryTiles from './RunSummaryTiles/RunSummaryTiles'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummaryCustomTooltip/TestSummaryCustomTooltip.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummaryCustomTooltip/TestSummaryCustomTooltip.component.tsx index 53976ef7cd83..782e2071b159 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummaryCustomTooltip/TestSummaryCustomTooltip.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummaryCustomTooltip/TestSummaryCustomTooltip.component.tsx @@ -11,12 +11,12 @@ * limitations under the License. */ import { + Box, Card, Divider, Owner, Typography, } from '@openmetadata/ui-core-components'; - import entries from 'lodash/entries'; import isNumber from 'lodash/isNumber'; import isUndefined from 'lodash/isUndefined'; @@ -25,7 +25,6 @@ import startCase from 'lodash/startCase'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { GREEN_3, RED_3 } from '../../../../constants/Color.constants'; import { TABLE_FRESHNESS_KEY } from '../../../../constants/TestSuite.constant'; import { Task } from '../../../../generated/entity/tasks/task'; import { TestCaseStatus } from '../../../../generated/tests/testCase'; @@ -73,11 +72,11 @@ const TestSummaryCustomTooltip = (props: TestSummaryCustomTooltipProps) => { const failedRows = payloadData.failedRows as number | undefined; const totalRows = (passedRows ?? 0) + (failedRows ?? 0); const formattedDateTime = formatDateTime(timestamp); - let statusColor: string | undefined; + let statusColor: 'danger' | 'success' | undefined; if (status === TestCaseStatus.Failed) { - statusColor = RED_3; + statusColor = 'danger'; } else if (status === TestCaseStatus.Success) { - statusColor = GREEN_3; + statusColor = 'success'; } // A placed value only positions a run that recorded nothing on the chart. // Listing it would report a result the run never produced. @@ -109,13 +108,15 @@ const TestSummaryCustomTooltip = (props: TestSummaryCustomTooltipProps) => { : value; return ( -
  • - + + {startCase(key)} - + {key === TABLE_FRESHNESS_KEY && isNumber(value) ? convertSecondsToHumanReadableFormat( value, @@ -124,7 +125,7 @@ const TestSummaryCustomTooltip = (props: TestSummaryCustomTooltipProps) => { ) : tooltipValue} -
  • +
    ); }, [t] @@ -153,95 +154,99 @@ const TestSummaryCustomTooltip = (props: TestSummaryCustomTooltipProps) => { data-testid="test-summary-tooltip" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> -
    -
    {formattedDateTime}
    - -
      -
    • - - {t('label.status')} - - - {status} - -
    • - {incidentDisplayId && incidentPath && ( -
    • - - {t('label.incident')} - - - - {`#${incidentDisplayId}`} - - -
    • - )} - {!isUndefined(passedRows) && totalRows > 0 && ( -
    • - - {t('label.passed-rows')} - - - {`${formatNumberWithComma(passedRows)}/${formatNumberWithComma( - totalRows - )}`} - -
    • - )} - {!isUndefined(failedRows) && totalRows > 0 && ( -
    • - - {t('label.failed-rows')} - - - {`${formatNumberWithComma(failedRows)}/${formatNumberWithComma( - totalRows - )}`} - -
    • - )} - {data.map((entry) => - tooltipRender(entry as [string, string | number]) - )} - {incidentAssignees && ( -
    • - - {t('label.assignee')} + + + + {formattedDateTime} + + + + + + {t('label.status')} - + color={statusColor} + data-testid="status" + size="text-sm" + weight="medium"> + {status} -
    • - )} -
    -
    + + {incidentDisplayId && incidentPath && ( + + + {t('label.incident')} + + + + {`#${incidentDisplayId}`} + + + + )} + {!isUndefined(passedRows) && totalRows > 0 && ( + + + {t('label.passed-rows')} + + + {`${formatNumberWithComma( + passedRows + )}/${formatNumberWithComma(totalRows)}`} + + + )} + {!isUndefined(failedRows) && totalRows > 0 && ( + + + {t('label.failed-rows')} + + + {`${formatNumberWithComma( + failedRows + )}/${formatNumberWithComma(totalRows)}`} + + + )} + {data.map((entry) => + tooltipRender(entry as [string, string | number]) + )} + {incidentAssignees && ( + + + {t('label.assignee')} + + + + + + )} + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummaryCustomTooltip/test-summary-custom-tooltip.less b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummaryCustomTooltip/test-summary-custom-tooltip.less index ec40c4809d06..1bf4840f80cb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummaryCustomTooltip/test-summary-custom-tooltip.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TestSummaryCustomTooltip/test-summary-custom-tooltip.less @@ -29,11 +29,4 @@ color: @primary-8; } } - - .tooltip-date-time { - font-size: @font-size-base; - font-weight: @font-medium; - color: @grey-900; - margin-bottom: @size-xs; - } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManager.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManager.test.tsx index 9594fe982144..ad3bdc4ef1bf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManager.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManager.test.tsx @@ -21,8 +21,8 @@ import { renderWithQueryClient as render, runQueryNotificationsSynchronously, } from '../../test/unit/test-utils'; +import { getPastDaysRange } from '../../utils/date-time/calendarDate.utils'; import observabilityRouterClassBase from '../../utils/ObservabilityRouterClassBase'; -import { getPastDaysRange } from '../observability/DataQuality/Dashboard/calendarDate.utils'; import IncidentManager from './IncidentManager.component'; jest.mock('../common/NextPrevious/NextPrevious', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.component.tsx index aedea6f43e70..bb6a729138e6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.component.tsx @@ -11,27 +11,36 @@ * limitations under the License. */ -import { CloseCircleFilled, CloseCircleOutlined } from '@ant-design/icons'; import { + Box, Button, - ButtonUtility, Dropdown, + Popover, + RangeCalendar, Typography, } from '@openmetadata/ui-core-components'; - -import { ChevronRight } from '@openmetadata/ui-core-components/icons'; -import { SizeType } from 'antd/lib/config-provider/SizeContext'; +import { + ChevronDown, + ChevronRight, + XCircle, +} from '@openmetadata/ui-core-components/icons'; import classNames from 'classnames'; import { isUndefined, pick } from 'lodash'; -import { DateTime } from 'luxon'; import { DateFilterType, DateRangeObject } from 'Models'; -import { Key, MouseEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { + ComponentProps, + Key, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as DropdownIcon } from '../../../assets/svg/drop-down.svg'; import { DEFAULT_SELECTED_RANGE, PROFILER_FILTER_RANGE, } from '../../../constants/profiler.constant'; +import { dateValueToMillis } from '../../../utils/date-time/calendarDate.utils'; import { getCurrentMillis, getEpochMillisForPastDays, @@ -42,10 +51,11 @@ import { getTimestampLabel, } from '../../../utils/DatePickerMenuUtils'; import { translateWithNestedKeys } from '../../../utils/i18next/LocalUtil'; -import MyDatePicker from '../DatePicker/DatePicker'; + +type DatePickerMenuSize = 'small' | 'middle' | 'large'; const getTriggerClassName = ( - size: SizeType, + size: DatePickerMenuSize | undefined, isCustomRangeSelected: boolean ) => { if (size !== 'small') { @@ -71,7 +81,7 @@ interface DatePickerMenuProps { handleSelectedTimeRange?: (value: string) => void; onClear?: () => void; placeholder?: string; - size?: SizeType; + size?: DatePickerMenuSize; } const DatePickerMenu = ({ @@ -158,12 +168,7 @@ const DatePickerMenu = ({ const [isMenuOpen, setIsMenuOpen] = useState(false); const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false); - // The range picker's panel renders inside the menu popover, so picking dates - // is not treated as an interaction outside the menu. - const customRangeContainerRef = useRef(null); - - const getCustomRangeContainer = () => - customRangeContainerRef.current ?? document.body; + const customRangeTriggerRef = useRef(null); const handleMenuOpenChange = (open: boolean) => { setIsMenuOpen(open); @@ -177,37 +182,32 @@ const DatePickerMenu = ({ setSelectedTimeRangeKey(defaultTimeRangeKey); }, [defaultTimeRangeKey, defaultTimeRangeTitle]); - const handleCustomDateChange = ( - values: [start: DateTime | null, end: DateTime | null] | null, - dateStrings: [string, string] - ) => { - if (values) { - const startTs = values[0]?.startOf('day').valueOf() ?? 0; - - const endTs = values[1]?.endOf('day').valueOf() ?? 0; - - const daysCount = getDaysCount(dateStrings[0], dateStrings[1]); - - const selectedRangeLabel = getTimestampLabel( - dateStrings[0], - dateStrings[1], - showSelectedCustomRange - ); - - setSelectedTimeRange(selectedRangeLabel); - setSelectedTimeRangeKey(CUSTOM_DATE_RANGE_KEY); - handleMenuOpenChange(false); - handleDateRangeChange?.( - { - startTs, - endTs, - key: CUSTOM_DATE_RANGE_KEY, - title: selectedRangeLabel, - }, - daysCount - ); - handleSelectedTimeRange?.(selectedRangeLabel); + const handleCustomDateChange: NonNullable< + ComponentProps['onChange'] + > = (values) => { + if (!values) { + return; } + const startDate = values.start.toString(); + const endDate = values.end.toString(); + const selectedRangeLabel = getTimestampLabel( + startDate, + endDate, + showSelectedCustomRange + ); + setSelectedTimeRange(selectedRangeLabel); + setSelectedTimeRangeKey(CUSTOM_DATE_RANGE_KEY); + handleMenuOpenChange(false); + handleDateRangeChange?.( + { + startTs: dateValueToMillis(values.start), + endTs: dateValueToMillis(values.end.add({ days: 1 })) - 1, + key: CUSTOM_DATE_RANGE_KEY, + title: selectedRangeLabel, + }, + getDaysCount(startDate, endDate) + ); + handleSelectedTimeRange?.(selectedRangeLabel); }; const handleOptionClick = (menuKey: Key) => { @@ -254,14 +254,15 @@ const DatePickerMenu = ({ className={getTriggerClassName(size, isCustomRangeSelected)} color="secondary" data-testid="date-picker-menu" - iconTrailing={} + iconTrailing={} size={size === 'small' ? 'sm' : 'md'}> + )} + ellipsis={!isCustomRangeSelected}> {selectedTimeRange} @@ -286,33 +287,30 @@ const DatePickerMenu = ({ id={CUSTOM_DATE_RANGE_KEY} shouldCloseOnSelect={false} textValue={t('label.custom-range')}> - + {t('label.custom-range')} - + )} {isCustomRangeOpen && ( - // Zero-size anchor beside the Custom Range row: the picker's input stays - // hidden and only its calendar panel shows, opening to the menu's left - // the way the antd submenu did. -
    - } - format={(value) => value.toFormat('yyyy-MM-dd')} - getPopupContainer={getCustomRangeContainer} - placement="bottomRight" - suffixIcon={null} + + -
    + )} @@ -323,7 +321,8 @@ const DatePickerMenu = ({ } return ( -
    {datePickerMenu} {selectedTimeRangeKey && ( - } + iconLeading={XCircle} size="xs" - onClick={(event: MouseEvent) => { - event.stopPropagation(); - handleClear(); - }} + onPress={handleClear} /> )} -
    + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.test.tsx index ad9b4b70a2ee..621f528866ec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.test.tsx @@ -10,50 +10,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { getLocalTimeZone, today } from '@internationalized/date'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { dateValueToMillis } from '../../../utils/date-time/calendarDate.utils'; import DatePickerMenu from './DatePickerMenu.component'; -jest.mock('../DatePicker/DatePicker', () => { - const { DateTime } = jest.requireActual('luxon'); - - return { - __esModule: true, - default: { - RangePicker: ({ - format, - onChange, - }: { - format: (value: unknown) => string; - onChange: ( - values: [start: unknown, end: unknown], - dateStrings: [string, string] - ) => void; - }) => { - const startDate = DateTime.fromISO('2025-02-06'); - const endDate = DateTime.fromISO('2025-02-28'); - - return ( - - ); - }, - }, - }; -}); - -jest.mock('../../../utils/DatePickerMenuUtils', () => ({ - ...jest.requireActual('../../../utils/DatePickerMenuUtils'), - CUSTOM_DATE_RANGE_KEY: 'customRange', - getDaysCount: jest.fn().mockReturnValue(3), -})); jest.mock('../../../constants/profiler.constant', () => ({ DEFAULT_SELECTED_RANGE: { key: 'last3days', @@ -176,30 +138,58 @@ describe('DatePickerMenu', () => { expect(screen.getByText(customRange)).toHaveClass('tw:whitespace-nowrap'); }); - it('should format custom range dates with a four digit year', async () => { - render(); - - fireEvent.click(screen.getByTestId('date-picker-menu')); - fireEvent.click(await screen.findByText('label.custom-range')); - - expect(await screen.findByTestId('custom-range-picker')).toHaveTextContent( - '2025-02-06' + it('commits a real calendar range at local day boundaries and closes the menu', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onChange = jest.fn(); + render( + ); - }); - - it('should show only the dates after selecting a custom range', async () => { - render(); - - fireEvent.click(screen.getByTestId('date-picker-menu')); - fireEvent.click(await screen.findByText('label.custom-range')); - fireEvent.click(await screen.findByTestId('custom-range-picker')); - - expect(screen.getByTestId('date-picker-menu')).toHaveTextContent( - '2025-02-06 -> 2025-02-28' + await user.click(screen.getByTestId('date-picker-menu')); + await user.click(await screen.findByText('label.custom-range')); + const calendar = await screen.findByRole('dialog', { + name: 'label.custom-range', + }); + const currentDate = today(getLocalTimeZone()); + const start = currentDate.set({ day: 6 }); + const end = currentDate.set({ day: 28 }); + const dateLabel = (date: typeof start) => + new Intl.DateTimeFormat('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(date.toDate(getLocalTimeZone())); + const startButton = within(calendar).getByRole('button', { + name: (label) => label.includes(dateLabel(start)), + }); + const endButton = within(calendar).getByRole('button', { + name: (label) => label.includes(dateLabel(end)), + }); + await user.click(startButton); + + expect(onChange).not.toHaveBeenCalled(); + + await user.click(endButton); + + expect(onChange).toHaveBeenCalledWith( + { + startTs: dateValueToMillis(start), + endTs: dateValueToMillis(end.add({ days: 1 })) - 1, + key: 'customRange', + title: start.toString() + ' -> ' + end.toString(), + }, + 22 ); - expect(screen.getByTestId('date-picker-menu')).not.toHaveTextContent( - 'label.custom-range' + expect(screen.getByTestId('date-picker-menu')).toHaveTextContent( + start.toString() + ' -> ' + end.toString() ); + expect( + screen.queryByRole('dialog', { name: 'label.custom-range' }) + ).not.toBeInTheDocument(); }); it('should not render the clear control by default', () => { @@ -235,7 +225,7 @@ describe('DatePickerMenu', () => { 'tw:[&_[data-testid=date-picker-menu]>span:first-of-type]:pr-6' ); expect(clearButton).toHaveAccessibleName('label.clear'); - expect(clearButton).toHaveClass('tw:absolute', 'tw:right-8'); + expect(clearButton.tagName).toBe('BUTTON'); expect(datePickerTrigger).not.toContainElement(clearButton); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormCardSection/FormCardSection.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormCardSection/FormCardSection.tsx index e1b8f4bffa5e..cc06a9410962 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/FormCardSection/FormCardSection.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormCardSection/FormCardSection.tsx @@ -12,9 +12,8 @@ */ import { Card, Grid, Typography } from '@openmetadata/ui-core-components'; - import classNames from 'classnames'; -import { getLayoutGutter } from '../../../utils/common/layout.utils'; +import { isNumber, isString } from 'lodash'; import { FormCardSectionProps } from './FormCardSection.interface'; function FormCardSection({ @@ -26,10 +25,16 @@ function FormCardSection({ }: Readonly) { return ( + className={className} + data-testid={ + isString(heading) || isNumber(heading) + ? `${heading}-container` + : undefined + }> - + {heading} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.test.tsx new file mode 100644 index 000000000000..22e6fc8aea89 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import SliderWithInput from './SliderWithInput'; + +const Sampling = () => { + const [value, setValue] = useState(50); + + return ( + + ); +}; + +describe('SliderWithInput', () => { + it('updates the slider from a percentage and clears the configured value', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByTestId('slider-input'); + await user.clear(input); + await user.type(input, '75%'); + await user.tab(); + + expect(screen.getByRole('slider')).toHaveValue('75'); + + await user.click(screen.getByTestId('clear-slider-input')); + + expect(input).toHaveValue(''); + expect(screen.getByRole('slider')).toHaveValue('1'); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx index ffe4f3be7ff7..97ca6126744e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx @@ -11,12 +11,16 @@ * limitations under the License. */ -import { Box, ButtonUtility, Tooltip } from '@openmetadata/ui-core-components'; +import { + Box, + Button, + NumberInput, + Slider, + Tooltip, +} from '@openmetadata/ui-core-components'; import { XClose } from '@openmetadata/ui-core-components/icons'; -import { InputNumber, Slider } from 'antd'; +import { isNumber } from 'lodash'; import { useTranslation } from 'react-i18next'; -import { percentageFormatter } from '../../../utils/ChartUtils'; -import { getLayoutGutter } from '../../../utils/common/layout.utils'; import { SliderWithInputProps } from './SliderWithInput.interface'; const SliderWithInput = ({ value, @@ -28,44 +32,44 @@ const SliderWithInput = ({ return ( - + `${value}%`} + maxValue={100} + minValue={min} + step={1} + value={value ?? min} + onChange={(next) => onChange(isNumber(next) ? next : next[0])} /> - -
    - + + onChange(Number.isNaN(next) ? null : next)} /> - onChange(null)} + onPress={() => onChange(null)} /> -
    +
    ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiFormFields.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiFormFields.interface.ts index 68802531a63b..23e4b510db65 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiFormFields.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiFormFields.interface.ts @@ -27,6 +27,7 @@ import { ModifiedEventSubscription, ObservabilityFilterResourceDescriptor, } from '../../../pages/AddObservabilityPage/AddObservabilityPage.interface'; +import type { AlertSourceSearch } from '../../../utils/Alerts/AlertSourceSearch'; export type AlertAiFormMode = 'add' | 'edit' | 'view'; @@ -131,6 +132,8 @@ export interface RuleSectionProps { } export interface RuleArgumentFieldProps { + testId?: string; + sourceSearch?: AlertSourceSearch; argument: string; containerEntities?: string[]; supportedEventTypes?: EventType[]; @@ -138,6 +141,8 @@ export interface RuleArgumentFieldProps { index: number; name: number; isViewOnly?: boolean; + /** Names the control with `aria-label` instead of a visible label. */ + isLabelHidden?: boolean; onChange?: AlertAiFormFieldsProps['onChange']; validationErrors?: AlertAiFormValidationErrors; value: AlertAiFormValue; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiFormFieldsSearchUtils.ts b/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiFormFieldsSearchUtils.ts index 3f3599b80583..22a5be5dd412 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiFormFieldsSearchUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiFormFieldsSearchUtils.ts @@ -19,6 +19,7 @@ import { UUID_REGEX } from '../../../constants/regex.constants'; import { SearchIndex } from '../../../enums/search.enum'; import { ObservabilityFilterResourceDescriptor } from '../../../pages/AddObservabilityPage/AddObservabilityPage.interface'; import { searchQuery } from '../../../rest/searchAPI'; +import type { AlertSourceSearch } from '../../../utils/Alerts/AlertSourceSearch'; import { searchEntity } from '../../../utils/Alerts/AlertsUtil'; import alertsClassBase from '../../../utils/AlertsClassBase'; import { EntityIconSize } from '../../../utils/EntityIconUtils'; @@ -238,18 +239,60 @@ const ALERT_AI_ARGUMENT_CONFIG_BUILDERS: Record< testSuiteList: () => ({ searchIndex: SearchIndex.TEST_SUITE }), }; +const SOURCE_ARGUMENT_SEARCHERS: Record< + string, + (search: AlertSourceSearch, text: string) => Promise +> = { + fqnList: async (search, text) => + (await search.byName(text)).map(({ value, label }) => ({ + id: value, + label, + })), + entityIdList: async (search, text) => + (await search.byId(text)).map(({ id, fullyQualifiedName }) => ({ + id, + label: id, + supportingText: fullyQualifiedName, + })), +}; +const getSourceArgumentOptions = ( + argument: string, + text: string, + search?: AlertSourceSearch +) => { + if (!search) { + return undefined; + } + + return SOURCE_ARGUMENT_SEARCHERS[argument]?.(search, text).catch((error) => { + showErrorToast(error as AxiosError); + + return []; + }); +}; + export const searchAlertAiArgumentOptions = async ({ argument, containerEntities = [], searchText, selectedSource, + sourceSearch, }: { argument: string; containerEntities?: string[]; searchText: string; selectedSource?: string; + sourceSearch?: AlertSourceSearch; }): Promise => { const trimmedSearchText = searchText.trim(); + const sourceOptions = getSourceArgumentOptions( + argument, + trimmedSearchText, + sourceSearch + ); + if (sourceOptions) { + return sourceOptions; + } // A source whose names are not in the search indexes, such as a data contract, has a search of its own. const ownSearch = selectedSource diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiRuleSection.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiRuleSection.component.tsx index 4ecd8edf2336..f63728690fe5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiRuleSection.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiRuleSection.component.tsx @@ -77,9 +77,9 @@ const getSearchResultItems = ( nextItems: SelectItemType[] ) => mergeSelectItems([ + ...nextItems, ...currentItems.filter((item) => selectedValues.includes(String(item.id))), ...getSelectedValueItems(selectedValues), - ...nextItems, ]); /** Persists the next selected values for a single rule argument input. */ @@ -102,7 +102,9 @@ const updateArgumentInput = ( /** Renders a comma-separated text input for rule arguments that accept free-form values. */ const AiArgumentTextInput = ({ argument, + testId, field, + isLabelHidden, isViewOnly, index, label, @@ -122,11 +124,12 @@ const AiArgumentTextInput = ({ return (
    + [String(item.id), item.label ?? ''].some((text) => + text.toLocaleLowerCase().includes(search.toLocaleLowerCase()) + ) + } hint={error} isDisabled={isViewOnly} isInvalid={Boolean(error)} items={items} key={selectedValues.join('|')} - label={label} + label={isLabelHidden ? undefined : label} placeholder={placeholder} selectedItems={selectedValueItems} onItemCleared={(key) => @@ -217,8 +228,11 @@ const NO_RESULTS_ID = '__no_results__'; /** Renders a searchable Core UI autocomplete for rule arguments backed by OSS search utilities. */ const AiArgumentAutocomplete = ({ argument, + testId, + sourceSearch, containerEntities, field, + isLabelHidden, isViewOnly, index, label, @@ -256,8 +270,8 @@ const AiArgumentAutocomplete = ({ useEffect(() => { setItems((currentItems) => mergeSelectItems([ - ...getSelectedValueItems(selectedValues), ...currentItems, + ...getSelectedValueItems(selectedValues), ]) ); }, [selectedValues]); @@ -268,12 +282,13 @@ const AiArgumentAutocomplete = ({ containerEntities: stableContainerEntities, searchText: '', selectedSource, + sourceSearch, }).then((nextItems) => { setItems((currentItems) => - mergeSelectItems([...currentItems, ...nextItems]) + mergeSelectItems([...nextItems, ...currentItems]) ); }); - }, [argument, selectedSource, stableContainerEntities]); + }, [argument, selectedSource, sourceSearch, stableContainerEntities]); const selectedItems = useMemo( () => @@ -309,6 +324,7 @@ const AiArgumentAutocomplete = ({ containerEntities: stableContainerEntities, searchText, selectedSource, + sourceSearch, }); setHasNoResults(searchText.trim() !== '' && nextItems.length === 0); @@ -320,7 +336,7 @@ const AiArgumentAutocomplete = ({ ) ); }, 500), - [argument, selectedSource, stableContainerEntities] + [argument, selectedSource, sourceSearch, stableContainerEntities] ); useEffect( @@ -340,6 +356,7 @@ const AiArgumentAutocomplete = ({ containerEntities: stableContainerEntities, searchText: '', selectedSource, + sourceSearch, }).then((nextItems) => { setItems(mergeSelectItems([...nextItems])); }); @@ -349,6 +366,7 @@ const AiArgumentAutocomplete = ({ argument, hasNoResults, selectedSource, + sourceSearch, selectedValues.length, stableContainerEntities, ] @@ -361,14 +379,15 @@ const AiArgumentAutocomplete = ({ ALERT_AI_FORM_CLASS_NAMES.autocompleteField )}> true} hint={error} isDisabled={isViewOnly} isInvalid={Boolean(error)} items={displayItems} key={resetKey} - label={label} + label={isLabelHidden ? undefined : label} placeholder={placeholder} selectedItems={selectedItems} onItemCleared={(key) => @@ -406,10 +425,13 @@ const AiArgumentAutocomplete = ({ }; /** Chooses the correct argument input renderer based on the OSS alert rule descriptor. */ -const RuleArgumentField = ({ +export const RuleArgumentField = ({ argument, + testId, + sourceSearch, containerEntities, field, + isLabelHidden, isViewOnly, index, name, @@ -433,8 +455,11 @@ const RuleArgumentField = ({ argument={argument} field={field} index={index} + isLabelHidden={isLabelHidden} isViewOnly={isViewOnly} name={name} + sourceSearch={sourceSearch} + testId={testId} validationErrors={validationErrors} value={value} onChange={onChange} @@ -450,9 +475,12 @@ const RuleArgumentField = ({ containerEntities={containerEntities} field={field} index={index} + isLabelHidden={isLabelHidden} isViewOnly={isViewOnly} name={name} selectedSource={selectedSource} + sourceSearch={sourceSearch} + testId={testId} validationErrors={validationErrors} value={value} onChange={onChange} @@ -466,8 +494,10 @@ const RuleArgumentField = ({ argument={argument} field={field} index={index} + isLabelHidden={isLabelHidden} isViewOnly={isViewOnly} name={name} + testId={testId} validationErrors={validationErrors} value={value} onChange={onChange} @@ -476,7 +506,6 @@ const RuleArgumentField = ({ ); }; -/** Renders filter or trigger rules for the alert form and read-only configuration view. */ const AlertAiRuleSection = ({ containerEntities, field, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/DataQuality/Dashboard/DqDateRangeFilter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/observability/DataQuality/Dashboard/DqDateRangeFilter.tsx index 8b1deacc4a1c..ce825188edf4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/DataQuality/Dashboard/DqDateRangeFilter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/DataQuality/Dashboard/DqDateRangeFilter.tsx @@ -13,7 +13,10 @@ import { DateRangePicker } from '@openmetadata/ui-core-components'; import { useEffect, useState } from 'react'; import type { DateValue } from 'react-aria-components'; -import { dateValueToMillis, millisToDateValue } from './calendarDate.utils'; +import { + dateValueToMillis, + millisToDateValue, +} from '../../../../utils/date-time/calendarDate.utils'; type PickerRange = { start: DateValue; end: DateValue } | null; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/TestSuiteDetail/TestSuiteDetail.tsx b/openmetadata-ui/src/main/resources/ui/src/components/observability/TestSuiteDetail/TestSuiteDetail.tsx index c620511e55c6..376f0bc9ce03 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/TestSuiteDetail/TestSuiteDetail.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/TestSuiteDetail/TestSuiteDetail.tsx @@ -136,21 +136,30 @@ const TestSuiteAddTestCaseDialog = ({ - - - (trigger.closest('[role="dialog"]') as HTMLElement) ?? - document.body - } - onCancel={onClose} - onSubmit={onSubmit} - /> - + + (trigger.closest('[role="dialog"]') as HTMLElement) ?? + document.body + } + renderLayout={(content, actions) => ( + <> + + + {content} + + + {actions} + + )} + onCancel={onClose} + onSubmit={onSubmit} + /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/common/FilterChip/FilterChip.tsx b/openmetadata-ui/src/main/resources/ui/src/components/observability/common/FilterChip/FilterChip.tsx index eb601812336e..b8aeb7828f5b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/common/FilterChip/FilterChip.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/common/FilterChip/FilterChip.tsx @@ -87,7 +87,8 @@ const SelectChip = ({ } onOpenChange?.(open); }} - onSearch={(search) => descriptor.onSearch?.(search)} + // Left undefined for local-only filters so FilterSelect filters options itself. + onSearch={descriptor.onSearch} /> ); }; 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 index 96dff6729fb8..a738c2fa1d0b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddCustomMetricPage/AddCustomMetricPage.tsx @@ -16,10 +16,10 @@ import { Grid, Typography, } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; 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'; @@ -44,7 +44,6 @@ import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; import { useFqn } from '../../hooks/useFqn'; import { putCustomMetric } from '../../rest/customMetricAPI'; import { getTableDetailsByFQN } from '../../rest/tableAPI'; -import { getLayoutGutter } from '../../utils/common/layout.utils'; import { getEntityBreadcrumbs } from '../../utils/EntityBreadcrumbPureUtils'; import { getEntityName } from '../../utils/EntityNameUtils'; import { getNameFromFQN } from '../../utils/FqnUtils'; @@ -62,11 +61,13 @@ const AddCustomMetricPage = () => { const navigate = useNavigate(); const location = useCustomLocation(); const isColumnMetric = dashboardType === ProfilerDashboardType.COLUMN; - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { name: '', expression: '' }, + }); const [table, setTable] = useState
    (); const [isLoading, setIsLoading] = useState(true); const [isActionLoading, setIsActionLoading] = useState(false); - const columnName = Form.useWatch('columnName', form); + const columnName = useWatch({ control: form.control, name: 'columnName' }); const entityFqn = useMemo(() => table?.fullyQualifiedName ?? '', [table]); const breadcrumb = useMemo(() => { @@ -106,7 +107,7 @@ const AddCustomMetricPage = () => { const initialValues = useMemo( () => activeColumnFqn - ? ({ columnName: getNameFromFQN(activeColumnFqn) } as CustomMetric) + ? { columnName: getNameFromFQN(activeColumnFqn) } : undefined, [activeColumnFqn] ); @@ -220,8 +221,8 @@ const AddCustomMetricPage = () => { children: (
    + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> @@ -257,15 +258,17 @@ const AddCustomMetricPage = () => { color="secondary" data-testid="cancel-button" isDisabled={isActionLoading} - onClick={handleBackClick}> + size="md" + onPress={handleBackClick}> {t('label.cancel')} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx index 43dbb47e8d3f..6602fa755f5c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx @@ -21,7 +21,7 @@ import { } from '@openmetadata/ui-core-components'; import { Form, Input } from 'antd'; import { useForm } from 'antd/lib/form/Form'; -import { isEmpty, isUndefined } from 'lodash'; +import { isEmpty, isUndefined, pickBy } from 'lodash'; import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -36,6 +36,8 @@ import Loader from '../../components/common/Loader/Loader'; import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels'; import RichTextEditor from '../../components/common/RichTextEditor/RichTextEditor'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; +import { AlertAiFormValidationErrors } from '../../components/observability/Alerts/AlertAiFormFields.interface'; +import { validateAlertAiForm } from '../../components/observability/Alerts/AlertAiFormFieldsValidationUtils'; import { PAGE_SIZE_LARGE, ROUTES, @@ -77,8 +79,8 @@ import { } from '../../rest/alertsAPI'; import { getAllNotificationTemplates } from '../../rest/notificationtemplateAPI'; import { toCapabilitiesInput } from '../../utils/Alerts/AlertSelectionUtil'; +import { getAlertSourceChanges } from '../../utils/Alerts/ClassicAlertForm.utils'; import alertsClassBase from '../../utils/AlertsClassBase'; -import { getLayoutGutter } from '../../utils/common/layout.utils'; import { getEntityName } from '../../utils/EntityNameUtils'; import { getDerivedPermissionFlags } from '../../utils/PermissionDerivation'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; @@ -98,9 +100,15 @@ const NO_SOURCES: string[] = []; const AddNotificationPage = () => { const [form] = useForm(); + const [ruleValidationErrors, setRuleValidationErrors] = + useState({}); const navigate = useNavigate(); const { fqn } = useFqn(); const { t } = useTranslation(); + const getRuleValidationErrors = () => + pickBy(validateAlertAiForm(form.getFieldsValue(true), t), (_, path) => + path.startsWith('input.') + ); const { setInlineAlertDetails, inlineAlertDetails, currentUser } = useApplicationStore(); const { getResourceLimit } = useLimitStore(); @@ -310,10 +318,7 @@ const AddNotificationPage = () => { if (isSystemProvider) { return ( - + {t('message.system-alert-edit-message')} @@ -331,8 +336,8 @@ const AddNotificationPage = () => { + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> @@ -356,13 +361,16 @@ const AddNotificationPage = () => { resources: alert?.filteringRules?.resources, }} validateMessages={VALIDATION_MESSAGES} - onFinish={handleSave}> + onFinish={handleSave} + onFinishFailed={() => + setRuleValidationErrors(getRuleValidationErrors()) + }> {isLoading ? ( ) : ( + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-20)] tw:-mx-2.5" + rowGap="5"> { className="layout-row" justify="center" wrap="wrap"> - - + + + { + setRuleValidationErrors({}); + Object.entries( + getAlertSourceChanges(next, previous) + ).forEach(([key, value]) => + form.setFieldValue(key, value) + ); + }} + /> + {shouldShowFiltersSection && ( <> @@ -408,13 +431,51 @@ const AddNotificationPage = () => { orientation="vertical" /> - - + + { + const data = + typeof next === 'function' + ? next(form.getFieldsValue(true)) + : next; + form.setFieldValue('input', data.input); + if (!isEmpty(ruleValidationErrors)) { + setRuleValidationErrors( + getRuleValidationErrors() + ); + } + }} + /> + )} @@ -425,12 +486,7 @@ const AddNotificationPage = () => { orientation="vertical" /> - + ( { orientation="vertical" /> - + { templateResourcePermission } templates={templates} + values={values} + onValuesChange={(changes) => + Object.entries(changes).forEach( + ([key, value]) => + form.setFieldValue(key, value) + ) + } /> @@ -504,8 +562,16 @@ const AddNotificationPage = () => { /> + {!isUndefined(inlineAlertDetails) && ( @@ -533,6 +599,12 @@ const AddNotificationPage = () => { } templates={templates} values={values} + onValuesChange={(changes) => + Object.entries(changes).forEach( + ([key, value]) => + form.setFieldValue(key, value) + ) + } /> ) )} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.test.tsx index fa38901b903c..5afda08dd85b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.test.tsx @@ -110,7 +110,7 @@ jest.mock( ); jest.mock( - '../../components/Alerts/DestinationFormItem/DestinationFormItemFormBridge', + '../../components/Alerts/DestinationFormItem/DestinationFormItem.component', () => jest .fn() diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertForm.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertForm.tsx index bf90ac76b023..47cac508cd84 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertForm.tsx @@ -11,20 +11,33 @@ * limitations under the License. */ -import { Button, Grid, Typography } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; +import { + Box, + Button, + Grid, + HookForm, + Typography, +} from '@openmetadata/ui-core-components'; import { isUndefined } from 'lodash'; +import { useEffect, useState } from 'react'; +import { FormProvider, useWatch, Watch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { DESTINATIONS_MIN_COUNT_ERROR_PATH } from '../../../components/Alerts/DestinationFormItem/DestinationFormItem.constants'; import InlineAlert from '../../../components/common/InlineAlert/InlineAlert'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; -import { VALIDATION_MESSAGES } from '../../../constants/constants'; +import { AlertAiFormValidationErrors } from '../../../components/observability/Alerts/AlertAiFormFields.interface'; +import { validateAlertAiForm } from '../../../components/observability/Alerts/AlertAiFormFieldsValidationUtils'; import { AlertSelectionProvider } from '../../../hooks/useAlertSelection'; -import { getLayoutGutter } from '../../../utils/common/layout.utils'; -import { getEntityName } from '../../../utils/EntityNameUtils'; +import { + applyAlertValueChanges, + getClassicAlertInitialValues, +} from '../../../utils/Alerts/ClassicAlertForm.utils'; import { ModifiedCreateEventSubscription } from '../AddObservabilityPage.interface'; import { ObservabilityAlertFormProps } from '../hooks/useObservabilityAlertForm'; import ObservabilityAlertFormFields from './ObservabilityAlertFormFields'; +const NO_ERRORS: AlertAiFormValidationErrors = {}; + function ObservabilityAlertForm({ alert, breadcrumb, @@ -45,12 +58,41 @@ function ObservabilityAlertForm({ templates, }: Readonly) { const { t } = useTranslation(); - const values = Form.useWatch([], form); + const [hasSubmitted, setHasSubmitted] = useState(false); + // `compute` re-renders this form only when the errors change, not on every keystroke. + const currentErrors = useWatch({ + control: form.control, + compute: (values) => validateAlertAiForm(values, t), + }); + const validationErrors: AlertAiFormValidationErrors = hasSubmitted + ? currentErrors + : NO_ERRORS; + const { reset } = form; + useEffect(() => { + if (alert) { + reset(getClassicAlertInitialValues(alert)); + } + }, [alert, reset]); + const submit = async (data: ModifiedCreateEventSubscription) => { + const errors = validateAlertAiForm(data, t); + setHasSubmitted(true); + if (errors.destinations) { + form.setError(DESTINATIONS_MIN_COUNT_ERROR_PATH, { + message: errors.destinations, + type: 'manual', + }); + } + if (Object.keys(errors).length === 0) { + await handleSave(data); + } + }; + const onValuesChange = (changes: Partial) => + applyAlertValueChanges(form.setValue, changes); return ( + className="layout-row layout-grid tw:[--layout-gutter:var(--om-space-16)] tw:-mx-2" + rowGap="4"> @@ -65,70 +107,77 @@ function ObservabilityAlertForm({ - - form={form} - initialValues={{ - ...alert, - displayName: getEntityName(alert), - resources: alert?.filteringRules?.resources, - }} - validateMessages={VALIDATION_MESSAGES} - onFinish={handleSave}> - - - - + + setHasSubmitted(true))}> + + + + - {!isUndefined(inlineAlertDetails) && ( - - - - )} + {!isUndefined(inlineAlertDetails) && ( + + + + )} - -
    - + + + - {Object.entries(extraFormButtons).map( - ([name, ButtonComponent]) => ( - - ) - )} - -
    -
    -
    - + {Object.entries(extraFormButtons).map( + ([name, ButtonComponent]) => ( + ( + + )} + /> + ) + )} + +
    +
    +
    + +
    ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertFormFields.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertFormFields.tsx index 75aeaec0a892..4aaf403df61f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertFormFields.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertFormFields.tsx @@ -11,24 +11,36 @@ * limitations under the License. */ -import { Box, Divider, Grid } from '@openmetadata/ui-core-components'; -import { Form, Input } from 'antd'; +import { + Box, + Divider, + FormField, + FormItemLabel, + Grid, + Input, +} from '@openmetadata/ui-core-components'; import { isEmpty } from 'lodash'; import { Fragment } from 'react'; +import { useWatch, Watch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import AlertFormSourceItem from '../../../components/Alerts/AlertFormSourceItem/AlertFormSourceItem'; -import DestinationFormItemFormBridge, { - DestinationFormFieldRegistrar, -} from '../../../components/Alerts/DestinationFormItem/DestinationFormItemFormBridge'; +import DestinationFormItem from '../../../components/Alerts/DestinationFormItem/DestinationFormItem.component'; import ObservabilityFormFiltersItem from '../../../components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem'; import ObservabilityFormTriggerItem from '../../../components/Alerts/ObservabilityFormTriggerItem/ObservabilityFormTriggerItem'; import RichTextEditor from '../../../components/common/RichTextEditor/RichTextEditor'; -import { NAME_FIELD_RULES } from '../../../constants/Form.constants'; -import { ProviderType } from '../../../generated/entity/events/notificationTemplate'; -import { AlertType } from '../../../generated/events/eventSubscription'; +import { + AlertAiFormFieldsProps, + AlertAiFormValidationErrors, +} from '../../../components/observability/Alerts/AlertAiFormFields.interface'; +import { ENTITY_NAME_REGEX } from '../../../constants/regex.constants'; import { ModifiedCreateEventSubscription } from '../AddObservabilityPage.interface'; import { ObservabilityAlertFormFieldsProps } from '../hooks/useObservabilityAlertForm'; +import { + applyAlertValueChanges, + getAlertSourceChanges, +} from '../../../utils/Alerts/ClassicAlertForm.utils'; + function ObservabilityAlertFormFields({ alert, extraFormWidgets, @@ -39,41 +51,95 @@ function ObservabilityAlertFormFields({ shouldShowFiltersSection, templateResourcePermission, templates, -}: Readonly) { + validationErrors, +}: Readonly< + ObservabilityAlertFormFieldsProps & { + validationErrors: AlertAiFormValidationErrors; + } +>) { const { t } = useTranslation(); - const destinations = Form.useWatch('destinations', form); - const timeout = Form.useWatch('timeout', form); - const readTimeout = Form.useWatch('readTimeout', form); + // Subscribe only to what this component reads, so typing in name or the + // destination fields does not re-render the whole form. + const [resources, input] = useWatch({ + control: form.control, + name: ['resources', 'input'], + }); + // The rule sections read only `input`; they apply edits through `onChange`. + const ruleValue = { ...form.getValues(), input }; + const onChange: AlertAiFormFieldsProps['onChange'] = (next) => { + const latest = typeof next === 'function' ? next(form.getValues()) : next; + form.setValue('input', latest.input, { shouldDirty: true }); + }; + const onValuesChange = (changes: Partial) => + applyAlertValueChanges(form.setValue, changes); return ( <> - - - + rules={{ + required: t('label.field-required', { field: t('label.name') }), + maxLength: { + value: 128, + message: t('message.entity-size-in-between', { + entity: t('label.name'), + min: 1, + max: 128, + }), + }, + pattern: { + value: ENTITY_NAME_REGEX, + message: t('message.entity-name-validation'), + }, + }}> + {({ field, fieldState }) => ( + { + field.onChange(value); + void form.trigger('displayName'); + }} + /> + )} + - - - + + + + {({ field }) => ( + + )} + + - - + + { + onValuesChange(getAlertSourceChanges(next, previous)); + }} + /> {shouldShowFiltersSection && ( <> @@ -84,10 +150,12 @@ function ObservabilityAlertFormFields({ orientation="vertical" /> - - + + )} @@ -100,10 +168,12 @@ function ObservabilityAlertFormFields({ orientation="vertical" /> - - + + )} @@ -114,28 +184,8 @@ function ObservabilityAlertFormFields({ orientation="vertical" /> - - ( - - )} - values={{ destinations, readTimeout, timeout }} - onChange={(values) => { - // Each shared field must be replaced at its root. Ant's bulk - // setter deep-merges destination array entries and would restore - // config removed by a type change. - Object.entries(values).forEach(([name, value]) => - form.setFieldValue(name, value) - ); - }} - /> + + {!isEmpty(extraFormWidgets) && ( @@ -149,15 +199,21 @@ function ObservabilityAlertFormFields({ orientation="vertical" /> - - + ( + + )} /> @@ -166,16 +222,6 @@ function ObservabilityAlertFormFields({ )} - - hidden - initialValue={AlertType.Observability} - name="alertType" - /> - - hidden - initialValue={ProviderType.User} - name="provider" - /> ); } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertForm.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertForm.ts index e611b588b6f0..a443fc9d7549 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertForm.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertForm.ts @@ -11,7 +11,10 @@ * limitations under the License. */ -import { useForm } from 'antd/lib/form/Form'; +import { useForm, UseFormReturn } from 'react-hook-form'; +import { DEFAULT_READ_TIMEOUT } from '../../../constants/Alerts.constants'; +import { ProviderType } from '../../../generated/entity/events/notificationTemplate'; +import { AlertType } from '../../../generated/events/eventSubscription'; import { ModifiedCreateEventSubscription, UseAlertFormDataOptions, @@ -20,12 +23,9 @@ import { import { useAlertFormData } from './useAlertFormData'; import { useSelectedAlertSources } from './useObservabilityAlertResources'; -type ObservabilityAlertFormInstance = ReturnType< - typeof useForm ->[0]; +type ObservabilityAlertFormInstance = + UseFormReturn; -// The classic antd form types live here, beside the only hook that creates the form, so the -// shared AddObservabilityPage.interface stays antd-free for the AI alert modal. export interface UseObservabilityAlertFormOptions extends Omit { form?: ObservabilityAlertFormInstance; @@ -55,7 +55,17 @@ export function useObservabilityAlertForm({ form: providedForm, ...options }: UseObservabilityAlertFormOptions = {}): UseObservabilityAlertFormReturn { - const [internalForm] = useForm(); + const internalForm = useForm({ + defaultValues: { + resources: [], + destinations: [], + input: {}, + alertType: AlertType.Observability, + provider: ProviderType.User, + timeout: 10, + readTimeout: DEFAULT_READ_TIMEOUT, + }, + }); const form = providedForm ?? internalForm; const { sources, input } = useSelectedAlertSources(form); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.test.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.test.ts index bdfe74ec6f17..c346591db276 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.test.ts @@ -1,5 +1,5 @@ /* - * Copyright 2026 Collate. + * Copyright 2024 Collate. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -11,41 +11,31 @@ * limitations under the License. */ -import { renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; +import { useForm } from 'react-hook-form'; +import { ModifiedCreateEventSubscription } from '../AddObservabilityPage.interface'; import { useSelectedAlertSources } from './useObservabilityAlertResources'; -const mockUseWatch = jest.fn(); +it('tracks programmatic source and rule changes used for capabilities requests', () => { + const { result } = renderHook(() => { + const form = useForm({ + defaultValues: { resources: [], input: {} }, + }); -jest.mock('antd', () => ({ - Form: { useWatch: (...args: unknown[]) => mockUseWatch(...args) }, -})); - -const FORM = {} as Parameters[0]; - -describe('useSelectedAlertSources', () => { - beforeEach(() => { - mockUseWatch.mockReset(); + return { form, ...useSelectedAlertSources(form) }; }); - // The AI alert form copies its sources into the form with no field for them, and a watch - // without preserve sees only fields that are mounted. - it('reads the chosen sources from the whole form store', () => { - renderHook(() => useSelectedAlertSources(FORM)); + expect(result.current.sources).toEqual([]); - expect(mockUseWatch).toHaveBeenCalledWith( - ['resources'], - expect.objectContaining({ form: FORM, preserve: true }) - ); + act(() => { + result.current.form.setValue('resources', ['table', 'topic']); + result.current.form.setValue('input', { + filters: [{ name: 'filterByOwnerName' }], + }); }); - it('hands every chosen source and the choices so far to the caller', () => { - const input = { filters: [{ name: 'filterByOwnerName' }] }; - mockUseWatch.mockImplementation((name) => - name === 'input' ? input : ['table', 'topic'] - ); - - const { result } = renderHook(() => useSelectedAlertSources(FORM)); - - expect(result.current).toEqual({ sources: ['table', 'topic'], input }); + expect(result.current.sources).toEqual(['table', 'topic']); + expect(result.current.input).toEqual({ + filters: [{ name: 'filterByOwnerName' }], }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.ts index f0fca86d1dac..811441a8e19e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.ts @@ -11,31 +11,15 @@ * limitations under the License. */ -import type { FormInstance } from 'antd'; -import { Form } from 'antd'; -import { CreateEventSubscription } from '../../../generated/events/api/createEventSubscription'; +import { UseFormReturn } from 'react-hook-form'; import { ModifiedCreateEventSubscription } from '../AddObservabilityPage.interface'; - -// One array for "nothing selected", so what depends on the sources does not change every render. const NO_SOURCES: string[] = []; -/** - * The sources and the choices so far of a classic antd alert form. Kept in this file, which already - * imports antd Form, so the antd import does not spread to a new file. - */ export function useSelectedAlertSources( - form: FormInstance -): { - sources: string[]; - input?: ModifiedCreateEventSubscription['input']; -} { - // The AI alert form copies its sources into this form without a field for them, so read the store. - const sources = - Form.useWatch(['resources'], { - form, - preserve: true, - }) ?? NO_SOURCES; - const input = Form.useWatch('input', form); - - return { sources, input }; + form: UseFormReturn +): { sources: string[]; input: ModifiedCreateEventSubscription['input'] } { + return { + sources: form.watch('resources') ?? NO_SOURCES, + input: form.watch('input'), + }; } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/components/AlertDetailsContent.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/components/AlertDetailsContent.tsx index 6822850dd5fe..7748fe698154 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/components/AlertDetailsContent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/components/AlertDetailsContent.tsx @@ -11,28 +11,27 @@ * limitations under the License. */ -import { SyncOutlined } from '@ant-design/icons'; import { Box, Button, - ButtonUtility, Card, Owner, Skeleton, Tabs, Tooltip, } from '@openmetadata/ui-core-components'; - +import { + Edit05, + RefreshCw01, + Trash01, +} from '@openmetadata/ui-core-components/icons'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; -import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg'; import DeleteModal from '../../../components/common/DeleteModal/DeleteModal'; import Description from '../../../components/common/EntityDescription/Description'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { UserTeamSelectableList } from '../../../components/common/UserTeamSelectableList/UserTeamSelectableList.component'; import EntityHeaderTitle from '../../../components/Entity/EntityHeaderTitle/EntityHeaderTitle.component'; - import { AlertDetailTabs } from '../../../enums/Alerts.enum'; import { EntityType } from '../../../enums/entity.enum'; import { ProviderType } from '../../../generated/events/eventSubscription'; @@ -148,11 +147,14 @@ function AlertDetailsContent({ entity: t('label.alert'), })}>
    - + + { @@ -39,9 +37,7 @@ const IncidentManagerPage = () => { return ( - + diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.test.tsx new file mode 100644 index 000000000000..9e7087409ed1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.test.tsx @@ -0,0 +1,110 @@ +/* + * 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 { Box } from '@openmetadata/ui-core-components'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { + AlertType, + EventSubscription, + ProviderType, +} from '../../../generated/events/eventSubscription'; +import { ObservabilityAlertActionsProps } from '../ObservabilityAlertsPage.interface'; +import ObservabilityAlertActions from './ObservabilityAlertActions'; + +const alert: EventSubscription = { + id: 'alert-id', + name: 'classic-alert', + fullyQualifiedName: 'classic-alert', + alertType: AlertType.Observability, + destinations: [], + provider: ProviderType.User, +}; + +const renderActions = (props: Partial = {}) => { + const onSelectAlert = jest.fn(); + render( + , + { wrapper: MemoryRouter } + ); + + return { onSelectAlert }; +}; + +describe('ObservabilityAlertActions', () => { + it('links to the Classic edit page when no modal handler is supplied', () => { + renderActions(); + + expect(screen.getByRole('link', { name: 'label.edit' })).toHaveAttribute( + 'href', + '/observability/alerts/edit/classic-alert' + ); + }); + + it('opens the AI edit handler with keyboard activation without bubbling to the row', async () => { + const onEditAlert = jest.fn(); + const onRowClick = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + + + + ); + await user.tab(); + await user.keyboard('{Enter}'); + + expect(onEditAlert).toHaveBeenCalledWith(alert); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('selects the alert for deletion when permitted', () => { + const { onSelectAlert } = renderActions(); + + fireEvent.click(screen.getByRole('button', { name: 'label.delete' })); + + expect(onSelectAlert).toHaveBeenCalledWith(alert); + }); + + it('prevents deleting a system alert', () => { + const { onSelectAlert } = renderActions({ + record: { ...alert, provider: ProviderType.System }, + }); + const deleteButton = screen.getByRole('button', { name: 'label.delete' }); + + expect(deleteButton).toBeDisabled(); + + fireEvent.click(deleteButton); + + expect(onSelectAlert).not.toHaveBeenCalled(); + }); + + it('hides edit and delete controls when permission is absent', () => { + renderActions({ alertPermission: undefined }); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.tsx index f792f8dbe5a1..5d569a2d22bf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.tsx @@ -12,17 +12,16 @@ */ import { - ButtonUtility, + Box, + Button, Skeleton, Tooltip, Typography, } from '@openmetadata/ui-core-components'; - +import { Edit05, Trash01 } from '@openmetadata/ui-core-components/icons'; import { isUndefined } from 'lodash'; import { MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; -import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg'; import { NO_DATA_PLACEHOLDER } from '../../../constants/constants'; import { ProviderType } from '../../../generated/events/eventSubscription'; import observabilityRouterClassBase from '../../../utils/ObservabilityRouterClassBase'; @@ -52,47 +51,53 @@ function ObservabilityAlertActions({ return {NO_DATA_PLACEHOLDER}; } + const editButton = ( + diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.test.tsx index 78993af0f9d7..e844df8c0e8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.test.tsx @@ -125,6 +125,7 @@ jest.mock('@openmetadata/ui-core-components', () => { return { Box: MockBox, + Button: jest.requireActual('@openmetadata/ui-core-components').Button, EmptyPlaceholder: MockEmptyPlaceholder, Table: MockTable, TableCard: MockTableCard, @@ -198,6 +199,15 @@ describe('ObservabilityAlertsTable component', () => { jest.clearAllMocks(); }); + it('opens the selected alert when its name is pressed', () => { + const onViewAlert = jest.fn(); + renderTable({ onViewAlert }); + + fireEvent.click(screen.getByRole('button', { name: 'alert-test' })); + + expect(onViewAlert).toHaveBeenCalledWith(mockAlerts[0]); + }); + it('should render the table with alert rows when data is present', () => { renderTable(); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.tsx index ec79f91edec2..780edb3095ac 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.tsx @@ -112,7 +112,7 @@ function ObservabilityAlertsTable({ ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.test.tsx index 57bf5bf85004..135c7883ed99 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.test.tsx @@ -10,82 +10,150 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + DataType, + MetricType, +} from '../../generated/configuration/profilerConfiguration'; import { SettingType } from '../../generated/settings/settings'; -import { getSettingsConfigFromConfigType } from '../../rest/settingConfigAPI'; +import { + getSettingsConfigFromConfigType, + updateSettingsConfig, +} from '../../rest/settingConfigAPI'; import ProfilerConfigurationPage from './ProfilerConfigurationPage'; - const mockNavigate = jest.fn(); - -jest.mock('../../components/common/Loader/Loader', () => - jest.fn().mockReturnValue(
    Loading...
    ) -); jest.mock( '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component', - () => jest.fn().mockReturnValue(
    TitleBreadcrumb.component
    ) + () => () =>
    Breadcrumb
    ); -jest.mock('../../components/PageHeader/PageHeader.component', () => - jest.fn().mockReturnValue(
    PageHeader.component
    ) +jest.mock('../../components/PageHeader/PageHeader.component', () => () => ( +
    Profiler header
    +)); +jest.mock( + '../../components/PageLayoutV1/PageLayoutV1', + () => + ({ children }: { children: React.ReactNode }) => +
    {children}
    ); -jest.mock('react-router-dom', () => ({ - useNavigate: jest.fn().mockImplementation(() => mockNavigate), -})); +jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate })); jest.mock('../../rest/settingConfigAPI', () => ({ - getSettingsConfigFromConfigType: jest.fn().mockResolvedValue({}), + getSettingsConfigFromConfigType: jest.fn(), updateSettingsConfig: jest.fn(), })); -jest.mock('../../utils/GlobalSettingsUtils', () => ({ - getSettingPageEntityBreadCrumb: jest.fn().mockReturnValue([]), +jest.mock('../../utils/ToastUtils', () => ({ + showSuccessToast: jest.fn(), + showErrorToast: jest.fn(), })); -jest.mock('../../components/PageLayoutV1/PageLayoutV1', () => - jest.fn().mockImplementation(({ children }) =>
    {children}
    ) -); -jest.mock('../../constants/profiler.constant', () => ({ - DEFAULT_PROFILER_CONFIG_VALUE: { - metricConfiguration: [], - sampleDataConfig: { - storeSampleData: true, - readSampleData: true, +const config = { + metricConfiguration: [ + { + dataType: DataType.Int, + metrics: Object.values(MetricType), + disabled: false, }, - }, - PROFILER_METRICS_TYPE_OPTIONS: [], -})); + ], + sampleDataConfig: { storeSampleData: false, readSampleData: false }, +}; +const user = () => userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); describe('ProfilerConfigurationPage', () => { beforeEach(() => { + (getSettingsConfigFromConfigType as jest.Mock).mockResolvedValue({ + data: { config_value: config }, + } as Awaited>); + (updateSettingsConfig as jest.Mock).mockResolvedValue( + {} as Awaited> + ); + }); + + it('loads and saves unchanged metric selections and sample settings', async () => { render(); + await user().click(await screen.findByTestId('save-button')); + await waitFor(() => + expect(updateSettingsConfig).toHaveBeenCalledWith({ + config_type: SettingType.ProfilerConfiguration, + config_value: config, + }) + ); }); - it('renders the page correctly', async () => { - expect( - await screen.findByText('TitleBreadcrumb.component') - ).toBeInTheDocument(); - expect(await screen.findAllByText('PageHeader.component')).toHaveLength(3); - expect(await screen.findByText('label.data-type')).toBeInTheDocument(); - expect(await screen.findByText('label.disable')).toBeInTheDocument(); - expect(await screen.findByText('label.metric-type')).toBeInTheDocument(); + it('enabling sample storage also enables reading, while each can subsequently be changed', async () => { + render(); + await user().click(await screen.findByTestId('store-sample-data-switch')); + expect( - await screen.findByTestId('profiler-config-form') - ).toBeInTheDocument(); - expect(await screen.findByTestId('add-fields')).toBeInTheDocument(); - expect(await screen.findByTestId('cancel-button')).toBeInTheDocument(); - expect(await screen.findByTestId('save-button')).toBeInTheDocument(); + within(screen.getByTestId('read-sample-data-switch')).getByRole('switch') + ).toBeChecked(); + + await user().click(screen.getByTestId('store-sample-data-switch')); + await user().click(screen.getByTestId('save-button')); + await waitFor(() => + expect(updateSettingsConfig).toHaveBeenCalledWith( + expect.objectContaining({ + config_value: expect.objectContaining({ + sampleDataConfig: { storeSampleData: false, readSampleData: true }, + }), + }) + ) + ); }); - it('should fetch the profiler config data on initial render', () => { - const mockGetSettingsConfigFromConfigType = - getSettingsConfigFromConfigType as jest.Mock; + it('disabling a row keeps its configured metrics and disables its picker', async () => { + render(); + await user().click(await screen.findByTestId('disabled-switch')); + + expect( + within(screen.getByTestId('metric-type-select')).getByRole('textbox', { + name: 'label.metric-type', + }) + ).toBeDisabled(); - expect(mockGetSettingsConfigFromConfigType).toHaveBeenCalledWith( - SettingType.ProfilerConfiguration + await user().click(screen.getByTestId('save-button')); + await waitFor(() => + expect(updateSettingsConfig).toHaveBeenCalledWith( + expect.objectContaining({ + config_value: expect.objectContaining({ + metricConfiguration: [ + { ...config.metricConfiguration[0], disabled: true }, + ], + }), + }) + ) ); - expect(mockGetSettingsConfigFromConfigType).toHaveBeenCalledTimes(1); }); - it('onCancel should call navigate', async () => { - const cancelButton = await screen.findByTestId('cancel-button'); + it('prevents duplicate data types and requires a type in new rows', async () => { + render(); + await user().click(await screen.findByTestId('add-fields')); + const pickers = screen.getAllByTestId('data-type-select'); + await user().click(within(pickers[1]).getByRole('combobox')); + + expect( + await screen.findByRole('option', { name: /^INT$/ }) + ).toHaveAttribute('aria-disabled', 'true'); + + await user().keyboard('{Escape}'); + await user().click(screen.getByTestId('save-button')); + + expect( + await screen.findByText('message.field-text-is-required') + ).toBeVisible(); + expect(updateSettingsConfig).not.toHaveBeenCalled(); - cancelButton.click(); + await user().click(screen.getByTestId('remove-filter-1')); + await user().click(screen.getByTestId('save-button')); + await waitFor(() => + expect(updateSettingsConfig).toHaveBeenCalledWith({ + config_type: SettingType.ProfilerConfiguration, + config_value: config, + }) + ); + }); + + it('cancel returns to the previous page', async () => { + render(); + await user().click(await screen.findByTestId('cancel-button')); expect(mockNavigate).toHaveBeenCalledWith(-1); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx index 7b11f876a740..195f14bcbfa9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx @@ -10,110 +10,115 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CloseOutlined, PlusOutlined } from '@ant-design/icons'; import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, Box, Button, + Card, + Divider, Grid, + HookForm, Toggle, Typography, } from '@openmetadata/ui-core-components'; -import { Collapse, Form, Select, TreeSelect } from 'antd'; +import { Plus, XClose } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; -import { isEmpty, isEqual, values } from 'lodash'; import { Fragment, useEffect, useMemo, useState } from 'react'; +import { + Controller, + FormProvider, + useFieldArray, + 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 TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; -import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; +import { ProfilerColumnSelect } from '../../components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerColumnSelect'; +import { ProfilerMetricSelect } from '../../components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerMetricSelect'; import PageHeader from '../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants'; import { LEARNING_PAGE_IDS } from '../../constants/Learning.constants'; -import { - DEFAULT_PROFILER_CONFIG_VALUE, - PROFILER_METRICS_TYPE_OPTIONS, -} from '../../constants/profiler.constant'; -import { - DataType, - MetricConfigurationDefinition, - MetricType, - ProfilerConfiguration, -} from '../../generated/configuration/profilerConfiguration'; -import { Settings, SettingType } from '../../generated/settings/settings'; +import { MetricType } from '../../generated/configuration/profilerConfiguration'; +import { SettingType } from '../../generated/settings/settings'; import { getSettingsConfigFromConfigType, updateSettingsConfig, } from '../../rest/settingConfigAPI'; -import { getLayoutGutter } from '../../utils/common/layout.utils'; import { getSettingPageEntityBreadCrumb } from '../../utils/GlobalSettingsUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; -import './profiler-configuration-page.style.less'; import profilerConfigurationClassBase from './ProfilerConfigurationClassBase'; +import { + getDataTypeItems, + getProfilerConfigurationPayload, + getProfilerConfigurationValues, + getSelectedDataType, + ProfilerConfigurationValues, +} from './ProfilerConfigurationPage.utils'; +const METRIC_OPTIONS = Object.values(MetricType); const ProfilerConfigurationPage = () => { - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: getProfilerConfigurationValues(), + }); + const { control, reset, setValue } = form; + const { fields, append, remove } = useFieldArray({ + control, + name: 'metricConfiguration', + }); + const metricConfiguration = useWatch({ + control, + name: 'metricConfiguration', + }); const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(true); - const { t } = useTranslation(); const [isFormSubmitting, setIsFormSubmitting] = useState(false); - const breadcrumbs: TitleBreadcrumbProps['titleLinks'] = useMemo( + const { t } = useTranslation(); + const breadcrumbs = useMemo( () => getSettingPageEntityBreadCrumb( GlobalSettingsMenuCategory.PREFERENCES, t('label.profiler-configuration') ), - [] + [t] ); + const SparkAgentConfig = + profilerConfigurationClassBase.getSparkAgentConfigComponent(); - // Watchers - const selectedMetricConfiguration = Form.useWatch< - MetricConfigurationDefinition[] - >('metricConfiguration', form); - - const dataTypeOptions = useMemo(() => { - return values(DataType).map((value) => ({ - label: value, - key: value, - value, - // Disable the metric type selection if the data type is already selected - disabled: selectedMetricConfiguration?.some( - (data) => data?.dataType === value - ), - })); - }, [selectedMetricConfiguration]); - - const sparkAgentConfigComponent = useMemo(() => { - const SparkAgentConfig = - profilerConfigurationClassBase.getSparkAgentConfigComponent(); + useEffect(() => { + let active = true; + const fetchConfiguration = async () => { + try { + const { data } = await getSettingsConfigFromConfigType( + SettingType.ProfilerConfiguration + ); + if (active) { + reset(getProfilerConfigurationValues(data?.config_value)); + } + } finally { + if (active) { + setIsLoading(false); + } + } + }; + fetchConfiguration().catch(() => setIsLoading(false)); - return SparkAgentConfig ? ( - - - - ) : null; - }, []); + return () => { + active = false; + }; + }, [reset]); - const handleSubmit = async (data: ProfilerConfiguration) => { + const handleSubmit = async (data: ProfilerConfigurationValues) => { setIsFormSubmitting(true); - const metricConfiguration = data.metricConfiguration?.map((item) => { - if (isEqual(item.metrics, ['all'])) { - return { - ...item, - metrics: values(MetricType), - }; - } - - return item; - }); try { await updateSettingsConfig({ config_type: SettingType.ProfilerConfiguration, - config_value: { - metricConfiguration, - sampleDataConfig: data.sampleDataConfig, - } as Settings['config_value'], + config_value: getProfilerConfigurationPayload(data), }); showSuccessToast( t('server.update-entity-success', { @@ -126,315 +131,292 @@ const ProfilerConfigurationPage = () => { setIsFormSubmitting(false); } }; - - const fetchProfilerConfiguration = async () => { - setIsLoading(true); - try { - const { data } = await getSettingsConfigFromConfigType( - SettingType.ProfilerConfiguration - ); - - const configValue = data?.config_value as - | ProfilerConfiguration - | undefined; - - form.setFieldsValue({ - metricConfiguration: isEmpty(configValue?.metricConfiguration) - ? DEFAULT_PROFILER_CONFIG_VALUE.metricConfiguration - : configValue?.metricConfiguration, - sampleDataConfig: - configValue?.sampleDataConfig ?? - DEFAULT_PROFILER_CONFIG_VALUE.sampleDataConfig, - }); - } catch { - // do nothing - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchProfilerConfiguration(); - }, []); - if (isLoading) { return ; } return ( -
    + -
    - - className="new-form-style" - data-testid="profiler-config-form" - form={form} - id="profiler-config" - layout="vertical" - onFinish={handleSubmit}> - - - - - - - - } - key="profileConfig"> - - {(fields, { add, remove }) => { - return ( - - - {t('label.data-type')} - * + + + + + + + + + + + + + {t('label.metric-configuration')} + + + {t('message.metric-configuration-description')} + + + + + + + + {t('label.data-type')} + + * + + - + {t('label.metric-type')} - - {t('label.disable')} - - {fields.map(({ key, name }) => ( - - - - - ), - entityIdList: () => ( - - ), - pipelineStateList: () => ( - - ), - testStatusList: () => ( - - ), - contractStatusList: () => ( -