diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index 4ab55e2b23e1..09a813824641 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -740,8 +740,10 @@ "playwright/e2e/Features/AppMode/AppModeResolver.spec.ts", "playwright/e2e/Features/AppMode/AppModeRouteIsolation.spec.ts", "playwright/e2e/Features/AppMode/AppModeUrlHandling.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.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/Pages/BotsProfile.spec.ts" ] @@ -5130,6 +5132,7 @@ "playwright/e2e/Features/CustomizeDetailPage.spec.ts", "playwright/e2e/Features/DataProductPersonaCustomization.spec.ts", "playwright/e2e/Features/DataQuality/DataQuality.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", "playwright/e2e/Features/DataQuality/TableTestCasePagination.spec.ts", "playwright/e2e/Features/FailedTestCaseSampleData.spec.ts", "playwright/e2e/Features/Glossary/GlossaryAdvancedOperations.spec.ts", @@ -5249,6 +5252,15 @@ "playwright/e2e/Features/FeedEditorDuplicateId.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.tsx" + ], + "specs": [ + "playwright/e2e/Features/ActivityFeed.spec.ts", + "playwright/e2e/Features/Tasks/TaskNavigation.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew.tsx" @@ -5433,6 +5445,7 @@ "specs": [ "playwright/e2e/Features/AIMode/InboxTasks.spec.ts", "playwright/e2e/Features/DataQuality/DataObservabilityGovernanceTab.spec.ts", + "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", "playwright/e2e/Features/DataQuality/TestDefinitionFilters.spec.ts", "playwright/e2e/Features/DomainFilterQueryFilter.spec.ts", "playwright/e2e/Features/ExploreFilterComposition.spec.ts", @@ -5441,6 +5454,7 @@ "playwright/e2e/Features/ExploreSortOrderFilter.spec.ts", "playwright/e2e/Features/ExploreUrlState.spec.ts", "playwright/e2e/Features/ImpactAnalysis.spec.ts", + "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/Pagination.spec.ts", "playwright/e2e/Flow/ExploreDiscovery.spec.ts", "playwright/e2e/Pages/AuditLogs.spec.ts", @@ -5490,6 +5504,7 @@ "playwright/e2e/Features/ExploreQuickFilters.spec.ts", "playwright/e2e/Features/ExploreSortOrderFilter.spec.ts", "playwright/e2e/Features/ExploreUrlState.spec.ts", + "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts", "playwright/e2e/Features/SearchExport.spec.ts", "playwright/e2e/Flow/ExploreDiscovery.spec.ts", @@ -5696,6 +5711,7 @@ "playwright/e2e/Features/ExploreQuickFilters.spec.ts", "playwright/e2e/Features/ExploreSortOrderFilter.spec.ts", "playwright/e2e/Features/ExploreUrlState.spec.ts", + "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts", "playwright/e2e/Features/SearchExport.spec.ts", "playwright/e2e/Flow/ExploreDiscovery.spec.ts", @@ -7343,6 +7359,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManager.component.tsx" ], "specs": [ + "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", "playwright/e2e/Features/DomainIsolation/DomainIncidentIsolation.spec.ts", "playwright/e2e/Features/IncidentManager.spec.ts" ] @@ -8432,9 +8449,7 @@ ], "specs": [ "playwright/e2e/Features/AgentLogStreamHandover.spec.ts", - "playwright/e2e/Features/ServiceAgentsPauseResume.spec.ts", "playwright/e2e/Features/ServiceAgentsStatusDegradation.spec.ts", - "playwright/e2e/Flow/ServiceCreationPermissions.spec.ts", "playwright/e2e/Pages/LogsViewer.spec.ts" ] }, @@ -9062,8 +9077,6 @@ "openmetadata-ui/src/main/resources/ui/src/components/common/DatePickerMenu/DatePickerMenu.component.tsx" ], "specs": [ - "playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts", - "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Pages/AuditLogs.spec.ts", "playwright/e2e/Pages/DataInsight.spec.ts" ] @@ -9738,6 +9751,7 @@ "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts", "playwright/e2e/Features/Glossary/GlossaryAdvancedOperations.spec.ts", "playwright/e2e/Features/Glossary/GlossaryRemoveOperations.spec.ts", + "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/QueryEntity.spec.ts", "playwright/e2e/Flow/PersonaFlow.spec.ts", "playwright/e2e/Pages/DataContractInheritance.spec.ts", @@ -9945,6 +9959,7 @@ "playwright/e2e/Features/AIMode/ObservabilityFilters.spec.ts", "playwright/e2e/Features/DataAssetRulesDisabled.spec.ts", "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts", + "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/QueryEntity.spec.ts", "playwright/e2e/Pages/DomainUIInteractions.spec.ts", "playwright/e2e/Pages/TestSuite.spec.ts" @@ -12787,6 +12802,7 @@ "playwright/e2e/Features/ExploreQuickFilters.spec.ts", "playwright/e2e/Features/ExploreSortOrderFilter.spec.ts", "playwright/e2e/Features/ExploreUrlState.spec.ts", + "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts", "playwright/e2e/Features/SearchExport.spec.ts", "playwright/e2e/Flow/ExploreDiscovery.spec.ts", diff --git a/docs/decisions/2026-10-10-alert-widgets-use-values-and-oss-merges-before-collate.md b/docs/decisions/2026-10-10-alert-widgets-use-values-and-oss-merges-before-collate.md new file mode 100644 index 000000000000..e520d21e64e1 --- /dev/null +++ b/docs/decisions/2026-10-10-alert-widgets-use-values-and-oss-merges-before-collate.md @@ -0,0 +1,41 @@ +# Alert widgets consume form values, and the Observability migration merges OSS before Collate + +- **Status:** Accepted +- **Revisions:** v1 2026-10-10 (initial) +- **Deciders:** Shailesh Parmar +- **Guard:** `AlertEditModal.component.test.tsx` and Collate `TestAlertButton.test.tsx`; paired build and merge-order review +- **Related:** [OpenMetadata #34935](https://github.com/open-metadata/OpenMetadata/issues/34935), [OpenMetadata #34936](https://github.com/open-metadata/OpenMetadata/pull/34936), [Collate #7216](https://github.com/open-metadata/openmetadata-collate/pull/7216) + +## Context + +The AI Observability alert form now owns its state without Ant Design. Its footer widgets are +registered through `AlertsClassBase` and implemented downstream in Collate. The previous +`TestAlertButton` read an Ant Design form instance, which the AI form can no longer provide. +The same migration removes `pipeline-actions.less`, still imported by Collate main. + +## Decision + +Alert footer widgets read the current `ModifiedCreateEventSubscription` through the `values` +prop of `AddAlertFormWidgetProps`. Both Classic and AI callers supply these values. They are +the current editing state, not a promise of submission validation. Widgets remain responsible +for deciding whether their operation has the fields it requires. + +The AI form does not pass `formRef`. The optional `formRef` remains available to existing +Classic widgets that write into the legacy form; read-only widgets such as Collate's +`TestAlertButton` must not require it. Collate uses `values` to derive availability and the +test-notification request, including edits made after the widget first rendered. + +Validate the OSS and Collate migration branches together. Merge OSS #34936 first, then Collate +#7216 immediately afterward. Keep Collate's OpenMetadata submodule branch on `main` for merge. +The Collate change consumes the new OSS widget contract and removes the deleted stylesheet +import. Do not restore the deleted stylesheet or an Ant Design form instance to bridge the two +merges. + +## Consequences + +Widgets can serve both form implementations without importing Ant Design or retaining a form +instance. The companion PR is required for this migration: between the merges, Collate main +has an obsolete stylesheet import and its test-alert widget still expects `formRef`. Treat +that interval as a coordinated integration window, and verify Collate against the merged OSS +main before considering the migration integrated. A future independently deployable transition +would need an explicit compatibility phase instead of this immediate merge sequence. diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/modals/modal.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/modals/modal.tsx index fad5f37caa1d..b1a7fbe4fb43 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/modals/modal.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/modals/modal.tsx @@ -95,6 +95,7 @@ const DialogHeader = ({ title, children, className }: DialogHeaderProps) => ( {title && ( {title} diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/box/box.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/box/box.tsx index 5a2f8ac3b707..db537f86a5f0 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/box/box.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/box/box.tsx @@ -105,7 +105,7 @@ const getBoxItems = ( itemClassName: string, keyPrefix = '' ): ReactNode[] => - Children.toArray(children).flatMap((child, index) => { + Children.toArray(children).flatMap((child, index) => { const key = `${keyPrefix}/${isValidElement(child) ? child.key : index}`; // Fragments group JSX, not layout items; keep their children independently spaced. 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 9573c0aefee1..4c7861b05ac4 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 @@ -165,6 +165,12 @@ export * from './application/popover/popover'; export * from './application/progress-steps/progress-steps'; export { SlideoutMenu } from './application/slideout-menus/slideout-menu'; export * from './application/table/table'; +export { + GridList, + GridListItem, + ListLayout, + Virtualizer, +} from 'react-aria-components'; export * from './application/tabs/tabs'; export * from './application/toast/toast'; export * from './application/toast/toast-provider'; diff --git a/openmetadata-ui/src/main/resources/ui/docs/colors.md b/openmetadata-ui/src/main/resources/ui/docs/colors.md index 4d29afc5be7f..2462ed576586 100644 --- a/openmetadata-ui/src/main/resources/ui/docs/colors.md +++ b/openmetadata-ui/src/main/resources/ui/docs/colors.md @@ -85,7 +85,7 @@ Use background tokens for surfaces, containers, cards, modals, and page-level ba | Tailwind Class | CSS Variable | Light | Dark | When to Use | |---|---|---|---|---| -| `tw:bg-brand-primary` | `--color-bg-brand-primary` | `#eff8ff` | Brand 500 at 16% | Light brand tint surface | +| `tw:bg-brand-primary` | `--color-bg-brand-primary` | `#eff8ff` | Brand 500 at 14% | Light brand tint surface | | `tw:bg-brand-primary_alt` | `--color-bg-brand-primary_alt` | `#eff8ff` | `#191919` | Alternate brand tint | | `tw:bg-brand-secondary` | `--color-bg-brand-secondary` | `#d1e9ff` | `#1570ef` | Stronger brand tint | | `tw:bg-brand-solid` | `--color-bg-brand-solid` | `#1570ef` | `#1570ef` | Primary CTA button fill | diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 18a206c21ef9..aab27a2cf803 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -812,7 +812,7 @@ }, "playwright/utils/taskWorkflow.ts": { "om-playwright/no-positional-locator": { - "count": 3 + "count": 2 }, "playwright/no-wait-for-selector": { "count": 1 diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts index a1a436b6914b..eaf20b6b6e37 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts @@ -473,7 +473,7 @@ test.describe('FeedWidget on landing page', () => { await expect(seededCard).toBeVisible(); await seededCard.click(); - const drawer = page.locator('.activity-feed-drawer'); + const drawer = page.getByTestId('activity-feed-drawer'); await expect(drawer).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ColumnBulkOperationsTagsGlossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ColumnBulkOperationsTagsGlossary.spec.ts index eaca35b48e1b..32e64e3eb90b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ColumnBulkOperationsTagsGlossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ColumnBulkOperationsTagsGlossary.spec.ts @@ -221,16 +221,16 @@ test.describe('Column Bulk Operations - Tags & Glossary Select in Drawer', () => await searchRes; }); - await test.step('Option renders inside the drawer top layer', async () => { - // Before the fix this option portaled to document.body, outside the - // drawer — so scoping the locator to the drawer would not find it. + await test.step('Option renders above the drawer', async () => { + // The core listbox is a react-aria popover nested in the drawer's modal, + // so it portals to the body yet stays interactive above the drawer. await expect( - drawer.getByTestId(`tag-${CLASSIFICATION_TAG_FQN}`) + page.getByTestId(`tag-${CLASSIFICATION_TAG_FQN}`) ).toBeVisible(); }); await test.step('Clicking the option selects the tag', async () => { - await drawer.getByTestId(`tag-${CLASSIFICATION_TAG_FQN}`).click(); + await page.getByTestId(`tag-${CLASSIFICATION_TAG_FQN}`).click(); await expect( tagsField.locator('[data-testid^="selected-tag-"]') 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 29d5c186612d..45db3d001187 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 @@ -750,8 +750,11 @@ test.describe( await test.step('Reopen the incident as Acknowledged from the header', async () => { await page.click('[data-testid="edit-resolution-icon"]'); - await page.click('[data-testid="test-case-resolution-status-type"]'); - await page.click('[title="Ack"]'); + await page + .getByTestId('test-case-resolution-status-type') + .getByRole('button') + .click(); + await page.getByRole('option', { name: 'Ack', exact: true }).click(); const reopenResponse = page.waitForResponse( (response) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts index ede0cfe890cc..31a6e7a7b507 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/IncidentManagerDateFilter.spec.ts @@ -10,15 +10,143 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { expect } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; import { DOMAIN_TAGS } from '../../../constant/config'; import { SidebarItem } from '../../../constant/sidebar'; import { TableClass } from '../../../support/entity/TableClass'; import { performAdminLogin } from '../../../utils/admin'; -import { redirectToHomePage } from '../../../utils/common'; +import { + clickIgnoringToasts, + redirectToHomePage, + scrollIntoViewAndSettle, + selectOptionWithRetry, +} from '../../../utils/common'; import { waitForAllLoadersToDisappear } from '../../../utils/entity'; import { sidebarClick } from '../../../utils/sidebar'; +import { + waitForAriaOverlayToSettle, + waitForResponseWithStatus, +} from '../../../utils/waitHelpers'; import { test } from '../../fixtures/pages'; +import { + disableAiAppMode, + stubUserPreferencesAppMode, +} from '../../Utils/appMode'; + +const dateRangeTrigger = (page: Page) => + page + .getByRole('group', { name: 'Date Range', exact: true }) + .getByRole('button'); + +const waitForIncidentList = ( + page: Page, + matchesParams: (params: URLSearchParams) => boolean = () => true +) => + waitForResponseWithStatus( + page, + (response) => { + const url = new URL(response.url()); + return ( + response.request().method() === 'GET' && + url.pathname === + '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' && + matchesParams(url.searchParams) + ); + }, + 200 + ); + +const expectEmptyDateRange = async (page: Page) => { + await expect(dateRangeTrigger(page)).toHaveText('Select dates'); + await expect(page).toHaveURL( + (url) => !url.searchParams.has('startTs') && !url.searchParams.has('endTs') + ); + await expect(page.getByTestId('incident-clear-filters')).not.toBeVisible(); +}; + +const applyTodayRange = async (page: Page) => { + const trigger = dateRangeTrigger(page); + await expect(trigger).toHaveText('Select dates'); + await scrollIntoViewAndSettle(trigger); + await trigger.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await waitForAriaOverlayToSettle(page); + + const range = await page.evaluate(() => { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + return { + startTs: String(start.getTime()), + endTs: String(end.getTime() - 1), + }; + }); + await dialog.getByRole('button', { name: 'Today', exact: true }).click(); + await expect(dialog).toBeVisible(); + await expect(trigger).not.toHaveText('Select dates'); + await expect(page).toHaveURL( + (url) => !url.searchParams.has('startTs') && !url.searchParams.has('endTs') + ); + + const responsePromise = waitForIncidentList( + page, + (params) => + params.get('startTs') === range.startTs && + params.get('endTs') === range.endTs + ); + await clickIgnoringToasts( + dialog.getByRole('button', { name: 'Apply', exact: true }) + ); + await responsePromise; + await expect(dialog).not.toBeVisible(); + await expect(trigger).not.toHaveText('Select dates'); + await expect(page).toHaveURL( + (url) => + url.searchParams.get('startTs') === range.startTs && + url.searchParams.get('endTs') === range.endTs + ); + return range; +}; + +const clearDateRange = async (page: Page) => { + const clearButton = page.getByTestId('incident-clear-filters'); + await expect(clearButton).toBeVisible(); + const responsePromise = waitForIncidentList( + page, + (params) => !params.has('startTs') && !params.has('endTs') + ); + await clearButton.click(); + await responsePromise; + await expectEmptyDateRange(page); +}; + +const selectDateField = async ( + page: Page, + field: 'updatedAt' | 'timestamp' +) => { + const responsePromise = waitForIncidentList( + page, + (params) => params.get('dateField') === field + ); + await selectOptionWithRetry( + page.getByTestId('sort-field-dropdown-trigger'), + page.getByTestId('drop-down-menu').getByTestId(field) + ); + await responsePromise; + await expect(page.getByTestId('sort-field-dropdown-trigger')).toContainText( + field === 'updatedAt' ? 'Updated at' : 'Created at' + ); + await expect(page).toHaveURL( + (url) => url.searchParams.get('dateField') === field + ); +}; + +test.beforeEach(async ({ page }) => { + await disableAiAppMode(page); + await stubUserPreferencesAppMode(page, 'classic'); +}); test.describe( 'Incident Manager Date Filter', @@ -27,13 +155,9 @@ test.describe( let table: TableClass; test.beforeAll(async ({ browser }) => { - test.slow(); const { apiContext, afterAction } = await performAdminLogin(browser); table = new TableClass(); - await table.create(apiContext); - - // Create table and test cases await table.createTestCase(apiContext, { parameterValues: [ { name: 'minColValue', value: 12 }, @@ -43,24 +167,18 @@ test.describe( }); const testCase = table.testCasesResponseData[0]; - - // Create a failed result to generate an incident await table.addTestCaseResult(apiContext, testCase.fullyQualifiedName, { testCaseStatus: 'Failed', result: 'Column count was 10, expected between 12 and 24', timestamp: Date.now(), testResultValue: [{ name: 'columnCount', value: '10' }], }); - - // Add another failure from 5 days ago for range testing - const fiveDaysAgo = Date.now() - 5 * 24 * 60 * 60 * 1000; await table.addTestCaseResult(apiContext, testCase.fullyQualifiedName, { testCaseStatus: 'Failed', result: 'Column count was 10, expected between 12 and 24', - timestamp: fiveDaysAgo, + timestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, testResultValue: [{ name: 'columnCount', value: '10' }], }); - await afterAction(); }); @@ -74,367 +192,120 @@ test.describe( await redirectToHomePage(page); await table.visitEntityPage(page); await page.getByTestId('profiler').click(); - const incidentListResponse = page.waitForResponse((response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) - ); + const responsePromise = waitForIncidentList(page); await page.getByRole('tab', { name: 'Incidents' }).click(); - const response = await incidentListResponse; - expect(response.status()).toBe(200); + await responsePromise; }); test('Date picker shows placeholder when no date is selected', async ({ page, }) => { - const datePicker = page.getByTestId('date-picker-menu'); - await expect(datePicker).toBeVisible(); - await expect(datePicker).toContainText('Select Date'); - - // Verify URL does not have date params - const url = new URL(page.url()); - expect(url.searchParams.has('startTs')).toBeFalsy(); - expect(url.searchParams.has('endTs')).toBeFalsy(); + await expectEmptyDateRange(page); }); test('Select preset date range', async ({ page }) => { - const datePicker = page.getByTestId('date-picker-menu'); - await datePicker.click(); - - const last7DaysOption = page.getByRole('menuitem', { - name: 'Last 7 days', - }); - - // Wait for API call that happens on selection change. - // We strictly wait for a request that has searchParams to avoid catching the initial load or others. - const incidentListResponse = page.waitForResponse( - (response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) && response.url().includes('startTs') - ); - - await last7DaysOption.click(); - - const response = await incidentListResponse; - expect(response.status()).toBe(200); - const url = new URL(response.url()); - - // Validate API params - expect(url.searchParams.has('startTs')).toBeTruthy(); - expect(url.searchParams.has('endTs')).toBeTruthy(); - - const startTs = parseInt(url.searchParams.get('startTs') || '0'); - const endTs = parseInt(url.searchParams.get('endTs') || '0'); - - expect(startTs).toBeLessThan(endTs); - - // Verify button text - await expect(datePicker).toContainText('Last 7 days'); - - // Verify URL params - const pageUrl = new URL(page.url()); - expect(pageUrl.searchParams.get('key')).toBe('last7days'); + await applyTodayRange(page); }); test('Clear selected date range', async ({ page }) => { - // First select a range to ensure we can clear it - const datePicker = page.getByTestId('date-picker-menu'); - await datePicker.click(); - - // Wait for request with startTs to ensure selection is applied - const selectionResponse = page.waitForResponse( - (response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) && response.url().includes('startTs') - ); - await page.getByRole('menuitem', { name: 'Last 7 days' }).click(); - const afterSelectResponse = await selectionResponse; - expect(afterSelectResponse.status()).toBe(200); - - await expect(datePicker).toContainText('Last 7 days'); - - // Now clear it - const clearButton = page.getByTestId('clear-date-picker'); - await expect(clearButton).toBeVisible(); - - // Intercept response to verify params are GONE - const incidentListResponse = page.waitForResponse( - (response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) && !response.url().includes('startTs') - ); - - await clearButton.click(); - - const response = await incidentListResponse; - expect(response.status()).toBe(200); - const url = new URL(response.url()); - - // Validate API params do NOT exist - expect(url.searchParams.has('startTs')).toBeFalsy(); - expect(url.searchParams.has('endTs')).toBeFalsy(); - - // Verify UI reset - await expect(datePicker).toContainText('Select Date'); - await expect(clearButton).not.toBeVisible(); - - // Verify Page URL params removed - const pageUrl = new URL(page.url()); - expect(pageUrl.searchParams.has('startTs')).toBeFalsy(); - expect(pageUrl.searchParams.has('endTs')).toBeFalsy(); - expect(pageUrl.searchParams.has('key')).toBeFalsy(); + await applyTodayRange(page); + await clearDateRange(page); }); test('Date filter persists on page reload', async ({ page }) => { - const datePicker = page.getByTestId('date-picker-menu'); - await datePicker.click(); - await page.getByRole('menuitem', { name: 'Last 7 days' }).click(); - await expect(datePicker).toContainText('Last 7 days'); - - const incidentListResponse = page.waitForResponse((response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) + const range = await applyTodayRange(page); + const label = await dateRangeTrigger(page).innerText(); + const responsePromise = waitForIncidentList( + page, + (params) => + params.get('startTs') === range.startTs && + params.get('endTs') === range.endTs ); - - // Verify URL has the key BEFORE reloading - await expect(page).toHaveURL(/key=last7days/); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForLoadState('domcontentloaded'); + await responsePromise; await waitForAllLoadersToDisappear(page); - const response = await incidentListResponse; - expect(response.status()).toBe(200); - - const datePickerReloaded = page.getByTestId('date-picker-menu'); - await expect(datePickerReloaded).toBeVisible({ timeout: 15000 }); - await expect(datePickerReloaded).toContainText('Last 7 days'); - - const url = new URL(page.url()); - expect(url.searchParams.get('key')).toBe('last7days'); + await expect(dateRangeTrigger(page)).toHaveText(label); + await expect(page).toHaveURL( + (url) => + url.searchParams.get('startTs') === range.startTs && + url.searchParams.get('endTs') === range.endTs + ); }); } ); -/** - * Incident Manager Date Field Sort Dropdown - * @description Tests the date field sort dropdown (Created At / Updated At) on the Incidents tab. - */ test.describe( 'Incident Manager - Date Field Sort Dropdown', { tag: `${DOMAIN_TAGS.OBSERVABILITY}:Incident_Manager` }, () => { test.beforeEach(async ({ page }) => { await redirectToHomePage(page); - const incidentListResponse = page.waitForResponse((response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) - ); + const responsePromise = waitForIncidentList(page); await sidebarClick(page, SidebarItem.INCIDENT_MANAGER); - const response = await incidentListResponse; - expect(response.status()).toBe(200); + await responsePromise; }); test('should show "Created At" as the default sort field label', async ({ page, }) => { - // The trigger button should show the default label - const sortTrigger = page.locator('.sorting-dropdown'); - await expect(sortTrigger).toBeVisible(); - await expect(sortTrigger).toContainText('Created at'); + await expect( + page.getByTestId('sort-field-dropdown-trigger') + ).toContainText('Created at'); }); test('should open sort field dropdown on click', async ({ page }) => { - const sortTrigger = page.locator('.sorting-dropdown'); - await sortTrigger.click(); - - // Dropdown menu should appear with both options - await expect( - page.getByRole('menuitemradio', { name: 'Created at' }) - ).toBeVisible(); - await expect( - page.getByRole('menuitemradio', { name: 'Updated at' }) - ).toBeVisible(); + const trigger = page.getByTestId('sort-field-dropdown-trigger'); + await scrollIntoViewAndSettle(trigger); + await trigger.click(); + const menu = page.getByTestId('drop-down-menu'); + await expect(menu.getByTestId('timestamp')).toHaveText('Created at'); + await expect(menu.getByTestId('updatedAt')).toHaveText('Updated at'); }); test('should switch to "Updated At" and call API with dateField=updatedAt', async ({ page, }) => { - const sortTrigger = page.locator('.sorting-dropdown'); - await sortTrigger.click(); - - // Intercept the API call triggered by the selection change - // Select Updated at - const apiResponsePromise = page.waitForResponse( - (response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) && response.url().includes('dateField=updatedAt') - ); - await page.getByRole('menuitemradio', { name: 'Updated at' }).click(); - - const apiResponse = await apiResponsePromise; - expect(apiResponse.status()).toBe(200); - - // Trigger should now show Updated at - await expect(sortTrigger).toContainText('Updated at'); - - // URL should contain dateField=updatedAt - await expect(page).toHaveURL(/dateField=updatedAt/); + await selectDateField(page, 'updatedAt'); }); test('should switch back to "Created at" and call API with dateField=timestamp', async ({ page, }) => { - // First switch to Updated at - const sortTrigger = page.locator('.sorting-dropdown'); - await sortTrigger.click(); - const updatedAtRes = page.waitForResponse( - (response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) && response.url().includes('dateField=updatedAt') - ); - await page.getByRole('menuitemradio', { name: 'Updated at' }).click(); - await updatedAtRes; - - // Now switch back to Created at - await sortTrigger.click(); - const createdAtRes = page.waitForResponse( - (response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) && response.url().includes('dateField=timestamp') - ); - await page.getByRole('menuitemradio', { name: 'Created at' }).click(); - await createdAtRes; - - await expect(sortTrigger).toContainText('Created at'); - await expect(page).toHaveURL(/dateField=timestamp/); + await selectDateField(page, 'updatedAt'); + await selectDateField(page, 'timestamp'); }); test('should close sort dropdown after selecting an option', async ({ page, }) => { - const sortTrigger = page.locator('.sorting-dropdown'); - await sortTrigger.click(); - - // Menu should be visible - await expect( - page.getByRole('menuitemradio', { name: 'Updated at' }) - ).toBeVisible(); - - // Select an option - await page.getByRole('menuitemradio', { name: 'Updated at' }).click(); - - // Menu should close - await expect( - page.getByRole('menuitemradio', { name: 'Updated at' }) - ).not.toBeVisible(); + await selectDateField(page, 'updatedAt'); + await expect(page.getByTestId('drop-down-menu')).not.toBeVisible(); }); } ); -/** - * Incident Manager Date Filter - Sidebar Navigation - * @description Tests the date filter functionality when accessing Incident Manager from the sidebar. - */ test.describe( 'Incident Manager Date Filter - Sidebar', { tag: `${DOMAIN_TAGS.OBSERVABILITY}:Incident_Manager` }, () => { test.beforeEach(async ({ page }) => { await redirectToHomePage(page); - const incidentListResponse = page.waitForResponse((response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) - ); + const responsePromise = waitForIncidentList(page); await sidebarClick(page, SidebarItem.INCIDENT_MANAGER); - const response = await incidentListResponse; - expect(response.status()).toBe(200); + await responsePromise; }); test('Date picker shows placeholder by default on Incident Manager page', async ({ page, }) => { - const datePicker = page.getByTestId('date-picker-menu'); - await expect(datePicker).toBeVisible(); - await expect(datePicker).toContainText('Select Date'); - - // Clear button should not be visible when no date is selected - await expect(page.getByTestId('clear-date-picker')).not.toBeVisible(); + await expectEmptyDateRange(page); }); test('Select and clear date range on Incident Manager page', async ({ page, }) => { - const datePicker = page.getByTestId('date-picker-menu'); - await datePicker.click(); - - // Select Last 7 days - const dateFilterRes = page.waitForResponse( - (response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) && response.url().includes('startTs') - ); - await page.getByRole('menuitem', { name: 'Last 7 days' }).click(); - await dateFilterRes; - - await expect(datePicker).toContainText('Last 7 days'); - await expect(page).toHaveURL(/key=last7days/); - - // Clear button should be visible - const clearButton = page.getByTestId('clear-date-picker'); - await expect(clearButton).toBeVisible(); - - // Clear the selection - const clearRes = page.waitForResponse( - (response) => - response - .url() - .includes( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list' - ) && !response.url().includes('startTs') - ); - await clearButton.click(); - await clearRes; - - // Verify reset to placeholder - await expect(datePicker).toContainText('Select Date'); - await expect(clearButton).not.toBeVisible(); - - // Verify URL params removed - const pageUrl = new URL(page.url()); - expect(pageUrl.searchParams.has('startTs')).toBeFalsy(); - expect(pageUrl.searchParams.has('key')).toBeFalsy(); + await applyTodayRange(page); + await clearDateRange(page); }); } ); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts index b224288c11d6..9ae4d6b3bf50 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts @@ -41,6 +41,7 @@ import { waitForTaskResolveResponse } from '../../utils/task'; import { verifyTestCaseLastRunBanner } from '../../utils/testCases'; import { clickAndWaitFor } from '../../utils/waitHelpers'; import { test } from '../fixtures/pages'; +import { disableAiAppMode, stubUserPreferencesAppMode } from '../Utils/appMode'; let user1: UserClass; let user2: UserClass; @@ -178,7 +179,7 @@ const openIncidentReassignModal = async (page: Page, testCaseName?: string) => { '[data-testid="edit-assignees"]:visible' ); const reassignModal = page - .locator('.ant-modal-wrap:visible') + .locator('[role="dialog"]:visible') .filter({ hasText: /Re-?assign Task/i }); const reassignMenuItem = page .locator('.task-action-dropdown:visible') @@ -279,6 +280,9 @@ const reassignIncidentTask = async ( await expect(assigneeOption).toBeVisible({ timeout: ACTION_TIMEOUT }); await assigneeOption.click(); + // The open combobox popover aria-hides the rest of the dialog, Save included. + await reassignModal.getByTestId('dialog-title').click(); + await expect(assigneeOption).toBeHidden(); const updateAssignee = waitForTaskResolveResponse(page); @@ -313,8 +317,9 @@ const openIncidentResolveDialog = async ( const actionTrigger = page.locator( '[data-testid="incident-task-action-trigger"]:visible, [data-testid="workflow-task-action-trigger"]:visible' ); + // Menu popovers are role=dialog too; data-trigger tells them from modals. const resolveModal = page.locator( - '.ant-modal-wrap:visible .ant-modal-content' + '[role="dialog"]:not([data-trigger]):visible' ); const modalTextareas = resolveModal.locator('textarea'); @@ -497,6 +502,8 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { }); test.beforeEach(async ({ page }) => { + await disableAiAppMode(page); + await stubUserPreferencesAppMode(page, 'classic'); await redirectToHomePage(page); }); @@ -1099,13 +1106,9 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { testCaseFqn: table1.testCasesResponseData[2]?.['fullyQualifiedName'], }; const testCase1 = table1.testCasesResponseData[0]?.['name']; - const incidentDetailsRes = page.waitForResponse( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list?*' - ); - await sidebarClick(page, SidebarItem.INCIDENT_MANAGER); - await incidentDetailsRes; - const assignmentStartedAt = Date.now(); + // assignIncident navigates to Incident Manager itself; a second sidebar + // click while already there lands on the half-open Observability submenu. await assignIncident({ page, testCaseName: assigneeTestCase.testCaseName, @@ -1132,14 +1135,18 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { await afterAction(); } - await page.click('[data-testid="select-assignee"] input'); - const assigneeOption = page.locator( - `[data-testid="${assigneeTestCase.username}"]` - ); + await page.getByTestId('select-assignee').click(); + await page + .getByTestId('select-owner-tabs') + .getByRole('tab', { name: /Users/ }) + .click(); await page - .getByTestId('select-assignee') - .locator('input') + .getByTestId('owner-select-users-search-bar') .fill(assigneeTestCase.userDisplayName); + const assigneeOption = page + .getByTestId('owner-select-users-panel') + .getByTestId('owner-option') + .filter({ hasText: assigneeTestCase.userDisplayName }); await expect(assigneeOption).toBeVisible(); const assigneeFilterRes = page.waitForResponse( @@ -1153,13 +1160,20 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { assigneeTestCase.userDisplayName ); - const nonAssigneeFilterRes = page.waitForResponse( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list?*' + await expect(page.getByTestId('select-assignee')).toContainText( + assigneeTestCase.userDisplayName ); + await page.getByTestId('select-assignee').click(); + const nonAssigneeFilterRes = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith('/testCaseIncidentStatus/search/list') && + !url.searchParams.has('assignee') + ); + }); await page - .getByTestId('select-assignee') - .getByTestId('autocomplete-selected-item') - .getByRole('button') + .getByTestId('owner-select-users-panel') + .getByTestId('remove-owner') .click(); await nonAssigneeFilterRes; @@ -1167,20 +1181,29 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { const statusFilterRes = page.waitForResponse( '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list?*testCaseResolutionStatusType=Assigned*' ); - await page.click(`[title="Assigned"]`); + await page.getByTestId('drop-down-menu').getByTestId('Assigned').click(); await statusFilterRes; await expectIncidentTableRowsToContain(page, 'Assigned'); - const nonStatusFilterRes = page.waitForResponse( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list?*' - ); - await page.getByTestId('status-select').getByLabel('close-circle').click(); + const nonStatusFilterRes = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith('/testCaseIncidentStatus/search/list') && + !url.searchParams.has('testCaseResolutionStatusType') + ); + }); + await page.getByTestId('incident-clear-filters').click(); await nonStatusFilterRes; await page.click('[data-testid="test-case-select"]'); - const testCaseOption = page.locator(`[title="${testCase1}"]`); - await page.getByTestId('test-case-select').locator('input').fill(testCase1); + const testCaseOption = page + .getByTestId('drop-down-menu') + .getByTestId(table1.testCasesResponseData[0]?.['fullyQualifiedName']); + await page + .getByTestId('drop-down-menu') + .getByTestId('search-input') + .fill(testCase1); await expect(testCaseOption).toBeVisible(); const testCaseFilterRes = page.waitForResponse( @@ -1196,20 +1219,43 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { page.locator(`[data-testid="test-case-${testCase1}"]`) ).toBeVisible(); - const nonTestCaseFilterRes = page.waitForResponse( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list?*' - ); - await page - .getByTestId('test-case-select') - .getByLabel('close-circle') - .click(); + const nonTestCaseFilterRes = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith('/testCaseIncidentStatus/search/list') && + !url.searchParams.has('testCaseFQN') + ); + }); + await page.getByTestId('incident-clear-filters').click(); await nonTestCaseFilterRes; - await page.getByTestId('date-picker-menu').click(); - const timeSeriesFilterRes = page.waitForResponse( - '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list?*' + await page.getByTestId('sort-field-dropdown-trigger').click(); + const dateFieldResponse = page.waitForResponse( + '/api/v1/dataQuality/testCases/testCaseIncidentStatus/search/list?*dateField=updatedAt*' ); - await page.getByRole('menuitem', { name: 'Yesterday' }).click(); + await page.getByTestId('drop-down-menu').getByTestId('updatedAt').click(); + await dateFieldResponse; + + await page + .getByRole('group', { name: 'Date Range', exact: true }) + .getByRole('button') + .click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Today', exact: true }) + .click(); + const timeSeriesFilterRes = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith('/testCaseIncidentStatus/search/list') && + url.searchParams.has('startTs') && + url.searchParams.has('endTs') + ); + }); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Apply', exact: true }) + .click(); await timeSeriesFilterRes; for (const testCase of table1.testCasesResponseData) { @@ -1217,5 +1263,22 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { page.locator(`[data-testid="test-case-${testCase?.['name']}"]`) ).toBeVisible(); } + const clearFiltersResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith('/testCaseIncidentStatus/search/list') && + [ + 'assignee', + 'testCaseFQN', + 'testCaseResolutionStatusType', + 'startTs', + 'endTs', + 'dateField', + ].every((param) => !url.searchParams.has(param)) + ); + }); + await page.getByTestId('incident-clear-filters').click(); + await clearFiltersResponse; + await expect(page.getByTestId('incident-clear-filters')).not.toBeVisible(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks.spec.ts index 5817987ba99b..365a6168a281 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks.spec.ts @@ -156,9 +156,7 @@ test.describe('Task Workflow Tests', () => { }); // Add suggested tags - const tagsInput = page.locator( - '[data-testid="tag-selector"] .ant-select-selection-search input' - ); + const tagsInput = page.getByTestId('tag-selector').getByRole('combobox'); if (await tagsInput.isVisible().catch(() => false)) { await addTagSuggestion({ page, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskCreation.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskCreation.spec.ts index de71f67fc954..c19d1985b0c8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskCreation.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskCreation.spec.ts @@ -428,9 +428,7 @@ test.describe('Task Creation - Suggest Tags', () => { await expect(page.getByTestId('form-container')).toBeVisible(); // Add suggested tags using the tag selector - const tagsInput = page.locator( - '[data-testid="tag-selector"] .ant-select-selector input' - ); + const tagsInput = page.getByTestId('tag-selector').getByRole('combobox'); if (await tagsInput.isVisible()) { await tagsInput.click(); @@ -442,8 +440,10 @@ test.describe('Task Creation - Suggest Tags', () => { await expect(tagOption).toBeVisible(); await tagOption.click(); - // Close the dropdown by pressing Escape - await page.keyboard.press('Escape'); + // The multi-select schedules a re-open (~150ms) while its input still + // has focus, which would undo an Escape; blurring closes it for good. + await tagsInput.blur(); + await expect(tagsInput).toHaveAttribute('aria-expanded', 'false'); } // Submit - tag request pages use submit-tag-request diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts index bfd95bb79448..5c21e17c2a86 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts @@ -276,7 +276,7 @@ test.describe('Task Navigation - Entity Page', () => { await taskCard.click(); // Should open drawer with task details - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.getByTestId('activity-feed-drawer'); if (await drawer.isVisible({ timeout: 5000 })) { // Drawer should show task details diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Utils/appMode.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Utils/appMode.ts index ecb61e82f033..4bf9d8134d9a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Utils/appMode.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Utils/appMode.ts @@ -44,7 +44,7 @@ const buildSessionTuple = (mode: string) => * cross-test leakage. The interceptor forwards the real request and mutates the * response, so every other preference the server returns is preserved. */ -const stubUserPreferencesAppMode = async ( +export const stubUserPreferencesAppMode = async ( page: Page, mode: 'ai' | 'classic' ): Promise => { 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 ecfa7313f635..58d106e5e2c9 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 @@ -50,9 +50,9 @@ test('the suppressions baseline matches its recorded state exactly', () => { // the profile-menu helper no longer hovers the first avatar, the Glossary // language switch targets the core language selector by test id, and the // core assignee/schema-field selectors in the task and incident specs - // remove three more. Merging main into this branch combines both sets of - // removals, so the union is two below either side on its own. - 'om-playwright/no-positional-locator': 592, + // remove three more. The merged baseline retains independent locator + // fixes from both branches. + 'om-playwright/no-positional-locator': 591, '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/activityFeed.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/activityFeed.ts index 857e5a9b6cbf..fdc668957ba1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/activityFeed.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/activityFeed.ts @@ -47,9 +47,11 @@ export const checkDescriptionInEditModal = async ( await taskActionMenu.getByRole('menuitem', { name: 'edit' }).click(); - await expect(page.locator('[role="dialog"].ant-modal')).toBeVisible(); + const editTaskModal = page.getByTestId('suggestion-edit-task-modal'); - await expect(page.locator('.ant-modal-title')).toContainText( + await expect(editTaskModal).toBeVisible(); + + await expect(editTaskModal.getByRole('heading')).toContainText( `Update description for table ${taskValue.term} columns/${taskValue.columnName}` ); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/addTestCaseList.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/addTestCaseList.ts index 2b2ce260dedf..e2eb58917d1c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/addTestCaseList.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/addTestCaseList.ts @@ -22,6 +22,16 @@ export interface AddTestCaseListFilterContext { waitAfterFilterUpdate: () => Promise; } +// The filters sit below the fold of the modal's scroll area. Playwright's +// own scroll-before-click dispatches its scroll event after the popover has +// opened, and react-aria closes a popover on any ancestor scroll. Scrolling +// first lets that event flush before the click. +async function openAddTestCaseListFilter(page: Page, searchKey: string) { + const trigger = page.getByTestId(`search-dropdown-${searchKey}`); + await trigger.scrollIntoViewIfNeeded(); + await trigger.click(); +} + // The filter dropdown is a popover rendered outside the Add Test Cases modal // and can close again right after it opens (timing-dependent). Reopen and // retype until the searched option is shown instead of racing that close. @@ -36,7 +46,7 @@ async function searchAddTestCaseListFilterOption( await expect(async () => { if (!(await menu.isVisible())) { - await page.getByTestId(`search-dropdown-${searchKey}`).click(); + await openAddTestCaseListFilter(page, searchKey); } await menu.getByTestId('search-input').fill(searchText, { timeout: 5_000 }); await expect(option).toBeVisible({ timeout: 10_000 }); @@ -52,7 +62,7 @@ export async function addTestCaseListFilterByTestType( const listResponse = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list*' ); - await page.getByTestId('search-dropdown-Test Type').click(); + await openAddTestCaseListFilter(page, 'Test Type'); await page .getByTestId('drop-down-menu') .getByRole('menuitemradio', { name: label }) @@ -68,7 +78,7 @@ export async function addTestCaseListFilterByStatus( const listResponse = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list*' ); - await page.getByTestId('search-dropdown-Status').click(); + await openAddTestCaseListFilter(page, 'Status'); await page .getByTestId('drop-down-menu') .getByRole('menuitemradio', { name: label }) @@ -138,7 +148,7 @@ export async function addTestCaseListResetFilters( const clearTableResponse = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list*' ); - await page.getByTestId('search-dropdown-Table').click(); + await openAddTestCaseListFilter(page, 'Table'); await page.getByTestId('drop-down-menu').getByTestId(tableFqn).click(); await page.getByTestId('drop-down-menu').getByTestId('update-btn').click(); await clearTableResponse; @@ -146,7 +156,7 @@ export async function addTestCaseListResetFilters( const clearColumnResponse = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list*' ); - await page.getByTestId('search-dropdown-Column').click(); + await openAddTestCaseListFilter(page, 'Column'); await page.getByTestId('drop-down-menu').getByTestId(columnName).click(); await page.getByTestId('drop-down-menu').getByTestId('update-btn').click(); await clearColumnResponse; @@ -154,7 +164,7 @@ export async function addTestCaseListResetFilters( const clearStatusResponse = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list*' ); - await page.getByTestId('search-dropdown-Status').click(); + await openAddTestCaseListFilter(page, 'Status'); await page .getByTestId('drop-down-menu') .getByRole('menuitemradio', { name: 'Success' }) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts index 54c6fa068d78..9c93315af25f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts @@ -587,14 +587,13 @@ export const selectExistingBundleSuite = async ( await dropdownInput.click(); await dropdownInput.fill(suiteName); - // AddToBundleSuiteModal still renders an antd Select (not migrated to the - // react-aria stack), so scope to the visible antd dropdown and its option - // rows. A generic `[role="listbox"]` matches multiple listboxes on the page - // (e.g. the header asset search) and resolves ambiguously. - const dropdown = page.locator('.ant-select-dropdown:visible'); - const option = dropdown.locator('.ant-select-item-option', { - hasText: suiteName, - }); + // Scope to the popover owned by this combobox: a generic listbox locator + // also matches other listboxes on the page (e.g. the header asset search). + await expect(dropdownInput).toHaveAttribute('aria-controls', /.+/); + const listboxId = (await dropdownInput.getAttribute('aria-controls')) ?? ''; + const option = page + .locator(`[role="listbox"][id="${listboxId}"]`) + .getByRole('option', { name: suiteName }); await expect(option).toBeVisible(); await option.click(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts index 0118d3a0d6e8..b8ef51498d66 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts @@ -102,9 +102,7 @@ export const confirmationDragAndDropTeam = async ( dropTeam: string ) => { // Confirmation message before the transfer - await expect( - page.locator('[data-testid="confirmation-modal"] .ant-modal-body') - ).toContainText( + await expect(page.getByTestId('confirmation-modal')).toContainText( `Click on Confirm if you’d like to move ${dragTeam} team under ${dropTeam} team.` ); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 437c442060b1..94ba983f974b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -1367,9 +1367,7 @@ export const createTagTaskForGlossary = async ( if (addTag) { // select tags - const suggestTags = page.locator( - '[data-testid="tag-selector"] > .ant-select-selector .ant-select-selection-search-input' - ); + const suggestTags = page.locator('[data-testid="tag-selector"] input'); await suggestTags.click(); const querySearchResponse = page.waitForResponse( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/incidentManager.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/incidentManager.ts index b7fbe6c9caca..7d97828d3d35 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/incidentManager.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/incidentManager.ts @@ -151,8 +151,11 @@ export const acknowledgeTask = async (data: { await page.getByTestId(testCase).getByText(testCase).click(); await waitForAllLoadersToDisappear(page); await page.click('[data-testid="edit-resolution-icon"]'); - await page.click('[data-testid="test-case-resolution-status-type"]'); - await page.click('[title="Ack"]'); + await page + .getByTestId('test-case-resolution-status-type') + .getByRole('button') + .click(); + await page.getByRole('option', { name: 'Ack', exact: true }).click(); const statusChangeResponse = waitForTaskResolveResponse(page); await page.click('#update-status-button'); await statusChangeResponse; @@ -187,7 +190,7 @@ export const addAssigneeFromPopoverWidget = async (data: { await taskTabEditAssigneesButton.click(); await waitForAllLoadersToDisappear(page); - const assigneeModal = page.locator('.ant-modal-content').last(); + const assigneeModal = page.getByRole('dialog').last(); const assigneeSelect = assigneeModal.getByTestId('select-assignee'); const assigneeInput = assigneeSelect.getByRole('combobox'); const assigneeOption = page.getByTestId(user.name).first(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/task.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/task.ts index 82e65e92871a..f56a53954bd3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/task.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/task.ts @@ -185,9 +185,7 @@ export const createTagTask = async ( if (addTag) { // select tags - const suggestTags = page.locator( - '[data-testid="tag-selector"] > .ant-select-selector .ant-select-selection-search-input' - ); + const suggestTags = page.locator('[data-testid="tag-selector"] input'); await suggestTags.click(); const querySearchResponse = page.waitForResponse( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/taskWorkflow.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/taskWorkflow.ts index 147b10a56285..25faa92cace0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/taskWorkflow.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/taskWorkflow.ts @@ -41,7 +41,11 @@ export interface CreatedTask { const TASK_TAB_SELECTOR = '[data-testid="task-tab"]'; const TASK_PANEL_SELECTOR = '#task-panel'; -const VISIBLE_TASK_MODAL_SELECTOR = '.ant-modal-wrap:visible'; +// Menu and combobox popovers are role="dialog" too; they carry data-trigger, +// modal dialogs do not. Without the exclusion an open caret menu makes the +// selector match two elements and every strict locator built on it throws. +const VISIBLE_TASK_MODAL_SELECTOR = + '[role="dialog"]:not([data-trigger]):visible'; const logTaskDebug = (...messages: Array) => { if (process.env.PW_TASK_DEBUG) { @@ -64,7 +68,7 @@ const selectTagSuggestion = async ({ tagTestId: string; }) => { const tagSelector = root.locator('[data-testid="tag-selector"]'); - const tagsInput = tagSelector.locator('.ant-select-selection-search-input'); + const tagsInput = tagSelector.getByRole('combobox'); const tagOption = page.getByTestId(tagTestId); const tagSearchResponse = page .waitForResponse( @@ -93,10 +97,38 @@ const selectTagSuggestion = async ({ await expect(tagOption).toBeVisible({ timeout: 5000 }); logTaskDebug('selectTagSuggestion:optionVisible', tagTestId); await tagOption.click(); - await page.keyboard.press('Escape'); + // The multi-select schedules a re-open (~150ms) while its input still has + // focus, which would undo an Escape sent right after the pick. Moving focus + // away is what closes it for good, but it has to land inside the modal: a + // click on the body (or a bare blur()) makes the dialog's focus containment + // hand focus back to the input, and focus re-opens the menu. + const containingDialog = page.locator(VISIBLE_TASK_MODAL_SELECTOR).filter({ + has: page.getByTestId('tag-selector'), + }); + if (await containingDialog.count()) { + // The open menu aria-hides the title, so locate its test ID. + await containingDialog.getByTestId('dialog-title').click(); + } else { + await clickOutside(page); + } + await expect(tagsInput).toHaveAttribute('aria-expanded', 'false'); logTaskDebug('selectTagSuggestion:done', tagTestId); }; +// The dialog is visible before its body has mounted, so a one-shot isVisible() +// on the comment field races the mount and silently skips the required comment. +const fillModalComment = async (modal: Locator, comment: string) => { + const commentInput = modal.locator('textarea').last(); + const hasCommentField = await commentInput + .waitFor({ state: 'visible', timeout: 3000 }) + .then(() => true) + .catch(() => false); + + if (hasCommentField) { + await commentInput.fill(comment); + } +}; + const clickDropdownMenuItem = async ({ page, menuPattern, @@ -664,10 +696,7 @@ export const closeTaskFromDetails = async (page: Page) => { .catch(() => undefined); if (await visibleModal.isVisible().catch(() => false)) { - const commentInput = visibleModal.locator('textarea').last(); - if (await commentInput.isVisible().catch(() => false)) { - await commentInput.fill('Rejected by Playwright'); - } + await fillModalComment(visibleModal, 'Rejected by Playwright'); const rejectButton = visibleModal.getByRole('button', { name: /reject|decline|close/i, @@ -713,10 +742,7 @@ export const approveTaskFromDetails = async (page: Page) => { .catch(() => undefined); if (await visibleTaskModal.isVisible().catch(() => false)) { - const commentInput = visibleTaskModal.locator('textarea').last(); - if (await commentInput.isVisible().catch(() => false)) { - await commentInput.fill('Approved by Playwright'); - } + await fillModalComment(visibleTaskModal, 'Approved by Playwright'); const confirmButton = visibleTaskModal .getByRole('button', { name: /approve|accept|ok|save/i }) diff --git a/openmetadata-ui/src/main/resources/ui/scripts/bundle-boot-packages.json b/openmetadata-ui/src/main/resources/ui/scripts/bundle-boot-packages.json index 17ffe9cd4486..7c4c92bf8340 100644 --- a/openmetadata-ui/src/main/resources/ui/scripts/bundle-boot-packages.json +++ b/openmetadata-ui/src/main/resources/ui/scripts/bundle-boot-packages.json @@ -62,7 +62,6 @@ "rc-drawer", "rc-dropdown", "rc-field-form", - "rc-image", "rc-input", "rc-input-number", "rc-menu", diff --git a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-token-reference.js b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-token-reference.js index 407fdb7fe211..b0bc3adc10e7 100644 --- a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-token-reference.js +++ b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-token-reference.js @@ -128,6 +128,11 @@ const GROUPS = [ title: 'Legacy colors', note: 'Exact migrated one-offs (migration debt). Do not use in new code; re-express with a semantic token.', }, + { + key: /^--om-/, + title: 'Legacy LESS aliases', + note: 'Existing LESS palette names retain their light fallbacks; app.less supplies dark-mode overrides. Prefer semantic tokens for new work.', + }, ]; function classify(name) { diff --git a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-tokens.js b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-tokens.js index 21c58cf6d3b5..a764b167bffe 100644 --- a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-tokens.js +++ b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-tokens.js @@ -15,10 +15,9 @@ /** * Regenerates the generated block of `src/styles/tokens.css`. * - * Scans every CSS/LESS file once (without writing) so the token map records - * which palette / legacy / off-scale / z-index / duration tokens the codebase - * actually references, then writes the full upstream palette plus exactly those - * project tokens between the @tokens:generated markers. + * Scans CSS/LESS values and token usages in styles and JavaScript/TypeScript, + * then writes the full upstream palette plus exactly those project tokens + * between the @tokens:generated markers. * * Run after `token-migrate.js`, or any time raw values change. */ @@ -39,7 +38,8 @@ const SKIP_DIRS = new Set([ ]); const BEGIN = '/* @tokens:generated-begin'; const END = '/* @tokens:generated-end */'; -const OM_USAGE_RE = /var\(\s*(--om-[\w-]+)/g; +const OM_USAGE_RE = + /var\(\s*(--om-[\w-]+)|tw:[\w:!/-]+\((?:[\w-]+:)?(--om-[\w-]+)/g; function walk(dir, acc) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -48,7 +48,7 @@ function walk(dir, acc) { if (!SKIP_DIRS.has(entry.name)) { walk(full, acc); } - } else if (/\.(less|css)$/.test(entry.name)) { + } else if (/\.(less|css|tsx?|jsx?)$/.test(entry.name)) { acc.push(full); } } @@ -82,11 +82,13 @@ function populateRegistry() { const usageNames = new Set(); for (const file of files) { const text = fs.readFileSync(file, 'utf8'); - processText(text, path.relative(STYLES_ROOT, file)); + if (/\.(less|css)$/.test(file)) { + processText(text, path.relative(STYLES_ROOT, file)); + } let m; OM_USAGE_RE.lastIndex = 0; while ((m = OM_USAGE_RE.exec(text))) { - usageNames.add(m[1]); + usageNames.add(m[1] || m[2]); } } const legacyValues = loadSidecar(); diff --git a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-tokens.test.js b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-tokens.test.js new file mode 100644 index 000000000000..d86b29fcd7c3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/gen-tokens.test.js @@ -0,0 +1,53 @@ +/* + * 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. + */ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +test('preserves tokens used only by TSX when regenerating styles', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'om-token-generation-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const scripts = path.join(root, 'scripts/design-tokens'); + const styles = path.join(root, 'src/styles'); + fs.cpSync(__dirname, scripts, { recursive: true }); + fs.mkdirSync(styles, { recursive: true }); + const tokensFile = path.join(styles, 'tokens.css'); + fs.writeFileSync( + tokensFile, + '/* @tokens:generated-begin */\n:root {}\n/* @tokens:generated-end */\n' + ); + fs.writeFileSync( + path.join(root, 'src/Consumer.tsx'), + `
` + ); + + const generate = () => + execFileSync(process.execPath, [path.join(scripts, 'gen-tokens.js')]); + generate(); + const generated = fs.readFileSync(tokensFile, 'utf8'); + + assert.match(generated, /--om-legacy-color-f8f8f8:\s*#f8f8f8;/); + assert.match(generated, /--om-legacy-color-595959:\s*#595959;/); + assert.match(generated, /--om-legacy-color-f7f9fc:\s*#f7f9fc;/); + assert.doesNotMatch(generated, /#123456/); + + generate(); + assert.equal(fs.readFileSync(tokensFile, 'utf8'), generated); +}); diff --git a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/legacy-colors.json b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/legacy-colors.json index db169692d97b..9b4f7399eb21 100644 --- a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/legacy-colors.json +++ b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/legacy-colors.json @@ -56,6 +56,7 @@ "--om-legacy-color-1677ff": "#1677ff", "--om-legacy-color-1890ff": "#1890ff", "--om-legacy-color-1d7c4d": "#1d7c4d", + "--om-legacy-color-1f242f": "#1f242f", "--om-legacy-color-1f67e9": "#1f67e9", "--om-legacy-color-203-36-49-0-059": "#cb24310f", "--om-legacy-color-209-236-241-0-867": "#d1ecf1dd", @@ -99,6 +100,7 @@ "--om-legacy-color-373e44": "#373e44", "--om-legacy-color-3793ff": "#3793ff", "--om-legacy-color-3ca2f4": "#3ca2f4", + "--om-legacy-color-3e4456": "#3e4456", "--om-legacy-color-40-167-69-0-059": "#28a7450f", "--om-legacy-color-40-167-69-0-302": "#28a7454d", "--om-legacy-color-40a9ff": "#40a9ff", diff --git a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/theme-contract.test.js b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/theme-contract.test.js index 457252802d77..6238bd94f5e7 100644 --- a/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/theme-contract.test.js +++ b/openmetadata-ui/src/main/resources/ui/scripts/design-tokens/theme-contract.test.js @@ -243,7 +243,7 @@ test('uses shared dark interaction and feedback recipes', () => { '--color-bg-primary_hover': '--alpha(theme(--color-white) / 6%)', '--color-bg-secondary_hover': '--alpha(theme(--color-white) / 6%)', '--color-bg-active': '--alpha(theme(--color-white) / 10%)', - '--color-bg-brand-primary': '--alpha(theme(--color-brand-500) / 16%)', + '--color-bg-brand-primary': '--alpha(theme(--color-brand-500) / 14%)', '--color-bg-error-primary': '--alpha(theme(--color-error-500) / 16%)', '--color-bg-warning-primary': '--alpha(theme(--color-warning-500) / 16%)', '--color-bg-success-primary': '--alpha(theme(--color-success-500) / 16%)', diff --git a/openmetadata-ui/src/main/resources/ui/specs/components/badge.md b/openmetadata-ui/src/main/resources/ui/specs/components/badge.md index 3a3be6160bcd..3c49d6066f25 100644 --- a/openmetadata-ui/src/main/resources/ui/specs/components/badge.md +++ b/openmetadata-ui/src/main/resources/ui/specs/components/badge.md @@ -7,7 +7,7 @@ | **Name** | Badge | | **Category** | Base / status | | **Status** | Stable | -| **Styles** | [`src/styles/components/badge.less`](../../src/styles/components/badge.less) | +| **Styles** | [`badge.style.less`](../../src/components/common/Badge/badge.style.less) | | **Component** | `@openmetadata/ui-core-components` → `Badge`, `BadgeGroup` (new work); Ant Design `Badge` count (legacy) | ## Overview @@ -34,8 +34,8 @@ Parts: **pill container** (tinted surface + outline), **label**, optional ## Tokens used -The legacy `.less` still uses `@grey-*` LESS variables (no `--om-*` yet); the -modern core-components Badge maps to these semantic tokens. +The legacy badge styles use `@grey-*` LESS variables backed by project tokens; +the modern core-components Badge uses semantic theme tokens. | 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 57fd5809b57e..ac59eb8fc75a 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: **837**. +Total project tokens: **834**. -## Spacing (60) +## Spacing (57) padding / margin / gap. See foundations/spacing.md. @@ -27,9 +27,7 @@ padding / margin / gap. See foundations/spacing.md. | `--om-space-8` | `8px` | | `--om-space-9` | `9px` | | `--om-space-10` | `10px` | -| `--om-space-11` | `11px` | | `--om-space-12` | `12px` | -| `--om-space-13` | `13px` | | `--om-space-14` | `14px` | | `--om-space-15` | `15px` | | `--om-space-16` | `16px` | @@ -54,7 +52,6 @@ padding / margin / gap. See foundations/spacing.md. | `--om-space-46` | `46px` | | `--om-space-48` | `48px` | | `--om-space-50` | `50px` | -| `--om-space-54` | `54px` | | `--om-space-56` | `56px` | | `--om-space-60` | `60px` | | `--om-space-64` | `64px` | @@ -81,7 +78,7 @@ Shared viewport and shell-derived dimensions. | --- | --- | | `--om-page-height` | `calc(100vh - var(--ant-navbar-height, 80px))` | -## Radius (24) +## Radius (23) border-radius. See foundations/radius.md. @@ -94,7 +91,6 @@ border-radius. See foundations/radius.md. | `--om-radius-3xl` | `24px` | | `--om-radius-5` | `5px` | | `--om-radius-7` | `7px` | -| `--om-radius-9` | `9px` | | `--om-radius-10` | `10px` | | `--om-radius-13` | `13px` | | `--om-radius-14` | `14px` | @@ -112,7 +108,7 @@ border-radius. See foundations/radius.md. | `--om-radius-xl` | `12px` | | `--om-radius-xs` | `2px` | -## Font size (26) +## Font size (25) font-size. See foundations/typography.md. @@ -128,7 +124,6 @@ font-size. See foundations/typography.md. | `--om-font-size-15` | `15px` | | `--om-font-size-15_2` | `15.2px` | | `--om-font-size-22` | `22px` | -| `--om-font-size-26` | `26px` | | `--om-font-size-32` | `32px` | | `--om-font-size-42` | `42px` | | `--om-font-size-55` | `55px` | @@ -196,7 +191,7 @@ box-shadow. See foundations/elevation.md. 0px 8px 8px -4px rgba(10, 13, 18, 0.03)` | | `--om-shadow-xs` | `0px 1px 2px rgba(10, 13, 18, 0.05)` | -## z-index (32) +## z-index (30) stacking. Prefer the semantic ladder for new work. @@ -205,7 +200,6 @@ stacking. Prefer the semantic ladder for new work. | `--om-z-0` | `0` | | `--om-z-1` | `1` | | `--om-z-2` | `2` | -| `--om-z-3` | `3` | | `--om-z-4` | `4` | | `--om-z-5` | `5` | | `--om-z-6` | `6` | @@ -218,7 +212,6 @@ stacking. Prefer the semantic ladder for new work. | `--om-z-900` | `900` | | `--om-z-999` | `999` | | `--om-z-1001` | `1001` | -| `--om-z-1050` | `1050` | | `--om-z-1500` | `1500` | | `--om-z-2000` | `2000` | | `--om-z-9001` | `9001` | @@ -268,7 +261,7 @@ transition / animation. See foundations/motion.md. | `--om-ease-out` | `cubic-bezier(0, 0, 0.2, 1)` | | `--om-ease-standard` | `cubic-bezier(0.4, 0, 0.2, 1)` | -## Semantic colors (71) +## Semantic colors (72) Prefer these — they adapt to dark mode. @@ -284,6 +277,7 @@ Prefer these — they adapt to dark mode. | `--om-color-bg-overlay-surface` | `#ffffff` | | `--om-color-bg-page` | `#ffffff` | | `--om-color-bg-primary` | `#ffffff` | +| `--om-color-bg-primary-hover` | `#fafafa` | | `--om-color-bg-quaternary` | `#e9eaeb` | | `--om-color-bg-raised` | `#ffffff` | | `--om-color-bg-secondary` | `#fafafa` | @@ -712,19 +706,17 @@ Fixed swatches; do NOT adapt to dark mode. Prefer semantic tokens. -## Legacy colors (221) +## Legacy colors (191) Exact migrated one-offs (migration debt). Do not use in new code; re-express with a semantic token. -
Show all 221 +
Show all 191 | Token | Value | | --- | --- | | `--om-legacy-color-0-0-0-0` | `rgba(0, 0, 0, 0)` | | `--om-legacy-color-0-0-0-0-1` | `rgba(0, 0, 0, 0.1)` | -| `--om-legacy-color-0-0-0-0-02` | `#00000005` | | `--om-legacy-color-0-0-0-0-2` | `rgba(0, 0, 0, 0.2)` | -| `--om-legacy-color-0-0-0-0-3` | `rgba(0, 0, 0, 0.3)` | | `--om-legacy-color-0-0-0-0-04` | `rgba(0, 0, 0, 0.04)` | | `--om-legacy-color-0-0-0-0-05` | `rgba(0, 0, 0, 0.05)` | | `--om-legacy-color-0-0-0-0-5` | `rgba(0, 0, 0, 0.5)` | @@ -736,15 +728,13 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--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-059` | `#0000000f` | | `--om-legacy-color-0-0-0-0-102` | `#0000001a` | | `--om-legacy-color-0c5460` | `#0c5460` | -| `--om-legacy-color-0f141b` | `#0f141b` | -| `--om-legacy-color-1d7c4d` | `#1d7c4d` | | `--om-legacy-color-1f67e9` | `#1f67e9` | +| `--om-legacy-color-1f242f` | `#1f242f` | | `--om-legacy-color-2eaadc` | `#2eaadc` | | `--om-legacy-color-2f74eb` | `#2f74eb` | -| `--om-legacy-color-3ca2f4` | `#3ca2f4` | +| `--om-legacy-color-3e4456` | `#3e4456` | | `--om-legacy-color-4a4a4a` | `#4a4a4a` | | `--om-legacy-color-5b8def` | `#5b8def` | | `--om-legacy-color-5e5c58` | `#5e5c58` | @@ -755,7 +745,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-008a00` | `#008a00` | | `--om-legacy-color-8c8c8c` | `#8c8c8c` | | `--om-legacy-color-9-80-197-0-05` | `rgba(9, 80, 197, 0.05)` | -| `--om-legacy-color-10-13-18-0-1` | `rgba(10, 13, 18, 0.1)` | | `--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)` | @@ -778,7 +767,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-40-167-69-0-059` | `#28a7450f` | | `--om-legacy-color-40-167-69-0-302` | `#28a7454d` | | `--om-legacy-color-41-41-41-0-1` | `rgba(41, 41, 41, 0.1)` | -| `--om-legacy-color-43a047` | `#43a047` | | `--om-legacy-color-46-170-220-0-15` | `rgba(46, 170, 220, 0.15)` | | `--om-legacy-color-48-98-212-0-15` | `rgba(48, 98, 212, 0.15)` | | `--om-legacy-color-52c41a` | `#52c41a` | @@ -792,7 +780,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-2` | `#75757533` | | `--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` | @@ -803,7 +790,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-240-175-44-0-051` | `#f0af2c0d` | | `--om-legacy-color-245-245-245-0-2` | `rgba(245, 245, 245, 0.2)` | | `--om-legacy-color-248-215-218-0-867` | `#f8d7dadd` | -| `--om-legacy-color-255-76-59-0-2` | `#ff4c3b33` | | `--om-legacy-color-255-76-59-0-102` | `#ff4c3b1a` | | `--om-legacy-color-255-190-14-0-149` | `#ffbe0e26` | | `--om-legacy-color-255-193-67-0-15` | `rgba(255, 193, 67, 0.15)` | @@ -817,17 +803,14 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-349dea` | `#349dea` | | `--om-legacy-color-356fe7` | `rgb(53, 111, 231)` | | `--om-legacy-color-373e44` | `#373e44` | -| `--om-legacy-color-555f6d` | `#555f6d` | | `--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` | -| `--om-legacy-color-3062d4` | `#3062d4` | | `--om-legacy-color-3793ff` | `#3793ff` | | `--om-legacy-color-7558c1` | `#7558c1` | | `--om-legacy-color-9933ff` | `#93f` | -| `--om-legacy-color-21263c` | `#21263c` | | `--om-legacy-color-37352f` | `#37352f` | | `--om-legacy-color-52525b` | `#52525b` | | `--om-legacy-color-52607a` | `#52607a` | @@ -836,7 +819,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-111827` | `#111827` | | `--om-legacy-color-155724` | `#155724` | | `--om-legacy-color-344054` | `#344054` | -| `--om-legacy-color-444444` | `#444` | | `--om-legacy-color-515151` | `#515151` | | `--om-legacy-color-595959` | `#595959` | | `--om-legacy-color-666666` | `#666666` | @@ -845,7 +827,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-777777` | `#777` | | `--om-legacy-color-856404` | `#856404` | | `--om-legacy-color-a9efc5` | `#a9efc5` | -| `--om-legacy-color-a84c3d` | `#a84c3d` | | `--om-legacy-color-afafc1` | `#afafc1` | | `--om-legacy-color-b0e7d4` | `#b0e7d4` | | `--om-legacy-color-b1b1b7` | `rgb(177, 177, 183)` | @@ -859,9 +840,7 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-c9d7ff` | `#c9d7ff` | | `--om-legacy-color-c45296` | `#c45296` | | `--om-legacy-color-cb2431` | `#cb2431` | -| `--om-legacy-color-cb2531` | `#cb2531` | | `--om-legacy-color-cccccc` | `#cccccc` | -| `--om-legacy-color-cfd6dd` | `#cfd6dd` | | `--om-legacy-color-d4d4d8` | `#d4d4d8` | | `--om-legacy-color-d5d6d9` | `#d5d6d9` | | `--om-legacy-color-d9ceee` | `#d9ceee` | @@ -869,11 +848,8 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-d93025` | `#d93025` | | `--om-legacy-color-d99600` | `#d99600` | | `--om-legacy-color-dadada` | `#dadada` | -| `--om-legacy-color-dadde6` | `#dadde6` | -| `--om-legacy-color-dbe0e7` | `#dbe0e7` | | `--om-legacy-color-dbe4ff` | `#dbe4ff` | | `--om-legacy-color-dce3ec` | `rgb(220, 227, 236)` | -| `--om-legacy-color-dcf6ff` | `#dcf6ff` | | `--om-legacy-color-dddddd` | `#ddd` | | `--om-legacy-color-dde3ea` | `#dde3ea` | | `--om-legacy-color-e1e4e8` | `#e1e4e8` | @@ -885,7 +861,6 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-e6fffb` | `#e6fffb` | | `--om-legacy-color-e8e8e8` | `#e8e8e8` | | `--om-legacy-color-e8e8e9` | `#e8e8e9` | -| `--om-legacy-color-e8e8ed` | `#e8e8ed` | | `--om-legacy-color-e8ecf5` | `#e8ecf5` | | `--om-legacy-color-e60000` | `#e60000` | | `--om-legacy-color-eaecf0` | `#eaecf0` | @@ -895,20 +870,16 @@ Exact migrated one-offs (migration debt). Do not use in new code; re-express wit | `--om-legacy-color-ef6820` | `#ef6820` | | `--om-legacy-color-efbdc1` | `#efbdc1` | | `--om-legacy-color-f0f0f0` | `#f0f0f0` | -| `--om-legacy-color-f0f1f3` | `#f0f1f3` | | `--om-legacy-color-f0f5ff` | `#f0f5ff` | | `--om-legacy-color-f1edfd` | `#f1edfd` | | `--om-legacy-color-f1f1f1` | `#f1f1f1` | | `--om-legacy-color-f1f5ff` | `#f1f5ff` | -| `--om-legacy-color-f3f3f3` | `#f3f3f3` | | `--om-legacy-color-f3f7fd` | `#f3f7fd` | | `--om-legacy-color-f3f8ff` | `#f3f8ff` | | `--om-legacy-color-f4f0fd` | `#f4f0fd` | | `--om-legacy-color-f4f7f9` | `#f4f7f9` | | `--om-legacy-color-f4f7ff` | `#f4f7ff` | -| `--om-legacy-color-f4fbf7` | `#f4fbf7` | | `--om-legacy-color-f5c6cb` | `#f5c6cb` | -| `--om-legacy-color-f5f7f9` | `#f5f7f9` | | `--om-legacy-color-f5f7fb` | `#f5f7fb` | | `--om-legacy-color-f5f8fa` | `#f5f8fa` | | `--om-legacy-color-f6f6f6` | `#f6f6f6` | @@ -916,30 +887,63 @@ 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-f8f8fa` | `#f8f8fa` | | `--om-legacy-color-f8f9fa` | `#f8f9fa` | | `--om-legacy-color-f8faff` | `#f8faff` | | `--om-legacy-color-f9dbaf` | `#f9dbaf` | | `--om-legacy-color-f9f5ff` | `#f9f5ff` | -| `--om-legacy-color-f9fafc` | `#f9fafc` | | `--om-legacy-color-f9816c` | `#f9816c` | | `--om-legacy-color-f44336` | `#f44336` | -| `--om-legacy-color-f59638` | `#f59638` | | `--om-legacy-color-fbf8f8` | `#fbf8f8` | | `--om-legacy-color-fbfaf9` | `#fbfaf9` | | `--om-legacy-color-fbfcff` | `#fbfcff` | -| `--om-legacy-color-fcf0f1` | `#fcf0f1` | | `--om-legacy-color-fec0ac` | `#fec0ac` | | `--om-legacy-color-fee39c` | `#fee39c` | | `--om-legacy-color-fef6ee` | `#fef6ee` | -| `--om-legacy-color-ff4e27` | `#ff4e27` | -| `--om-legacy-color-ff7c50` | `#ff7c50` | | `--om-legacy-color-ff9900` | `#f90` | | `--om-legacy-color-ffab2a` | `#ffab2a` | | `--om-legacy-color-ffe1a6` | `#ffe1a6` | | `--om-legacy-color-ffeeba` | `#ffeeba` | | `--om-legacy-color-fff3dc` | `#fff3dc` | -| `--om-legacy-color-fff5eb` | `#fff5eb` | | `--om-legacy-color-ffff00` | `#ff0` |
+ +## Legacy LESS aliases (33) + +Existing LESS palette names retain their light fallbacks; app.less supplies dark-mode overrides. Prefer semantic tokens for new work. + +| Token | Value | +| --- | --- | +| `--om-alert-error-icon-bg-1` | `#fee4e2` | +| `--om-alert-warning-icon` | `#dc6803` | +| `--om-alert-warning-icon-bg-1` | `#fef0c7` | +| `--om-blue-13` | `#e0f2fe` | +| `--om-blue-22` | `#f0f9ff` | +| `--om-blue-29` | `#eff4fa` | +| `--om-blue-30` | `#d2dbeb` | +| `--om-blue-500` | `#0ba5ec` | +| `--om-border-color-base` | `#d9d9d9` | +| `--om-green-9` | `#ecfdf3` | +| `--om-green-10` | `#027a48` | +| `--om-green-12` | `#d1fadf` | +| `--om-green-100` | `#dcfae6` | +| `--om-green-500` | `#17b26a` | +| `--om-grey-1` | `#f8f8f8` | +| `--om-grey-2` | `#f2f2f2` | +| `--om-grey-5` | `#fbfbfb` | +| `--om-grey-6` | `#f9f9f9` | +| `--om-grey-7` | `#9ca3af` | +| `--om-grey-9` | `#f8f9fc` | +| `--om-grey-15` | `#eaecf5` | +| `--om-grey-25` | `#fdfdfd` | +| `--om-grey-26` | `#fcfcfd` | +| `--om-grey-38` | `#f2f3f9` | +| `--om-grey-50` | `#fafafa` | +| `--om-heading-color` | `rgba(0, 0, 0, 0.85)` | +| `--om-purple-1` | `#f2edfd` | +| `--om-red-9` | `#fef3f2` | +| `--om-red-10` | `#b42318` | +| `--om-red-22` | `#f04438` | +| `--om-warning-100` | `#fef0c7` | +| `--om-warning-400` | `#fdb022` | +| `--om-yellow-10` | `#fffaeb` | diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx index c4f2927e44d3..68798cd3f392 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx @@ -234,14 +234,14 @@ describe('ActivityFeedCardNew', () => { ); // The hover reveal in activity-feed-actions.less is written as - // `.activity-feed-card-new:hover > .ant-card-body > .feed-actions`. antd's - // Card puts its children inside .ant-card-body, so that middle step is - // load-bearing: drop it and the selector stops matching, leaving the - // reply/resolve/edit/delete bar hidden from mouse users. + // `.activity-feed-card-new:hover > .activity-feed-card-new-body > + // .feed-actions`, so that middle step is load-bearing: drop it and the + // selector stops matching, leaving the reply/resolve/edit/delete bar + // hidden from mouse users. const actions = screen.getByTestId('feed-actions'); const body = actions.parentElement; - expect(body).toHaveClass('ant-card-body'); + expect(body).toHaveClass('activity-feed-card-new-body'); expect(body?.parentElement).toHaveClass('activity-feed-card-new'); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx index 9452972c567e..24a03ca427cb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx @@ -13,11 +13,12 @@ import { Box, Button, + Card, + Divider, SkeletonParagraph, Tooltip, Typography, } from '@openmetadata/ui-core-components'; -import { Card } from 'antd'; import classNames from 'classnames'; import { compare } from 'fast-json-patch'; import { isUndefined, orderBy } from 'lodash'; @@ -64,9 +65,10 @@ const ActivityFeedEditorNew = withSuspenseFallback( const POST_SKELETON_KEYS = ['first', 'second', 'third']; -// Container threads top-align the avatar with the multi-line header. -const getHeaderAlign = (showThread?: boolean, entityType?: string) => - showThread && entityType === EntityType.CONTAINER ? 'start' : 'center'; +// Each child of the antd Space this markup replaced sat in its own block +// wrapper, which sets the line box the header text and avatars align in; +// keep that wrapper, and hide it when empty as antd's did. +const SPACE_ITEM_CLASS_NAME = 'tw:empty:hidden'; interface ActivityFeedCardNewProps { feed?: Conversation; @@ -107,6 +109,8 @@ const getHeaderTagsClassName = ( classNames('d-flex', { 'header-container-card': !showThread, 'flex-wrap': showThread && entityRefType !== EntityType.CONTAINER, + 'items-start': showThread && entityRefType === EntityType.CONTAINER, + 'items-center': !showThread || entityRefType !== EntityType.CONTAINER, }); const computeFeedId = (feed?: Conversation, activity?: ActivityEvent): string => @@ -400,10 +404,29 @@ const ActivityFeedCardNew = ({ return isPost ? post?.message ?? '' : feed?.message ?? ''; }, [isActivityEvent, activity, isPost, feed, post]); + const headerTags = useMemo( + () => ( + + + {feedHeaderText} + + + {renderEntityLink} + + ), + [showThread, entityRef?.type, feedHeaderText, renderEntityLink] + ); + const renderWidgetCard = () => ( - -
-
+ + + -
-
+ +
-
-
-
+ + +
{timestamp} -
- {!isPost && ( - - - {feedHeaderText} - - - {renderEntityLink} - - )} -
+ + {!isPost && headerTags} +
)} -
-
-
- {feedActions} + + + {feedActions} +
); @@ -531,7 +533,7 @@ const ActivityFeedCardNew = ({ ); const renderCommentsSection = () => ( -
+ {(showActivityFeedEditor || isOpenInDrawer) && ( {t('label.comment-plural')} @@ -550,16 +552,16 @@ const ActivityFeedCardNew = ({ onSave={onSave} /> ) : ( -
+
-
+ -
+
@@ -572,17 +574,20 @@ const ActivityFeedCardNew = ({ onPress={() => setShowFeedEditor(true)}> {t('message.input-placeholder')} -
+
)} {posts} -
+ ); + const isContainerThread = + showThread && entityRef?.type === EntityType.CONTAINER; + const renderFullCard = () => ( - - - - -
- -
-
+ + +
- - - + + + + + + + +
+ - - {getEntityName(user)} - - - - {timestamp} + itemClassName={SPACE_ITEM_CLASS_NAME}> + + + + {getEntityName(user)} + + + + + {timestamp} + +
+ {!isPost && ( +
{headerTags}
+ )}
- {!isPost && ( - - - {feedHeaderText} - - - {renderEntityLink} - - )}
-
- - setIsEditPost(false)} - onUpdate={onUpdate} - /> +
- {!isActivityEvent && feed && ( - + setIsEditPost(false)} + onUpdate={onUpdate} /> +
+ + {!isActivityEvent && feed && ( +
+ +
)} {isActivityEvent && activity && ( - +
+ +
)} - - {(showThread || isOpenInDrawer) && renderCommentsSection()} - {feedActions} + {(showThread || isOpenInDrawer) && renderCommentsSection()} + {feedActions} + ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/ActivityDescriptionFeed.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/ActivityDescriptionFeed.tsx index e47749dea416..7070043e19f9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/ActivityDescriptionFeed.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/ActivityDescriptionFeed.tsx @@ -12,8 +12,6 @@ */ import { Box } from '@openmetadata/ui-core-components'; -import { getLayoutGutter } from '../../../../../utils/common/layout.utils'; - import { useMemo } from 'react'; import { ReactComponent as AddIcon } from '../../../../../assets/svg/added-icon.svg'; import { ReactComponent as UpdatedIcon } from '../../../../../assets/svg/updated-icon.svg'; @@ -46,17 +44,14 @@ function ActivityDescriptionFeed({ }, [isAdded]); return ( - - {operationIcon} - + +
{operationIcon}
+
- +
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/OwnerFeed/ActivityOwnersFeed.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/OwnerFeed/ActivityOwnersFeed.tsx index 6bd5d970b6bb..4426aa20c534 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/OwnerFeed/ActivityOwnersFeed.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/OwnerFeed/ActivityOwnersFeed.tsx @@ -13,13 +13,10 @@ import { Box, - Grid, Owner, OwnerChip, Typography, } from '@openmetadata/ui-core-components'; -import { getLayoutGutter } from '../../../../../utils/common/layout.utils'; - import classNames from 'classnames'; import { isEmpty } from 'lodash'; import { useCallback, useMemo } from 'react'; @@ -103,7 +100,7 @@ function ActivityOwnersFeed({ const getOwnerItems = useCallback( (ownerList: EntityReference[]) => { return ownerList.length <= maxVisibleOwners ? ( - + {ownerList.map((owner: EntityReference) => owner.type === EntityType.USER ? ( @@ -161,39 +158,33 @@ function ActivityOwnersFeed({ ); return ( - + {!isEmpty(updatedOwner) && ( - - - - - - {t('label.owner-plural-with-colon')} - - - - {renderUpdatedOwner} + + + + + {t('label.owner-plural-with-colon')} + - + +
{renderUpdatedOwner}
+
)} {!isEmpty(previousOwner) && ( - - - - - - - {t('label.owner-plural-with-colon')} - - + +
+ + + + {t('label.owner-plural-with-colon')} + - {renderPreviousOwner} - - +
+
{renderPreviousOwner}
+
)} -
+
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/TagsFeed/ActivityTagsFeed.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/TagsFeed/ActivityTagsFeed.tsx index 3bf28d2c9750..ab202ab8e6b5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/TagsFeed/ActivityTagsFeed.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/TagsFeed/ActivityTagsFeed.tsx @@ -11,9 +11,7 @@ * limitations under the License. */ -import { Box, Grid } from '@openmetadata/ui-core-components'; -import { getLayoutGutter } from '../../../../../utils/common/layout.utils'; - +import { Box } from '@openmetadata/ui-core-components'; import { isEmpty } from 'lodash'; import { useMemo } from 'react'; import { ReactComponent as AddIcon } from '../../../../../assets/svg/added-icon.svg'; @@ -62,42 +60,28 @@ function ActivityTagsFeed({ activity }: Readonly) { }, [activity.oldValue, activity.newValue]); return ( - + {!isEmpty(updatedTags) && ( - - - - - - - - - - + +
+ +
+
+ +
+
)} {!isEmpty(previousTags) && ( - - - - - - - - - - + +
+ +
+
+ +
+
)} -
+
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter.test.tsx index 229d4a7ee582..e38230ba8842 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter.test.tsx @@ -159,6 +159,27 @@ describe('ActivityEventFooter', () => { expect(mockOnActivityClick).not.toHaveBeenCalled(); }); + it('lets the click reach the card container behind the footer', () => { + const activity = createMockActivity(); + const mockOnActivityClick = jest.fn(); + const mockContainerClick = jest.fn(); + + render( +
+ +
+ ); + + fireEvent.click(screen.getByTestId('comment-button')); + + expect(mockOnActivityClick).toHaveBeenCalledWith(activity); + expect(mockContainerClick).toHaveBeenCalled(); + }); + it('should not throw when onActivityClick is not provided', () => { const activity = createMockActivity(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter.tsx index eb2730f55b02..3257e66df51a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter.tsx @@ -11,11 +11,12 @@ * limitations under the License. */ -import { Grid } from '@openmetadata/ui-core-components'; -import { Button } from 'antd'; +import { Box, Button } from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { noop } from 'lodash'; import { useCallback } from 'react'; +import { PressEvent } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; import { ReactComponent as ThreadIcon } from '../../../../assets/svg/ic-reply-2.svg'; import { ReactionOperation } from '../../../../enums/reactions.enum'; import { ActivityEvent } from '../../../../generated/entity/activity/activityEvent'; @@ -34,6 +35,7 @@ function ActivityEventFooter({ isForFeedTab = false, onActivityClick, }: Readonly) { + const { t } = useTranslation(); const { updateActivityReaction } = useActivityFeedProvider(); const onReactionUpdate = useCallback( @@ -46,33 +48,41 @@ function ActivityEventFooter({ [updateActivityReaction, activity.id] ); - const handleCommentClick = useCallback(() => { - onActivityClick?.(activity); - }, [onActivityClick, activity]); + // The press must keep bubbling: the card container behind this footer + // selects the activity on click, as it did when this was a native button. + const handleCommentPress = useCallback( + (event: PressEvent) => { + event.continuePropagation(); + if (isForFeedTab) { + onActivityClick?.(activity); + } + }, + [isForFeedTab, onActivityClick, activity] + ); return ( - - + +
-
+ + iconLeading={ + + } + onPress={handleCommentPress} + /> -
+
-
-
+
+
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/FeedCardFooterNew.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/FeedCardFooterNew.tsx index 54093b1eac9c..736124ea2521 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/FeedCardFooterNew.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardFooter/FeedCardFooterNew.tsx @@ -11,11 +11,12 @@ * limitations under the License. */ -import { AvatarStack, Grid } from '@openmetadata/ui-core-components'; -import { Button } from 'antd'; +import { AvatarStack, Box, Button } from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { noop } from 'lodash'; import { useCallback, useMemo } from 'react'; +import { PressEvent } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; import { ReactComponent as ThreadIcon } from '../../../../assets/svg/ic-reply-2.svg'; import { ReactionOperation } from '../../../../enums/reactions.enum'; import { ReactionType } from '../../../../generated/type/reaction'; @@ -35,6 +36,7 @@ function FeedCardFooterNew({ isReply = false, isForFeedTab = false, }: Readonly) { + const { t } = useTranslation(); const { showDrawer, updateReactions } = useActivityFeedProvider(); const postLength = useMemo( @@ -76,26 +78,37 @@ function FeedCardFooterNew({ } }, [showDrawer, conversation]); + // The press must keep bubbling: the card container behind this footer + // selects the conversation on click, as it did when these were native + // buttons. + const handleRepliesPress = useCallback( + (event: PressEvent) => { + event.continuePropagation(); + if (isForFeedTab) { + showReplies(); + } + }, + [isForFeedTab, showReplies] + ); + return ( - - + + direction="col">
-
+ {postLength > 0 && !isReply && ( ( + iconLeading={ + + } + onPress={handleRepliesPress} + /> )} -
+
-
-
+ + ); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.test.tsx new file mode 100644 index 000000000000..28be148f6685 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; +import ActivityFeedDrawer from './ActivityFeedDrawer'; + +const mockHideDrawer = jest.fn(); +const mockProvider = jest.fn(); + +jest.mock('../ActivityFeedProvider/ActivityFeedProvider', () => ({ + useActivityFeedProvider: () => mockProvider(), +})); + +jest.mock('../ActivityFeedPanel/FeedPanelHeader', () => + jest.fn(({ onCancel }) => ( + + )) +); + +jest.mock('../ActivityFeedPanel/FeedPanelBodyV1', () => + jest.fn(() =>
) +); + +jest.mock('../ActivityFeedPanel/ActivityPanelHeader', () => + jest.fn(() =>
) +); + +jest.mock('../ActivityFeedPanel/ActivityPanelBody', () => + jest.fn(() =>
) +); + +jest.mock('../ActivityFeedPanel/TaskPanelHeader', () => + jest.fn(() =>
) +); + +jest.mock('../../Entity/Task/TaskTab/TaskTabNew.component', () => ({ + TaskTabNew: jest.fn(() =>
), +})); + +const thread = { id: 'thread-1', about: '<#E::table::a.b>' }; + +describe('ActivityFeedDrawer', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProvider.mockReturnValue({ + hideDrawer: mockHideDrawer, + selectedThread: thread, + }); + }); + + it('renders nothing without a selected thread, task or activity', () => { + mockProvider.mockReturnValue({ hideDrawer: mockHideDrawer }); + + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders the thread header and body in the drawer panel', () => { + render(); + + const dialog = screen.getByRole('dialog'); + + expect(screen.getByTestId('activity-feed-drawer')).toContainElement(dialog); + expect(screen.getByTestId('closeDrawer')).toBeInTheDocument(); + expect(dialog.querySelector('#feed-panel')).toContainElement( + screen.getByTestId('feed-panel-body') + ); + }); + + it('renders the activity panel when an activity is selected', () => { + mockProvider.mockReturnValue({ + hideDrawer: mockHideDrawer, + selectedActivity: { id: 'activity-1' }, + }); + + render(); + + expect(screen.getByTestId('activity-panel-header')).toBeInTheDocument(); + expect(screen.getByTestId('activity-panel-body')).toBeInTheDocument(); + }); + + it('renders the task panel when a task is selected', async () => { + mockProvider.mockReturnValue({ + hideDrawer: mockHideDrawer, + selectedTask: { id: 'task-1', about: { type: 'table' } }, + }); + + render(); + + expect(screen.getByTestId('task-panel-header')).toBeInTheDocument(); + expect(await screen.findByTestId('task-tab')).toBeInTheDocument(); + }); + + it('hides the drawer on Escape', () => { + render(); + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + + expect(mockHideDrawer).toHaveBeenCalled(); + }); + + it('hides the drawer from the header close action', () => { + render(); + + fireEvent.click(screen.getByTestId('closeDrawer')); + + expect(mockHideDrawer).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.tsx index 7fafe1b9f120..ca38e3fcd49e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.tsx @@ -11,12 +11,11 @@ * limitations under the License. */ -import { Grid } from '@openmetadata/ui-core-components'; -import { Drawer } from 'antd'; +import { SlideoutMenu } from '@openmetadata/ui-core-components'; import classNames from 'classnames'; -import { FC, lazy, useMemo } from 'react'; +import { FC, lazy, ReactNode, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { EntityType } from '../../../enums/entity.enum'; -import { getLayoutGutter } from '../../../utils/common/layout.utils'; import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import ActivityPanelBody from '../ActivityFeedPanel/ActivityPanelBody'; import ActivityPanelHeader from '../ActivityFeedPanel/ActivityPanelHeader'; @@ -26,7 +25,9 @@ import TaskPanelHeader from '../ActivityFeedPanel/TaskPanelHeader'; import { useActivityFeedProvider } from '../ActivityFeedProvider/ActivityFeedProvider'; import './activity-feed-drawer.less'; -const ACTIVITY_FEED_DRAWER_CLASS = 'activity-feed-drawer'; +const PANEL_HEADER_CLASS_NAME = + 'tw:px-4 tw:shrink-0 tw:text-md tw:font-medium tw:leading-snug tw:text-(color:--om-heading-color)'; +const DRAWER_WIDTH = 576; const TaskTabNew = withSuspenseFallback( lazy(() => @@ -45,6 +46,7 @@ const ActivityFeedDrawer: FC = ({ open, className, }) => { + const { t } = useTranslation(); const { hideDrawer, selectedThread, selectedTask, selectedActivity } = useActivityFeedProvider(); @@ -56,102 +58,82 @@ const ActivityFeedDrawer: FC = ({ return EntityType.TABLE; }, [selectedTask]); - if (!selectedThread && !selectedTask && !selectedActivity) { - return null; - } - - if (selectedTask) { - return ( - { + if (selectedTask) { + return { + header: ( - } - width={576} - onClose={hideDrawer}> - - - - - - - ); - } + ), + body: ( + + ), + }; + } - if (selectedActivity) { - return ( - - } - width={576} - onClose={hideDrawer}> - - - - - - - ); - } - - if (!selectedThread) { - return null; - } + ), + body: , + }; + } - return ( - - } - width={576} - onClose={hideDrawer}> - - + if (selectedThread) { + return { + header: ( + + ), + body: ( - - - + ), + }; + } + + return null; + }, [selectedTask, selectedActivity, selectedThread, entityType, hideDrawer]); + + if (!panel) { + return null; + } + + return ( + !isOpen && hideDrawer()}> + {panel.header} + +
{panel.body}
+
+
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/activity-feed-drawer.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/activity-feed-drawer.less index 65a4fcb4ef3c..b9d494523bb2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/activity-feed-drawer.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/activity-feed-drawer.less @@ -20,11 +20,3 @@ color: @primary-heading-color; } } -.activity-feed-drawer { - .ant-drawer-body { - padding: 0; - } - .ant-drawer-header { - padding: 0; - } -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/SendButton.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/SendButton.tsx index 714347625fa9..756384d321d4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/SendButton.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedEditor/SendButton.tsx @@ -11,10 +11,10 @@ * limitations under the License. */ -import Icon from '@ant-design/icons/lib/components/Icon'; -import { Button } from 'antd'; +import { ButtonUtility } from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { ReactComponent as IconPaperPlanePrimary } from '../../../assets/svg/paper-plane-fill.svg'; import './send-button.less'; @@ -28,13 +28,19 @@ export const SendButton: FC = ({ editorValue, className, onSaveHandler, -}) => ( -
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BubbleMenu/BubbleMenu.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BubbleMenu/BubbleMenu.test.tsx index b73b7a52b8bd..21e1715de8f8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BubbleMenu/BubbleMenu.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BubbleMenu/BubbleMenu.test.tsx @@ -139,9 +139,11 @@ describe('BubbleMenu', () => { render(); const bold = screen.getByLabelText('Bold'); - fireEvent.mouseDown(bold); + const isNotPrevented = fireEvent.mouseDown(bold); expect(mockToggleBold).toHaveBeenCalled(); + // default prevented so the editor keeps focus and its selection + expect(isNotPrevented).toBe(false); }); it('should call toggleItalic when clicking on Italic', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BubbleMenu/BubbleMenu.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BubbleMenu/BubbleMenu.tsx index 0aa1e0380b5c..546a35a68bf0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BubbleMenu/BubbleMenu.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BubbleMenu/BubbleMenu.tsx @@ -10,16 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Typography } from '@openmetadata/ui-core-components'; +import { ButtonUtility, Typography } from '@openmetadata/ui-core-components'; import { isNodeSelection, type Editor } from '@tiptap/core'; import { BubbleMenu as CoreBubbleMenu, BubbleMenuProps as CoreBubbleMenuProps, } from '@tiptap/react'; -import { Button, Tooltip } from 'antd'; import classNames from 'classnames'; import { isString } from 'lodash'; -import { FC, useMemo } from 'react'; +import { FC, MouseEvent, useMemo } from 'react'; import { ReactComponent as FormatBoldIcon } from '../../../assets/svg/ic-format-bold.svg'; import { ReactComponent as FormatInlineCodeIcon } from '../../../assets/svg/ic-format-inline-code.svg'; import { ReactComponent as FormatItalicIcon } from '../../../assets/svg/ic-format-italic.svg'; @@ -133,29 +132,31 @@ const BubbleMenu: FC = ({ editor, toggleLink }) => { shouldShow={handleShouldShow}> {menuList.map( ({ icon: Icon, ariaLabel, className, command, isActive }) => ( - - - + ) + } + key={ariaLabel} + tooltip={ariaLabel} + onMouseDown={(e: MouseEvent) => { + // To prevent losing focus from editor + // The mouseDown event fires before the click event and before focus changes, + // so we can intercept it and prevent the default focus behavior. + e.preventDefault(); + command(); + }} + /> ) )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.test.tsx index c2e16dd218f5..3adb3d23aa91 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.test.tsx @@ -25,9 +25,15 @@ import { EditorSlotsRef } from './BlockEditor.interface'; import EditorSlots from './EditorSlots'; import { LinkExtension } from './Extensions/link'; -jest.mock('./BlockMenu/BlockMenu', () => () => null); -jest.mock('./BubbleMenu/BubbleMenu', () => () => null); -jest.mock('./TableMenu/TableMenu', () => () => null); +jest.mock('./BlockMenu/BlockMenu', () => () => ( +
+)); +jest.mock('./BubbleMenu/BubbleMenu', () => () => ( +
+)); +jest.mock('./TableMenu/TableMenu', () => () => ( +
+)); jest.mock('./LinkPopup/LinkPopup', () => () => null); jest.mock('tippy.js', () => ({ __esModule: true, @@ -43,7 +49,7 @@ const createTestEditor = (content: string) => const fillLinkAndSave = async (href: string) => { await waitFor(() => expect(screen.getByRole('textbox')).toBeInTheDocument()); fireEvent.change(screen.getByRole('textbox'), { target: { value: href } }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); }; describe('EditorSlots link handling', () => { @@ -164,3 +170,29 @@ describe('EditorSlots link handling', () => { document.body.removeChild(dialog); }); }); + +describe('EditorSlots menus', () => { + it('renders the editing menus for an editable editor', () => { + const editor = createTestEditor('

Hello

'); + + render(); + + expect(screen.getByTestId('bubble-menu')).toBeInTheDocument(); + expect(screen.getByTestId('block-menu')).toBeInTheDocument(); + expect(screen.getByTestId('table-menu')).toBeInTheDocument(); + + editor.destroy(); + }); + + it('skips the editing menus for a read-only editor', () => { + const editor = createTestEditor('

Hello

'); + + render(); + + expect(screen.queryByTestId('bubble-menu')).not.toBeInTheDocument(); + expect(screen.queryByTestId('block-menu')).not.toBeInTheDocument(); + expect(screen.queryByTestId('table-menu')).not.toBeInTheDocument(); + + editor.destroy(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx index 1054069f0d10..deea0382a359 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx @@ -23,7 +23,7 @@ import LinkPopup from './LinkPopup/LinkPopup'; import TableMenu from './TableMenu/TableMenu'; const EditorSlots = forwardRef( - ({ editor, menuType }, ref) => { + ({ editable = true, editor, menuType }, ref) => { const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); const handleLinkToggle = () => { @@ -163,7 +163,7 @@ const EditorSlots = forwardRef( * render the bubble menu only if the editor is available * and the menu type is bubble */ - const menus = !isNil(editor) && menuType === 'bubble' && ( + const menus = !isNil(editor) && editable && menuType === 'bubble' && ( ); @@ -193,7 +193,9 @@ const EditorSlots = forwardRef( /> )} {menus} - {!isNil(editor) && ( + {/* Read-only previews never use these menus. Their react-aria buttons + would also capture the trigger of any tooltip they render inside. */} + {!isNil(editor) && editable && ( <> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/Callout/CalloutComponent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/Callout/CalloutComponent.tsx index fe98dec0c99a..95087a3923e0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/Callout/CalloutComponent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/Callout/CalloutComponent.tsx @@ -11,12 +11,12 @@ * limitations under the License. */ import { + Button, ButtonUtility, Popover, PopoverTrigger, } from '@openmetadata/ui-core-components'; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react'; -import { Button } from 'antd'; import { startCase } from 'lodash'; import { FC, useState } from 'react'; import { CALLOUT_CONTENT } from '../../../../constants/BlockEditor.constants'; @@ -31,13 +31,13 @@ const PopoverContent = ({ {Object.entries(CALLOUT_CONTENT).map(([key, CalloutIcon]) => { return ( ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/CodeBlock/CodeBlockComponent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/CodeBlock/CodeBlockComponent.tsx index ff8fc1a383ab..13667d10de36 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/CodeBlock/CodeBlockComponent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/CodeBlock/CodeBlockComponent.tsx @@ -10,9 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { ButtonUtility, Tooltip } from '@openmetadata/ui-core-components'; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react'; -import { Button, Tooltip } from 'antd'; -import { FC, useCallback } from 'react'; +import { FC, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as CopyIcon } from '../../../../assets/svg/icon-copy.svg'; import { useClipboard } from '../../../../hooks/useClipBoard'; @@ -20,6 +20,7 @@ import { useClipboard } from '../../../../hooks/useClipBoard'; const CodeBlockComponent: FC = ({ node }) => { const { t } = useTranslation(); const { onCopyToClipBoard, hasCopied } = useClipboard('', 2000); + const [isTooltipOpen, setIsTooltipOpen] = useState(false); const handleCopy = useCallback(async () => { await onCopyToClipBoard(node.textContent); @@ -30,12 +31,15 @@ const CodeBlockComponent: FC = ({ node }) => { -
@@ -88,16 +93,14 @@ export const MathEquationComponent: FC = ({ )} {/* Show edit button only when the editor is editable */} {!isEditing && editor.isEditable && ( - -
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/mention/MentionList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/mention/MentionList.tsx index da2e9050c2ed..c8c8e7e0db7b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/mention/MentionList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/mention/MentionList.tsx @@ -12,7 +12,6 @@ */ import { Box, Typography } from '@openmetadata/ui-core-components'; import { SuggestionProps } from '@tiptap/suggestion'; - import classNames from 'classnames'; import { isEmpty } from 'lodash'; import { forwardRef, useImperativeHandle, useState } from 'react'; @@ -107,25 +106,19 @@ export default forwardRef>( return ( + id="mention-viewport"> {items.map((item, index) => ( selectItem(index)}> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/slash-command/SlashCommandList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/slash-command/SlashCommandList.tsx index e2794252abac..34a3519ffd21 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/slash-command/SlashCommandList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/slash-command/SlashCommandList.tsx @@ -12,7 +12,6 @@ */ import { Box, Typography } from '@openmetadata/ui-core-components'; import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; -import { Image } from 'antd'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; import { forwardRef, useImperativeHandle, useState } from 'react'; @@ -116,41 +115,29 @@ export const SlashCommandList = forwardRef( return ( + id="editor-commands-viewport"> {filteredItems.map((item, index) => ( selectItem(index)}> - - + {item.title} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.test.tsx index 390c517930b4..a9ad8c73c8f4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.test.tsx @@ -47,7 +47,7 @@ describe('LinkModal', () => { fireEvent.change(screen.getByRole('textbox'), { target: { value: '{{buildEntityUrl event.entityType entity}}' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); await waitFor(() => expect(onSave).toHaveBeenCalledWith({ @@ -59,7 +59,7 @@ describe('LinkModal', () => { it('should call onCancel when the cancel button is clicked', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.click(screen.getByRole('button', { name: 'label.cancel' })); expect(onCancel).toHaveBeenCalledTimes(1); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.tsx index 31a9786e0130..b83b7e694473 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.tsx @@ -10,8 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Form, FormProps, Input, Modal } from 'antd'; -import { FC } from 'react'; +import { + Button, + Dialog, + Input, + Modal, + ModalOverlay, +} from '@openmetadata/ui-core-components'; +import { FC, FormEvent, useState } from 'react'; +import { useTranslation } from 'react-i18next'; export interface LinkData { href: string; @@ -36,38 +43,55 @@ const LinkModal: FC = ({ onCancel, getContainer, }) => { - const handleSubmit: FormProps['onFinish'] = (values) => { - onSave(values); + const { t } = useTranslation(); + const [href, setHref] = useState(data.href); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSave({ href }); }; return ( - - - + !open && onCancel()}> + + + + + + + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkPopup/LinkPopup.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkPopup/LinkPopup.tsx index de497e4acffb..b6ab8e94494d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkPopup/LinkPopup.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkPopup/LinkPopup.tsx @@ -10,14 +10,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Box } from '@openmetadata/ui-core-components'; -import { Button } from 'antd'; +import { Box, ButtonUtility } from '@openmetadata/ui-core-components'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; import { ReactComponent as ExternalLinkIcon } from '../../../assets/svg/external-links.svg'; import { ReactComponent as UnlinkIcon } from '../../../assets/svg/ic-format-unlink.svg'; -import { FC } from 'react'; - interface LinkPopupProps { href: string; handleLinkToggle: () => void; @@ -31,34 +30,35 @@ const LinkPopup: FC = ({ handleLinkToggle, handleUnlink, }) => { + const { t } = useTranslation(); + const linkLabel = t('label.link'); + return ( - - - - - - - - - - - - - - - - - - - + } + tooltip="Delete table" + onClick={() => { + editor.chain().focus().deleteTable().run(); + tableMenuPopup.current?.hide(); + }} + />
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less index 009c40eabb57..ecb42cde1718 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less @@ -363,15 +363,6 @@ word-break: break-word; } -.block-editor-link-modal, -.block-editor-image-modal { - .ant-modal-content { - .ant-modal-body { - padding: var(--om-space-16); - } - } -} - .link-popup { align-items: center; padding: var(--om-space-4); @@ -524,10 +515,11 @@ body .om-image-node-embed-link-btn-col { display: flex; flex-direction: column; align-items: baseline; - .ant-btn { + button { width: 100%; display: inline-flex; gap: var(--om-space-4); + justify-content: flex-start; text-align: left; padding: var(--om-space-8) var(--om-space-16); height: auto; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx index 81ed5daffc58..7c00f2b6fc5a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx @@ -346,8 +346,11 @@ const BulkEditEntity = ({ Updating store will trigger re-render of the component This will cause the owner dropdown or full grid to re-render */ - const editDataGrid = useMemo(() => { - const dataSourceWithOperations: Record[] = + // react-data-grid closes an open cell editor, discarding its draft, as soon as + // the row object it is editing is replaced. Keep these identities stable + // across re-renders that do not change the data (columns, handlers, ...). + const dataSourceWithOperations = useMemo[]>( + () => filteredDataSource.map((row, index) => { const rowId = row.id ?? `${index}`; const originalRow = initialRowById.get(row.id); @@ -363,7 +366,11 @@ const BulkEditEntity = ({ [BULK_EDIT_OPERATION_KEY]: operation, [BULK_EDIT_ORIGINAL_NAME_KEY]: getBulkEditRowName(originalRow), }; - }); + }), + [filteredDataSource, initialRowById, changedCellKeysByRowId, workflowMode] + ); + + const editDataGrid = useMemo(() => { const operationColumn: Column> = { key: BULK_EDIT_OPERATION_KEY, name: t('label.operation'), @@ -507,18 +514,16 @@ const BulkEditEntity = ({ }, [ changedCellKeysByRowId, columns, - filteredDataSource, + dataSourceWithOperations, handleCopy, handlePaste, handleOnRowsChange, handleRemoveRow, highlightedRowId, - initialRowById, isImportWorkflow, bulkEditConfig, setGridRef, t, - workflowMode, ]); const operationSummary = useMemo(() => { 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 807a4e736ca5..a8845be9bf01 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 @@ -12,10 +12,10 @@ */ import { fireEvent, - queryByAttribute, render, screen, waitFor, + within, } from '@testing-library/react'; import { act } from 'react'; import { MemoryRouter } from 'react-router-dom'; @@ -103,6 +103,10 @@ jest.mock('../../../constants/constants', () => ({ PAGE_SIZE_MEDIUM: 25, })); +// Core Checkbox puts the test id on its
- ), -})); - jest.mock('@openmetadata/ui-core-components', () => { const MockDialogContent = ({ children }: { children: React.ReactNode }) => (
{children}
@@ -126,7 +108,35 @@ jest.mock('@openmetadata/ui-core-components', () => { } ); + const MockSelect = Object.assign(() => null, { + ComboBox: ({ + onInputChange, + onSelectionChange, + 'data-testid': testId, + }: MockComboBoxProps) => ( +
+ onInputChange?.(e.target.value)} + /> + + +
+ ), + Item: () => null, + }); + return { + Select: MockSelect, Button: ({ children, onPress, @@ -300,6 +310,18 @@ describe('AddToBundleSuiteModal', () => { expect(mockNavigate).toHaveBeenCalled(); }); + it('should disable add button again once the selection is cleared', async () => { + render(); + + fireEvent.click(await screen.findByTestId('bundle-suite-select-option')); + + expect(screen.getByTestId('add-button')).not.toBeDisabled(); + + fireEvent.click(screen.getByTestId('bundle-suite-select-clear')); + + expect(screen.getByTestId('add-button')).toBeDisabled(); + }); + it('should search bundle suites on input', async () => { jest.useFakeTimers(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.interface.ts index e3eabce58b57..95694fe191c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.interface.ts @@ -11,14 +11,12 @@ * limitations under the License. */ -import { DrawerProps } from 'antd'; import { ReactNode } from 'react'; import { TestCase } from '../../../generated/tests/testCase'; import { TestSuite } from '../../../generated/tests/testSuite'; import { AddTestCaseListChangePayload } from '../AddTestCaseList/AddTestCaseList.interface'; export interface BundleSuiteFormProps { - drawerProps?: DrawerProps; className?: string; onCancel?: () => void; onSuccess?: (testSuite: TestSuite) => void; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/Severity.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/Severity.component.tsx index 12c4145a835d..d0e880754ee9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/Severity.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/Severity.component.tsx @@ -52,7 +52,7 @@ const Severity = ({ const onCancel = useCallback(() => setIsEditSeverity(false), []); const handleSubmit = useCallback( - async (data: Severities) => { + async (data?: Severities) => { await onSubmit?.(data); onCancel(); }, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/Severity.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/Severity.interface.ts index 5f9ee18ecbbf..cbfcff881d7b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/Severity.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/Severity.interface.ts @@ -28,7 +28,7 @@ export interface SeverityProps extends SeverityFormProps { export interface SeverityModalProps { initialSeverity?: Severities; onCancel: () => void; - onSubmit: (severity: Severities) => Promise; + onSubmit: (severity?: Severities) => Promise; } export interface InlineSeverityProps { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/SeverityModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/SeverityModal.component.tsx index c68c9d98f931..7e94f3185fde 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/SeverityModal.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/SeverityModal.component.tsx @@ -11,69 +11,76 @@ * limitations under the License. */ -import { Form, Modal, Select } from 'antd'; -import { FormProps, useForm } from 'antd/lib/form/Form'; -import { keys, startCase } from 'lodash'; +import { Form, Select, SimpleModal } from '@openmetadata/ui-core-components'; +import { startCase } from 'lodash'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Severities } from '../../../../generated/tests/testCaseResolutionStatus'; import { SeverityModalProps } from './Severity.interface'; +const NO_SEVERITY = 'none'; + const SeverityModal = ({ initialSeverity, onCancel, onSubmit, }: SeverityModalProps) => { - const [form] = useForm(); const { t } = useTranslation(); + const [severity, setSeverity] = useState( + initialSeverity + ); const [isLoading, setIsLoading] = useState(false); - const handleFormSubmit: FormProps['onFinish'] = (data) => { + // The leading "No severity" item stands in for antd's allowClear. + const severityItems = [ + { + id: NO_SEVERITY, + label: t('label.no-entity', { entity: t('label.severity') }), + }, + ...Object.values(Severities).map((value) => ({ + id: value, + label: startCase(value), + })), + ]; + + const handleSubmit = () => { setIsLoading(true); - onSubmit(data.severity).finally(() => { + onSubmit(severity).finally(() => { setIsLoading(false); }); }; return ( - form.submit()}> -
- - - + onOk={handleSubmit}> + +
-
+ ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/SeverityModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/SeverityModal.test.tsx index b3cc19d1cf14..8fc97a15afb8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/SeverityModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/Severity/SeverityModal.test.tsx @@ -20,21 +20,47 @@ const mockProps = { onSubmit: jest.fn().mockResolvedValue([]), }; +const press = (element: HTMLElement) => { + fireEvent.pointerDown(element, { + button: 0, + pointerId: 1, + pointerType: 'mouse', + }); + fireEvent.pointerUp(element, { + button: 0, + pointerId: 1, + pointerType: 'mouse', + }); + fireEvent.click(element); +}; + +const pickSeverity = async (optionName: string) => { + await act(async () => { + press(screen.getByRole('button', { name: /label.severity/ })); + }); + await act(async () => { + press(screen.getByRole('option', { name: optionName })); + }); +}; + describe('SeverityModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('Should render component', async () => { render(); - const form = await screen.findByTestId('severity-form'); - const severitySelect = await screen.findByTestId('severity-form'); - - expect(form).toBeInTheDocument(); - expect(severitySelect).toBeInTheDocument(); + expect(await screen.findByTestId('severity-form')).toBeInTheDocument(); + expect(await screen.findByTestId('severity-select')).toBeInTheDocument(); }); it('Initial value should be visible', async () => { render(); - expect(await screen.findByText('Severity 1')).toBeInTheDocument(); + expect( + await screen.findByRole('button', { name: /Severity 1/ }) + ).toBeInTheDocument(); }); it('onCancel should work', async () => { @@ -54,4 +80,31 @@ describe('SeverityModal', () => { expect(mockProps.onSubmit).toHaveBeenCalledWith(Severities.Severity1); }); + + it('should submit the newly picked severity', async () => { + render(); + + await pickSeverity('Severity 3'); + await act(async () => { + fireEvent.click(screen.getByText('label.save')); + }); + + expect(mockProps.onSubmit).toHaveBeenCalledWith(Severities.Severity3); + }); + + it('should submit undefined once the severity is cleared', async () => { + render(); + + await pickSeverity('label.no-entity'); + + expect( + screen.getByRole('button', { name: /label.please-select-entity/ }) + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText('label.save')); + }); + + expect(mockProps.onSubmit).toHaveBeenCalledWith(undefined); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/TestCaseStatus/TestCaseIncidentManagerStatus.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/TestCaseStatus/TestCaseIncidentManagerStatus.component.tsx index 3b45f2fe9f31..f779dca7b008 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/TestCaseStatus/TestCaseIncidentManagerStatus.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/IncidentManager/TestCaseStatus/TestCaseIncidentManagerStatus.component.tsx @@ -13,9 +13,9 @@ import { Box, + Tooltip, Typography as CoreTypography, } from '@openmetadata/ui-core-components'; -import { Tooltip } from 'antd'; import classNames from 'classnames'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -107,14 +107,16 @@ const TestCaseIncidentManagerStatus = ({ /> )}
- + + gap={2}> - - + gap={2}> + + + + {hasEditPermission && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.component.tsx index 28dd4166b5d8..6f91c0b743d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.component.tsx @@ -10,13 +10,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Form, Modal, Select } from 'antd'; +import { + Box, + Button, + FormField, + HintText, + HookForm, + Label, + Select, + SimpleModal, +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import { startCase, unionBy } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { EntityType } from '../../../enums/entity.enum'; -import { CreateTestCaseResolutionStatus } from '../../../generated/api/tests/createTestCaseResolutionStatus'; import { TestCaseFailureReasonType } from '../../../generated/tests/resolved'; import { TestCaseResolutionStatusTypes } from '../../../generated/tests/testCaseResolutionStatus'; import Assignees from '../../../pages/TasksPage/shared/Assignees'; @@ -34,16 +43,21 @@ import { } from '../../../utils/TaskAssigneeUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -import { - PAGE_SIZE_MEDIUM, - VALIDATION_MESSAGES, -} from '../../../constants/constants'; +import { PAGE_SIZE_MEDIUM } from '../../../constants/constants'; import { TEST_CASE_RESOLUTION_STATUS_LABELS } from '../../../constants/TestSuite.constant'; import { EntityReference } from '../../../generated/tests/testCase'; -import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; import { getUsers } from '../../../rest/userAPI'; -import { generateFormFields } from '../../../utils/formUtils'; -import { TestCaseStatusModalProps } from './TestCaseStatusModal.interface'; +import RichTextEditor from '../../common/RichTextEditor/RichTextEditor'; +import { + TestCaseStatusFormValues, + TestCaseStatusModalProps, +} from './TestCaseStatusModal.interface'; + +const FORM_ID = 'update-status-form'; + +const FAILURE_REASON_ITEMS = Object.values(TestCaseFailureReasonType).map( + (value) => ({ id: value, label: startCase(value) }) +); export const TestCaseStatusModal = ({ open, @@ -53,7 +67,17 @@ export const TestCaseStatusModal = ({ onCancel, }: TestCaseStatusModalProps) => { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { + testCaseResolutionStatusType: data?.testCaseResolutionStatusType, + testCaseResolutionStatusDetails: { + testCaseFailureReason: + data?.testCaseResolutionStatusDetails?.testCaseFailureReason, + testCaseFailureComment: + data?.testCaseResolutionStatusDetails?.testCaseFailureComment, + }, + }, + }); const [isLoading, setIsLoading] = useState(false); const [options, setOptions] = useState([]); const [usersList, setUsersList] = useState([]); @@ -70,11 +94,12 @@ export const TestCaseStatusModal = ({ return { initialAssignees, assigneeOptions }; }, [data, usersList]); - const statusType = Form.useWatch('testCaseResolutionStatusType', form); - const updatedAssignees = Form.useWatch( - ['testCaseResolutionStatusDetails', 'assignee'], - form + const statusType = form.watch('testCaseResolutionStatusType'); + const updatedAssignees = form.watch( + 'testCaseResolutionStatusDetails.assignee' ); + const requiredMessage = (fieldText: string) => + t('message.field-text-is-required', { fieldText }); const statusOptions = useMemo(() => { const status = @@ -87,14 +112,14 @@ export const TestCaseStatusModal = ({ : Object.values(TestCaseResolutionStatusTypes); return status.map((value) => ({ + id: value, label: TEST_CASE_RESOLUTION_STATUS_LABELS[value], - value, })); }, [data]); const handleReopenFromResolved = async ( targetStatus: TestCaseResolutionStatusTypes, - formData: CreateTestCaseResolutionStatus + formData: TestCaseStatusFormValues ) => { const testCaseFqn = data?.testCaseReference?.fullyQualifiedName; const testCaseName = data?.testCaseReference?.name; @@ -102,7 +127,7 @@ export const TestCaseStatusModal = ({ return; } - const assignee = updatedAssignees?.length > 0 ? updatedAssignees[0] : null; + const assignee = updatedAssignees?.[0]; const latest = await reopenResolvedIncident({ testCaseFqn, testCaseName, @@ -111,10 +136,10 @@ export const TestCaseStatusModal = ({ details: { assignee: assignee ? { - id: assignee.value ?? assignee.id, + id: assignee.value, type: EntityType.USER, name: assignee.name, - fullyQualifiedName: assignee.fullyQualifiedName ?? assignee.name, + fullyQualifiedName: assignee.name, displayName: assignee.displayName, } : undefined, @@ -136,7 +161,7 @@ export const TestCaseStatusModal = ({ TestCaseResolutionStatusTypes.Assigned ? 'reassign' : 'assign'; - const assignee = updatedAssignees?.length > 0 ? updatedAssignees[0] : null; + const assignee = updatedAssignees?.[0]; return { transitionId, @@ -144,11 +169,10 @@ export const TestCaseStatusModal = ({ ? { assignees: [ { - id: assignee.value ?? assignee.id, + id: assignee.value, type: EntityType.USER, name: assignee.name, - fullyQualifiedName: - assignee.fullyQualifiedName ?? assignee.name, + fullyQualifiedName: assignee.name, displayName: assignee.displayName, }, ], @@ -158,7 +182,7 @@ export const TestCaseStatusModal = ({ }; const buildResolvedResolveRequest = ( - formData: CreateTestCaseResolutionStatus + formData: TestCaseStatusFormValues ): ResolveTask => { return { transitionId: 'resolve', @@ -175,7 +199,7 @@ export const TestCaseStatusModal = ({ const buildResolveRequest = ( status: TestCaseResolutionStatusTypes, - formData: CreateTestCaseResolutionStatus + formData: TestCaseStatusFormValues ): ResolveTask | null => { if (status === TestCaseResolutionStatusTypes.New) { return { transitionId: 'new' }; @@ -193,7 +217,7 @@ export const TestCaseStatusModal = ({ return null; }; - const handleFormSubmit = async (formData: CreateTestCaseResolutionStatus) => { + const handleFormSubmit = async (formData: TestCaseStatusFormValues) => { const currentStatus = data?.testCaseResolutionStatusType; const status = formData.testCaseResolutionStatusType; @@ -230,35 +254,6 @@ export const TestCaseStatusModal = ({ } }; - const descriptionField: FieldProp = useMemo( - () => ({ - name: ['testCaseResolutionStatusDetails', 'testCaseFailureComment'], - required: true, - label: t('label.comment'), - id: 'root/description', - type: FieldTypes.DESCRIPTION, - rules: [ - { - required: true, - }, - ], - props: { - 'data-testid': 'description', - initialValue: - data?.testCaseResolutionStatusDetails?.testCaseFailureComment ?? '', - placeHolder: t('message.write-your-text', { - text: t('label.comment'), - }), - - onTextChange: (value: string) => - form.setFieldValue( - ['testCaseResolutionStatusDetails', 'testCaseFailureComment'], - value - ), - }, - }), - [data?.testCaseResolutionStatusDetails?.testCaseFailureComment] - ); const fetchInitialAssign = useCallback(async () => { try { const { data } = await getUsers({ @@ -288,110 +283,147 @@ export const TestCaseStatusModal = ({ TestCaseResolutionStatusTypes.Assigned && assignee ) { - form.setFieldValue( - ['testCaseResolutionStatusDetails', 'assignee'], - [assignee.id] + form.setValue( + 'testCaseResolutionStatusDetails.assignee', + generateOptions([assignee]) ); } setOptions(assigneeOptions); }, [data, assigneeOptions]); return ( - + + + + } + isDismissable={false} + isOpen={open} title={t('label.update-entity', { entity: t('label.status') })} width={750} onCancel={onCancel}> - - data-testid="update-status-form" + - + - + {(item) => {item.label}} + + )} + {statusType === TestCaseResolutionStatusTypes.Resolved && ( <> - - - - {generateFormFields([descriptionField])} + + {({ field, fieldState }) => ( + + )} + + + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + )} {statusType === TestCaseResolutionStatusTypes.Assigned && ( - - - form.setFieldValue( - ['testCaseResolutionStatusDetails', 'assignee'], - values - ) - } - onSearch={(query) => - fetchOptions({ - query, - setOptions, - onlyUsers: true, - initialOptions: assigneeOptions, - }) - } - /> - + + {({ field, fieldState }) => ( + + + + fetchOptions({ + query, + setOptions, + onlyUsers: true, + initialOptions: assigneeOptions, + }) + } + /> + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + )} - - + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.interface.ts index 0ade2bf81d98..e31de8065018 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.interface.ts @@ -10,7 +10,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TestCaseResolutionStatus } from '../../../generated/tests/testCaseResolutionStatus'; +import { TestCaseFailureReasonType } from '../../../generated/tests/resolved'; +import { + TestCaseResolutionStatus, + TestCaseResolutionStatusTypes, +} from '../../../generated/tests/testCaseResolutionStatus'; +import { Option } from '../../../pages/TasksPage/TasksPage.interface'; export interface TestCaseStatusModalProps { open: boolean; @@ -19,3 +24,12 @@ export interface TestCaseStatusModalProps { onCancel: () => void; onSubmit: (data: TestCaseResolutionStatus) => void; } + +export interface TestCaseStatusFormValues { + testCaseResolutionStatusType: TestCaseResolutionStatusTypes; + testCaseResolutionStatusDetails?: { + testCaseFailureReason?: TestCaseFailureReasonType; + testCaseFailureComment?: string; + assignee?: Option[]; + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.test.tsx index 4c925c69c34d..12eb41aa532b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.test.tsx @@ -11,10 +11,13 @@ * limitations under the License. */ import { act, fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { forwardRef } from 'react'; -import { TestCaseResolutionStatusTypes } from '../../../generated/tests/testCaseResolutionStatus'; +import { TestCaseFailureReasonType } from '../../../generated/tests/resolved'; +import { + TestCaseResolutionStatus, + TestCaseResolutionStatusTypes, +} from '../../../generated/tests/testCaseResolutionStatus'; import { transitionIncident } from '../../../rest/incidentManagerAPI'; +import { TaskResolutionType } from '../../../rest/tasksAPI'; import { TestCaseStatusModal } from './TestCaseStatusModal.component'; import { TestCaseStatusModalProps } from './TestCaseStatusModal.interface'; @@ -25,16 +28,65 @@ const mockProps: TestCaseStatusModalProps = { onSubmit: jest.fn().mockImplementation(() => Promise.resolve()), }; -jest.mock('../../../components/common/RichTextEditor/RichTextEditor', () => { - return forwardRef(jest.fn().mockReturnValue(
RichTextEditor
)); -}); -jest.mock('../../../generated/tests/testCase', () => ({ - ...jest.requireActual('../../../generated/tests/testCase'), - TestCaseResolutionStatusTypes: { - ACK: 'Ack', - New: 'New', - Resolved: 'Resolved', - }, +// An incident whose status the user has not picked yet. +const NO_STATUS_INCIDENT = { + stateId: 'test-state-id', +} as TestCaseResolutionStatus; + +const mockAssignee = { + id: 'user-1', + type: 'user', + name: 'aaron', + displayName: 'Aaron', +}; + +jest.mock('../../../components/common/RichTextEditor/RichTextEditor', () => + jest + .fn() + .mockImplementation( + ({ onTextChange }: { onTextChange: (value: string) => void }) => ( +