From 80ce0459caaeb4871d36b592ff66d3f4bd37c748 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 8 Oct 2026 16:46:36 +0530 Subject: [PATCH 01/12] refactor(ui): remove antd from AI-mode Observability Migrate every component reachable from AI-mode Observability to @openmetadata/ui-core-components in place, with no behaviour change: alerts, incidents, test cases and suites, pipelines, learning drawer, entity headers, block editor, tasks and activity feed. - Table.interface.ts now owns its table types instead of re-exporting antd's - SlideoutMenu overlay stacks at z-50 like the core modal overlay - EditorSlots skips the editing menus for read-only previews - Playwright helpers target the core DOM instead of .ant-* selectors Part of open-metadata/openmetadata-collate#6826 --- .../slideout-menu.theme.test.tsx | 14 +- .../slideout-menus/slideout-menu.tsx | 2 +- .../e2e/Features/ActivityAPI.spec.ts | 5 +- .../ColumnBulkOperationsTagsGlossary.spec.ts | 10 +- .../DataQuality/DataQualityDashboard.spec.ts | 7 +- .../e2e/Features/IncidentManager.spec.ts | 19 +- .../ui/playwright/e2e/Features/Tasks.spec.ts | 4 +- .../Tasks/TaskAssigneeManagement.spec.ts | 6 +- .../e2e/Features/Tasks/TaskCreation.spec.ts | 16 +- .../e2e/Features/Tasks/TaskNavigation.spec.ts | 2 +- .../ui/playwright/utils/activityFeed.ts | 11 +- .../ui/playwright/utils/addTestCaseList.ts | 22 +- .../ui/playwright/utils/dataQuality.ts | 15 +- .../resources/ui/playwright/utils/dragDrop.ts | 2 +- .../resources/ui/playwright/utils/glossary.ts | 28 +- .../ui/playwright/utils/incidentManager.ts | 13 +- .../resources/ui/playwright/utils/task.ts | 28 +- .../ui/playwright/utils/taskWorkflow.ts | 16 +- .../ActivityFeedcardNew.component.test.tsx | 10 +- .../ActivityFeedcardNew.component.tsx | 241 ++++---- .../ActivityDescriptionFeed.tsx | 12 +- .../OwnerFeed/ActivityOwnersFeed.tsx | 60 +- .../TagsFeed/ActivityTagsFeed.tsx | 42 +- .../ActivityEventFooter.test.tsx | 21 + .../FeedCardFooter/ActivityEventFooter.tsx | 40 +- .../FeedCardFooter/FeedCardFooterNew.tsx | 56 +- .../ActivityFeedDrawer.test.tsx | 121 ++++ .../ActivityFeedDrawer/ActivityFeedDrawer.tsx | 137 ++--- .../activity-feed-drawer.less | 17 +- .../ActivityFeedEditor/SendButton.tsx | 30 +- .../ActivityFeedEditor/send-button.less | 2 +- .../ActivityFeedPanel/FeedPanelHeader.tsx | 32 +- .../ActivityFeedPanel/TaskPanelHeader.tsx | 29 +- .../ActivityFeedPanel/feed-panel-body-v1.less | 12 +- .../ActivityFeedPanel/feed-panel-header.less | 8 +- .../ActivityFeedTab/activity-feed-tab.less | 43 +- .../Shared/ActivityFeedActions.test.tsx | 16 + .../Shared/ActivityFeedActions.tsx | 141 +++-- .../Shared/activity-feed-actions.less | 20 +- .../BlockEditor/BlockEditor.interface.ts | 1 + .../components/BlockEditor/BlockEditor.tsx | 7 +- .../BubbleMenu/BubbleMenu.test.tsx | 4 +- .../BlockEditor/BubbleMenu/BubbleMenu.tsx | 43 +- .../BlockEditor/EditorSlots.test.tsx | 40 +- .../components/BlockEditor/EditorSlots.tsx | 8 +- .../Extensions/Callout/CalloutComponent.tsx | 8 +- .../CodeBlock/CodeBlockComponent.tsx | 16 +- .../AttachmentPlaceholder.tsx | 9 +- .../Extensions/File/file-node.less | 2 +- .../MathEquationComponent.test.tsx | 71 +++ .../MathEquation/MathEquationComponent.tsx | 69 ++- .../Extensions/mention/MentionList.tsx | 18 +- .../slash-command/SlashCommandList.tsx | 26 +- .../BlockEditor/LinkModal/LinkModal.test.tsx | 4 +- .../BlockEditor/LinkModal/LinkModal.tsx | 86 ++- .../BlockEditor/LinkPopup/LinkPopup.tsx | 34 +- .../BlockEditor/TableMenu/TableMenu.tsx | 100 ++-- .../components/BlockEditor/block-editor.less | 12 +- .../AddTestCaseList.component.test.tsx | 105 +++- .../AddTestCaseList.component.tsx | 254 ++++---- .../AddTestCaseListFilters.component.tsx | 7 +- .../AddToBundleSuiteModal.component.tsx | 61 +- .../AddToBundleSuiteModal.test.tsx | 64 +- .../BundleSuiteForm.interface.ts | 2 - .../Severity/Severity.component.tsx | 2 +- .../Severity/Severity.interface.ts | 2 +- .../Severity/SeverityModal.component.tsx | 85 +-- .../Severity/SeverityModal.test.tsx | 65 +- ...estCaseIncidentManagerStatus.component.tsx | 43 +- .../TestCaseStatusModal.component.tsx | 320 +++++----- .../TestCaseStatusModal.interface.ts | 16 +- .../TestCaseStatusModal.test.tsx | 229 ++++++- .../TestCases/TestCases.component.tsx | 4 +- .../TestCases/useTestCaseFilterOptions.tsx | 18 +- .../TestCases/useTestCaseFilters.test.tsx | 37 +- .../TestCases/useTestCaseFilters.ts | 45 +- .../TestCases/useTestCaseList.test.ts | 7 +- .../DataQuality/TestCases/useTestCaseList.ts | 10 +- .../TestCases/useTestCaseListPage.test.tsx | 29 +- .../TestCases/useTestCaseListPage.tsx | 10 +- .../TestSuitePipelineTab.component.tsx | 318 +++++----- .../TestSuitePipelineTab.test.tsx | 17 +- .../ColumnDetailPanel.test.tsx | 1 + .../ProfilerProgressWidget.test.tsx | 39 +- .../ProfilerProgressWidget.tsx | 62 +- .../Database/SchemaEditor/CodeEditor.tsx | 24 +- .../Database/SchemaEditor/SchemaEditor.tsx | 21 +- .../SchemaTable/SchemaTable.component.tsx | 3 +- .../EntityStatusBadge.component.tsx | 7 +- .../EntityVersionTimeLine.tsx | 60 +- .../Task/TaskTab/TaskTabNew.component.tsx | 563 +++++++++--------- .../Entity/Task/TaskTab/task-tab-new.less | 2 +- .../Entity/Task/TaskTab/task-tab.less | 2 +- ...asktabIncidentManagerHeaderNewFromTask.tsx | 164 +++-- .../LineageTab/LineageTabContent.test.tsx | 17 - .../LineageTab/LineageTabContent.tsx | 17 +- .../ExploreSearchCard.test.tsx | 1 + .../LearningDrawer.component.tsx | 88 ++- .../LearningDrawer/learning-drawer.less | 75 --- .../ArticleViewer.component.tsx | 36 +- .../ResourcePlayer/ArticleViewer.test.tsx | 1 + .../StorylaneTour.component.tsx | 4 +- .../ResourcePlayer/StorylaneTour.test.tsx | 6 +- .../ResourcePlayer/VideoPlayer.component.tsx | 4 +- .../ResourcePlayer/VideoPlayer.test.tsx | 6 +- .../ConfirmationModal.test.tsx | 20 + .../ConfirmationModal/ConfirmationModal.tsx | 111 ++-- .../IngestionRunDetailsModal.test.tsx | 14 +- .../IngestionRunDetailsModal.tsx | 116 ++-- .../KillIngestionPipelineModal.tsx | 15 +- .../ModalWithMarkdownEditor.tsx | 114 ++-- .../modal-with-markdown-editor.less | 20 - .../IngestionListTable.interface.ts | 6 +- .../IngestionStatusCount.tsx | 27 +- .../PipelineActions.interface.ts | 3 +- .../PipelineActions/PipelineActions.tsx | 141 ++--- .../PipelineActions/pipeline-actions.less | 32 - .../IngestionRecentRuns.component.tsx | 9 +- .../SuggestionsAlert/SuggestionsAlert.tsx | 24 +- .../SuggestionsAlert/suggestions-alert.less | 8 +- .../SuggestionsSlider/SuggestionsSlider.tsx | 54 +- .../TestDefinitionList.test.tsx | 8 +- .../TestDefinitionTable.component.tsx | 42 +- .../Chart/CustomPieChart.component.tsx | 11 +- .../AirflowMessageBanner.tsx | 14 +- .../AsyncSelectList.interface.ts | 15 +- .../AsyncSelectList/AsyncSelectList.test.tsx | 33 +- .../AsyncSelectList/AsyncSelectList.tsx | 299 ++++++---- .../AsyncSelectListSubmission.test.tsx | 46 +- .../AsyncSelectList/async-select-list.less | 39 -- .../common/AvatarCarousel/AvatarCarousel.tsx | 35 +- .../AvatarCarousel/avatar-carousel.less | 42 +- .../AvatarCarouselItem/AvatarCarouselItem.tsx | 42 +- .../src/components/common/Banner/Banner.tsx | 12 +- .../CertificationTag/CertificationTag.tsx | 12 +- .../DescriptionSourceBadge.test.tsx | 4 - .../DescriptionSourceBadge.tsx | 9 +- .../ManageButton/ManageButton.interface.ts | 5 +- .../ManageButton/ManageButton.tsx | 37 +- .../AssignErrorPlaceHolder.tsx | 7 +- .../CreateErrorPlaceHolder.tsx | 32 +- .../ErrorPlaceHolderIngestion.tsx | 20 +- .../FilterErrorPlaceHolder.tsx | 7 +- .../PermissionErrorPlaceholder.tsx | 13 +- .../common/IconButtons/EditIconButton.tsx | 244 +++----- .../common/InlineAlert/InlineAlert.test.tsx | 39 ++ .../common/InlineAlert/InlineAlert.tsx | 144 ++--- .../common/InlineAlert/inline-alert.less | 80 --- .../InlineEdit/InlineEdit.component.tsx | 42 +- .../common/InlineEdit/InlineEdit.interface.ts | 4 +- .../common/InlineEdit/InlineEdit.test.tsx | 2 +- .../common/InlineEdit/inline-edit.less | 6 - .../common/ListView/ListView.interface.ts | 2 +- .../CommonEntitySummaryInfoV1.test.tsx | 9 - .../CommonEntitySummaryInfoV1.tsx | 8 +- .../RichTextEditorPreviewNew.tsx | 7 +- .../RichTextEditorPreviewerV1.tsx | 7 +- .../rich-text-editor-previewerV1.less | 8 +- .../SelectionCardGroup.test.tsx | 89 +++ .../SelectionCardGroup/SelectionCardGroup.tsx | 21 +- .../selection-card-group.less | 145 +++-- .../ServiceDocPanel/ServiceDocPanel.tsx | 7 +- .../LabelCountSkeleton.component.tsx | 15 +- .../SummaryPanelSkeleton.component.tsx | 38 +- .../common/Table/Table.interface.ts | 340 ++++++++++- .../ConnectionStepCard.test.tsx | 74 +++ .../ConnectionStepCard/ConnectionStepCard.tsx | 107 ++-- .../connection-step-card.less | 13 - .../components/common/TierCard/TierCard.tsx | 11 +- .../CustomPropertiesDetailPage.test.tsx | 12 +- .../Alerts/AlertAiFormFields.interface.ts | 8 +- .../Alerts/AlertEditModal.component.test.tsx | 75 ++- .../Alerts/AlertEditModal.component.tsx | 27 +- .../AddNotificationPage.tsx | 2 + .../AddObservabilityPage.interface.ts | 31 +- .../AddObservabilityPage.test.tsx | 27 + .../components/ObservabilityAlertForm.tsx | 8 +- .../ObservabilityAlertFormFields.tsx | 6 +- .../hooks/useObservabilityAlertForm.ts | 35 +- .../ExplorePage/ExplorePage.interface.ts | 10 +- .../IncidentManagerPage.test.tsx | 1 + .../ObservabilityAlertsPage.test.tsx | 1 + .../pages/TasksPage/shared/Assignees.test.tsx | 138 ++++- .../src/pages/TasksPage/shared/Assignees.tsx | 289 ++++++--- .../pages/TasksPage/shared/TagSuggestion.tsx | 68 +-- .../shared/TaskPayloadSchemaFields.test.tsx | 29 + .../shared/TaskPayloadSchemaFields.tsx | 203 ++++--- .../components/entity-version-time-line.less | 28 +- .../resources/ui/src/utils/AlertsClassBase.ts | 8 +- .../ui/src/utils/IngestionListTableUtils.tsx | 52 +- 190 files changed, 5105 insertions(+), 3734 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedDrawer/ActivityFeedDrawer.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/MathEquationComponent.test.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningDrawer/learning-drawer.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithMarkdownEditor/modal-with-markdown-editor.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/pipeline-actions.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/async-select-list.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/InlineAlert/inline-alert.less create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/SelectionCardGroup/SelectionCardGroup.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/ConnectionStepCard/ConnectionStepCard.test.tsx diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.theme.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.theme.test.tsx index e1eca987805a..df82760f6eb0 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.theme.test.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.theme.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { Dialog, Modal } from './slideout-menu'; +import { Dialog, Modal, SlideoutMenu } from './slideout-menu'; describe('SlideoutMenu theme roles', () => { it('renders the drawer panel on the overlay surface with overlay elevation', () => { @@ -17,4 +17,16 @@ describe('SlideoutMenu theme roles', () => { // Modal wrapper owns the elevation shadow. expect(panel.closest('.tw\\:shadow-overlay')).not.toBeNull(); }); + + it('stacks the overlay above page content that sets its own z-index', () => { + render( + + Body + + ); + + const overlay = screen.getByRole('dialog').closest('.tw\\:fixed'); + + expect(overlay).toHaveClass('tw:z-50'); + }); }); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.tsx index 241fa16043cd..ab4bffc92a38 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.tsx @@ -27,7 +27,7 @@ export const ModalOverlay = (props: ModalOverlayProps) => { {...props} className={(state) => cx( - 'tw:fixed tw:inset-0 tw:flex tw:min-h-dvh tw:w-full tw:items-center tw:justify-end tw:bg-overlay/70 tw:pl-6 tw:outline-hidden tw:ease-linear tw:md:pl-10', + 'tw:fixed tw:inset-0 tw:z-50 tw:flex tw:min-h-dvh tw:w-full tw:items-center tw:justify-end tw:bg-overlay/70 tw:pl-6 tw:outline-hidden tw:ease-linear tw:md:pl-10', state.isEntering && 'tw:duration-300 tw:animate-in tw:fade-in', state.isExiting && 'tw:duration-500 tw:animate-out tw:fade-out', typeof props.className === 'function' diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityAPI.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityAPI.spec.ts index 1e11678060f0..ce90bb1ccfe8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityAPI.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityAPI.spec.ts @@ -368,7 +368,10 @@ test.describe( .includes(`/api/v1/conversations/${firstActivityId}/replies/`) && response.request().method() === 'DELETE' ); - await page.locator('.ant-modal').getByTestId('save-button').click(); + await page + .getByTestId('confirmation-modal') + .getByTestId('save-button') + .click(); await deleteResponse; await expect(editedReplyCard).toHaveCount(0); 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/IncidentManager.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts index 68abdb66ab83..2581d2d35317 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 @@ -107,6 +107,7 @@ const waitForIncidentTask = async (page: Page, testCaseFqn?: string) => { category: 'Incident', limit: 100, fields: 'about,payload,assignees', + ...(testCaseFqn ? { aboutEntity: testCaseFqn } : {}), }, }); @@ -175,7 +176,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') @@ -259,16 +260,18 @@ const reassignIncidentTask = async ( ) => { const reassignModal = await openIncidentReassignModal(page, testCaseName); const assigneeSelect = reassignModal.getByTestId('select-assignee'); - const assigneeSelector = assigneeSelect.locator('.ant-select-selector'); const assigneeInput = assigneeSelect.getByRole('combobox'); const assigneeOption = page.getByTestId(assignee.name.toLowerCase()); - await expect(assigneeSelector).toBeVisible(); - await assigneeSelector.click(); + await expect(assigneeInput).toBeVisible(); + await assigneeInput.click(); await assigneeInput.fill(assignee.displayName); 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.locator('h2').click(); + await expect(assigneeOption).toBeHidden(); const updateAssignee = waitForTaskResolveResponse(page); @@ -303,8 +306,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'); @@ -743,7 +747,7 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { const resolveModal = await openIncidentResolveDialog(actorPage); const resolveTextareas = resolveModal.locator('textarea'); const resolveReasonSelect = resolveModal - .locator('.ant-select-selector') + .locator('button[aria-haspopup="listbox"]') .first(); const textareaCount = await resolveTextareas.count(); @@ -1149,7 +1153,8 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { ); await page .getByTestId('select-assignee') - .getByLabel('close-circle') + .getByTestId('autocomplete-selected-item') + .getByRole('button') .click(); await nonAssigneeFilterRes; 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/TaskAssigneeManagement.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskAssigneeManagement.spec.ts index 7faea5f3ea15..c651ab0dca8d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskAssigneeManagement.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskAssigneeManagement.spec.ts @@ -99,9 +99,9 @@ test.describe('Task Assignee Management', () => { await expect(page.getByTestId('select-assignee')).toBeVisible(); const existingAssigneeRemoveButton = page - .locator( - '[data-testid="select-assignee"] .ant-select-selection-item-remove' - ) + .getByTestId('select-assignee') + .getByTestId('autocomplete-selected-item') + .getByRole('button') .first(); await existingAssigneeRemoveButton.click(); 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 b375f0f89b6e..252590993411 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 @@ -175,9 +175,9 @@ test.describe('Task Creation - Request Description', () => { await expect(page.getByTestId('form-container')).toBeVisible(); // Assignee field - search and select user - const assigneeInput = page.locator( - '[data-testid="select-assignee"] .ant-select-selector input' - ); + const assigneeInput = page + .getByTestId('select-assignee') + .getByRole('combobox'); await assigneeInput.click(); // Search for user @@ -422,9 +422,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(); @@ -436,8 +434,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..d4e1838a1814 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.locator('.activity-feed-drawer'); if (await drawer.isVisible({ timeout: 5000 })) { // Drawer should show task details 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 409b7d2283ee..35fc9995de34 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/activityFeed.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/activityFeed.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { expect, Locator, Page } from '@playwright/test'; -import { getDescriptionBox, waitForAntdModalToSettle } from './common'; +import { getDescriptionBox } from './common'; import { waitForAllLoadersToDisappear } from './entity'; import { waitForPageLoaded } from './polling'; import { TaskDetails } from './task'; @@ -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}` ); @@ -79,8 +81,7 @@ export const deleteFeedComments = async (page: Page, feed: Locator) => { await page.locator('[data-testid="delete-message"]').click(); - await page.locator('[role="dialog"].ant-modal').waitFor(); - await waitForAntdModalToSettle(page); + await page.getByTestId('confirmation-modal').waitFor(); const deleteResponse = page.waitForResponse( '/api/v1/conversations/*/replies/*' 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 2a25de9343f8..d6bb099400a8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts @@ -588,14 +588,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..b758fb19d03f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dragDrop.ts @@ -103,7 +103,7 @@ export const confirmationDragAndDropTeam = async ( ) => { // Confirmation message before the transfer await expect( - page.locator('[data-testid="confirmation-modal"] .ant-modal-body') + page.getByTestId('confirmation-modal').getByTestId('body-text') ).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 346ad592747f..5ec5debab257 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -1287,18 +1287,16 @@ export const createDescriptionTaskForGlossary = async ( ); if (isUndefined(value.assignee)) { - await expect( - page.locator('[data-testid="select-assignee"] > .ant-select-selector') - ).toHaveText(value.assignee ?? ''); + await expect(page.getByTestId('select-assignee')).toHaveText( + value.assignee ?? '' + ); await expect( - page.locator( - '[data-testid="select-assignee"] > .ant-select-selector input' - ) + page.locator('[data-testid="select-assignee"] input') ).toBeDisabled(); } else { const assigneeField = page.locator( - '[data-testid="select-assignee"] > .ant-select-selector #assignees' + '[data-testid="select-assignee"] #assignees' ); await assigneeField.click(); @@ -1341,19 +1339,17 @@ export const createTagTaskForGlossary = async ( ); if (isUndefined(value.assignee)) { - await expect( - page.locator('[data-testid="select-assignee"] > .ant-select-selector') - ).toHaveText(value.assignee ?? ''); + await expect(page.getByTestId('select-assignee')).toHaveText( + value.assignee ?? '' + ); await expect( - page.locator( - '[data-testid="select-assignee"] > .ant-select-selector input' - ) + page.locator('[data-testid="select-assignee"] input') ).toBeDisabled(); } else { // select assignee const assigneeField = page.locator( - '[data-testid="select-assignee"] > .ant-select-selector #assignees' + '[data-testid="select-assignee"] #assignees' ); await assigneeField.click(); const userSearchResponse = page.waitForResponse( @@ -1371,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 be15c477bfd3..233fd9ab6c2d 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,10 +190,10 @@ 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 assigneeSelector = assigneeSelect.locator('.ant-select-selector'); - const assigneeInput = assigneeSelect.locator('input').last(); + const assigneeSelector = assigneeSelect.getByRole('combobox'); + const assigneeInput = assigneeSelector; const assigneeOption = page.getByTestId(user.name).first(); const normalizedAssigneeOption = page .getByTestId(user.name.toLowerCase()) 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 2a7e1b68e83b..a9965bf56eb6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/task.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/task.ts @@ -117,18 +117,16 @@ export const createDescriptionTask = async ( ); if (isUndefined(value.assignee) || assigneeDisabled) { - await expect( - page.locator('[data-testid="select-assignee"] > .ant-select-selector') - ).toHaveText(value.assignee); + await expect(page.getByTestId('select-assignee')).toHaveText( + value.assignee + ); await expect( - page.locator( - '[data-testid="select-assignee"] > .ant-select-selector input' - ) + page.locator('[data-testid="select-assignee"] input') ).toBeDisabled(); } else { const assigneeField = page.locator( - '[data-testid="select-assignee"] > .ant-select-selector #assignees' + '[data-testid="select-assignee"] #assignees' ); await assigneeField.click(); await assigneeField.fill(value.assignee); @@ -162,19 +160,17 @@ export const createTagTask = async ( ); if (isUndefined(value.assignee) || assigneeDisabled) { - await expect( - page.locator('[data-testid="select-assignee"] > .ant-select-selector') - ).toHaveText(value.assignee); + await expect(page.getByTestId('select-assignee')).toHaveText( + value.assignee + ); await expect( - page.locator( - '[data-testid="select-assignee"] > .ant-select-selector input' - ) + page.locator('[data-testid="select-assignee"] input') ).toBeDisabled(); } else { // select assignee const assigneeField = page.locator( - '[data-testid="select-assignee"] > .ant-select-selector #assignees' + '[data-testid="select-assignee"] #assignees' ); await assigneeField.click(); await assigneeField.fill(value.assignee); @@ -189,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 1c75883a602b..96f91712a58f 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,7 @@ 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'; +const VISIBLE_TASK_MODAL_SELECTOR = '[role="dialog"]:visible'; const logTaskDebug = (...messages: Array) => { if (process.env.PW_TASK_DEBUG) { @@ -64,7 +64,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,7 +93,11 @@ 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. Blurring the + // input closes the menu and cancels that re-open for good. + await tagsInput.blur(); + await expect(tagsInput).toHaveAttribute('aria-expanded', 'false'); logTaskDebug('selectTagSuggestion:done', tagTestId); }; @@ -214,9 +218,9 @@ export const openTaskForm = async (page: Page, route: string) => { }; export const selectAssignee = async (page: Page, assigneeName: string) => { - const assigneeInput = page.locator( - '[data-testid="select-assignee"] .ant-select-selection-search input' - ); + const assigneeInput = page + .getByTestId('select-assignee') + .getByRole('combobox'); const assigneeOption = page.getByTestId(assigneeName); const assigneeSearchResponse = page .waitForResponse( 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 700bb000f367..b71b52c245b9 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 @@ -17,7 +17,6 @@ import { Tooltip, Typography, } from '@openmetadata/ui-core-components'; -import { Card, Space } from 'antd'; import classNames from 'classnames'; import { compare } from 'fast-json-patch'; import { isUndefined, orderBy } from 'lodash'; @@ -64,6 +63,11 @@ const ActivityFeedEditorNew = withSuspenseFallback( const POST_SKELETON_KEYS = ['first', 'second', 'third']; +// 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; activity?: ActivityEvent; @@ -100,11 +104,11 @@ const getHeaderTagsClassName = ( showThread: boolean | undefined, entityRefType?: string ): string => - classNames('d-flex gap-1', { + 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, + 'items-center': !showThread || entityRefType !== EntityType.CONTAINER, }); const computeFeedId = (feed?: Conversation, activity?: ActivityEvent): string => @@ -398,10 +402,26 @@ const ActivityFeedCardNew = ({ return isPost ? post?.message ?? '' : feed?.message ?? ''; }, [isActivityEvent, activity, isPost, feed, post]); + const renderHeaderTags = () => ( + +
+ + {feedHeaderText} + +
+
{renderEntityLink}
+
+ ); + const renderWidgetCard = () => ( - - +
{timestamp}
- {!isPost && ( - - - {feedHeaderText} - - - {renderEntityLink} - - )} + {!isPost && renderHeaderTags()}
-
- {feedActions} -
+ {feedActions} + + ); if (isFeedWidget) { @@ -569,10 +574,13 @@ const ActivityFeedCardNew = ({ ); + const isContainerThread = + showThread && entityRef?.type === EntityType.CONTAINER; + const renderFullCard = () => ( - - - - - -
- +
+ +
+ +
+ +
+ +
+
- - - - - - - {getEntityName(user)} - - - - {timestamp} - - {!isPost && ( - - - {feedHeaderText} - - - {renderEntityLink} - - )} - - - - setIsEditPost(false)} - onUpdate={onUpdate} - /> +
+
+
+
+
+ + + + {getEntityName(user)} + + + +
+
{timestamp}
+
+
+ {!isPost && ( +
+ {renderHeaderTags()} +
+ )} +
+
+
+
- {!isActivityEvent && feed && ( - + setIsEditPost(false)} + onUpdate={onUpdate} /> +
+ + {!isActivityEvent && feed && ( +
+ +
)} {isActivityEvent && activity && ( - +
+ +
)} - - - {(showThread || isOpenInDrawer) && renderCommentsSection()} - {feedActions} - + + {(showThread || isOpenInDrawer) && renderCommentsSection()} + {feedActions} +
+ ); return renderFullCard(); 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 f7996ce9a32c..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 @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Col, Row } from 'antd'; +import { Box } from '@openmetadata/ui-core-components'; import { useMemo } from 'react'; import { ReactComponent as AddIcon } from '../../../../../assets/svg/added-icon.svg'; import { ReactComponent as UpdatedIcon } from '../../../../../assets/svg/updated-icon.svg'; @@ -44,15 +44,15 @@ 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 419bb071b8ee..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 @@ -11,8 +11,12 @@ * limitations under the License. */ -import { Owner, OwnerChip, Typography } from '@openmetadata/ui-core-components'; -import { Col, Row } from 'antd'; +import { + Box, + Owner, + OwnerChip, + Typography, +} from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; import { useCallback, useMemo } from 'react'; @@ -96,7 +100,7 @@ function ActivityOwnersFeed({ const getOwnerItems = useCallback( (ownerList: EntityReference[]) => { return ownerList.length <= maxVisibleOwners ? ( - + {ownerList.map((owner: EntityReference) => owner.type === EntityType.USER ? ( @@ -129,7 +133,7 @@ function ActivityOwnersFeed({ ) )} - + ) : ( + {!isEmpty(updatedOwner) && ( - - - - + + + + + {t('label.owner-plural-with-colon')} + + + +
{renderUpdatedOwner}
+
+ )} + {!isEmpty(previousOwner) && ( + +
+ + {t('label.owner-plural-with-colon')} - - - {renderUpdatedOwner} - - - )} - {!isEmpty(previousOwner) && ( - - - - - - - {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 5a4aebdbcc8f..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,7 +11,7 @@ * limitations under the License. */ -import { Col, Row } from 'antd'; +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'; @@ -60,32 +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 222424df6754..10fb3f742ebe 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,10 +11,12 @@ * limitations under the License. */ -import { Button, Col, Row } 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'; @@ -33,6 +35,7 @@ function ActivityEventFooter({ isForFeedTab = false, onActivityClick, }: Readonly) { + const { t } = useTranslation(); const { updateActivityReaction } = useActivityFeedProvider(); const onReactionUpdate = useCallback( @@ -45,30 +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 d607f2b0a73c..863553445351 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 } from '@openmetadata/ui-core-components'; -import { Button, Col, Row } 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,12 +78,24 @@ 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 ( - - + +
{postLength > 0 && !isReply && ( @@ -89,10 +103,11 @@ function FeedCardFooterNew({ avatarSize={AVATAR_SIZE} items={repliedUsers.map((user) => ( + 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..8c9c7afa4981 --- /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(dialog).toHaveClass('activity-feed-drawer'); + 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 8d847c79c2e0..4ba3559ba595 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,9 +11,10 @@ * limitations under the License. */ -import { Col, Drawer, Row } 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 withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import ActivityPanelBody from '../ActivityFeedPanel/ActivityPanelBody'; @@ -25,6 +26,7 @@ import { useActivityFeedProvider } from '../ActivityFeedProvider/ActivityFeedPro import './activity-feed-drawer.less'; const ACTIVITY_FEED_DRAWER_CLASS = 'activity-feed-drawer'; +const DRAWER_WIDTH = 576; const TaskTabNew = withSuspenseFallback( lazy(() => @@ -43,6 +45,7 @@ const ActivityFeedDrawer: FC = ({ open, className, }) => { + const { t } = useTranslation(); const { hideDrawer, selectedThread, selectedTask, selectedActivity } = useActivityFeedProvider(); @@ -54,93 +57,85 @@ 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..4c547cdbacb3 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,20 @@ color: @primary-heading-color; } } +// The header and body chrome antd's Drawer used to supply (its title +// typography, a scrolling body), less the padding this drawer always removed. .activity-feed-drawer { - .ant-drawer-body { - padding: 0; + .activity-feed-drawer-header { + flex-shrink: 0; + font-size: var(--om-font-size-md); + font-weight: var(--om-font-weight-medium); + line-height: 22px; + color: @heading-color; } - .ant-drawer-header { - padding: 0; + + .activity-feed-drawer-body { + flex: 1; + min-height: 0; + overflow-y: auto; } } 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/ActivityFeed/ActivityFeedPanel/feed-panel-body-v1.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedPanel/feed-panel-body-v1.less index 197438a46e6b..2d0c1e164246 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedPanel/feed-panel-body-v1.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedPanel/feed-panel-body-v1.less @@ -21,20 +21,16 @@ flex-wrap: wrap; width: inherit; - &.ant-card { + &.activity-feed-card-new-surface { background: @grey-9; border-radius: var(--om-radius-xl); margin-bottom: var(--om-space-20); border: 0.5px solid @grey-15; } - .ant-card-body { + .activity-feed-card-new-body { width: inherit; padding: var(--om-space-20); - - .ant-space-item { - width: inherit; - } } .header-link { word-wrap: break-word; @@ -55,9 +51,9 @@ .activity-feed-card-new-right-panel { // Surface (== bg-primary in light) so the thread blends into the // bg-surface right panel / drawer instead of a black slab in dark. - &.ant-card { + &.activity-feed-card-new-surface { border: 1px solid var(--om-color-bg-surface); - .ant-card-body { + .activity-feed-card-new-body { background-color: var(--om-color-bg-surface); border: 1px solid var(--om-color-bg-surface); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedPanel/feed-panel-header.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedPanel/feed-panel-header.less index b16d75c3a830..1fcd3cac488e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedPanel/feed-panel-header.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedPanel/feed-panel-header.less @@ -14,7 +14,13 @@ .feed-panel-header { padding: var(--om-space-16); border-bottom: 1px solid var(--om-legacy-color-e8e8e8); - background-color: var(--om-color-gray-50); + // bg-secondary is the same gray-50 in light, and flips in dark where the + // drawer surface does. + background-color: var(--om-color-bg-secondary); + + .dark-mode & { + border-bottom-color: var(--om-color-border-secondary); + } .task-status-badge { padding: var(--om-space-2) var(--om-space-8); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less index 9cb86a68150a..227a653c28e1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less @@ -126,13 +126,9 @@ width: inherit; border: 0.5px solid @grey-15; - .ant-card-body { + .activity-feed-card-new-body { width: inherit; padding: var(--om-space-20); - - .ant-space-item { - width: inherit; - } } } @@ -178,7 +174,7 @@ flex-wrap: wrap; width: inherit; - .ant-card-body { + .activity-feed-card-new-body { width: inherit; padding: 0; // margin: 10px 0px 20px 0px; @@ -187,10 +183,6 @@ background: var(--om-legacy-color-239-244-250-0-25); padding-bottom: var(--om-space-16); - - .ant-space-item { - width: inherit; - } } .activity-feed-reply-card-message { @@ -235,6 +227,33 @@ } } +// The box antd's Card used to give the feed card. :where() keeps it at zero +// specificity so every card rule in these files overrides it, as they did +// antd's own .ant-card. +:where(.activity-feed-card-new-surface) { + position: relative; + color: @text-color; + background: var(--om-color-bg-surface); + border: 1px solid @border-color; +} + +// The landing-page feed widget lays out its own card body through a descendant +// `.ant-card-body` rule (feed-widget.less, widget-wrapper.less) that also +// reached this card's body while it was an antd Card. Mirror it so widget +// cards keep their compact, unpadded layout. +.feed-widget-container, +.widget-wrapper-container { + .activity-feed-card-new .activity-feed-card-new-body { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + padding: 0; + overflow: hidden; + position: relative; + } +} + .activity-feed-task { background-color: @grey-1; } @@ -277,7 +296,7 @@ .activity-feed-editor-send-btn { color: var(--tw-color-text-white); - .ant-btn.send-button { + .send-button { position: absolute; bottom: 8px; right: 8px; @@ -450,7 +469,7 @@ // normal flow is what stops the bar overlapping the comment text - it can no // longer be mispositioned relative to a padding or line-height it doesn't know // about. -.reply-card .feed-actions.ant-space { +.reply-card .feed-actions { position: static; padding-top: var(--om-space-2); padding-bottom: var(--om-space-2); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx index 566497154054..f2c0751f111d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx @@ -117,6 +117,22 @@ describe('ActivityFeedActions', () => { expect(screen.queryByTestId('delete-message')).not.toBeInTheDocument(); }); + it('renders no action bar when the user has no action on the post', () => { + mockUseApplicationStore.mockReturnValue({ + currentUser: { id: 'other-id', name: 'bob', isAdmin: false }, + }); + + render( + + ); + + expect(screen.queryByTestId('feed-actions')).not.toBeInTheDocument(); + }); + it('allows an administrator to edit and delete another users reply', () => { mockUseApplicationStore.mockReturnValue({ currentUser: { id: 'admin-id', name: 'admin', isAdmin: true }, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx index d7bebb7dd89b..757e69010e7b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx @@ -10,8 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ButtonUtility } from '@openmetadata/ui-core-components'; -import { Space } from 'antd'; +import { Box, ButtonUtility } from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { lazy, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -83,15 +82,22 @@ interface ActivityFeedActionsProps { const resolveActionVisibility = ( isAuthor: boolean, isAdmin: boolean, + canReply: boolean, canEdit?: boolean, canDelete?: boolean ) => { const canManage = isAuthor || isAdmin; + const showDelete = canDelete ?? canManage; + const showEdit = canEdit ?? canManage; return { - canManage, - showDelete: canDelete ?? canManage, - showEdit: canEdit ?? canManage, + showReply: canReply, + showResolve: canReply && canManage, + showDelete, + showEdit, + // antd's Space rendered nothing without children; keep an empty, bordered + // bar from appearing on hover when the user may not act on this post. + hasActions: canReply || showEdit || showDelete, }; }; @@ -176,12 +182,14 @@ const ActivityFeedActions = ({ } }; - const { canManage, showEdit, showDelete } = resolveActionVisibility( - isAuthor, - Boolean(currentUser?.isAdmin), - canEdit, - canDelete - ); + const { showReply, showResolve, showEdit, showDelete, hasActions } = + resolveActionVisibility( + isAuthor, + Boolean(currentUser?.isAdmin), + !isReply && Boolean(conversation), + canEdit, + canDelete + ); const handleResolvedChange = () => { if (!conversation || isReply || !conversationId) { @@ -198,63 +206,66 @@ const ActivityFeedActions = ({ return ( <> - - {!isReply && conversation && ( - - )} + {hasActions && ( + + {showReply && ( + + )} - {!isReply && conversation && canManage && ( - - )} + {showResolve && ( + + )} - {showEdit && ( - - )} + {showEdit && ( + + )} - {showDelete && ( - setShowDeleteDialog(true)} - /> - )} - + {showDelete && ( + setShowDeleteDialog(true)} + /> + )} + + )} .ant-card-body > .feed-actions.ant-space, -.reply-card:hover .feed-actions.ant-space { +// The conversation's bar sits inside the card's body wrapper, so it is a +// grandchild of the card, not a child. A test in +// ActivityFeedcardNew.component.test.tsx pins that shape so a markup change +// fails loudly rather than silently leaving these hidden. +.activity-feed-card-new:hover > .activity-feed-card-new-body > .feed-actions, +.reply-card:hover .feed-actions { opacity: 1; pointer-events: auto; } -.feed-actions.ant-space { +.feed-actions { position: absolute; right: 10px; top: 24px; @@ -55,7 +55,7 @@ color: @text-color; } -.feed-actions.ant-space[dir='rtl'] { +.feed-actions[dir='rtl'] { right: auto; left: 10px; } @@ -72,7 +72,7 @@ // buttons. Written here rather than passed as a class because a Tailwind // utility would tie on specificity with p-1.5 and win or lose on stylesheet // order. -.feed-actions.ant-space .toolbar-button { +.feed-actions .toolbar-button { padding: var(--om-space-2); // ButtonUtility's `tertiary` colour paints the glyph tw:text-quaternary, a // mid grey. Fall back to the bar's own text colour, which is what these diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.interface.ts index 2c9fd644b531..bd6f1ed85850 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.interface.ts @@ -94,6 +94,7 @@ export interface BlockEditorAttachmentProps { } export interface EditorSlotsProps { + editable?: boolean; editor: Editor | null; menuType: MenuType; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx index 983e17605c6d..ec480baa198d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx @@ -307,7 +307,12 @@ const BlockEditor = forwardRef( placeholder={placeholder} onMouseDown={editorSlots.current?.onMouseDown} /> - +
); } 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 }) => { -
) : ( {equation} )} {/* 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 99f0ffe2ba1a..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 @@ -10,9 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Typography } from '@openmetadata/ui-core-components'; +import { Box, Typography } from '@openmetadata/ui-core-components'; import { SuggestionProps } from '@tiptap/suggestion'; -import { Space } from 'antd'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; import { forwardRef, useImperativeHandle, useState } from 'react'; @@ -105,15 +104,20 @@ export default forwardRef>( })); return ( - {items.map((item, index) => ( - selectItem(index)}> @@ -121,9 +125,9 @@ export default forwardRef>( {item.label} - + ))} - + ); } ); 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 afa7fb82aa7b..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 @@ -10,9 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Typography } from '@openmetadata/ui-core-components'; +import { Box, Typography } from '@openmetadata/ui-core-components'; import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; -import { Image, Space } from 'antd'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; import { forwardRef, useImperativeHandle, useState } from 'react'; @@ -114,34 +113,39 @@ export const SlashCommandList = forwardRef( } return ( - {filteredItems.map((item, index) => ( - selectItem(index)}> - - + {item.title} {item.description} - - + + ))} - + ); } ); 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 12e0dcab44b6..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,13 +10,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button, Space } 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; @@ -30,32 +30,38 @@ 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 ec931e5bd570..6d5e118d306f 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 @@ -358,15 +358,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); @@ -517,10 +508,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/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx index 807a4e736ca5..86e7bdbcaeda 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 1ca693bd2219..602e6112ec63 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 @@ -11,8 +11,10 @@ * limitations under the License. */ -import { Typography as CoreTypography } from '@openmetadata/ui-core-components'; -import { Space, Tooltip } from 'antd'; +import { + Tooltip, + Typography as CoreTypography, +} from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -104,9 +106,13 @@ const TestCaseIncidentManagerStatus = ({ /> )}
- - +
- +
{isEditStatus && ( @@ -143,14 +149,23 @@ const TestCaseIncidentManagerStatus = ({ return ( <> - - - + + + + {hasEditPermission && ( @@ -166,7 +181,7 @@ const TestCaseIncidentManagerStatus = ({ onClick={onEditStatus} /> )} - +
{isEditStatus && ( ({ 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,111 +283,148 @@ 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 }) => ( +