From aab5b535ac06c2e92369836773905064fb21c2d3 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Fri, 16 Oct 2026 12:49:51 +0530 Subject: [PATCH 01/28] Fix dark-mode status counts, tab counts, table header divider and entity icons - Ingestion/automation status counts use core Badge pills, left-aligned - Active tab counts use an outlined brand Badge instead of a solid fill - Core Table header divider uses border-primary in dark so it stays visible - FeaturedIcon light theme keeps its tinted tile in dark instead of going solid - Avatar gains placeholderIconColor; entity avatars use the brand tint by default --- .../components/application/table/table.tsx | 3 + .../components/base/avatar/avatar.test.tsx | 57 +++++++++++++++++ .../ui/src/components/base/avatar/avatar.tsx | 23 ++++++- .../featured-icon/featured-icon.tsx | 13 ++-- .../CriticalAnnouncementBanner.tsx | 5 -- .../IngestionStatusCount.test.tsx | 38 +++++++++++ .../IngestionStatusCount.tsx | 36 +++++------ .../ingestion-status-count.less | 63 ------------------- .../common/TabsLabel/TabsLabel.test.tsx | 12 ++-- .../MyData/components/StatCard.tsx | 4 +- .../ui/src/styles/components/tabs.less | 6 -- .../src/utils/EntityDisplayPureUtils.test.tsx | 21 +++---- .../ui/src/utils/EntityDisplayPureUtils.tsx | 16 +++-- .../resources/ui/src/utils/IconUtils.test.tsx | 19 ++++++ .../main/resources/ui/src/utils/IconUtils.tsx | 11 ++-- 15 files changed, 199 insertions(+), 128 deletions(-) create mode 100644 openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.test.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/ingestion-status-count.less diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/table/table.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/table/table.tsx index 3f1323cdcb81..071a47ecfa41 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/table/table.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/table/table.tsx @@ -297,6 +297,9 @@ const TableHeader = ({ // Row border—using an "after" pseudo-element to avoid the border taking up space. bordered && 'tw:[&>tr>th]:after:pointer-events-none tw:[&>tr>th]:after:absolute tw:[&>tr>th]:after:inset-x-0 tw:[&>tr>th]:after:bottom-0 tw:[&>tr>th]:after:h-px tw:[&>tr>th]:after:bg-border-secondary tw:[&>tr>th]:focus-visible:after:bg-transparent', + // Dark: the 8% white divider resolves to the body-row surface under the darker + // header band, so the edge vanishes; border-primary keeps it visible. + bordered && 'tw:dark:[&>tr>th]:after:bg-border-primary', typeof className === 'function' ? className(state) : className ) diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.test.tsx new file mode 100644 index 000000000000..b5bab0667777 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Avatar } from './avatar'; + +const Icon = ({ className }: { className?: string }) => ( + +); + +describe('Avatar placeholderIconColor', () => { + it('tints the surface behind the placeholder icon', () => { + render( + + ); + + expect(screen.getByTestId('avatar')).toHaveClass( + 'tw:bg-utility-brand-50', + 'tw:text-utility-brand-600' + ); + }); + + it('keeps the gray surface when no color is set', () => { + render(); + + expect(screen.getByTestId('avatar')).toHaveClass('tw:bg-tertiary'); + }); + + it('ignores the tint once an image is shown', () => { + render( + + ); + + expect(screen.getByTestId('avatar')).not.toHaveClass( + 'tw:bg-utility-brand-50' + ); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.tsx index 2b48d93e94fb..a2146e21fdec 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.tsx @@ -6,6 +6,16 @@ import { getAvatarColorClasses } from './utils'; type AvatarSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; +export type AvatarIconColor = 'brand' | 'gray' | 'success' | 'warning' | 'error'; + +const iconColors: Record = { + brand: 'tw:bg-utility-brand-50 tw:text-utility-brand-600', + gray: 'tw:bg-utility-gray-50 tw:text-utility-gray-600', + success: 'tw:bg-utility-success-50 tw:text-utility-success-600', + warning: 'tw:bg-utility-warning-50 tw:text-utility-warning-600', + error: 'tw:bg-utility-error-50 tw:text-utility-error-600', +}; + export interface AvatarProps { size?: AvatarSize; className?: string; @@ -49,6 +59,12 @@ export interface AvatarProps { * An icon to display if no image is available. */ placeholderIcon?: FC<{ className?: string }>; + /** + * Tints the surface behind `placeholderIcon` with a theme-adapting + * `utility-*` color (the same tile treatment as tinted featured icons). + * Unset keeps the plain gray surface. + */ + placeholderIconColor?: AvatarIconColor; /** * A placeholder to display if no image is available. */ @@ -112,6 +128,7 @@ export const Avatar = ({ colorVariant = 'auto', placeholder, placeholderIcon: PlaceholderIcon, + placeholderIconColor, badge, status, verified, @@ -140,6 +157,10 @@ export const Avatar = ({ colorVariant === 'solid' ? 'solid' : 'outlined' ) : undefined; + const iconColor = + placeholderIconColor && PlaceholderIcon && !initials && !(src && !isFailed) + ? iconColors[placeholderIconColor] + : undefined; const renderMainContent = () => { if (src && !isFailed) { @@ -210,7 +231,7 @@ export const Avatar = ({ 'tw:relative tw:inline-flex tw:shrink-0 tw:items-center tw:justify-center tw:rounded-full tw:outline-transparent', // Colored initials bring their own tinted surface (+ border for the // outlined variant); otherwise fall back to the neutral gray surface. - initialsColor ? initialsColor.container : 'tw:bg-tertiary', + initialsColor?.container ?? iconColor ?? 'tw:bg-tertiary', // Initials text color lives on the root (the span inherits it) so a // caller's `className` — applied last — can still override it. showingInitials && (initialsColor?.text ?? 'tw:text-quaternary'), diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/foundations/featured-icon/featured-icon.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/foundations/featured-icon/featured-icon.tsx index 3e6b65d19fa0..6ad1fb4b4d5a 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/foundations/featured-icon/featured-icon.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/foundations/featured-icon/featured-icon.tsx @@ -29,8 +29,9 @@ const outlineBorders: Record = { }; const styles = sortCx({ + // Dark: tinted tile (the home topic-card look), not a solid fill. light: { - base: 'tw:rounded-full tw:dark:text-fg-white', + base: 'tw:rounded-full', sizes: { sm: 'tw:size-8', md: 'tw:size-10', @@ -39,14 +40,14 @@ const styles = sortCx({ }, colors: { brand: - 'tw:bg-brand-secondary tw:text-featured-icon-light-fg-brand tw:dark:bg-brand-solid', + 'tw:bg-brand-secondary tw:text-featured-icon-light-fg-brand tw:dark:bg-utility-brand-50 tw:dark:text-utility-brand-600', error: - 'tw:bg-error-secondary tw:text-featured-icon-light-fg-error tw:dark:bg-error-solid', - gray: 'tw:bg-tertiary tw:text-featured-icon-light-fg-gray tw:dark:bg-secondary-solid', + 'tw:bg-error-secondary tw:text-featured-icon-light-fg-error tw:dark:bg-utility-error-50 tw:dark:text-utility-error-600', + gray: 'tw:bg-tertiary tw:text-featured-icon-light-fg-gray tw:dark:bg-utility-gray-50 tw:dark:text-utility-gray-600', success: - 'tw:bg-success-secondary tw:text-featured-icon-light-fg-success tw:dark:bg-success-solid', + 'tw:bg-success-secondary tw:text-featured-icon-light-fg-success tw:dark:bg-utility-success-50 tw:dark:text-utility-success-600', warning: - 'tw:bg-warning-secondary tw:text-featured-icon-light-fg-warning tw:dark:bg-warning-solid', + 'tw:bg-warning-secondary tw:text-featured-icon-light-fg-warning tw:dark:bg-utility-warning-50 tw:dark:text-utility-warning-600', }, }, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/HomeLandingPage/CriticalAnnouncementBanner.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/HomeLandingPage/CriticalAnnouncementBanner.tsx index effd19ec6625..896ddb64611a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/HomeLandingPage/CriticalAnnouncementBanner.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/HomeLandingPage/CriticalAnnouncementBanner.tsx @@ -28,10 +28,6 @@ export interface CriticalAnnouncementBannerProps { onDismiss: () => void; } -// FeaturedIcon's light theme fills solid red under `dark-mode`; the design -// tints the circle instead, the way the Critical surface reads elsewhere. -const ICON_CLASS = 'tw:dark:bg-utility-error-50 tw:dark:text-utility-error-500'; - // A global `h2` rule outranks Typography's size, so it is restated as // important to keep the heading at the banner's scale. const TITLE_CLASS = @@ -59,7 +55,6 @@ const CriticalAnnouncementBanner = ({ data-testid="critical-announcement-banner"> { + it('renders each count as a status-coloured badge', () => { + render( + + ); + + expect(screen.getByTestId('success-count')).toHaveTextContent('33'); + expect(screen.getByTestId('success-count')).toHaveClass( + 'tw:bg-utility-success-50' + ); + expect(screen.getByTestId('failed-count')).toHaveTextContent('2'); + expect(screen.getByTestId('failed-count')).toHaveClass( + 'tw:bg-utility-error-50' + ); + expect(screen.getByTestId('warning-count')).toHaveTextContent('1'); + expect(screen.getByTestId('warning-count')).toHaveClass( + 'tw:bg-utility-warning-50' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.tsx index aa95ad2f6272..d488e2ab0543 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.tsx @@ -11,13 +11,18 @@ * limitations under the License. */ -import { Box, Typography } from '@openmetadata/ui-core-components'; -import classNames from 'classnames'; +import type { BadgeColors } from '@openmetadata/ui-core-components'; +import { Badge, Box } from '@openmetadata/ui-core-components'; import { useMemo } from 'react'; import { getIngestionStatusCountData } from '../../../../../../utils/IngestionConfigUtils'; -import './ingestion-status-count.less'; import { IngestionStatusCountProps } from './IngestionStatusCount.interface'; +const STATUS_BADGE_COLOR: Record = { + success: 'success', + failed: 'error', + warning: 'warning', +}; + function IngestionStatusCount({ summary, runId, @@ -28,22 +33,17 @@ function IngestionStatusCount({ ); return ( - + {records.map((record) => ( - - {record.value} - {record.label} - + + {record.value}  + {record.label} + ))} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/ingestion-status-count.less b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/ingestion-status-count.less deleted file mode 100644 index 1569cfaf13ab..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/ingestion-status-count.less +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2025 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@import (reference) '../../../../../../styles/variables.less'; - -.ingestion-status-count { - .status-count { - height: 48px; - width: 62px; - padding: var(--om-space-6) var(--om-space-12); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - border-radius: var(--om-radius-xl); - - .record-count { - font-size: var(--om-font-size-sm); - line-height: 20px; - font-weight: var(--om-font-weight-bold); - } - - .record-label { - font-size: var(--om-font-size-8); - } - } - - .success { - background-color: @green-9; - border-color: @green-9; - .record-count, - .record-label { - color: @green-10; - } - } - - .failed { - background-color: @red-9; - border-color: @red-9; - .record-count, - .record-label { - color: @red-10; - } - } - - .warning { - background-color: @yellow-10; - border-color: @yellow-10; - .record-count, - .record-label { - color: @yellow-11; - } - } -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TabsLabel/TabsLabel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TabsLabel/TabsLabel.test.tsx index 7bc036ce19fd..3a9c16234f79 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TabsLabel/TabsLabel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TabsLabel/TabsLabel.test.tsx @@ -92,13 +92,13 @@ describe('TabsLabel component', () => { const badge = (id: string) => screen.getByTestId(id).querySelector('[data-testid="count"] > span'); - expect(badge('first')).toHaveClass('bg-primary', 'text-white'); - expect(badge('second')).toHaveClass('ant-tag'); + expect(badge('first')).toHaveClass('tw:bg-utility-brand-50'); + expect(badge('second')).toHaveClass('tw:bg-utility-gray-50'); fireEvent.click(screen.getByRole('tab', { name: /Second/ })); - expect(badge('second')).toHaveClass('bg-primary', 'text-white'); - expect(badge('first')).toHaveClass('ant-tag'); + expect(badge('second')).toHaveClass('tw:bg-utility-brand-50'); + expect(badge('first')).toHaveClass('tw:bg-utility-gray-50'); }); it('Count badge stays neutral inside a non-card tab list', () => { @@ -116,7 +116,7 @@ describe('TabsLabel component', () => { .getByTestId('first') .querySelector('[data-testid="count"] > span'); - expect(badge).not.toHaveClass('bg-primary'); - expect(badge).not.toHaveClass('ant-tag'); + expect(badge).not.toHaveClass('tw:bg-utility-brand-50'); + expect(badge).not.toHaveClass('tw:bg-utility-gray-50'); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/MyData/components/StatCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/MyData/components/StatCard.tsx index f1f540ceb7a0..4aafdb676410 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/MyData/components/StatCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/MyData/components/StatCard.tsx @@ -57,8 +57,8 @@ const StatCard: React.FC = ({ gap={2}> span { - background-color: @primary-color; - border-color: @primary-color; - color: var(--tw-color-text-white); - } } } } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityDisplayPureUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityDisplayPureUtils.test.tsx index 8a02203d77b7..b5f23d6c73e7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityDisplayPureUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityDisplayPureUtils.test.tsx @@ -16,26 +16,21 @@ import { getCountBadge } from './EntityDisplayPureUtils'; const getBadgeClassName = (badge: ReactElement): string => (badge as ReactElement<{ className: string }>).props.className; -describe('getCountBadge active class branch', () => { - it('renders the active classes when isActive is true', () => { - const className = getBadgeClassName(getCountBadge(1, '', true)); +const getBadgeColor = (badge: ReactElement): string => + (badge as ReactElement<{ color: string }>).props.color; - expect(className).toContain('bg-primary text-white no-border'); - expect(className).not.toContain('ant-tag'); +describe('getCountBadge active class branch', () => { + it('renders a brand badge when isActive is true', () => { + expect(getBadgeColor(getCountBadge(1, '', true))).toBe('brand'); }); - it('renders the ant-tag class when isActive is false', () => { - const className = getBadgeClassName(getCountBadge(1, '', false)); - - expect(className).toContain('ant-tag'); - expect(className).not.toContain('bg-primary'); + it('renders a gray badge when isActive is false', () => { + expect(getBadgeColor(getCountBadge(1, '', false))).toBe('gray'); }); - it('renders neither active nor ant-tag class when isActive is undefined', () => { + it('renders the plain bordered count when isActive is undefined', () => { const className = getBadgeClassName(getCountBadge(1)); - expect(className).not.toContain('ant-tag'); - expect(className).not.toContain('bg-primary'); expect(className).toContain('global-border'); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityDisplayPureUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityDisplayPureUtils.tsx index 54e845bb977e..5599feef41b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityDisplayPureUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityDisplayPureUtils.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Tooltip } from '@openmetadata/ui-core-components'; +import { Badge, Tooltip } from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { capitalize, isUndefined } from 'lodash'; import type { CurrentState } from 'Models'; @@ -28,14 +28,22 @@ export const getCountBadge = ( isActive?: boolean, excludeTriggerFromTabOrder?: boolean ) => { - const activeCls = isActive ? 'bg-primary text-white no-border' : 'ant-tag'; - const clsBG = isUndefined(isActive) ? '' : activeCls; + if (!isUndefined(isActive)) { + return ( + + {count} + + ); + } return ( { expect(result.placeholderIcon).toBeDefined(); }); + + it('should use the core brand tint when the entity has no color', () => { + const result = getEntityAvatarProps({ entityType: 'domain' }); + + expect(result).toMatchObject({ placeholderIconColor: 'brand' }); + expect(result).not.toHaveProperty('style'); + }); + + it('should keep a user-picked entity color as a solid fill', () => { + const result = getEntityAvatarProps({ + style: { color: '#ff0000' }, + entityType: 'domain', + }); + + expect(result).toMatchObject({ + style: { backgroundColor: '#ff0000' }, + }); + expect(result).not.toHaveProperty('placeholderIconColor'); + }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/IconUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/IconUtils.tsx index 74e243b154a5..87a5d42f833e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/IconUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/IconUtils.tsx @@ -180,12 +180,15 @@ export const getEntityAvatarProps = (entity: { const isUrl = iconURL?.startsWith('http') || iconURL?.startsWith('/'); const iconComponent = iconURL ? ICON_MAP[iconURL] : undefined; + const customColor = entity.style?.color; + return { src: isUrl ? iconURL : undefined, - className: 'tw:text-white', - style: { - backgroundColor: entity.style?.color ?? 'var(--tw-color-brand-600)', - }, + // A user-picked entity color is data, so it stays a solid fill; otherwise + // the core tinted tile. + ...(customColor + ? { className: 'tw:text-white', style: { backgroundColor: customColor } } + : { placeholderIconColor: 'brand' as const }), placeholderIcon: (isUrl ? undefined : iconComponent ?? getDefaultIconForEntityType(entity.entityType)) as FC<{ From e554ab0b0a5f8f42a4b11e61ab45bd08af0650c0 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Fri, 16 Oct 2026 12:58:19 +0530 Subject: [PATCH 02/28] Pin the Connections composer to the canvas edge and record the tinted-icon decision The Connections list footer sat inside the padded PageLayout.Content, so the AI composer stopped 8px above the canvas edge; it now mounts on the page root like Home's footer slot. --- ...ns-and-icon-avatars-stay-tinted-in-dark.md | 29 +++++++++++++++++++ .../ui/src/components/base/avatar/avatar.tsx | 7 ++++- .../IngestionStatusCount.test.tsx | 8 ++++- .../ConnectionsPage/ConnectionsPage.test.tsx | 6 +++- .../ConnectionsPage/ConnectionsPage.tsx | 22 +++++++------- 5 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 docs/decisions/2026-10-16-light-featured-icons-and-icon-avatars-stay-tinted-in-dark.md diff --git a/docs/decisions/2026-10-16-light-featured-icons-and-icon-avatars-stay-tinted-in-dark.md b/docs/decisions/2026-10-16-light-featured-icons-and-icon-avatars-stay-tinted-in-dark.md new file mode 100644 index 000000000000..2b780855e2e4 --- /dev/null +++ b/docs/decisions/2026-10-16-light-featured-icons-and-icon-avatars-stay-tinted-in-dark.md @@ -0,0 +1,29 @@ +# Light featured icons and icon avatars stay tinted in dark mode + +- **Status:** Accepted +- **Revisions:** v1 2026-10-16 (initial) +- **Deciders:** UI team +- **Guard:** `avatar.test.tsx` (core), `IconUtils.test.tsx`; `reviewer` for `featured-icon.tsx` +- **Related:** open-metadata/openmetadata-collate#7533, ADR:2026-10-12-dark-mode-targets-ai-mode-core-surfaces-only + +## Context +`FeaturedIcon` with `theme="light"` switched to a solid fill (`bg-*-solid`, white glyph) under +`.dark-mode`, inherited from the UntitledUI port (#29412). The AI-mode Home topic cards draw a +tinted tile instead (`bg-utility--50` + `text-utility--600`), so AI Automations, Data +Marketplace and other pages read as heavier, louder blocks next to Home. Callers patched it +locally (`CriticalAnnouncementBanner`, `StatCard`), and entity avatars forced a solid +`brand-600` fill from the caller through `getEntityAvatarProps`. + +## Decision +- `FeaturedIcon` `theme="light"` keeps a tinted tile in dark: `tw:dark:bg-utility--50 + tw:dark:text-utility--600`. Light mode is unchanged. +- Core `Avatar` gains `placeholderIconColor` (`brand | gray | success | warning | error`), which + tints the surface behind `placeholderIcon` with the same `utility--50 / -600` pair. Unset keeps + the neutral `bg-tertiary` surface. +- `getEntityAvatarProps` passes `placeholderIconColor: 'brand'` when the entity has no color of its + own, and keeps a user-picked `style.color` as a solid fill (that color is entity data). + +## Consequences +Tinted icon tiles come from the core components, so callers stop overriding colors to match Home. +A page that wants a solid tile in dark must pick a solid theme (`dark`, `modern`) rather than +`light`. Revisit only if design moves Home itself back to solid tiles. diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.tsx index a2146e21fdec..7a9d283698c3 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar.tsx @@ -6,7 +6,12 @@ import { getAvatarColorClasses } from './utils'; type AvatarSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; -export type AvatarIconColor = 'brand' | 'gray' | 'success' | 'warning' | 'error'; +export type AvatarIconColor = + | 'brand' + | 'gray' + | 'success' + | 'warning' + | 'error'; const iconColors: Record = { brand: 'tw:bg-utility-brand-50 tw:text-utility-brand-600', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.test.tsx index 5acdc6555e48..a259edde30a8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionStatusCount/IngestionStatusCount.test.tsx @@ -18,7 +18,13 @@ describe('IngestionStatusCount', () => { render( ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.test.tsx index 7eb07c9bc36b..ae016ea17c70 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.test.tsx @@ -119,7 +119,7 @@ jest.mock('@openmetadata/ui-core-components', () => ({ ), Content: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), } ), @@ -399,6 +399,10 @@ describe('ConnectionsPage', () => { render(); expect(screen.getByTestId('connections-footer-slot')).toBeInTheDocument(); + // Outside the padded Content, so it pins to the canvas edge like Home's. + expect(screen.getByTestId('page-layout-content')).not.toContainElement( + screen.getByTestId('connections-footer-slot') + ); }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.tsx index 22fbc57af571..3f8798728516 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.tsx @@ -232,17 +232,19 @@ const ConnectionsPage: React.FC = () => { onCategoryChange={handleCategoryChange} /> - - {footerContributions.length > 0 && ( -
- {footerContributions.map((contribution) => ( - - ))} -
- )} + + {/* Pinned to the page canvas, not the padded Content, so the composer meets the canvas + edge the way Home's footer slot does. */} + {footerContributions.length > 0 && ( +
+ {footerContributions.map((contribution) => ( + + ))} +
+ )} ); }; From ceecf801cdaf6f3b755d5091ff10bc09c4c52962 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Fri, 16 Oct 2026 13:16:00 +0530 Subject: [PATCH 03/28] Mark Classic mode as deprecated in the mode switchers and the admin setting Classic shows a Deprecated badge with a retirement notice and release-notes link only where a user changes mode (AI-shell switcher, classic profile dropdown) and on the admin Default App Mode setting. --- .../AppModeSwitcher/AppModeSwitcher.test.tsx | 29 +++++++ .../AppModeSwitcher/AppModeSwitcher.tsx | 75 ++++++++++-------- .../ClassicDeprecationNotice.tsx | 77 +++++++++++++++++++ .../InterfaceModeMenuItem.test.tsx | 11 +++ .../AppModeSwitcher/InterfaceModeMenuItem.tsx | 31 +++++--- .../AppModeSettings.test.tsx | 2 +- .../platform-settings/AppModeSettingsForm.tsx | 17 +++- .../ui/src/constants/appMode.constants.ts | 4 + .../ui/src/locale/languages/ar-sa.json | 4 + .../ui/src/locale/languages/de-de.json | 4 + .../ui/src/locale/languages/en-us.json | 4 + .../ui/src/locale/languages/es-es.json | 4 + .../ui/src/locale/languages/fr-fr.json | 4 + .../ui/src/locale/languages/gl-es.json | 4 + .../ui/src/locale/languages/he-he.json | 4 + .../ui/src/locale/languages/ja-jp.json | 4 + .../ui/src/locale/languages/ko-kr.json | 4 + .../ui/src/locale/languages/mr-in.json | 4 + .../ui/src/locale/languages/nl-nl.json | 4 + .../ui/src/locale/languages/pr-pr.json | 4 + .../ui/src/locale/languages/pt-br.json | 4 + .../ui/src/locale/languages/pt-pt.json | 4 + .../ui/src/locale/languages/ru-ru.json | 4 + .../ui/src/locale/languages/sv-se.json | 4 + .../ui/src/locale/languages/th-th.json | 4 + .../ui/src/locale/languages/tr-tr.json | 4 + .../ui/src/locale/languages/zh-cn.json | 4 + .../ui/src/locale/languages/zh-tw.json | 4 + .../DefaultAppModePage.test.tsx | 13 ++++ .../DefaultAppModePage/DefaultAppModePage.tsx | 17 +++- 30 files changed, 312 insertions(+), 44 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/ClassicDeprecationNotice.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/AppModeSwitcher.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/AppModeSwitcher.test.tsx index e79e82f73da7..c115d511369a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/AppModeSwitcher.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/AppModeSwitcher.test.tsx @@ -59,6 +59,16 @@ jest.mock('react-i18next', () => ({ })); jest.mock('@openmetadata/ui-core-components', () => ({ + Badge: ({ + children, + ...props + }: React.PropsWithChildren>) => + React.createElement('span', props, children), + HoverCard: ({ + children, + content, + }: React.PropsWithChildren<{ content: React.ReactNode }>) => + React.createElement(React.Fragment, null, children, content), Box: ({ children, ...props @@ -100,6 +110,7 @@ jest.mock('../../hooks/currentUserStore/useCurrentUserStore', () => ({ jest.mock('../../constants/appMode.constants', () => ({ AI_APP_MODE: 'ai', DEFAULT_APP_MODE: 'default', + CLASSIC_MODE_RELEASE_NOTES_URL: 'https://docs.example/releases', })); const getTrigger = () => screen.getByTestId('app-mode-switcher-trigger'); @@ -220,4 +231,22 @@ describe('AppModeSwitcher', () => { expect(container).not.toBeEmptyDOMElement(); expect(getTrigger()).toBeInTheDocument(); }); + + it('marks Classic as deprecated inside the switcher only', () => { + render(); + + expect( + screen.queryByTestId('classic-deprecated-badge') + ).not.toBeInTheDocument(); + + fireEvent.click(getTrigger()); + + expect(screen.getByTestId('classic-deprecated-badge')).toHaveTextContent( + 'label.deprecated' + ); + expect(screen.getByTestId('classic-deprecation-card')).toBeInTheDocument(); + expect( + screen.getByTestId('classic-deprecation-release-notes') + ).toHaveAttribute('href', 'https://docs.example/releases'); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/AppModeSwitcher.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/AppModeSwitcher.tsx index 69af2f9d9705..71a39a8de615 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/AppModeSwitcher.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppModeSwitcher/AppModeSwitcher.tsx @@ -11,7 +11,13 @@ * limitations under the License. */ -import { Box, Popover, Typography } from '@openmetadata/ui-core-components'; +import { + Badge, + Box, + HoverCard, + Popover, + Typography, +} from '@openmetadata/ui-core-components'; import { Check, ChevronUp } from '@openmetadata/ui-core-components/icons'; import classNames from 'classnames'; import React, { useRef, useState } from 'react'; @@ -30,6 +36,7 @@ import { useIsAiMode, writeAppMode, } from '../../hooks/useAppMode'; +import { ClassicDeprecationCard } from './ClassicDeprecationNotice'; const OPTION_ICON_BOX = 'tw:w-9 tw:h-9 tw:rounded-[10px] tw:bg-utility-blue-50 tw:border tw:border-utility-blue-100 tw:shrink-0'; @@ -183,35 +190,43 @@ const AppModeSwitcher: React.FC<{ {t('label.switch-interface')}

- + } placement="right"> + + + } placement="left"> + + + status={dataContract.latestResult.status} + /> ); } return null; - }, [dataContract, customizedPage?.tabs, entityType, dataAsset, navigate, t]); + }, [dataContract, customizedPage?.tabs, entityType, dataAsset]); const triggerTheAutoPilotApplication = useCallback(async () => { try { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx index 4f62c31784f4..8b3b0ff13db4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx @@ -1274,12 +1274,15 @@ describe('DataAssetsHeader component', () => { const button = screen.getByTestId('data-contract-latest-result-btn'); - expect(button).toBeInTheDocument(); - expect(button).toHaveClass('data-contract-latest-result-button'); - expect(button).toHaveClass('failed'); + expect(button).toHaveTextContent('label.entity-failed'); + // Core Badge error palette: meets contrast in both themes. + expect(button).toHaveClass( + 'tw:bg-utility-error-50', + 'tw:text-utility-error-700' + ); }); - it('should tint the aborted contract button via legacy tokens that flip in dark', async () => { + it('should tint the aborted contract badge with the orange badge palette', async () => { mockUseCustomPages.mockReturnValue({ customizedPage: { tabs: [{ id: EntityTabs.CONTRACT }] }, }); @@ -1296,10 +1299,8 @@ describe('DataAssetsHeader component', () => { }); expect(screen.getByTestId('data-contract-latest-result-btn')).toHaveClass( - 'tw:text-(--om-legacy-color-b93815)!', - 'tw:bg-(--om-legacy-color-fef6ee)!', - 'tw:dark:text-utility-orange-700!', - 'tw:dark:bg-utility-orange-50!' + 'tw:bg-utility-orange-50', + 'tw:text-utility-orange-700' ); }); @@ -1323,13 +1324,15 @@ describe('DataAssetsHeader component', () => { ).toBeInTheDocument(); }); - it('should navigate to contract tab when button is clicked', async () => { + it('should link to the contract tab', async () => { mockUseCustomPages.mockReturnValue({ customizedPage: { tabs: [{ id: EntityTabs.CONTRACT }], }, }); + mockGetEntityDetailsPath.mockReturnValue('/contract-tab'); + const mockDataContract = { ...MOCK_DATA_CONTRACT, latestResult: { @@ -1345,15 +1348,14 @@ describe('DataAssetsHeader component', () => { render(); }); - const button = screen.getByTestId('data-contract-latest-result-btn'); - fireEvent.click(button); - expect(mockGetEntityDetailsPath).toHaveBeenCalledWith( EntityType.CONTAINER, 'fullyQualifiedName', EntityTabs.CONTRACT ); - expect(mockNavigate).toHaveBeenCalled(); + expect( + screen.getByTestId('data-contract-latest-result-btn') + ).toHaveAttribute('href', '/contract-tab'); }); it('should not render data contract button when contract tab is not visible', async () => { @@ -1470,7 +1472,9 @@ describe('DataAssetsHeader component', () => { const button = screen.getByTestId('data-contract-latest-result-btn'); - expect(button.querySelector('[data-icon="leading"]')).toBeInTheDocument(); + expect( + within(button).getByTestId('contract-status-icon') + ).toBeInTheDocument(); }); it('should render button without icon when getDataContractStatusIcon returns null', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractLatestResultBadge/ContractLatestResultBadge.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractLatestResultBadge/ContractLatestResultBadge.tsx new file mode 100644 index 000000000000..764311f63d62 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractLatestResultBadge/ContractLatestResultBadge.tsx @@ -0,0 +1,47 @@ +/* + * 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 { Badge } from '@openmetadata/ui-core-components'; +import { toLower } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { CONTRACT_RESULT_BADGE_COLOR } from '../../../constants/DataContract.constants'; +import { ContractExecutionStatus } from '../../../generated/type/contractExecutionStatus'; +import { getDataContractStatusIcon } from '../../../utils/DataContract/DataContractUtils'; + +interface ContractLatestResultBadgeProps { + status: ContractExecutionStatus; + /** Contract tab of the asset; the pill is a link so it navigates like one. */ + href: string; +} + +/** Header pill for a failed, aborted or running contract, linking to its Contract tab. */ +const ContractLatestResultBadge = ({ + status, + href, +}: ContractLatestResultBadgeProps) => { + const { t } = useTranslation(); + const StatusIcon = getDataContractStatusIcon(status); + + return ( + + {StatusIcon && } + {t(`label.entity-${toLower(status)}`, { entity: t('label.contract') })} + + ); +}; + +export default ContractLatestResultBadge; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.test.tsx index c07e74b01009..00e8669ef26c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.test.tsx @@ -440,26 +440,23 @@ describe('DataProductsDetailsPage — contract latest-result button', () => { setMockPermissions(ENTITY_PERMISSIONS); }); - it('renders the aborted state with its status tint and opens the contract tab', async () => { + it('renders the aborted state as an orange badge linking to the contract tab', async () => { (getContractByEntityId as jest.Mock).mockResolvedValueOnce({ latestResult: { status: ContractExecutionStatus.Aborted }, }); render(); - const button = await screen.findByTestId('data-contract-latest-result-btn'); + const badge = await screen.findByTestId('data-contract-latest-result-btn'); - expect(button.tagName).toBe('BUTTON'); - expect(button).toHaveClass( - 'tw:bg-(--om-legacy-color-fef6ee)!', - 'tw:after:outline-(--om-legacy-color-f9dbaf)!' + expect(badge.tagName).toBe('A'); + expect(badge).toHaveClass( + 'tw:bg-utility-orange-50', + 'tw:text-utility-orange-700' ); - - fireEvent.click(button); - - expect((useNavigate as jest.Mock)()).toHaveBeenCalledWith( - '/data-product/test.dataproduct/contract', - { replace: true } + expect(badge).toHaveAttribute( + 'href', + '/data-product/test.dataproduct/contract' ); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index 2eac6d27ff3a..c5d344353720 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -16,8 +16,7 @@ import { RefreshCcw01, } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; -import classNames from 'classnames'; -import { toLower, toString } from 'lodash'; +import { toString } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -27,7 +26,6 @@ import { ReactComponent as ExportIcon } from '../../../assets/svg/ic-export.svg' import { ReactComponent as ImportIcon } from '../../../assets/svg/ic-import.svg'; import { ReactComponent as StyleIcon } from '../../../assets/svg/style.svg'; import { ROUTES } from '../../../constants/constants'; -import { CONTRACT_RESULT_BUTTON_CLASS } from '../../../constants/DataContract.constants'; import { FEED_COUNT_INITIAL_DATA } from '../../../constants/entity.constants'; import { EntityField } from '../../../constants/Feeds.constants'; import { LEARNING_PAGE_IDS } from '../../../constants/Learning.constants'; @@ -75,7 +73,7 @@ import { getRenderedActiveTab, getTabLabelMapFromTabs, } from '../../../utils/CustomizePage/CustomizePageEntityTabUtils'; -import { getDataContractStatusIcon } from '../../../utils/DataContract/DataContractUtils'; +import ContractLatestResultBadge from '../../DataContract/ContractLatestResultBadge/ContractLatestResultBadge'; import dataProductClassBase from '../../../utils/DataProduct/DataProductClassBase'; import { getQueryFilterToIncludeDomain } from '../../../utils/DomainFilterUtils'; import { getEntityName } from '../../../utils/EntityNameUtils'; @@ -139,20 +137,6 @@ type TranslateFn = (key: string, options?: Record) => string; // 12px padding + the 1px antd border that the core ::after outline no longer // takes; not-last:-mr-px keeps antd's `.ant-btn + .ant-btn` 1px overlap with // the next group button. -const CONTRACT_RESULT_BUTTON_BASE_CLASS = [ - 'tw:h-10 tw:gap-2 tw:rounded-xl tw:px-3.25 tw:py-1.5 tw:text-sm tw:font-semibold tw:not-last:-mr-px', - 'tw:shadow-[0px_2px_2px_-1px_var(--om-legacy-color-10-13-18-0-04),0px_4px_6px_-2px_var(--om-legacy-color-10-13-18-0-04),0px_12px_16px_-4px_var(--om-legacy-color-10-13-18-0-04)]!', -].join(' '); - -const CONTRACT_RESULT_BUTTON_BORDER_CLASS: Partial< - Record -> = { - [ContractExecutionStatus.Failed]: 'tw:after:outline-utility-error-200!', - [ContractExecutionStatus.Aborted]: - 'tw:after:outline-(--om-legacy-color-f9dbaf)! tw:dark:after:outline-utility-orange-200!', - [ContractExecutionStatus.Running]: 'tw:after:outline-utility-brand-200!', -}; - // Extracted from DataProductsDetailsPage's render to keep the component's // complexity down. Builds the "Manage" dropdown menu content from the // permission flags and the modal/action toggles the page owns. @@ -958,37 +942,16 @@ const DataProductsDetailsPage = ({ ContractExecutionStatus.Running, ].includes(dataContract.latestResult.status) ) { - const StatusIcon = getDataContractStatusIcon( - dataContract.latestResult.status - ); - return ( - + ); } return null; - }, [dataContract]); + }, [dataContract, dataProductFqn]); const statusBadge = useMemo(() => { const shouldShowStatus = entityUtilClassBase.shouldShowEntityStatus( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TabsLabel/tabs-label.less b/openmetadata-ui/src/main/resources/ui/src/components/common/TabsLabel/tabs-label.less index a9a1b7162c40..b205c16d6641 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TabsLabel/tabs-label.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TabsLabel/tabs-label.less @@ -20,19 +20,4 @@ color: @text-grey-muted; line-height: 12px; } - .service-beta-tag { - position: absolute; - top: 3px; - right: -22px; - - sup { - height: 14px; - padding: var(--om-space-2) var(--om-space-4); - font-size: var(--om-font-size-8); - font-weight: var(--om-font-weight-medium); - display: flex; - justify-content: center; - align-items: center; - } - } } diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts index d3051e76f880..8f53dae8b07f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts @@ -11,6 +11,7 @@ * limitations under the License. */ +import type { BadgeColors } from '@openmetadata/ui-core-components'; import type { ChartStatus } from '@openmetadata/ui-core-components/charts'; import { EntityReferenceFields } from '../enums/AdvancedSearch.enum'; import { EntityType } from '../enums/entity.enum'; @@ -121,17 +122,12 @@ export const MAX_LATENCY_UNITS = ['minute', 'hour', 'day']; export const REFRESH_FREQUENCY_UNITS = ['hour', 'day', 'week', 'month', 'year']; export const RETENTION_UNITS = ['day', 'week', 'month', 'year']; -// Latest-result button tints. `!` beats the secondary core Button's own -// bg/text/hover utilities. Aborted's light hexes (legacy @orange-7/@orange-50) -// have no upstream palette step, so they come from legacy tokens and flip to -// the utility-orange pair in dark. -export const CONTRACT_RESULT_BUTTON_CLASS: Partial< - Record +// Header pill for the latest contract run, drawn with the core Badge palette +// (utility-*-50 fill, -700 text, -200 outline) so it meets contrast in both themes. +export const CONTRACT_RESULT_BADGE_COLOR: Partial< + Record > = { - [ContractExecutionStatus.Failed]: - 'tw:text-utility-error-600! tw:bg-utility-error-50!', - [ContractExecutionStatus.Aborted]: - 'tw:text-(--om-legacy-color-b93815)! tw:bg-(--om-legacy-color-fef6ee)! tw:dark:text-utility-orange-700! tw:dark:bg-utility-orange-50!', - [ContractExecutionStatus.Running]: - 'tw:text-utility-brand-700! tw:bg-utility-brand-50!', + [ContractExecutionStatus.Failed]: 'error', + [ContractExecutionStatus.Aborted]: 'orange', + [ContractExecutionStatus.Running]: 'brand', }; From eafd3caf5821347da3dd18cdac788a7f431e63ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 08:50:56 +0000 Subject: [PATCH 09/28] chore(playwright): auto-refresh impact-map.generated.json --- .github/playwright/impact-map.generated.json | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index 94a6b8029489..a91eb32f661e 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -6112,8 +6112,6 @@ "playwright/e2e/Features/StorageMetadataAgentForm.spec.ts", "playwright/e2e/Features/Table.spec.ts", "playwright/e2e/Features/TierWidget.spec.ts", - "playwright/e2e/Pages/DataContracts.spec.ts", - "playwright/e2e/Pages/DataContractsSemanticRules.spec.ts", "playwright/e2e/Pages/ExplorePageRightPanel_KnowledgeCenter.spec.ts", "playwright/e2e/Pages/TableCreationTime.spec.ts" ] @@ -6182,6 +6180,15 @@ "playwright/e2e/Pages/ODCSImportExportPermissions.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractLatestResultBadge/ContractLatestResultBadge.tsx" + ], + "specs": [ + "playwright/e2e/Pages/DataContracts.spec.ts", + "playwright/e2e/Pages/DataContractsSemanticRules.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractQualityFormTab/ContractQualityFormTab.tsx" @@ -6369,8 +6376,6 @@ ], "specs": [ "playwright/e2e/Features/DataAssetRulesEnabled.spec.ts", - "playwright/e2e/Pages/DataContracts.spec.ts", - "playwright/e2e/Pages/DataContractsSemanticRules.spec.ts", "playwright/e2e/Pages/DataProductAndSubdomains.spec.ts", "playwright/e2e/Pages/DomainAdvanced.spec.ts" ] From e7bda4048ea3ac709ba0a27e71275dd45fa4ae98 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Fri, 16 Oct 2026 14:28:10 +0530 Subject: [PATCH 10/28] Keep the empty test-status chart inside its card The no-data footer sits in a core Tooltip whose trigger is w-max, so the placeholder SVG resolved its 100% width against max-content (313px) and spilled over the neighbouring cards. The trigger now takes the card width. Also tidies imports left by the contract badge change. --- .../DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx | 2 +- .../DataProductsDetailsPage.component.test.tsx | 3 +-- .../DataProductsDetailsPage.component.tsx | 2 +- .../TestCaseStatusAreaChartWidget.component.tsx | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx index 84f542849fa1..6a9fe131f96e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx @@ -66,7 +66,6 @@ import { Table } from '../../../generated/entity/data/table'; import { Operation } from '../../../generated/entity/policies/policy'; import { EntityReference } from '../../../generated/type/entityReference'; import { Style } from '../../../generated/type/schema'; -import ContractLatestResultBadge from '../../DataContract/ContractLatestResultBadge/ContractLatestResultBadge'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useClipboard } from '../../../hooks/useClipBoard'; import { useCustomPages } from '../../../hooks/useCustomPages'; @@ -110,6 +109,7 @@ import { getGlossaryHomeCrumb } from '../../common/HeaderBreadcrumb/HeaderBreadc import { EditIconButton } from '../../common/IconButtons/EditIconButton'; import TitleBreadcrumbSkeleton from '../../common/Skeleton/BreadCrumb/TitleBreadcrumbSkeleton.component'; import RetentionPeriod from '../../Database/RetentionPeriod/RetentionPeriod.component'; +import ContractLatestResultBadge from '../../DataContract/ContractLatestResultBadge/ContractLatestResultBadge'; import { EntityStatusBadge } from '../../Entity/EntityStatusBadge/EntityStatusBadge.component'; import { LearningIcon } from '../../Learning/LearningIcon/LearningIcon.component'; import IconColorModal from '../../Modals/IconColorModal/IconColorModal'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.test.tsx index 00e8669ef26c..9a65d66ed3f3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.test.tsx @@ -11,8 +11,7 @@ * limitations under the License. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { useNavigate } from 'react-router-dom'; +import { render, screen, waitFor } from '@testing-library/react'; import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { ResourceEntity } from '../../../enums/permissions.enum'; import { DataProduct } from '../../../generated/entity/domains/dataProduct'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index c5d344353720..32344c458c64 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -73,7 +73,6 @@ import { getRenderedActiveTab, getTabLabelMapFromTabs, } from '../../../utils/CustomizePage/CustomizePageEntityTabUtils'; -import ContractLatestResultBadge from '../../DataContract/ContractLatestResultBadge/ContractLatestResultBadge'; import dataProductClassBase from '../../../utils/DataProduct/DataProductClassBase'; import { getQueryFilterToIncludeDomain } from '../../../utils/DomainFilterUtils'; import { getEntityName } from '../../../utils/EntityNameUtils'; @@ -113,6 +112,7 @@ import { ManageButtonItemLabel } from '../../common/ManageButtonContentItem/Mana import { GenericProvider } from '../../Customization/GenericProvider/GenericProvider'; import { AssetSelectionDrawer } from '../../DataAssets/AssetsSelectionModal/AssetSelectionDrawer'; import { StatItem } from '../../DataAssets/DataAssetsHeader/StatItem.component'; +import ContractLatestResultBadge from '../../DataContract/ContractLatestResultBadge/ContractLatestResultBadge'; import { EntityHeader } from '../../Entity/EntityHeader/EntityHeader.component'; import { EntityStatusBadge } from '../../Entity/EntityStatusBadge/EntityStatusBadge.component'; import Voting from '../../Entity/Voting/Voting.component'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/ChartWidgets/TestCaseStatusAreaChartWidget/TestCaseStatusAreaChartWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/ChartWidgets/TestCaseStatusAreaChartWidget/TestCaseStatusAreaChartWidget.component.tsx index 48cf56922a60..ee352376f3b2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/ChartWidgets/TestCaseStatusAreaChartWidget/TestCaseStatusAreaChartWidget.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/ChartWidgets/TestCaseStatusAreaChartWidget/TestCaseStatusAreaChartWidget.component.tsx @@ -119,7 +119,7 @@ const TestCaseStatusAreaChartWidget = ({ {showEmptyFooter ? ( - +
{footerWhenEmpty}
From 89671699fd62a55a560e1696e6235928c06058d2 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Fri, 16 Oct 2026 16:56:35 +0530 Subject: [PATCH 11/28] Address review: keep HoverCard open on focus, keep DP contract link on version HoverCard: focus moving from the trigger into the card no longer schedules a close, so a link inside it (the Classic deprecation release-notes link) stays usable from the keyboard. Data product header: the contract result badge keeps the selected version in version history, as the previous handleTabChange navigation did. --- .../application/hover-card/hover-card.test.tsx | 18 ++++++++++++++++++ .../application/hover-card/hover-card.tsx | 8 +++++++- .../DataProductsDetailsPage.component.test.tsx | 14 ++++++++++++++ .../DataProductsDetailsPage.component.tsx | 13 +++++++++++-- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/hover-card/hover-card.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/hover-card/hover-card.test.tsx index a7e9836bebe8..aef0898c5d5e 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/hover-card/hover-card.test.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/hover-card/hover-card.test.tsx @@ -89,6 +89,24 @@ describe('HoverCard', () => { expect(screen.queryByText('Profile')).toBeNull(); }); + it('stays open while keyboard focus moves from the trigger into the card', () => { + renderCard({ openDelay: 0, closeDelay: 200 }); + + fireEvent.keyDown(document.body, { key: 'Tab' }); + act(() => screen.getByText('Jane').focus()); + advance(0); + + act(() => screen.getByText('Profile').focus()); + advance(500); + + expect(screen.getByText('Profile')).toBeInTheDocument(); + + act(() => screen.getByText('Profile').blur()); + advance(500); + + expect(screen.queryByText('Profile')).toBeNull(); + }); + it('never opens when disabled', () => { renderCard({ isDisabled: true, openDelay: 0 }); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/hover-card/hover-card.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/hover-card/hover-card.tsx index 8683b73495ad..17c9b816d336 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/hover-card/hover-card.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/hover-card/hover-card.tsx @@ -92,6 +92,12 @@ export const HoverCard = ({ onHoverStart: () => schedule(true), onHoverEnd: () => schedule(false), }); + // Focus moving from the trigger into the card (e.g. to a link inside it) must + // keep the card open; otherwise the trigger's blur closes it under the user. + const { focusWithinProps: cardFocusWithinProps } = useFocusWithin({ + isDisabled, + onFocusWithinChange: (isFocused) => schedule(isFocused), + }); // Only keyboard focus opens the card; a mouse click that focuses the trigger // is already covered by hover. const { focusWithinProps } = useFocusWithin({ @@ -121,7 +127,7 @@ export const HoverCard = ({ triggerRef={triggerRef} onOpenChange={setIsOpen}>
{ ); }); + it('keeps the selected version when linking to the contract tab from version history', async () => { + (getContractByEntityId as jest.Mock).mockResolvedValueOnce({ + latestResult: { status: ContractExecutionStatus.Failed }, + }); + + render(); + + const badge = await screen.findByTestId('data-contract-latest-result-btn'); + const href = badge.getAttribute('href') ?? ''; + + expect(href).toContain('/versions/0.1'); + expect(href.endsWith('/contract')).toBe(true); + }); + it('does not render the button for a passing contract', async () => { (getContractByEntityId as jest.Mock).mockResolvedValueOnce({ latestResult: { status: ContractExecutionStatus.Success }, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index 32344c458c64..1deb8cf594e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -944,14 +944,23 @@ const DataProductsDetailsPage = ({ ) { return ( ); } return null; - }, [dataContract, dataProductFqn]); + }, [dataContract, dataProductFqn, dataProduct.version, isVersionsView]); const statusBadge = useMemo(() => { const shouldShowStatus = entityUtilClassBase.shouldShowEntityStatus( From 513169e3f08f306d70753ed384306f82bec93951 Mon Sep 17 00:00:00 2001 From: Rohit Jain <60229265+Rohit0301@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:05:48 +0000 Subject: [PATCH 12/28] fix(ui): converge Archive badge count after an async hard delete (#35446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): converge Archive badge count after an async hard delete A drive file's hard delete answers 202 Accepted and removes the row on a background thread. ContextCenterArchivePage invalidated the archive-count query the moment that request resolved, so the refetch raced the background deletion. When the refetch won, React Query cached the pre-delete total and nothing invalidated the key again, leaving the sidebar badge one too high for the rest of the session. Replace the single fire-and-forget invalidation with reconcileCountAfterAsyncDelete: drop the count optimistically (matching what the page already does to its own list), then refetch until the server agrees or the attempts run out (5 x 1s). Uses refetchQueries rather than invalidateQueries — invalidation only marks an *inactive* query stale without fetching it, so the reconcile would silently no-op whenever the sidebar observing the key is unmounted. On timeout the corrected figure is written back: the last refetch leaves the server's pre-delete total in the cache, and keeping that would restore the staleness this fixes. Deletions still in flight are counted per key so a concurrent delete derives its target from the server's number rather than a cached value a sibling's refetch may have just overwritten. Co-Authored-By: Claude Opus 5 * fix(ui): poll the deleted row's absence to converge the Archive badge The reconcile targeted `baseline - started`, a count frozen when the first delete started. Anything else moving the archive count inside the ~5s window threw it off, and the page's own restore handler invalidates that very key: a restore mid-window let the poll see its target early and exit, leaving the badge one too high once the background delete landed — the bug this helper exists to fix. A soft-delete in the window broke it the other way, with `clampedTotal` capping the badge one too low. Use the deleted entity's own absence as the completion signal instead: poll `getContextFileById` until it stops resolving, then refetch once and keep whatever the server reports. No precomputed target, so a concurrent restore or soft-delete cannot skew it, and the baseline/in-flight bookkeeping and the clamp all go away with it. Co-Authored-By: Claude Opus 5 * fix(ui): probe the archive delete with Include.All and only treat 404 as landed The absence probe called getContextFileById without an include param, but GET /contextCenter/drive/files/{id} defaults to include=non-deleted. Every row on the Archive page is already soft-deleted (the list is loaded with include=deleted), so the probe 404'd before the background hard delete even started. The rejection handler mapped any error to "landed", so the first poll ended the wait: the race this fixes was still there, only pushed back ~1s. Let getContextFileById take an include argument and pass Include.All, so the probe resolves true only once the row is genuinely purged. Narrow the predicate to a 404 as well, leaving a transient error to the next attempt rather than ending the poll early. The helper's unit tests stub hasLanded, so neither gap was visible to them: cover the include wiring in a new assetAPI test and the transient-error case in the helper's own suite. Co-Authored-By: Claude Opus 5 * lint fix * fix(ui): mark the archive reconcile float with void Sonar flagged the unawaited reconcileCountAfterAsyncDelete call. The float is deliberate — awaiting it would hold `isDeleting` through the ~5s poll and spin the modal button — so `void` says so explicitly. Nothing escapes: the probe handles both branches and refetchQueries resolves rather than throws. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...-badge-counts-reconcile-by-bounded-poll.md | 56 ++++++ .../ContextCenterArchivePage.test.tsx | 1 + .../ContextCenterArchivePage.tsx | 24 ++- .../resources/ui/src/rest/assetAPI.test.ts | 48 +++++ .../main/resources/ui/src/rest/assetAPI.ts | 8 +- .../src/utils/ContextCenterQueryKeys.test.ts | 167 ++++++++++++++++++ .../ui/src/utils/ContextCenterQueryKeys.ts | 34 ++++ 7 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 docs/decisions/2026-10-14-context-center-badge-counts-reconcile-by-bounded-poll.md create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.test.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.test.ts diff --git a/docs/decisions/2026-10-14-context-center-badge-counts-reconcile-by-bounded-poll.md b/docs/decisions/2026-10-14-context-center-badge-counts-reconcile-by-bounded-poll.md new file mode 100644 index 000000000000..acd0ca68aaa0 --- /dev/null +++ b/docs/decisions/2026-10-14-context-center-badge-counts-reconcile-by-bounded-poll.md @@ -0,0 +1,56 @@ +# Context Center badge counts reconcile a 202 async delete by bounded poll + +- **Status:** Accepted +- **Revisions:** v1 2026-10-14 (initial) +- **Deciders:** @Rohit0301 +- **Guard:** `openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.test.ts` — + eight cases pin the optimistic drop, the early exit, the retry budget, the timeout behaviour and + the concurrent-delete arithmetic (including a server that applies only some of several + concurrent deletes) +- **Related:** #35446 + +## Context + +A drive file's hard delete answers **202 Accepted** and removes the row on a background thread +(`ContextFileResource.delete` → `deleteByIdAsync`). `ContextCenterArchivePage` invalidated the +archive-count query the instant that request resolved, so the refetch raced the background +deletion. When the refetch won, React Query cached the pre-delete total and nothing invalidated +the key again (`staleTime: 0`, but no further trigger exists), leaving the sidebar badge one too +high until a page reload. + +The backend does emit a completion signal on `DELETE_ENTITY_CHANNEL`, but today it is consumed +only by `NavBar` → `AsyncDeleteProvider`, which does not drive this page's query keys. Wiring a +new socket subscription into the Archive page would mean a payload-to-query-key mapping and +teardown handling for a single call site — the only caller that passes `hardDelete=true`. + +## Decision + +`reconcileCountAfterAsyncDelete` polls the **deleted entity's own absence** — +`getContextFileById` until it stops resolving — for **`attempts = 5` × `intervalMs = 1000` +(~5s)**, then refetches the count once and keeps whatever the server reports. + +Three choices inside that are easy to undo by accident: + +- **`refetchQueries`, not `invalidateQueries`.** Invalidation only marks an *inactive* query stale + without fetching it, so the reconcile would silently no-op whenever the sidebar observing the + key is unmounted — the exact staleness this exists to prevent. +- **The budget is sized against the backend, not a happy path.** An earlier ~2s window (5 × 400ms) + let a parallel Playwright run slip past it and leave the badge stale for good. Early exit means + a fast delete still settles on the first poll, so the ceiling costs nothing when unneeded. +- **The completion signal is the row's absence, never a precomputed count.** An earlier version + targeted `baseline - started` — the count cached when the first delete started, minus the + deletes accepted since. Anything else moving the archive count inside the window skewed it, and + this page's own restore handler invalidates that very key: a restore mid-window let the poll + meet its target early and exit, pinning the badge one too **high** once the background delete + landed. A soft-delete in the window broke it the other way, with the clamp holding the badge one + too **low**. Asking whether *this* row is gone cannot be confused by a concurrent change, and + leaving the final number to the server removes the baseline bookkeeping and the clamp entirely. + +## Consequences + +If the background delete outlives the budget the badge takes the server's current figure, which is +simply the count before that deletion lands. It is a transient over-count rather than the permanent +staleness this fixes: the key is refetched by any later create, delete or restore on the page. + +Switch to the `DELETE_ENTITY_CHANNEL` socket event once a second Context Center surface needs the +same polling; at that point the subscription cost is amortised and the budget stops being a guess. diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.test.tsx index d8fa8861d4ef..1877f217ce84 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.test.tsx @@ -42,6 +42,7 @@ jest.mock('../../../rest/assetAPI', () => ({ }), deleteDriveFile: jest.fn(), restoreDriveFile: jest.fn(), + getContextFileById: jest.fn(), })); jest.mock('../../../utils/ContextCenterClassBase', () => ({ diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx index 557304fcef5f..bcdbb9f4b245 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx @@ -26,11 +26,13 @@ import { ARCHIVE_PAGE_SIZE } from '../../../constants/ContextCenter.constants'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { ResourceEntity } from '../../../enums/permissions.enum'; +import { Include } from '../../../generated/type/include'; import { usePaging } from '../../../hooks/paging/usePaging'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { queryClient } from '../../../queryClient'; import { deleteDriveFile, + getContextFileById, listArchivedContextFiles, restoreDriveFile, } from '../../../rest/assetAPI'; @@ -39,6 +41,7 @@ import { getFilterTabClassName } from '../../../utils/ContextCenterPureUtils'; import { CONTEXT_CENTER_ARCHIVE_COUNT_QUERY_KEY, CONTEXT_CENTER_DOCUMENTS_COUNT_QUERY_KEY, + reconcileCountAfterAsyncDelete, } from '../../../utils/ContextCenterQueryKeys'; import { getEntityName } from '../../../utils/EntityNameUtils'; import { getDerivedPermissionFlags } from '../../../utils/PermissionDerivation'; @@ -212,9 +215,6 @@ const ContextCenterArchivePage: FC = () => { try { setIsDeleting(true); await deleteDriveFile(itemToDelete.id, true); - queryClient.invalidateQueries({ - queryKey: CONTEXT_CENTER_ARCHIVE_COUNT_QUERY_KEY, - }); showSuccessToast( t('server.entity-deleted-successfully', { entity: itemToDelete.name }) ); @@ -223,7 +223,25 @@ const ContextCenterArchivePage: FC = () => { ...prev, total: Math.max(0, prev.total - 1), })); + const deletedId = itemToDelete.id; setItemToDelete(undefined); + // A 202 finishes on a background thread, so the count is converged once + // the row is actually gone, not invalidated on the response. Not awaited + // — holding `isDeleting` through the poll would spin the modal button, so + // `void` marks the float as deliberate. + void reconcileCountAfterAsyncDelete( + queryClient, + CONTEXT_CENTER_ARCHIVE_COUNT_QUERY_KEY, + () => + // `Include.All` because every archive row is already soft-deleted — + // the endpoint's `non-deleted` default would 404 at once and make an + // un-purged file look gone. Only a 404 counts as landed; a transient + // error leaves it to the next attempt. + getContextFileById(deletedId, Include.All).then( + () => false, + (err) => (err as AxiosError).response?.status === 404 + ) + ); } catch (err) { showErrorToast(err as AxiosError); } finally { diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.test.ts b/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.test.ts new file mode 100644 index 000000000000..074c660de902 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.test.ts @@ -0,0 +1,48 @@ +/* + * 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 { Include } from '../generated/type/include'; +import { getContextFileById } from './assetAPI'; +import APIClient from './axiosClient'; + +jest.mock('./axiosClient', () => ({ + __esModule: true, + default: { get: jest.fn().mockResolvedValue({ data: {} }) }, +})); + +const mockGet = APIClient.get as jest.Mock; + +describe('getContextFileById', () => { + beforeEach(() => jest.clearAllMocks()); + + it('forwards include so a soft-deleted file can be fetched', async () => { + // The archive list is loaded with `include: 'deleted'`, so every row there + // is already soft-deleted. The endpoint defaults to `non-deleted`, which + // would 404 on an archived file that is still very much present — the + // async-delete probe relies on being able to ask for it with Include.All. + await getContextFileById('file-1', Include.All); + + expect(mockGet).toHaveBeenCalledWith( + '/contextCenter/drive/files/file-1', + expect.objectContaining({ + params: expect.objectContaining({ include: Include.All }), + }) + ); + }); + + it('omits include when none is given', async () => { + await getContextFileById('file-1'); + + expect(mockGet.mock.calls[0][1].params.include).toBeUndefined(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.ts index 2fd1948d7dfe..54d92bd43ba0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/assetAPI.ts @@ -18,6 +18,7 @@ import { Asset, AssetType } from '../generated/attachments/asset'; import { ContextFile } from '../generated/entity/data/contextFile'; import { Folder } from '../generated/entity/data/folder'; import { BulkOperationResult } from '../generated/type/bulkOperationResult'; +import { Include } from '../generated/type/include'; import { ListParams } from '../interface/API.interface'; import APIClient from './axiosClient'; @@ -131,10 +132,13 @@ export const listContextFiles = async (params: ListContextFilesParams = {}) => { return response.data; }; -export const getContextFileById = async (id: string): Promise => { +export const getContextFileById = async ( + id: string, + include?: Include +): Promise => { const response = await APIClient.get( `/contextCenter/drive/files/${id}`, - { params: { fields: 'folder,memoryCount' } } + { params: { fields: 'folder,memoryCount', include } } ); return response.data; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.test.ts new file mode 100644 index 000000000000..e6eb68df5b47 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.test.ts @@ -0,0 +1,167 @@ +/* + * 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 { QueryClient } from '@tanstack/react-query'; +import { reconcileCountAfterAsyncDelete } from './ContextCenterQueryKeys'; + +const KEY = ['test-count']; + +// jest.config enables fake timers globally; this helper's whole job is to wait +// between polls, so it needs a clock that advances. `intervalMs: 0` keeps the +// real waits at a tick. +beforeAll(() => jest.useRealTimers()); + +afterAll(() => jest.useFakeTimers()); + +/** A client whose query for KEY resolves to whatever the server now reports. */ +const buildClient = (serverTotal: () => number) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + queryClient.setQueryDefaults(KEY, { queryFn: async () => serverTotal() }); + + return queryClient; +}; + +describe('reconcileCountAfterAsyncDelete', () => { + it('waits for the row to disappear before trusting the count', async () => { + // The server still reports the pre-delete total until the 3rd poll, when + // the background delete lands and the row is gone. + let polls = 0; + const queryClient = buildClient(() => (polls < 3 ? 5 : 4)); + queryClient.setQueryData(KEY, 5); + + await reconcileCountAfterAsyncDelete( + queryClient, + KEY, + async () => ++polls >= 3, + { attempts: 5, intervalMs: 0 } + ); + + expect(polls).toBe(3); + expect(queryClient.getQueryData(KEY)).toBe(4); + }); + + it('stops polling as soon as the row is gone', async () => { + let polls = 0; + const queryClient = buildClient(() => 4); + queryClient.setQueryData(KEY, 5); + + await reconcileCountAfterAsyncDelete( + queryClient, + KEY, + async () => { + polls++; + + return true; + }, + { attempts: 5, intervalMs: 0 } + ); + + expect(polls).toBe(1); + }); + + it('refetches once even when the delete never lands', async () => { + // The budget runs out with the row still present. The badge must end up on + // the server's own figure rather than a number computed in advance. + let polls = 0; + const queryClient = buildClient(() => 5); + queryClient.setQueryData(KEY, 5); + + await reconcileCountAfterAsyncDelete( + queryClient, + KEY, + async () => { + polls++; + + return false; + }, + { attempts: 3, intervalMs: 0 } + ); + + expect(polls).toBe(3); + expect(queryClient.getQueryData(KEY)).toBe(5); + }); + + it('does not treat a transient error as the delete having landed', async () => { + // A 500 mid-poll must not end the wait — only the row's confirmed absence + // does. Ending early would refetch before the delete lands and pin the + // pre-delete total, which is the bug this helper exists to fix. + let polls = 0; + const queryClient = buildClient(() => (polls < 3 ? 5 : 4)); + queryClient.setQueryData(KEY, 5); + + await reconcileCountAfterAsyncDelete( + queryClient, + KEY, + async () => { + polls++; + + // Probe fails twice, then the row is genuinely gone. + return polls >= 3; + }, + { attempts: 5, intervalMs: 0 } + ); + + expect(polls).toBe(3); + expect(queryClient.getQueryData(KEY)).toBe(4); + }); + + it('settles on the server total when a restore lands in the same window', async () => { + // Badge cached at 10. A hard delete is pending and the user restores + // another file mid-poll, so the server moves 10 → 9 → 8. A target frozen + // at 9 would stop early and pin the badge one too high; polling the row's + // absence lets the final refetch read the truth. + let serverTotal = 10; + const queryClient = buildClient(() => serverTotal); + queryClient.setQueryData(KEY, 10); + + let polls = 0; + await reconcileCountAfterAsyncDelete( + queryClient, + KEY, + async () => { + polls++; + // Restore lands first, then the background hard delete. + serverTotal = polls === 1 ? 9 : 8; + + return polls >= 2; + }, + { attempts: 5, intervalMs: 0 } + ); + + expect(queryClient.getQueryData(KEY)).toBe(8); + }); + + it('does not cap the badge when a soft delete raises the count mid-poll', async () => { + // The reverse case: another document is archived while the hard delete is + // pending, so the server total rises. Nothing may clamp it back down. + let serverTotal = 5; + const queryClient = buildClient(() => serverTotal); + queryClient.setQueryData(KEY, 5); + + await reconcileCountAfterAsyncDelete( + queryClient, + KEY, + async () => { + // One archived (+1), the hard delete landed (-1), plus another archive. + serverTotal = 6; + + return true; + }, + { attempts: 5, intervalMs: 0 } + ); + + expect(queryClient.getQueryData(KEY)).toBe(6); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.ts index 8b61ad94caad..cf0cc61d1d8c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ContextCenterQueryKeys.ts @@ -11,6 +11,8 @@ * limitations under the License. */ +import { QueryClient } from '@tanstack/react-query'; + /** * Per-entity React Query keys for Context Center sidebar badge counts. Each * entity's create/delete/restore call sites invalidate only their own key so @@ -40,3 +42,35 @@ export const CONTEXT_CENTER_MEMORY_REVIEWS_QUERY_KEY = [ export const CONTEXT_CENTER_MEMORY_DETAIL_QUERY_KEY = [ 'context-center-memory-detail', ]; + +/** + * Converge a badge-count query after an async (`202`) delete: poll `hasLanded` + * until the deleted row is gone, then refetch once and keep the server's + * figure. Polling the row's absence rather than a precomputed count is what + * keeps a concurrent restore or soft-delete from skewing the badge. + * + * ponytail: bounded poll — the backend emits a completion event on + * `DELETE_ENTITY_CHANNEL`; switch to it if a second surface needs this. + * ADR:2026-10-14-context-center-badge-counts-reconcile-by-bounded-poll + */ +export const reconcileCountAfterAsyncDelete = async ( + queryClient: QueryClient, + queryKey: readonly unknown[], + hasLanded: () => Promise, + { + attempts = 5, + intervalMs = 1_000, + }: { attempts?: number; intervalMs?: number } = {} +): Promise => { + for (let attempt = 0; attempt < attempts; attempt++) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + if (await hasLanded()) { + break; + } + } + + // `refetchQueries`, not `invalidateQueries`: invalidation only marks an + // *inactive* query stale without fetching, so this would silently no-op + // whenever the sidebar observing the key is unmounted. + await queryClient.refetchQueries({ queryKey }); +}; From 75357a607ab3659a6aaebcd4e71878a85d9c472f Mon Sep 17 00:00:00 2001 From: Teddy Date: Fri, 16 Oct 2026 04:24:34 +0000 Subject: [PATCH 13/28] Fixes #35473: Export and Bulk Edit only the filtered test cases (#35474) * Fixes #26756: Add filter and pagination support to test case export endpoints GET /v1/dataQuality/testCases/name/{name}/export and /exportAsync now accept testCaseStatus, testCaseType, entityFQN, entityLink, tags, tier, owner, limit and offset through one shared @BeanParam, so the two endpoints cannot drift. Matches are resolved through search with the same search_after walk the filtered bundle-suite add uses, so the export selects exactly what the filtered test case list shows (including table-inherited owners); rows are read from the database. Async jobs carry the validated filters as CsvAsyncJobArgs.TestCaseExportArgs, and progress reflects the filtered count. Requests without filters or paging keep the original export path. * Support every test case list filter on the test case export endpoints The export now also accepts q, testPlatforms, dataQualityDimension, serviceName, dataProductFqn and startTimestamp/endTimestamp, so a Bulk Edit opened from a filtered test case list can pass the list's filters through unchanged instead of silently exporting a superset. * Harden filtered test case export coverage and test isolation Validate every CSV row and cover inherited metadata, suite and platform scopes, entity links, status lists, and timestamp validation. Use an export caller per test to avoid concurrent result-retention conflicts, and record the domain-filtered paging contract. * Document test case export filters as query parameters in OpenAPI TestCaseExportQuery's private fields had no getters, so swagger could not see them as properties and documented the @BeanParam as a JSON request body instead of listing the filters as query parameters. * Fixes #35473: Load only the filtered test cases into Bulk Edit Bulk Edit always exported every test case in scope, whatever the list it was opened from was filtered to. The test case list, the table Data Quality tab and the test suite page now carry their active filters into the Bulk Edit URL, and Bulk Edit sends them with the export that loads the grid. The platform list builds its search request and its Bulk Edit filters from the same helper, so the two cannot drift. * Export only the test cases matching the list filters The Export action in the test case manage menu exported everything in scope. It now sends the same list filters as Bulk Edit, so both select the test cases the list shows. * Fix table test case filters after list actions --- .../TableTestCaseExportFilters.spec.ts | 212 ++++++++++++++++++ .../BulkEditEntity.component.tsx | 18 +- .../BulkEditEntity/BulkEditEntity.test.tsx | 38 ++++ .../DataQuality/TestCases/useTestCaseList.ts | 13 +- .../TestCases/useTestCaseListPage.test.tsx | 93 +++++++- .../TestCases/useTestCaseListPage.tsx | 7 +- .../QualityTab/QualityTab.component.tsx | 47 ++-- .../QualityTab/QualityTab.test.tsx | 108 +++++++++ .../hooks/useTestSuiteDetailsPage.tsx | 16 +- .../useTestSuiteDetailsPage.test.tsx | 30 +++ .../src/main/resources/ui/src/rest/testAPI.ts | 23 +- .../utils/DataQuality/DataQualityPureUtils.ts | 82 ++++++- .../DataQuality/DataQualityUtils.test.ts | 124 ++++++++++ .../utils/DataQuality/TestCaseExport.utils.ts | 22 ++ .../EntityBulkEditUtils.test.tsx | 31 +++ .../EntityBulkEdit/EntityBulkEditUtils.tsx | 10 +- .../ui/src/utils/TestCaseUtils.test.tsx | 58 ++++- .../resources/ui/src/utils/TestCaseUtils.tsx | 24 +- 18 files changed, 907 insertions(+), 49 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/TableTestCaseExportFilters.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/TestCaseExport.utils.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/TableTestCaseExportFilters.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/TableTestCaseExportFilters.spec.ts new file mode 100644 index 000000000000..41998fa11ab4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/TableTestCaseExportFilters.spec.ts @@ -0,0 +1,212 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from '@playwright/test'; +import { parse } from 'papaparse'; +import { DatabaseServiceClass } from '../../../support/entity/service/DatabaseServiceClass'; +import { TableClass } from '../../../support/entity/TableClass'; +import { performAdminLogin } from '../../../utils/admin'; +import { okJson } from '../../../utils/apiResponse'; +import { + scrollIntoViewAndSettle, + selectOptionWithRetry, + uuid, +} from '../../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../../utils/entity'; +import { getRowByName } from '../../../utils/scopedLocators'; +import { + clickManageButton, + navigateToBulkEditPage, + performTestCaseExport, + visitDataQualityTab, +} from '../../../utils/testCases'; +import { waitForResponseWithStatus } from '../../../utils/waitHelpers'; +import { test } from '../../fixtures/pages'; + +test.describe( + 'Table test case list and export filters', + { tag: ['@Features', '@Observability', '@import-export'] }, + () => { + let table: TableClass; + let search: string; + let matchingName: string; + let otherFailedName: string; + let successfulName: string; + + test.beforeAll(async ({ browser }) => { + table = new TableClass({ service: new DatabaseServiceClass() }); + search = `nulls_${uuid()}`; + matchingName = `${search}_failed`; + successfulName = `${search}_success`; + otherFailedName = `other_${uuid()}`; + const { apiContext, afterAction } = await performAdminLogin(browser); + + try { + await table.create(apiContext); + for (const [name, status] of [ + [matchingName, 'Failed'], + [otherFailedName, 'Failed'], + [successfulName, 'Success'], + ]) { + const testCase = await table.createTestCase(apiContext, { name }); + await table.addTestCaseResult( + apiContext, + testCase.fullyQualifiedName, + { + result: 'Filter regression', + testCaseStatus: status, + timestamp: Date.now(), + } + ); + } + await expect + .poll(async () => { + const response = await okJson<{ + data: { + name: string; + testCaseResult?: { testCaseStatus: string }; + }[]; + }>( + await apiContext.get( + '/api/v1/dataQuality/testCases/search/list', + { + params: { + entityLink: `<#E::table::${table.entityResponseData.fullyQualifiedName}>`, + fields: 'testCaseResult', + }, + } + ), + 'Wait for test case results to be indexed' + ); + + return response.data + .map( + ({ name, testCaseResult }) => + `${name}:${testCaseResult?.testCaseStatus}` + ) + .sort(); + }) + .toEqual( + [ + `${matchingName}:Failed`, + `${otherFailedName}:Failed`, + `${successfulName}:Success`, + ].sort() + ); + } finally { + await afterAction(); + } + }); + + test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + await table.delete(apiContext); + } finally { + await afterAction(); + } + }); + + test('keeps matching rows when searching, filtering, sorting, exporting and opening Bulk Edit', async ({ + page, + }) => { + await test.step('Search, then select Failed without losing the search', async () => { + await visitDataQualityTab(page, table); + const searchResponse = waitForResponseWithStatus( + page, + (response) => + response.url().includes('/dataQuality/testCases/search/list') && + response.request().method() === 'GET' && + new URL(response.url()).searchParams.get('q') === search, + 200 + ); + await page + .getByTestId('quality-tab-search') + .getByRole('textbox') + .fill(search); + await searchResponse; + await expect(getRowByName(page, matchingName)).toBeVisible(); + await expect(getRowByName(page, successfulName)).toBeVisible(); + + const statusResponse = waitForResponseWithStatus( + page, + (response) => + response.url().includes('/dataQuality/testCases/search/list') && + response.request().method() === 'GET' && + new URL(response.url()).searchParams.get('testCaseStatus') === + 'Failed', + 200 + ); + const trigger = page + .getByTestId('classic-quality-status-filter') + .getByRole('button'); + await scrollIntoViewAndSettle(trigger); + await selectOptionWithRetry( + trigger, + page.getByRole('option', { name: 'Failed', exact: true }) + ); + const response = await statusResponse; + + expect(new URL(response.url()).searchParams.get('q')).toBe(search); + await expect(getRowByName(page, matchingName)).toBeVisible(); + await expect(getRowByName(page, otherFailedName)).not.toBeVisible(); + await expect(getRowByName(page, successfulName)).not.toBeVisible(); + }); + + await test.step('Sorting preserves both filters', async () => { + const sortResponse = waitForResponseWithStatus( + page, + (response) => + response.url().includes('/dataQuality/testCases/search/list') && + response.request().method() === 'GET' && + new URL(response.url()).searchParams.get('sortField') === + 'name.keyword', + 200 + ); + await page.getByRole('columnheader', { name: /^Name/ }).click(); + const response = await sortResponse; + + expect(new URL(response.url()).searchParams.get('q')).toBe(search); + expect(new URL(response.url()).searchParams.get('testCaseStatus')).toBe( + 'Failed' + ); + await expect(getRowByName(page, matchingName)).toBeVisible(); + await expect(getRowByName(page, otherFailedName)).not.toBeVisible(); + }); + + await test.step('CSV contains only the matching test case', async () => { + await clickManageButton(page, 'table'); + const download = await performTestCaseExport(page); + const rows = parse(await download.text(), { + skipEmptyLines: true, + }).data; + + expect(rows.slice(1).map(([name]) => name)).toEqual([matchingName]); + }); + + await test.step('Bulk Edit keeps the same rows after a reload', async () => { + await page.locator('.csv-jobs-tray-close').click(); + await clickManageButton(page, 'table'); + await navigateToBulkEditPage(page); + await expect(getRowByName(page, matchingName)).toBeVisible(); + await expect(getRowByName(page, otherFailedName)).not.toBeVisible(); + await expect(getRowByName(page, successfulName)).not.toBeVisible(); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForAllLoadersToDisappear(page); + await expect(getRowByName(page, matchingName)).toBeVisible(); + await expect(getRowByName(page, otherFailedName)).not.toBeVisible(); + await expect(getRowByName(page, successfulName)).not.toBeVisible(); + }); + }); + } +); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx index 7c00f2b6fc5a..39e110b0787e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx @@ -42,12 +42,13 @@ import type { } from 'react-data-grid'; import { useTranslation } from 'react-i18next'; import { readString } from 'react-papaparse'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { ENTITY_BULK_EDIT_STEPS } from '../../constants/BulkEdit.constants'; import { ExportTypes } from '../../constants/Export.constants'; import { EntityType } from '../../enums/entity.enum'; import { useFqn } from '../../hooks/useFqn'; import entityBulkEditConfigClassBase from '../../utils/CSV/EntityBulkEditConfigClassBase'; +import { getTestCaseExportFiltersFromSearch } from '../../utils/DataQuality/DataQualityPureUtils'; import { getBulkEditCSVExportEntityApi, getBulkEntityNavigationPath, @@ -162,8 +163,16 @@ const BulkEditEntity = ({ }: BulkEditEntityProps) => { const { t } = useTranslation(); const navigate = useNavigate(); + const { search } = useLocation(); const { fqn } = useFqn(); const { entityType } = useRequiredParams<{ entityType: EntityType }>(); + const testCaseExportFilters = useMemo( + () => + entityType === EntityType.TEST_CASE + ? getTestCaseExportFiltersFromSearch(search) + : undefined, + [entityType, search] + ); const { triggerExportForBulkEdit, csvExportData, @@ -261,10 +270,13 @@ const BulkEditEntity = ({ triggeredExportKeyRef.current = `${entityType}:${fqn}`; triggerExportForBulkEdit({ name: fqn, - onExport: getBulkEditCSVExportEntityApi(entityType), + onExport: getBulkEditCSVExportEntityApi( + entityType, + testCaseExportFilters + ), exportTypes: [ExportTypes.CSV], }); - }, [entityType, fqn, triggerExportForBulkEdit]); + }, [entityType, fqn, testCaseExportFilters, triggerExportForBulkEdit]); useEffect(() => { if (!isExportHydrationRequired) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.test.tsx index fbfb8031e287..b9dff4c1656f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.test.tsx @@ -16,6 +16,8 @@ import { MemoryRouter } from 'react-router-dom'; import { VALIDATION_STEP } from '../../constants/BulkImport.constant'; import { EntityType } from '../../enums/entity.enum'; import { CSVImportResult, Status } from '../../generated/type/csvImportResult'; +import { exportTestCasesInCSV } from '../../rest/testAPI'; +import { getBulkEditCSVExportEntityApi } from '../../utils/EntityBulkEdit/EntityBulkEditUtils'; import BulkEditEntity from './BulkEditEntity.component'; import { BulkEditEntityProps } from './BulkEditEntity.interface'; @@ -128,6 +130,10 @@ jest.mock('../common/Loader/Loader', () => { return jest.fn(() =>
Loading...
); }); +jest.mock('../../rest/testAPI', () => ({ + exportTestCasesInCSV: jest.fn(), +})); + jest.mock('../../utils/EntityBulkEdit/EntityBulkEditUtils', () => ({ getBulkEditCSVExportEntityApi: jest.fn(() => jest.fn()), getBulkEntityNavigationPath: jest.fn( @@ -634,6 +640,38 @@ describe('BulkEditEntity', () => { expect(mockTriggerExportForBulkEdit).toHaveBeenCalled(); }); + + it('should load the test case grid with the filters the list was opened with', async () => { + mockEntityType = EntityType.TEST_CASE; + (getBulkEditCSVExportEntityApi as jest.Mock).mockImplementation( + jest.requireActual('../../utils/EntityBulkEdit/EntityBulkEditUtils') + .getBulkEditCSVExportEntityApi + ); + + try { + render( + + + + ); + const { name, onExport } = + mockTriggerExportForBulkEdit.mock.calls[0][0]; + await onExport(name, { recursive: false }); + + expect(exportTestCasesInCSV).toHaveBeenCalledWith('test.entity.fqn', { + recursive: false, + testCaseStatus: ['Failed', 'Aborted'], + q: 'nulls', + }); + } finally { + (getBulkEditCSVExportEntityApi as jest.Mock).mockImplementation(() => + jest.fn() + ); + } + }); }); describe('Edge Cases', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseList.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseList.ts index 1a467cce008d..094411f83a9b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseList.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseList.ts @@ -34,7 +34,7 @@ import { getListTestCaseBySearch, ListTestCaseParamsBySearch, } from '../../../rest/testAPI'; -import { getTestCaseFiltersValue } from '../../../utils/DataQuality/DataQualityPureUtils'; +import { getTestCaseListFilterParams } from '../../../utils/DataQuality/DataQualityPureUtils'; import { getDerivedPermissionFlags } from '../../../utils/PermissionDerivation'; import { showErrorToast } from '../../../utils/ToastUtils'; import { PagingHandlerParams } from '../../common/NextPrevious/NextPrevious.interface'; @@ -103,9 +103,10 @@ export const useTestCaseList = ({ // Visibility, filters, and sorting can overlap requests; only the newest // response may update the rows or clear the shared loading state. const requestId = ++latestRequestId.current; - const updatedParams = getTestCaseFiltersValue( + const filterParams = getTestCaseListFilterParams( params, - activeFilters ?? selectedFilter + activeFilters ?? selectedFilter, + searchValue ); setIsLoading(true); @@ -115,11 +116,8 @@ export const useTestCaseList = ({ paging: pagingResponse, entityPermissions: listPermissions, } = await getListTestCaseBySearch({ - ...updatedParams, + ...filterParams, ...sortOptions, - testCaseStatus: isEmpty(params?.testCaseStatus) - ? undefined - : params?.testCaseStatus, limit: pageSize, includeAllTests: true, includePermissions: true, @@ -129,7 +127,6 @@ export const useTestCaseList = ({ TabSpecificField.INCIDENT_ID, TabSpecificField.INCIDENT_STATUS, ], - q: searchValue || undefined, offset: (page - 1) * pageSize, include: showDeleted ? Include.Deleted : Include.NonDeleted, ...apiParams, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseListPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseListPage.test.tsx index b97ac9d0cce5..7ec47c1415bf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseListPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseListPage.test.tsx @@ -22,12 +22,17 @@ import { SearchIndex } from '../../../enums/search.enum'; import { TestCase, TestCaseStatus } from '../../../generated/tests/testCase'; import { searchQuery } from '../../../rest/searchAPI'; import { getTags } from '../../../rest/tagAPI'; -import { getListTestCaseBySearch } from '../../../rest/testAPI'; +import { + exportTestCasesInCSV, + getListTestCaseBySearch, +} from '../../../rest/testAPI'; +import { getTestCaseExportFiltersFromSearch } from '../../../utils/DataQuality/DataQualityPureUtils'; import tagClassBase from '../../../utils/TagClassBase'; import { TestCaseSearchParams } from '../DataQuality.interface'; import { useTestCaseListPage } from './useTestCaseListPage'; const mockNavigate = jest.fn(); +const mockShowModal = jest.fn(); const mockLocation = { search: '' }; @@ -72,7 +77,7 @@ jest.mock('../../../pages/DataQuality/DataQualityProvider', () => ({ jest.mock( '../../Entity/EntityExportModalProvider/EntityExportModalProvider.component', () => ({ - useEntityExportModalProvider: jest.fn(() => ({ showModal: jest.fn() })), + useEntityExportModalProvider: jest.fn(() => ({ showModal: mockShowModal })), }) ); @@ -93,6 +98,7 @@ jest.mock('../../../rest/testAPI', () => ({ getListTestCaseBySearch: jest .fn() .mockResolvedValue({ data: [], paging: { total: 0 } }), + exportTestCasesInCSV: jest.fn(), })); jest.mock('../../../rest/searchAPI', () => ({ @@ -122,6 +128,7 @@ const buildTestCase = ( describe('useTestCaseListPage', () => { beforeEach(() => { mockNavigate.mockClear(); + mockShowModal.mockClear(); mockLocation.search = ''; mockPermissions.testCase = { ViewAll: true, ViewBasic: true }; (getListTestCaseBySearch as jest.Mock).mockResolvedValue({ @@ -409,6 +416,88 @@ describe('useTestCaseListPage', () => { expect(payload.sortType).toBe('desc'); }); + it('should open Bulk Edit with the filters the list sent to search', async () => { + mockPermissions.testCase = { + ViewAll: true, + ViewBasic: true, + EditAll: true, + }; + mockLocation.search = QueryString.stringify( + { + testCaseStatus: [TestCaseStatus.Failed, TestCaseStatus.Aborted], + testCaseType: 'column', + serviceName: 'sample_data', + searchValue: 'orders', + currentPage: 2, + }, + { arrayFormat: 'brackets' } + ); + const { result } = renderHook(() => useTestCaseListPage()); + + await waitFor(() => + expect(getListTestCaseBySearch).toHaveBeenLastCalledWith( + expect.objectContaining({ serviceName: 'sample_data' }) + ) + ); + const listParams = (getListTestCaseBySearch as jest.Mock).mock.calls.at( + -1 + )?.[0]; + + act(() => { + result.current.extraDropdownContent + .find((item) => item.key === 'bulk-edit-button') + ?.onClick?.(); + }); + + const [path, search] = ( + mockNavigate.mock.calls.at(-1)?.[0] as string + ).split('?'); + + expect(path).toBe('/bulk/edit/testCase/*'); + expect(getTestCaseExportFiltersFromSearch(search)).toEqual({ + testCaseStatus: listParams.testCaseStatus, + testCaseType: listParams.testCaseType, + serviceName: listParams.serviceName, + q: listParams.q, + }); + expect(listParams.q).toBe('orders'); + }); + + it('should export only the test cases the filtered list shows', async () => { + mockLocation.search = QueryString.stringify( + { + testCaseType: 'all', + currentPage: 1, + testCaseStatus: [TestCaseStatus.Aborted], + }, + { arrayFormat: 'brackets' } + ); + const { result } = renderHook(() => useTestCaseListPage()); + + await waitFor(() => + expect(getListTestCaseBySearch).toHaveBeenLastCalledWith( + expect.objectContaining({ testCaseStatus: [TestCaseStatus.Aborted] }) + ) + ); + const listParams = (getListTestCaseBySearch as jest.Mock).mock.calls.at( + -1 + )?.[0]; + + act(() => { + result.current.extraDropdownContent + .find((item) => item.key === 'export-button') + ?.onClick?.(); + }); + const [{ name, onExport }] = mockShowModal.mock.calls[0]; + onExport(name, { recursive: true }); + + expect(exportTestCasesInCSV).toHaveBeenLastCalledWith('*', { + recursive: true, + testCaseStatus: listParams.testCaseStatus, + testCaseType: listParams.testCaseType, + }); + }); + it('should prefetch the default table and tag filter options on mount', async () => { const getTagsSpy = jest .spyOn(tagClassBase, 'getTags') diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseListPage.tsx index e3eb800eed94..c3bca4ca4c67 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/useTestCaseListPage.tsx @@ -20,6 +20,7 @@ import { usePaging } from '../../../hooks/paging/usePaging'; import DataQualityClassBase from '../../../pages/DataQuality/DataQualityClassBase'; import { DataQualityPageTabs } from '../../../pages/DataQuality/DataQualityPage.interface'; import { useDataQualityProvider } from '../../../pages/DataQuality/DataQualityProvider'; +import { getTestCaseListFilterParams } from '../../../utils/DataQuality/DataQualityPureUtils'; import { checkPermission } from '../../../utils/PermissionsUtils'; import { getTestCaseManageMenuItems } from '../../../utils/TestCaseUtils'; import { useEntityExportModalProvider } from '../../Entity/EntityExportModalProvider/EntityExportModalProvider.component'; @@ -147,9 +148,11 @@ export const useTestCaseListPage = ({ }, false, navigate, - showModal + showModal, + undefined, + getTestCaseListFilterParams(params, selectedFilter, searchValue) ), - [permissions, navigate, showModal] + [permissions, navigate, showModal, params, selectedFilter, searchValue] ); return { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx index 7364519bb849..4d501fed9bc3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx @@ -44,6 +44,7 @@ import { TestCaseStatus } from '../../../../../generated/tests/testCase'; import useCustomLocation from '../../../../../hooks/useCustomLocation/useCustomLocation'; import { getIngestionPipelines } from '../../../../../rest/ingestionPipelineAPI'; import { ListTestCaseParamsBySearch } from '../../../../../rest/testAPI'; +import { getTestCaseExportFilters } from '../../../../../utils/DataQuality/DataQualityPureUtils'; import { getBreadcrumbForTable } from '../../../../../utils/EntityDataBreadcrumbUtils'; import { getEntityName } from '../../../../../utils/EntityNameUtils'; import { getDerivedPermissionFlags } from '../../../../../utils/PermissionDerivation'; @@ -133,6 +134,17 @@ export const QualityTab = () => { const [searchValue, setSearchValue] = useState(); const [sortOptions, setSortOptions] = useState(DEFAULT_SORT_ORDER); + const testCaseParams = useMemo( + () => ({ + ...sortOptions, + testCaseType: selectedTestType, + testCaseStatus: isEmpty(selectedTestCaseStatus) + ? undefined + : selectedTestCaseStatus, + q: searchValue, + }), + [sortOptions, selectedTestType, selectedTestCaseStatus, searchValue] + ); const testSuite = useMemo(() => table?.testSuite, [table]); const [ingestionPipelineCount, setIngestionPipelineCount] = useState(0); @@ -201,11 +213,7 @@ export const QualityTab = () => { }) => { if (currentPage) { fetchAllTests({ - ...sortOptions, - testCaseType: selectedTestType, - testCaseStatus: isEmpty(selectedTestCaseStatus) - ? undefined - : selectedTestCaseStatus, + ...testCaseParams, offset: (currentPage - 1) * pageSize, }); } @@ -215,17 +223,18 @@ export const QualityTab = () => { const handleSearchTestCase = (value?: string) => { setSearchValue(value); fetchAllTests({ - testCaseType: selectedTestType, - testCaseStatus: isEmpty(selectedTestCaseStatus) - ? undefined - : selectedTestCaseStatus, + ...testCaseParams, q: value, }); }; const handleSortTestCase = async (apiParams?: ListTestCaseParamsBySearch) => { setSortOptions(apiParams ?? DEFAULT_SORT_ORDER); - await fetchAllTests({ ...(apiParams ?? DEFAULT_SORT_ORDER), offset: 0 }); + await fetchAllTests({ + ...testCaseParams, + ...(apiParams ?? DEFAULT_SORT_ORDER), + offset: 0, + }); handlePageChange(INITIAL_PAGING_VALUE); }; @@ -254,7 +263,7 @@ export const QualityTab = () => { if (value !== selectedTestCaseStatus) { setSelectedTestCaseStatus(value); fetchAllTests({ - testCaseType: selectedTestType, + ...testCaseParams, testCaseStatus: isEmpty(value) ? undefined : value, }); } @@ -283,19 +292,18 @@ export const QualityTab = () => { table?.deleted ?? false, navigate, showModal, - EntityType.TABLE + EntityType.TABLE, + getTestCaseExportFilters(testCaseParams) ) : []; - }, [globalPermissions, table, navigate, showModal]); + }, [globalPermissions, table, navigate, showModal, testCaseParams]); const handleTestCaseTypeChange = (value: TestCaseType) => { if (value !== selectedTestType) { setSelectedTestType(value); fetchAllTests({ + ...testCaseParams, testCaseType: value, - testCaseStatus: isEmpty(selectedTestCaseStatus) - ? undefined - : selectedTestCaseStatus, }); } }; @@ -517,10 +525,9 @@ export const QualityTab = () => { {qualityTab === EntityTabs.TEST_CASES && ( { - await fetchAllTests(...params); - params?.length && - (await getResourceLimit('dataQuality', true, true)); + afterDeleteAction={async () => { + await fetchAllTests(testCaseParams); + await getResourceLimit('dataQuality', true, true); }} breadcrumbData={tableBreadcrumb} entityPermissions={allTestCasesPermissions} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx index eba1e5121f94..5b3fb41ee2c6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx @@ -53,6 +53,7 @@ jest.mock('@openmetadata/ui-core-components', () => { }); import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { act } from 'react'; import { MOCK_TABLE } from '../../../../../mocks/TableData.mock'; import { getIngestionPipelines } from '../../../../../rest/ingestionPipelineAPI'; @@ -610,6 +611,113 @@ describe('QualityTab', () => { expect(ExtraTestCaseDropdownOptions).toHaveBeenCalled(); }); + it('should open Bulk Edit with the search text and the type the tab shows', async () => { + const { ExtraTestCaseDropdownOptions } = jest.requireMock( + '../../../../../utils/TestCaseUtils' + ); + const Searchbar = jest.requireMock( + '../../../../common/SearchBarComponent/SearchBar.component' + ); + + (useTableProfiler as jest.Mock).mockReturnValue({ + ...mockUseTableProfiler, + table: { + ...MOCK_TABLE, + fullyQualifiedName: 'test.table.fqn', + deleted: false, + }, + }); + + await act(async () => { + render(); + }); + await act(async () => { + Searchbar.mock.calls.at(-1)[0].onSearch('nulls'); + }); + + expect(ExtraTestCaseDropdownOptions).toHaveBeenLastCalledWith( + 'test.table.fqn', + expect.anything(), + false, + expect.anything(), + expect.anything(), + 'table', + { testCaseType: 'all', q: 'nulls' } + ); + }); + + it.each(['status', 'type', 'sort', 'page', 'delete', 'search'])( + 'should keep list and export filters aligned after changing %s', + async (action) => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { ExtraTestCaseDropdownOptions } = jest.requireMock( + '../../../../../utils/TestCaseUtils' + ); + const Searchbar = jest.requireMock( + '../../../../common/SearchBarComponent/SearchBar.component' + ); + const DataQualityTab = jest.requireMock( + '../../DataQualityTab/DataQualityTab' + ); + + (useTableProfiler as jest.Mock).mockReturnValue(mockUseTableProfiler); + + await act(async () => { + render(); + }); + await act(async () => { + Searchbar.mock.calls.at(-1)[0].onSearch('nulls'); + }); + await user.click(screen.getByRole('button', { name: /label.type/ })); + await user.click(screen.getByRole('option', { name: 'label.column' })); + await user.click(screen.getByRole('button', { name: /label.status/ })); + await user.click(screen.getByRole('option', { name: 'label.failed' })); + + const sortOptions = { sortField: 'name', sortType: 'asc' }; + await act(async () => { + await DataQualityTab.mock.calls.at(-1)[0].fetchTestCases(sortOptions); + }); + mockUseTableProfiler.fetchAllTests.mockClear(); + + if (action === 'status') { + await user.click(screen.getByRole('button', { name: /label.status/ })); + await user.click(screen.getByRole('option', { name: 'label.aborted' })); + } else if (action === 'type') { + await user.click(screen.getByRole('button', { name: /label.type/ })); + await user.click(screen.getByRole('option', { name: 'label.table' })); + } else { + await act(async () => { + const props = DataQualityTab.mock.calls.at(-1)[0]; + if (action === 'sort') { + await props.fetchTestCases(sortOptions); + } else if (action === 'page') { + props.pagingData.pagingHandler({ currentPage: 2 }); + } else if (action === 'delete') { + await props.afterDeleteAction(); + } else { + Searchbar.mock.calls.at(-1)[0].onSearch('updated nulls'); + } + }); + } + + const expectedFilters = { + testCaseType: action === 'type' ? 'table' : 'column', + testCaseStatus: action === 'status' ? 'Aborted' : 'Failed', + q: action === 'search' ? 'updated nulls' : 'nulls', + }; + + expect(mockUseTableProfiler.fetchAllTests).toHaveBeenLastCalledWith({ + ...sortOptions, + ...expectedFilters, + ...(action === 'page' ? { offset: 10 } : {}), + ...(action === 'sort' ? { offset: 0 } : {}), + }); + expect(ExtraTestCaseDropdownOptions.mock.calls.at(-1)[6]).toEqual( + expectedFilters + ); + } + ); + it('should not call ExtraTestCaseDropdownOptions when table has no fullyQualifiedName', async () => { const { ExtraTestCaseDropdownOptions } = jest.requireMock( '../../../../../utils/TestCaseUtils' diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/hooks/useTestSuiteDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/hooks/useTestSuiteDetailsPage.tsx index c6422f4f2ca7..17720f9eb346 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/hooks/useTestSuiteDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/hooks/useTestSuiteDetailsPage.tsx @@ -72,6 +72,7 @@ import { ListTestCaseParamsBySearch, updateTestSuiteById, } from '../../../rest/testAPI'; +import { getTestCaseExportFilters } from '../../../utils/DataQuality/DataQualityPureUtils'; import { getEntityName } from '../../../utils/EntityNameUtils'; import observabilityRouterClassBase from '../../../utils/ObservabilityRouterClassBase'; import { checkPermission } from '../../../utils/PermissionsUtils'; @@ -283,9 +284,20 @@ export const useTestSuiteDetailsPage = (): UseTestSuiteDetailsPageResult => { testSuite?.deleted ?? false, navigate, showModal, - EntityType.TEST_SUITE + EntityType.TEST_SUITE, + getTestCaseExportFilters({ + ...testCaseRequestParams, + q: normalizedTestCaseSearchQuery, + }) ); - }, [globalPermissions, testSuite, navigate, showModal]); + }, [ + globalPermissions, + testSuite, + navigate, + showModal, + testCaseRequestParams, + normalizedTestCaseSearchQuery, + ]); const slashedBreadCrumb = useMemo( () => [ diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/useTestSuiteDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/useTestSuiteDetailsPage.test.tsx index 764d79c79064..1c2d1fcda14b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/useTestSuiteDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/useTestSuiteDetailsPage.test.tsx @@ -16,6 +16,7 @@ import { ReactNode } from 'react'; import { OperationPermission } from '../../context/PermissionProvider/PermissionProvider.interface'; import { SORT_ORDER } from '../../enums/common.enum'; import { EntityTabs } from '../../enums/entity.enum'; +import { TestCaseStatus } from '../../generated/tests/testCase'; import { getIngestionPipelines } from '../../rest/ingestionPipelineAPI'; import { addTestCasesToLogicalTestSuiteBulk, @@ -238,6 +239,35 @@ describe('useTestSuiteDetailsPage', () => { expect(getIngestionPipelines).not.toHaveBeenCalled(); }); + it('should open Bulk Edit with the suite list filters', async () => { + const { ExtraTestCaseDropdownOptions } = jest.requireMock( + '../../utils/TestCaseUtils' + ); + const { result } = renderTestSuiteDetailsHook(); + + await waitFor(() => { + expect(result.current.testSuite).toEqual(mockTestSuite); + }); + + await act(async () => { + await result.current.fetchTestCases({ + testCaseStatus: TestCaseStatus.Failed, + offset: 20, + }); + await result.current.handleTestCaseSearch(' tc_9 '); + }); + + expect(ExtraTestCaseDropdownOptions).toHaveBeenLastCalledWith( + mockTestSuite.fullyQualifiedName, + expect.anything(), + false, + expect.anything(), + expect.anything(), + 'testSuite', + { testCaseStatus: TestCaseStatus.Failed, q: 'tc_9' } + ); + }); + it('should preserve search input whitespace while normalizing the request', async () => { const { result } = renderTestSuiteDetailsHook(); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/testAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/testAPI.ts index 422c28b292c6..835f0fb2e143 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/testAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/testAPI.ts @@ -94,6 +94,27 @@ export type ListTestCaseParamsBySearch = Omit< testCaseStatus?: TestCaseStatus | TestCaseStatus[]; }; +/** + * The test case list filters that `/name/{name}/export` and `/exportAsync` + * accept, so an export selects the rows the user filtered the list to. + */ +export type TestCaseExportFilters = Pick< + ListTestCaseParamsBySearch, + | 'testCaseStatus' + | 'testCaseType' + | 'entityLink' + | 'tags' + | 'tier' + | 'owner' + | 'q' + | 'testPlatforms' + | 'dataQualityDimension' + | 'serviceName' + | 'dataProductFqn' + | 'startTimestamp' + | 'endTimestamp' +>; + export type ListTestDefinitionsParams = ListParams & { entityType?: EntityType; testPlatform?: TestPlatform; @@ -549,7 +570,7 @@ export const listTestCases = async (params: ListTestCasesParams) => { export const exportTestCasesInCSV = async ( name: string, - params?: { recursive?: boolean } + params?: { recursive?: boolean } & TestCaseExportFilters ): Promise => { const response = await APIClient.get( `/dataQuality/testCases/name/${getEncodedFqn(name)}/exportAsync`, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityPureUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityPureUtils.ts index c727ae3ec67f..eed100d7b75d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityPureUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityPureUtils.ts @@ -17,11 +17,13 @@ import { isArray, isEmpty, isNil, + isString, isUndefined, lowerCase, omit, omitBy, parseInt, + pick, } from 'lodash'; import QueryString from 'qs'; import { ReactComponent as AccuracyIcon } from '../../assets/svg/ic-accuracy.svg'; @@ -57,7 +59,10 @@ import { import { type DataQualityDashboardChartFilters } from '../../interface/observability/dataQuality.interface'; import type { TableSearchSource } from '../../interface/search.interface'; import { DataQualityPageTabs } from '../../pages/DataQuality/DataQualityPage.interface'; -import type { ListTestCaseParamsBySearch } from '../../rest/testAPI'; +import type { + ListTestCaseParamsBySearch, + TestCaseExportFilters, +} from '../../rest/testAPI'; import { formatDate } from '../date-time/DateTimeUtils'; import { CUSTOM_DATE_RANGE_KEY } from '../DatePickerMenuUtils'; import EntityLink from '../EntityLink'; @@ -290,6 +295,81 @@ export const getTestCaseFiltersValue = ( return updatedParams; }; +/** + * The filters the test case list sends to `/search/list`. The Bulk Edit export + * is built from the same params so it loads the rows the list showed. + */ +export const getTestCaseListFilterParams = ( + params: TestCaseSearchParams, + selectedFilter: string[], + searchValue?: string +): ListTestCaseParamsBySearch => ({ + ...getTestCaseFiltersValue(params, selectedFilter), + testCaseStatus: isEmpty(params?.testCaseStatus) + ? undefined + : params?.testCaseStatus, + q: searchValue || undefined, +}); + +const TEST_CASE_EXPORT_FILTER_KEYS: (keyof TestCaseExportFilters)[] = [ + 'testCaseStatus', + 'testCaseType', + 'entityLink', + 'tags', + 'tier', + 'owner', + 'q', + 'testPlatforms', + 'dataQualityDimension', + 'serviceName', + 'dataProductFqn', + 'startTimestamp', + 'endTimestamp', +]; + +const isEmptyFilterValue = (value: unknown) => + isNil(value) || ((isString(value) || isArray(value)) && isEmpty(value)); + +/** + * Keeps only the list params the test case export accepts. Paging, sorting and + * listing-only params such as `fields` or `include` must not reach the export. + */ +export const getTestCaseExportFilters = ( + params?: ListTestCaseParamsBySearch +): TestCaseExportFilters => + omitBy( + pick(params ?? {}, TEST_CASE_EXPORT_FILTER_KEYS), + isEmptyFilterValue + ) as TestCaseExportFilters; + +/** + * Bulk Edit carries the export filters in its URL, next to + * `sourceEntityType`, so a reload keeps the rows the user opened it with. + */ +export const getTestCaseBulkEditSearch = ( + sourceEntityType?: string, + filters?: TestCaseExportFilters +) => + QueryString.stringify( + { sourceEntityType, ...getTestCaseExportFilters(filters) }, + // Only the comma joining array items stays raw, so a comma inside a + // value (e.g. the search text) is not split back into an array. + { + arrayFormat: 'comma', + encodeValuesOnly: true, + skipNulls: true, + addQueryPrefix: true, + } + ); + +export const getTestCaseExportFiltersFromSearch = (search: string) => + getTestCaseExportFilters( + QueryString.parse(search, { + ignoreQueryPrefix: true, + comma: true, + }) as ListTestCaseParamsBySearch + ); + export const transformToTestCaseStatusObject = ( data: DataQualityReport['data'] ) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.test.ts index c95d2f963e80..4323c1f33295 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.test.ts @@ -15,6 +15,7 @@ import { applyPatch, type Operation } from 'fast-json-patch'; import { TestCaseFormType } from '../../components/DataQuality/AddDataQualityTest/AddDataQualityTest.interface'; import { TestCaseSearchParams } from '../../components/DataQuality/DataQuality.interface'; import { DataQualityDimensions } from '../../enums/DataQuality.enum'; +import { EntityType } from '../../enums/entity.enum'; import { TestCaseType } from '../../enums/TestSuite.enum'; import { Table } from '../../generated/entity/data/table'; import { DataQualityReport } from '../../generated/tests/dataQualityReport'; @@ -24,6 +25,7 @@ import { TestDefinition, TestPlatform, } from '../../generated/tests/testDefinition'; +import { Include } from '../../generated/type/include'; import { LabelType, State, @@ -48,7 +50,11 @@ import { getEntityLinkForColumnFilter, getSelectedOptionsFromKeys, getServiceTypeForTestDefinition, + getTestCaseBulkEditSearch, + getTestCaseExportFilters, + getTestCaseExportFiltersFromSearch, getTestCaseFiltersValue, + getTestCaseListFilterParams, getTestCaseTabPath, parseColumnAggregateBuckets, transformToTestCaseStatusByDimension, @@ -393,6 +399,124 @@ describe('DataQualityUtils', () => { }); }); + describe('getTestCaseListFilterParams', () => { + const params = { + testCaseStatus: [TestCaseStatus.Failed, TestCaseStatus.Aborted], + testCaseType: 'column', + tier: 'Tier.Tier1', + tableFqn: 'sample_data.ecommerce_db.shopify.fact_sale', + } as unknown as TestCaseSearchParams; + + it('should send the selected filters, the status and the search text', () => { + expect( + getTestCaseListFilterParams( + params, + ['testCaseType', 'tableFqn'], + 'nulls' + ) + ).toEqual({ + testCaseType: 'column', + entityLink: '<#E::table::sample_data.ecommerce_db.shopify.fact_sale>', + testCaseStatus: [TestCaseStatus.Failed, TestCaseStatus.Aborted], + q: 'nulls', + }); + }); + + it('should drop an empty status and search text', () => { + expect( + getTestCaseListFilterParams( + { testCaseStatus: [] } as unknown as TestCaseSearchParams, + [], + '' + ) + ).toEqual({ testCaseStatus: undefined, q: undefined }); + }); + }); + + describe('getTestCaseExportFilters', () => { + it('should keep only the params the export accepts', () => { + expect( + getTestCaseExportFilters({ + testCaseStatus: TestCaseStatus.Failed, + q: 'nulls', + startTimestamp: 1720417883445, + endTimestamp: 1721022683445, + limit: 15, + offset: 30, + sortField: 'testCaseResult.timestamp', + fields: 'testCaseResult', + include: Include.Deleted, + includeAllTests: true, + includePermissions: true, + testSuiteId: 'suite-id', + } as ListTestCaseParamsBySearch) + ).toEqual({ + testCaseStatus: TestCaseStatus.Failed, + q: 'nulls', + startTimestamp: 1720417883445, + endTimestamp: 1721022683445, + }); + }); + + it('should drop empty values', () => { + expect( + getTestCaseExportFilters({ + testCaseStatus: [], + q: '', + tier: undefined, + testCaseType: TestCaseType.all, + } as unknown as ListTestCaseParamsBySearch) + ).toEqual({ testCaseType: TestCaseType.all }); + }); + + it('should return no filters for no params', () => { + expect(getTestCaseExportFilters()).toEqual({}); + }); + }); + + describe('getTestCaseBulkEditSearch', () => { + const filters = { + testCaseStatus: [TestCaseStatus.Failed, TestCaseStatus.Aborted], + entityLink: '<#E::table::sample_data.ecommerce_db.shopify.fact_sale>', + tags: 'PII.Sensitive', + q: 'nulls, a & b', + }; + + it('should round-trip the filters through the Bulk Edit URL', () => { + const search = getTestCaseBulkEditSearch(EntityType.TABLE, filters); + + expect(new URLSearchParams(search).get('sourceEntityType')).toBe( + EntityType.TABLE + ); + expect(getTestCaseExportFiltersFromSearch(search)).toEqual(filters); + }); + + it('should keep a single status a single value', () => { + expect( + getTestCaseExportFiltersFromSearch( + getTestCaseBulkEditSearch(undefined, { + testCaseStatus: TestCaseStatus.Failed, + }) + ) + ).toEqual({ testCaseStatus: TestCaseStatus.Failed }); + }); + + it('should not add a query string without a source or filters', () => { + expect(getTestCaseBulkEditSearch()).toBe(''); + expect(getTestCaseBulkEditSearch(EntityType.TEST_SUITE)).toBe( + '?sourceEntityType=testSuite' + ); + }); + + it('should ignore params the export does not accept', () => { + expect( + getTestCaseExportFiltersFromSearch( + '?sourceEntityType=table&testCaseType=column&limit=10¤tPage=3' + ) + ).toEqual({ testCaseType: 'column' }); + }); + }); + describe('buildMustEsFilterForOwner', () => { it('should return nested filter for owner when isTestCaseResult is false', () => { const ownerFqn = 'owner1'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/TestCaseExport.utils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/TestCaseExport.utils.ts new file mode 100644 index 000000000000..09509f7766d6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/TestCaseExport.utils.ts @@ -0,0 +1,22 @@ +/* + * 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 { + exportTestCasesInCSV, + TestCaseExportFilters, +} from '../../rest/testAPI'; + +export const withTestCaseExportFilters = (filters?: TestCaseExportFilters) => { + return (name: string, params?: { recursive?: boolean }) => + exportTestCasesInCSV(name, { ...params, ...filters }); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityBulkEdit/EntityBulkEditUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityBulkEdit/EntityBulkEditUtils.test.tsx index 9f3c1ee3790d..2db75f42c11d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityBulkEdit/EntityBulkEditUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityBulkEdit/EntityBulkEditUtils.test.tsx @@ -15,6 +15,8 @@ import { render, screen } from '@testing-library/react'; import { WILD_CARD_CHAR } from '../../constants/char.constants'; import { ROUTES } from '../../constants/constants'; import { EntityType } from '../../enums/entity.enum'; +import { TestCaseStatus } from '../../generated/tests/testCase'; +import { exportTestCasesInCSV } from '../../rest/testAPI'; import { getBulkEditButton, getBulkEditCSVExportEntityApi, @@ -144,6 +146,35 @@ describe('EntityBulkEditUtils', () => { expect(result).toBeDefined(); }); + it('should send the test case list filters with the export', async () => { + const exportApi = getBulkEditCSVExportEntityApi(EntityType.TEST_CASE, { + testCaseStatus: TestCaseStatus.Failed, + q: 'nulls', + }); + + await exportApi('sample_data.ecommerce_db.shopify.fact_sale', { + recursive: false, + }); + + expect(exportTestCasesInCSV).toHaveBeenCalledWith( + 'sample_data.ecommerce_db.shopify.fact_sale', + { recursive: false, testCaseStatus: TestCaseStatus.Failed, q: 'nulls' } + ); + }); + + it('should export every test case when the list had no filters', async () => { + await getBulkEditCSVExportEntityApi(EntityType.TEST_CASE)( + WILD_CARD_CHAR, + { + recursive: false, + } + ); + + expect(exportTestCasesInCSV).toHaveBeenCalledWith(WILD_CARD_CHAR, { + recursive: false, + }); + }); + it('should return exportTableDetailsInCSV for unknown entity type', () => { const result = getBulkEditCSVExportEntityApi( 'UNKNOWN_TYPE' as EntityType diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityBulkEdit/EntityBulkEditUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityBulkEdit/EntityBulkEditUtils.tsx index 689e2d1277f2..8092b6f8b7b7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityBulkEdit/EntityBulkEditUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityBulkEdit/EntityBulkEditUtils.tsx @@ -28,7 +28,8 @@ import { import { exportMetricDetailsInCSVSync } from '../../rest/metricsAPI'; import { exportDatabaseServiceDetailsInCSV } from '../../rest/serviceAPI'; import { exportTableDetailsInCSV } from '../../rest/tableAPI'; -import { exportTestCasesInCSV } from '../../rest/testAPI'; +import { TestCaseExportFilters } from '../../rest/testAPI'; +import { withTestCaseExportFilters } from '../DataQuality/TestCaseExport.utils'; import entityUtilClassBase from '../EntityUtilClassBase'; import { t } from '../i18next/LocalUtil'; import { getDataQualityPagePath } from '../RouterUtils'; @@ -37,7 +38,10 @@ export const isBulkEditRoute = (pathname: string) => { return pathname.includes(ROUTES.BULK_EDIT_ENTITY); }; -export const getBulkEditCSVExportEntityApi = (entityType: EntityType) => { +export const getBulkEditCSVExportEntityApi = ( + entityType: EntityType, + testCaseExportFilters?: TestCaseExportFilters +) => { switch (entityType) { case EntityType.DATABASE_SERVICE: return exportDatabaseServiceDetailsInCSV; @@ -63,7 +67,7 @@ export const getBulkEditCSVExportEntityApi = (entityType: EntityType) => { return exportMetricDetailsInCSVSync; case EntityType.TEST_CASE: - return exportTestCasesInCSV; + return withTestCaseExportFilters(testCaseExportFilters); default: return exportTableDetailsInCSV; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TestCaseUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TestCaseUtils.test.tsx index 63ca48376e37..8413ded32aa5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TestCaseUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TestCaseUtils.test.tsx @@ -16,6 +16,8 @@ import { MemoryRouter } from 'react-router-dom'; import { ManageMenuItem as ItemType } from '../components/common/EntityPageInfos/ManageButton/ManageMenu'; import { ExportTypes } from '../constants/Export.constants'; import { EntityType } from '../enums/entity.enum'; +import { TestCaseType } from '../enums/TestSuite.enum'; +import { TestCaseStatus } from '../generated/tests/testCase'; import { exportTestCasesInCSV } from '../rest/testAPI'; import { ExtraTestCaseDropdownOptions } from './TestCaseUtils'; @@ -190,9 +192,40 @@ describe('TestCaseUtils', () => { expect(mockShowModal).toHaveBeenCalledWith({ name: fqn, - onExport: exportTestCasesInCSV, + onExport: expect.any(Function), exportTypes: [ExportTypes.CSV], }); + + mockShowModal.mock.calls.at(-1)[0].onExport(fqn, { recursive: true }); + + expect(exportTestCasesInCSV).toHaveBeenLastCalledWith(fqn, { + recursive: true, + }); + }); + + it('should export only the test cases matching the list filters', () => { + const permission = { ViewAll: true, EditAll: true }; + const result = ExtraTestCaseDropdownOptions( + fqn, + permission, + false, + mockNavigate, + mockShowModal, + EntityType.TABLE, + { + testCaseStatus: TestCaseStatus.Aborted, + testCaseType: TestCaseType.all, + } + ); + + (result[1] as { onClick?: () => void }).onClick?.(); + mockShowModal.mock.calls.at(-1)[0].onExport(fqn, { recursive: true }); + + expect(exportTestCasesInCSV).toHaveBeenLastCalledWith(fqn, { + recursive: true, + testCaseStatus: TestCaseStatus.Aborted, + testCaseType: TestCaseType.all, + }); }); it('should render bulk edit button with correct props', () => { @@ -248,6 +281,29 @@ describe('TestCaseUtils', () => { ); }); + it('should carry the list filters into the bulk edit URL', () => { + const permission = { ViewAll: true, EditAll: true }; + const result = ExtraTestCaseDropdownOptions( + fqn, + permission, + false, + mockNavigate, + mockShowModal, + EntityType.TABLE, + { + testCaseStatus: [TestCaseStatus.Failed, TestCaseStatus.Aborted], + testCaseType: TestCaseType.column, + q: '', + } + ); + + (result[2] as { onClick?: () => void }).onClick?.(); + + expect(mockNavigate).toHaveBeenCalledWith( + `/bulk-edit/testCase/${fqn}?sourceEntityType=table&testCaseStatus=Failed,Aborted&testCaseType=column` + ); + }); + it('should return import and bulk edit options when EditAll is true but ViewAll is false', () => { const permission = { ViewAll: false, EditAll: true }; const result = ExtraTestCaseDropdownOptions( diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TestCaseUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TestCaseUtils.tsx index b394d33997e2..617c539d5247 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TestCaseUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TestCaseUtils.tsx @@ -26,7 +26,9 @@ import { ExportTypes } from '../constants/Export.constants'; import { EntityType } from '../enums/entity.enum'; import { TestCaseStatus } from '../generated/tests/testCase'; import LimitWrapper from '../hoc/LimitWrapper'; -import { exportTestCasesInCSV } from '../rest/testAPI'; +import { TestCaseExportFilters } from '../rest/testAPI'; +import { getTestCaseBulkEditSearch } from './DataQuality/DataQualityPureUtils'; +import { withTestCaseExportFilters } from './DataQuality/TestCaseExport.utils'; import { getEntityBulkEditPath, getEntityImportPath } from './EntityPureUtils'; import { t } from './i18next/LocalUtil'; @@ -50,13 +52,18 @@ interface TestCasePermission { EditAll: boolean; } +/** + * `listFilters` are the filters of the test case list the menu belongs to, so + * Export and Bulk Edit both select the test cases the list shows. + */ export const getTestCaseManageMenuItems = ( fqn: string, permission: TestCasePermission, deleted: boolean, navigate: NavigateFunction, showModal: (data: ExportData) => void, - sourceEntityType?: EntityType.TABLE | EntityType.TEST_SUITE + sourceEntityType?: EntityType.TABLE | EntityType.TEST_SUITE, + listFilters?: TestCaseExportFilters ): ManageMenuItem[] => { const { ViewAll, EditAll } = permission; @@ -99,7 +106,7 @@ export const getTestCaseManageMenuItems = ( onClick: () => showModal({ name: fqn, - onExport: exportTestCasesInCSV, + onExport: withTestCaseExportFilters(listFilters), exportTypes: [ExportTypes.CSV], }), }, @@ -118,7 +125,10 @@ export const getTestCaseManageMenuItems = ( limitResource: 'testCase', onClick: () => navigate( - withSource(getEntityBulkEditPath(EntityType.TEST_CASE, fqn)) + `${getEntityBulkEditPath( + EntityType.TEST_CASE, + fqn + )}${getTestCaseBulkEditSearch(sourceEntityType, listFilters)}` ), }, ] @@ -132,7 +142,8 @@ export const ExtraTestCaseDropdownOptions = ( deleted: boolean, navigate: NavigateFunction, showModal: (data: ExportData) => void, - sourceEntityType?: EntityType.TABLE | EntityType.TEST_SUITE + sourceEntityType?: EntityType.TABLE | EntityType.TEST_SUITE, + listFilters?: TestCaseExportFilters ): ItemType[] => getTestCaseManageMenuItems( fqn, @@ -140,7 +151,8 @@ export const ExtraTestCaseDropdownOptions = ( deleted, navigate, showModal, - sourceEntityType + sourceEntityType, + listFilters ).map((item) => { const label = ( Date: Fri, 16 Oct 2026 05:21:01 +0000 Subject: [PATCH 14/28] Fixes open-metadata/openmetadata-collate#7447: forward aria-label on ProgressBarBase; show Deprecated on the Column Bulk Operations page (#35491) * fix(ui): forward aria-label and aria-labelledby on ProgressBarBase ProgressBarBase had a closed props interface and rendered none of the ARIA naming props, so every caller's aria-label was dropped and the progressbar had no accessible name. ProgressBar passes them through too. * feat(ui): support a text badge on sub-nav items SubNavItem gains badgeKey, an i18n key rendered as a text badge in place of the count, so an item can be tagged (e.g. Deprecated). The badge text joins the item's aria-label, which would otherwise hide it. * feat(ui): mark Column Bulk Operations as Deprecated in the classic sidebar Classic sidebar items gain isDeprecated, rendered as a warning badge. Column Bulk Operations switches from the Beta badge to Deprecated. * Revert "feat(ui): mark Column Bulk Operations as Deprecated in the classic sidebar" This reverts commit 3be32e5e66747f6de11bea19ab4d003d95cf7621. * Revert "feat(ui): support a text badge on sub-nav items" This reverts commit 1b1e78bd1fd5937d01a263edb83bf9d44e574756. * feat(ui): show a Deprecated badge on the Column Bulk Operations page header Mark the feature deprecated on the page itself rather than in the navigation: the badge sits next to the title in AI mode and next to the breadcrumb in classic mode. The classic sidebar drops its Beta tag. * fix(ui): align the Deprecated badge with the classic breadcrumb The breadcrumb's bottom margin made the row taller than the text, so the centred badge sat low. Move the margin from the breadcrumb to the row. * feat(ui): explain the Column Bulk Operations deprecation on badge hover Hovering (or keyboard-focusing) the Deprecated badge opens a HoverCard stating the feature is deprecated but remains fully functional. Co-Authored-By: Claude Opus 5.5 * fix(ui): describe the deprecation to screen readers on the badge The hover card is portalled and absent until opened, so the badge button now points aria-describedby at a visually hidden copy of the message. Co-Authored-By: Claude Opus 5.5 * refactor(ui): use the core Tooltip for the Column Bulk Operations deprecation The explanation is non-interactive, so core Tooltip fits better than HoverCard: it wraps the badge in its own focusable trigger and links its content via aria-describedby, removing the raw AriaButton and the visually hidden duplicate description. Co-Authored-By: Claude Opus 5.5 * refactor(ui): trim the deprecation Tooltip's class overrides to the minimum Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../progress-indicators.test.tsx | 47 ++++++++++++- .../progress-indicators.tsx | 15 +++- .../ui/src/constants/LeftSidebar.constants.ts | 1 - .../ui/src/locale/languages/ar-sa.json | 2 + .../ui/src/locale/languages/de-de.json | 2 + .../ui/src/locale/languages/en-us.json | 2 + .../ui/src/locale/languages/es-es.json | 2 + .../ui/src/locale/languages/fr-fr.json | 2 + .../ui/src/locale/languages/gl-es.json | 2 + .../ui/src/locale/languages/he-he.json | 2 + .../ui/src/locale/languages/ja-jp.json | 2 + .../ui/src/locale/languages/ko-kr.json | 2 + .../ui/src/locale/languages/mr-in.json | 2 + .../ui/src/locale/languages/nl-nl.json | 2 + .../ui/src/locale/languages/pr-pr.json | 2 + .../ui/src/locale/languages/pt-br.json | 2 + .../ui/src/locale/languages/pt-pt.json | 2 + .../ui/src/locale/languages/ru-ru.json | 2 + .../ui/src/locale/languages/sv-se.json | 2 + .../ui/src/locale/languages/th-th.json | 2 + .../ui/src/locale/languages/tr-tr.json | 2 + .../ui/src/locale/languages/zh-cn.json | 2 + .../ui/src/locale/languages/zh-tw.json | 2 + .../ColumnBulkOperations.component.tsx | 70 ++++++++++++++++++- .../ColumnBulkOperations.test.tsx | 70 +++++++++++++++++++ 25 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnBulkOperations.test.tsx diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/progress-indicators/progress-indicators.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/progress-indicators/progress-indicators.test.tsx index 99d62f064827..1783fe3cc02a 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/progress-indicators/progress-indicators.test.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/progress-indicators/progress-indicators.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 Collate. + * 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 @@ -12,7 +12,36 @@ */ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { ProgressBar } from './progress-indicators'; +import { ProgressBar, ProgressBarBase } from './progress-indicators'; + +describe('ProgressBarBase', () => { + it('names the progressbar from aria-label', () => { + render(); + + expect( + screen.getByRole('progressbar', { name: 'Marketing' }) + ).toHaveAttribute('aria-valuenow', '51'); + }); + + it('names the progressbar from aria-labelledby', () => { + render( + <> + Coverage + + + ); + + expect( + screen.getByRole('progressbar', { name: 'Coverage' }) + ).toBeInTheDocument(); + }); + + it('adds no name when none is given', () => { + render(); + + expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-label'); + }); +}); describe('ProgressBar', () => { it('retains chart-series colors through the labeled component', () => { @@ -26,4 +55,18 @@ describe('ProgressBar', () => { }); expect(screen.getByText('42%')).toBeInTheDocument(); }); + + it('passes aria-label through to the progressbar', () => { + render( + + ); + + expect( + screen.getByRole('progressbar', { name: 'Assets graded' }) + ).toBeInTheDocument(); + }); }); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/progress-indicators/progress-indicators.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/progress-indicators/progress-indicators.tsx index a13f11aa9010..8983876ab557 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/progress-indicators/progress-indicators.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/progress-indicators/progress-indicators.tsx @@ -1,6 +1,11 @@ +import type { HTMLAttributes } from 'react'; import { cx } from '@/utils/cx'; -export interface ProgressBarProps { +export interface ProgressBarProps + extends Pick< + HTMLAttributes, + 'aria-label' | 'aria-labelledby' + > { /** * The current value of the progress bar. */ @@ -45,11 +50,15 @@ export const ProgressBarBase = ({ max = 100, className, progressClassName, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, }: ProgressBarProps) => { const percentage = ((value - min) * 100) / (max - min); return (
{ const percentage = ((value - min) * 100) / (max - min); const formattedValue = valueFormatter @@ -110,6 +121,8 @@ export const ProgressBar = ({ const baseProgressBar = ( = [ redirect_url: ROUTES.COLUMN_BULK_OPERATIONS, icon: ColumnBulkIcon, dataTestId: `app-bar-item-${SidebarItem.COLUMN_BULK_OPERATIONS}`, - isBeta: true, }, { key: ROUTES.WORKFLOWS, diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json index 35e76b8ce481..ba7506fb657d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json @@ -8,6 +8,7 @@ "a-to-z": "من A إلى Z", "a-z": "أ-ي", "aborted": "تم الإحباط", + "about-this-feature": "حول هذه الميزة", "accept": "قبول", "accept-all": "قبول الكل", "accept-count-non-conflicting": "قبول {{count}} بدون تعارض", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "فشل تحميل الصورة", "failed-to-load-learning-resources": "تعذّر تحميل موارد التعلم. يرجى المحاولة لاحقًا.", "failed-x-checks": "فشل {{failed}} من أصل {{count}} عملية تحقق", + "feature-deprecated-but-functional": "هذه الميزة مهملة لكنها لا تزال تعمل بالكامل. يمكنك الاستمرار في استخدامها لسير العمل الحالي.", "feed-asset-action-header": "{{action}} <0>أصل بيانات", "feed-custom-property-header": "تم تحديث الخصائص المخصصة على", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 8ffcb6c5c4e8..f2fe1e8d818a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -8,6 +8,7 @@ "a-to-z": "A bis Z", "a-z": "A–Z", "aborted": "Abgebrochen", + "about-this-feature": "Über diese Funktion", "accept": "Akzeptieren", "accept-all": "Alle akzeptieren", "accept-count-non-conflicting": "{{count}} konfliktfreie akzeptieren", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Bild konnte nicht geladen werden", "failed-to-load-learning-resources": "Lernressourcen können nicht geladen werden. Bitte versuchen Sie es später noch einmal.", "failed-x-checks": "{{failed}} fehlgeschlagen von {{count}} Prüfungen", + "feature-deprecated-but-functional": "Diese Funktion ist veraltet, bleibt aber voll funktionsfähig. Sie können sie weiterhin für Ihre bestehenden Workflows verwenden.", "feed-asset-action-header": "{{action}} <0>Datenbestand", "feed-custom-property-header": "Benutzerdefinierte Eigenschaften aktualisiert auf", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 30b18c32c0f7..d2120ee13d19 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -8,6 +8,7 @@ "a-to-z": "A to Z", "a-z": "A–Z", "aborted": "Aborted", + "about-this-feature": "About this feature", "accept": "Accept", "accept-all": "Accept All", "accept-count-non-conflicting": "Accept {{count}} non-conflicting", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Failed to load image", "failed-to-load-learning-resources": "Unable to load learning resources. Please try again later.", "failed-x-checks": "{{failed}} failed out of {{count}} checks", + "feature-deprecated-but-functional": "This feature is deprecated but remains fully functional. You can continue using it for your existing workflows.", "feed-asset-action-header": "{{action}} <0>data asset", "feed-custom-property-header": "updated Custom Properties on", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index 5f572a0c53e8..39601d336002 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -8,6 +8,7 @@ "a-to-z": "A a Z", "a-z": "A–Z", "aborted": "Abortado", + "about-this-feature": "Acerca de esta función", "accept": "Aceptar", "accept-all": "Aceptar todo", "accept-count-non-conflicting": "Aceptar {{count}} sin conflictos", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Error al cargar la imagen", "failed-to-load-learning-resources": "No se pueden cargar recursos de aprendizaje.Inténtelo de nuevo más tarde.", "failed-x-checks": "{{failed}} fallidas de {{count}} verificaciones", + "feature-deprecated-but-functional": "Esta función está obsoleta, pero sigue siendo totalmente funcional. Puede seguir usándola para sus flujos de trabajo existentes.", "feed-asset-action-header": "{{action}} <0>activo de datos", "feed-custom-property-header": "actualizado Propiedades Personalizadas en", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 80ab95485689..e4b53a9c4a8b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -8,6 +8,7 @@ "a-to-z": "A à Z", "a-z": "A–Z", "aborted": "Interrompu", + "about-this-feature": "À propos de cette fonctionnalité", "accept": "Accepter", "accept-all": "Tout Accepter", "accept-count-non-conflicting": "Accepter {{count}} sans conflit", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Échec du chargement de l'image", "failed-to-load-learning-resources": "Impossible de charger les ressources d'apprentissage.Veuillez réessayer plus tard.", "failed-x-checks": "{{failed}} échecs sur {{count}} vérifications", + "feature-deprecated-but-functional": "Cette fonctionnalité est obsolète mais reste pleinement fonctionnelle. Vous pouvez continuer à l'utiliser pour vos flux de travail existants.", "feed-asset-action-header": "{{action}} <0>actif de données", "feed-custom-property-header": "a mis à jour la propriété personnalisée le", "feed-entity-action-header": "{{action}} <0>actif de données", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index 798005fb573e..01fe66b0e705 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -8,6 +8,7 @@ "a-to-z": "A a Z", "a-z": "A–Z", "aborted": "Abortado", + "about-this-feature": "Sobre esta funcionalidade", "accept": "Aceptar", "accept-all": "Aceptar todo", "accept-count-non-conflicting": "Aceptar {{count}} sen conflitos", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Erro ao cargar a imaxe", "failed-to-load-learning-resources": "Non se poden cargar os recursos de aprendizaxe. Téntao máis tarde.", "failed-x-checks": "{{failed}} fallidas de {{count}} verificacións", + "feature-deprecated-but-functional": "Esta funcionalidade está obsoleta, pero segue sendo totalmente funcional. Podes seguir usándoa para os teus fluxos de traballo existentes.", "feed-asset-action-header": "{{action}} <0>activo de datos", "feed-custom-property-header": "actualizou Propiedades Personalizadas en", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index 48a8961eaf9b..3bcdac1aa4fb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -8,6 +8,7 @@ "a-to-z": "א עד ז", "a-z": "א–ת", "aborted": "בוטל", + "about-this-feature": "אודות תכונה זו", "accept": "קבל", "accept-all": "אשר הכל", "accept-count-non-conflicting": "קבל {{count}} ללא התנגשות", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "טעינת התמונה נכשלה", "failed-to-load-learning-resources": "לא ניתן לטעון משאבי למידה. אנא נסה שוב מאוחר יותר.", "failed-x-checks": "{{failed}} נכשלו מתוך {{count}} בדיקות", + "feature-deprecated-but-functional": "תכונה זו הוצאה משימוש אך עדיין פועלת במלואה. ניתן להמשיך להשתמש בה עבור תהליכי העבודה הקיימים שלך.", "feed-asset-action-header": "{{action}} <0>נכס נתונים", "feed-custom-property-header": "עודכן מאפיינים מותאמים אישית על", "feed-entity-action-header": "{{action}} <0>data asset", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 9e34eaa2c46b..ac75072bee37 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -8,6 +8,7 @@ "a-to-z": "AからZ", "a-z": "五十音順", "aborted": "中断", + "about-this-feature": "この機能について", "accept": "承認", "accept-all": "すべて承認", "accept-count-non-conflicting": "競合のない {{count}} 件を承認", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "画像の読み込みに失敗しました", "failed-to-load-learning-resources": "学習リソースをロードできません。後でもう一度試してください。", "failed-x-checks": "{{count}}件のチェック中{{failed}}件が失敗", + "feature-deprecated-but-functional": "この機能は非推奨ですが、引き続きすべての機能をご利用いただけます。既存のワークフローで引き続き使用できます。", "feed-asset-action-header": "<0>データアセット を {{action}}", "feed-custom-property-header": "カスタムプロパティを更新", "feed-entity-action-header": "<0>データアセット を {{action}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index a457ab630985..602e92a000e9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -8,6 +8,7 @@ "a-to-z": "A에서 Z", "a-z": "가나다순", "aborted": "중단됨", + "about-this-feature": "이 기능 정보", "accept": "수락", "accept-all": "모두 수락", "accept-count-non-conflicting": "충돌 없는 {{count}}개 수락", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "이미지 로드 실패", "failed-to-load-learning-resources": "학습 자료를 불러올 수 없습니다. 나중에 다시 시도하세요.", "failed-x-checks": "{{count}}개 중 {{failed}}개 실패", + "feature-deprecated-but-functional": "이 기능은 더 이상 사용되지 않지만 모든 기능이 정상적으로 작동합니다. 기존 워크플로에서 계속 사용할 수 있습니다.", "feed-asset-action-header": "<0>데이터 자산 {{action}}", "feed-custom-property-header": "사용자 정의 속성 업데이트됨:", "feed-entity-action-header": "<0>{{entity}} {{action}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 0402ed245d84..35cbede5e928 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -8,6 +8,7 @@ "a-to-z": "अ ते ज", "a-z": "अ–ज्ञ", "aborted": "निरस्त केले", + "about-this-feature": "या वैशिष्ट्याबद्दल", "accept": "स्वीकार करा", "accept-all": "सर्व स्वीकार करा", "accept-count-non-conflicting": "संघर्ष नसलेले {{count}} स्वीकार करा", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "प्रतिमा लोड करण्यात अयशस्वी", "failed-to-load-learning-resources": "शिक्षण संसाधने लोड करता आली नाहीत. कृपया नंतर पुन्हा प्रयत्न करा.", "failed-x-checks": "{{count}} तपासण्यांपैकी {{failed}} अयशस्वी", + "feature-deprecated-but-functional": "हे वैशिष्ट्य कालबाह्य झाले आहे परंतु पूर्णपणे कार्यरत आहे. तुम्ही तुमच्या विद्यमान वर्कफ्लोसाठी ते वापरणे सुरू ठेवू शकता.", "feed-asset-action-header": "{{action}} <0>डेटा ॲसेट", "feed-custom-property-header": "सानुकूल गुणधर्म अद्यतनित केले", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 388d88609456..bc46ad7fe5e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -8,6 +8,7 @@ "a-to-z": "A tot Z", "a-z": "A–Z", "aborted": "Afgebroken", + "about-this-feature": "Over deze functie", "accept": "Accepteren", "accept-all": "Alles Accepteren", "accept-count-non-conflicting": "{{count}} zonder conflict accepteren", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Laden van afbeelding mislukt", "failed-to-load-learning-resources": "Kan leerbronnen niet laden.Probeer het later opnieuw.", "failed-x-checks": "{{failed}} mislukt van de {{count}} controles", + "feature-deprecated-but-functional": "Deze functie is verouderd, maar blijft volledig functioneel. U kunt deze blijven gebruiken voor uw bestaande workflows.", "feed-asset-action-header": "{{action}} <0>gegevensitem", "feed-custom-property-header": "bijgewerkte aangepaste eigenschappen ingeschakeld", "feed-entity-action-header": "{{action}} <0>data asset", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index dcdfe476b1c7..dd9dcf027883 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -8,6 +8,7 @@ "a-to-z": "A'dan Z'ye", "a-z": "الف–ی", "aborted": "لغو شده", + "about-this-feature": "درباره این ویژگی", "accept": "پذیرفتن", "accept-all": "پذیرفتن همه", "accept-count-non-conflicting": "پذیرفتن {{count}} مورد بدون تعارض", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "بارگذاری تصویر ناموفق بود", "failed-to-load-learning-resources": "نتوانست منابع یادگیری را بارگذاری کند. لطفاً بعداً دوباره امتحان کنید.", "failed-x-checks": "{{failed}} از {{count}} بررسی ناموفق", + "feature-deprecated-but-functional": "این ویژگی منسوخ شده است اما همچنان به طور کامل کار می‌کند. می‌توانید همچنان از آن برای گردش‌های کاری موجود خود استفاده کنید.", "feed-asset-action-header": "{{action}} <0>دارایی داده", "feed-custom-property-header": "بروزرسانی ویژگی‌های سفارشی روی", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 6d3030088b44..b4856a85874d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -8,6 +8,7 @@ "a-to-z": "A a Z", "a-z": "A–Z", "aborted": "Abortado", + "about-this-feature": "Sobre este recurso", "accept": "Aceitar", "accept-all": "Aceitar todos", "accept-count-non-conflicting": "Aceitar {{count}} sem conflito", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Falha ao carregar imagem", "failed-to-load-learning-resources": "Não foi possível carregar os recursos de aprendizagem.Por favor, tente novamente mais tarde.", "failed-x-checks": "{{failed}} falharam de {{count}} verificações", + "feature-deprecated-but-functional": "Este recurso está obsoleto, mas continua totalmente funcional. Você pode continuar usando-o em seus fluxos de trabalho existentes.", "feed-asset-action-header": "{{action}} <0>ativo de dados ", "feed-custom-property-header": "propriedades personalizadas atualizadas em", "feed-entity-action-header": "{{action}} <0>data asset", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index ad050170ee19..e39c7fe8c6e5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -8,6 +8,7 @@ "a-to-z": "A a Z", "a-z": "A–Z", "aborted": "Abortado", + "about-this-feature": "Sobre esta funcionalidade", "accept": "Aceitar", "accept-all": "Aceitar Tudo", "accept-count-non-conflicting": "Aceitar {{count}} sem conflito", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Falha ao carregar imagem", "failed-to-load-learning-resources": "Não foi possível carregar os recursos de aprendizagem. Tente novamente mais tarde.", "failed-x-checks": "{{failed}} falharam de {{count}} verificações", + "feature-deprecated-but-functional": "Esta funcionalidade está obsoleta, mas continua totalmente funcional. Pode continuar a utilizá-la nos seus fluxos de trabalho existentes.", "feed-asset-action-header": "{{action}} <0>ativo de dados", "feed-custom-property-header": "atualizou Propriedades Personalizadas em", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 146a9921d273..68cbe6627843 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -8,6 +8,7 @@ "a-to-z": "А до Я", "a-z": "А–Я", "aborted": "Прервано", + "about-this-feature": "Об этой функции", "accept": "Принять", "accept-all": "Принять все", "accept-count-non-conflicting": "Принять {{count}} без конфликтов", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Не удалось загрузить изображение", "failed-to-load-learning-resources": "Невозможно загрузить учебные ресурсы.Пожалуйста, повторите попытку позже.", "failed-x-checks": "{{failed}} не прошли из {{count}} проверок", + "feature-deprecated-but-functional": "Эта функция устарела, но остаётся полностью работоспособной. Вы можете продолжать использовать её в существующих рабочих процессах.", "feed-asset-action-header": "{{action}} <0>объект данных", "feed-custom-property-header": "обновлены дополнительные поля для", "feed-entity-action-header": "{{action}} <0>объект данных", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json index 3491869a9a1e..65883d0dd6b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json @@ -8,6 +8,7 @@ "a-to-z": "A till Ö", "a-z": "A–Ö", "aborted": "Avbruten", + "about-this-feature": "Om den här funktionen", "accept": "Acceptera", "accept-all": "Acceptera alla", "accept-count-non-conflicting": "Acceptera {{count}} utan konflikt", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Det gick inte att läsa in bilden", "failed-to-load-learning-resources": "Det går inte att läsa in utbildningsresurser. Försök igen senare.", "failed-x-checks": "{{failed}} misslyckades av {{count}} kontroller", + "feature-deprecated-but-functional": "Den här funktionen är föråldrad men fungerar fortfarande fullt ut. Du kan fortsätta att använda den för dina befintliga arbetsflöden.", "feed-asset-action-header": "{{action}} <0>datatillgång", "feed-custom-property-header": "uppdaterade anpassade egenskaper på", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index 68d4ef3a816a..07e7cca37085 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -8,6 +8,7 @@ "a-to-z": "A ถึง Z", "a-z": "ก–ฮ", "aborted": "ยกเลิก", + "about-this-feature": "เกี่ยวกับฟีเจอร์นี้", "accept": "ยอมรับ", "accept-all": "ยอมรับทั้งหมด", "accept-count-non-conflicting": "ยอมรับ {{count}} รายการที่ไม่ขัดแย้ง", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "โหลดภาพล้มเหลว", "failed-to-load-learning-resources": "ไม่สามารถโหลดทรัพยากรการเรียนรู้ได้ โปรดลองอีกครั้งในภายหลัง", "failed-x-checks": "ล้มเหลว {{failed}} จาก {{count}} การตรวจสอบ", + "feature-deprecated-but-functional": "ฟีเจอร์นี้เลิกใช้งานแล้ว แต่ยังคงทำงานได้อย่างสมบูรณ์ คุณสามารถใช้งานต่อไปสำหรับเวิร์กโฟลว์ที่มีอยู่ของคุณได้", "feed-asset-action-header": "{{action}} <0>สินทรัพย์ข้อมูล", "feed-custom-property-header": "ปรับปรุงคุณสมบัติที่กำหนดเองเมื่อ", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index 1f7c89d8bc8f..eb26ba070c79 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -8,6 +8,7 @@ "a-to-z": "A'dan Z'ye", "a-z": "A–Z", "aborted": "İptal Edildi", + "about-this-feature": "Bu özellik hakkında", "accept": "Kabul Et", "accept-all": "Tümünü Kabul Et", "accept-count-non-conflicting": "Çakışmayan {{count}} öğeyi kabul et", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "Görsel yüklenemedi", "failed-to-load-learning-resources": "Öğrenme kaynakları yüklenemiyor. Lütfen daha sonra tekrar deneyin.", "failed-x-checks": "{{count}} kontrolden {{failed}} tanesi başarısız", + "feature-deprecated-but-functional": "Bu özellik kullanımdan kaldırılmıştır ancak tamamen işlevsel olmaya devam etmektedir. Mevcut iş akışlarınız için kullanmaya devam edebilirsiniz.", "feed-asset-action-header": "{{action}} <0>veri varlığı", "feed-custom-property-header": "üzerindeki Özel Özellikler güncellendi", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index bacc60236e14..70bff2af4f28 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -8,6 +8,7 @@ "a-to-z": "A到Z", "a-z": "按名称排序", "aborted": "已中止", + "about-this-feature": "关于此功能", "accept": "接受", "accept-all": "接受所有", "accept-count-non-conflicting": "接受 {{count}} 项无冲突更改", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "加载图片失败", "failed-to-load-learning-resources": "无法加载学习资源。请稍后重试。", "failed-x-checks": "{{count}} 项检查中 {{failed}} 项失败", + "feature-deprecated-but-functional": "此功能已弃用,但仍可完全正常使用。您可以继续在现有工作流中使用它。", "feed-asset-action-header": "{{action}} <0>数据资产", "feed-custom-property-header": "更新了自定义属性于", "feed-entity-action-header": "{{action}} <0>data asset", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index 8d34010ec0ee..47ad4c284876 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -8,6 +8,7 @@ "a-to-z": "A 到 Z", "a-z": "按名稱排序", "aborted": "已中止", + "about-this-feature": "關於此功能", "accept": "接受", "accept-all": "全部接受", "accept-count-non-conflicting": "接受 {{count}} 項無衝突變更", @@ -4640,6 +4641,7 @@ "failed-to-load-image": "加載圖片失敗", "failed-to-load-learning-resources": "無法載入學習資源。請稍後再試。", "failed-x-checks": "{{count}} 個檢查中有 {{failed}} 個失敗", + "feature-deprecated-but-functional": "此功能已棄用,但仍可完全正常使用。您可以繼續在現有工作流程中使用它。", "feed-asset-action-header": "{{action}} <0>資料資產", "feed-custom-property-header": "更新了自訂屬性於", "feed-entity-action-header": "{{action}} <0>{{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnBulkOperations.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnBulkOperations.component.tsx index ac4b69bae9ee..a021748beec6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnBulkOperations.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnBulkOperations.component.tsx @@ -10,7 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PageLayout } from '@openmetadata/ui-core-components'; +import { + Badge, + BadgeWithIcon, + Box, + PageLayout, + Tooltip, + Typography, +} from '@openmetadata/ui-core-components'; +import { InfoCircle } from '@openmetadata/ui-core-components/icons'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import HeaderBreadcrumb from '../../components/common/HeaderBreadcrumb/HeaderBreadcrumb.component'; @@ -31,6 +39,56 @@ const ColumnBulkOperations = () => { { label: t('label.column-bulk-operations') }, ]; + const deprecatedBadge = ( + + + + + {t('label.about-this-feature')} + + + + + + {t('label.column-bulk-operations')} + + + {t('label.deprecated')} + + + + {t('message.feature-deprecated-but-functional')} + + +
+ } + triggerClassName="tw:rounded-full"> + + {t('label.deprecated')} + + + ); + return ( { })}> {isAiMode ? ( { variant="gradient" /> ) : ( - + + + {deprecatedBadge} + )}
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnBulkOperations.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnBulkOperations.test.tsx new file mode 100644 index 000000000000..95e9f27e3add --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnBulkOperations.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { useIsAiMode } from '../../hooks/useAppMode'; +import ColumnBulkOperations from './ColumnBulkOperations.component'; + +jest.mock('../../hooks/useAppMode', () => ({ + useIsAiMode: jest.fn(), +})); + +jest.mock('./ColumnGrid/ColumnGrid.component', () => + jest.fn(() =>
) +); + +jest.mock('../../components/PageLayoutV1/PageLayoutV1', () => + jest.fn(({ children }) =>
{children}
) +); + +const renderPage = (isAiMode: boolean) => { + (useIsAiMode as jest.Mock).mockReturnValue(isAiMode); + + return render( + + + + ); +}; + +const getBadge = () => screen.getByRole('button', { name: 'label.deprecated' }); + +describe('ColumnBulkOperations', () => { + it.each([ + ['AI', true], + ['classic', false], + ])('shows the Deprecated badge in %s mode', (_, isAiMode) => { + renderPage(isAiMode); + + expect(getBadge()).toBeInTheDocument(); + expect(screen.getByTestId('column-grid')).toBeInTheDocument(); + }); + + it('explains the deprecation in a tooltip linked to the badge on keyboard focus', () => { + renderPage(true); + // Keyboard modality, so react-aria opens the tooltip on focus. + fireEvent.keyDown(document.body, { key: 'Tab' }); + act(() => getBadge().focus()); + + expect(screen.getByRole('tooltip')).toHaveTextContent( + 'message.feature-deprecated-but-functional' + ); + expect(getBadge()).toHaveAccessibleDescription( + expect.stringContaining('message.feature-deprecated-but-functional') + ); + + fireEvent.keyDown(getBadge(), { key: 'Escape' }); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); +}); From 6bfc93b7be9b75e631f640b0ec98f8bc9b36ee48 Mon Sep 17 00:00:00 2001 From: Anujkumar Yadav Date: Fri, 16 Oct 2026 05:30:23 +0000 Subject: [PATCH 15/28] Fixes #35519: Stabilise flaky glossary Playwright specs (#35520) * Fixes #35519: Stabilise flaky glossary Playwright specs - Related terms: pick the relation type through selectOptionWithRetry and re-pick until the trigger shows it; the bottom-row non-modal Select closed on scroll or took a stale click on the neighbouring option. - addAssetToGlossaryTerm: wait for PUT /assets/add before returning so teardown cannot delete the term mid-add. - Tag label row mapper: tolerate a tag_usage row whose tag/term is gone instead of throwing an NPE that made the asset unreadable and undeletable. - Request tags: open the glossary by FQN so the /glossary auto-select redirect cannot replace the request-tags navigation. - Glossary form tags: centre the trigger before opening so the drawer's late scroll does not close the dropdown. - Glossary import: use saveBulkImport so the toast wait starts before Update. - Bulk edit glossary: clean up on a fresh API context (idle keep-alive socket hang up). * chore(playwright): auto-refresh impact-map.generated.json * Drop positional locators from glossary related-terms helpers and spec Rows are found by the id the add-row click introduces (ids are Date.now()), the relation-type trigger by button[aria-haspopup=listbox], and the row to remove by its trigger's selected value. Prune the now-stale suppressions. * chore(playwright): auto-refresh impact-map.generated.json * Trim review comments to one-line summaries * Lower recorded no-positional-locator suppression count to 552 --------- Co-authored-by: github-actions[bot] --- .github/playwright/impact-map.generated.json | 8 -- .../service/jdbi3/ClassificationTagDAOs.java | 21 +++-- ...agLabelRowMapperWithTargetFqnHashTest.java | 47 ++++++++++++ .../resources/ui/eslint-suppressions.json | 7 +- .../e2e/Features/BulkEditEntity.spec.ts | 5 +- .../Glossary/GlossaryTermRelatedTerms.spec.ts | 32 +++++--- .../ui/playwright/e2e/Pages/Glossary.spec.ts | 10 ++- .../e2e/Pages/GlossaryImportExport.spec.ts | 23 ++---- .../eslint-rules/tests/corpus.test.mjs | 2 +- .../resources/ui/playwright/utils/glossary.ts | 76 ++++++++++++++----- .../ui/playwright/utils/glossaryForm.ts | 11 ++- 11 files changed, 168 insertions(+), 74 deletions(-) create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/TagLabelRowMapperWithTargetFqnHashTest.java diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index a91eb32f661e..1088e4838aa1 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -7458,14 +7458,6 @@ "playwright/e2e/Features/Glossary/GlossaryTermRelatedTerms.spec.ts" ] }, - { - "sources": [ - "openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/TermsRowEditor.component.tsx" - ], - "specs": [ - "playwright/e2e/Features/Glossary/GlossaryTermRelatedTerms.spec.ts" - ] - }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/WorkFlowTab/WorkflowHistory.component.tsx" diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ClassificationTagDAOs.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ClassificationTagDAOs.java index bf1ba3a2f8a8..6567371b0a23 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ClassificationTagDAOs.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ClassificationTagDAOs.java @@ -735,18 +735,23 @@ public Pair map(ResultSet r, StatementContext ctx) throws SQLE .withAppliedBy(r.getString("appliedBy")) .withMetadata(JsonUtils.readValue(r.getString("metadata"), TagLabelMetadata.class)); TagLabel.TagSource source = TagLabel.TagSource.values()[r.getInt("source")]; + // json is null when the tag/term was deleted (orphan tag_usage row); keep the label as-is. if (source == TagLabel.TagSource.CLASSIFICATION) { Tag tag = JsonUtils.readValue(r.getString("json"), Tag.class); - label.setName(tag.getName()); - label.setDisplayName(tag.getDisplayName()); - label.setDescription(tag.getDescription()); - label.setStyle(tag.getStyle()); + if (tag != null) { + label.setName(tag.getName()); + label.setDisplayName(tag.getDisplayName()); + label.setDescription(tag.getDescription()); + label.setStyle(tag.getStyle()); + } } else if (source == TagLabel.TagSource.GLOSSARY) { GlossaryTerm glossaryTerm = JsonUtils.readValue(r.getString("json"), GlossaryTerm.class); - label.setName(glossaryTerm.getName()); - label.setDisplayName(glossaryTerm.getDisplayName()); - label.setDescription(glossaryTerm.getDescription()); - label.setStyle(glossaryTerm.getStyle()); + if (glossaryTerm != null) { + label.setName(glossaryTerm.getName()); + label.setDisplayName(glossaryTerm.getDisplayName()); + label.setDescription(glossaryTerm.getDescription()); + label.setStyle(glossaryTerm.getStyle()); + } } else { throw new IllegalArgumentException("Invalid source type " + source); } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/TagLabelRowMapperWithTargetFqnHashTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/TagLabelRowMapperWithTargetFqnHashTest.java new file mode 100644 index 000000000000..3b092583a8fa --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/TagLabelRowMapperWithTargetFqnHashTest.java @@ -0,0 +1,47 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import java.sql.SQLException; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.type.TagLabel; +import org.openmetadata.service.jdbi3.ClassificationTagDAOs.TagUsageDAO.TagLabelRowMapperWithTargetFqnHash; + +class TagLabelRowMapperWithTargetFqnHashTest { + + private static ResultSet orphanRow(TagLabel.TagSource source) throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getInt("source")).thenReturn(source.ordinal()); + when(rs.getInt("labelType")).thenReturn(TagLabel.LabelType.MANUAL.ordinal()); + when(rs.getInt("state")).thenReturn(TagLabel.State.CONFIRMED.ordinal()); + when(rs.getString("tagFQN")).thenReturn("g.deletedTerm"); + when(rs.getString("targetFQNHash")).thenReturn("hash"); + when(rs.getString("json")).thenReturn(null); + return rs; + } + + @Test + void orphanGlossaryUsageMapsToUnenrichedLabel() throws SQLException { + Pair row = + new TagLabelRowMapperWithTargetFqnHash().map(orphanRow(TagLabel.TagSource.GLOSSARY), null); + + assertEquals("hash", row.getLeft()); + assertEquals("g.deletedTerm", row.getRight().getTagFQN()); + assertNull(row.getRight().getName()); + } + + @Test + void orphanClassificationUsageMapsToUnenrichedLabel() throws SQLException { + Pair row = + new TagLabelRowMapperWithTargetFqnHash() + .map(orphanRow(TagLabel.TagSource.CLASSIFICATION), null); + + assertEquals("g.deletedTerm", row.getRight().getTagFQN()); + assertNull(row.getRight().getName()); + } +} diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 8bbaae337986..fe085ff05b5b 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -202,11 +202,6 @@ "count": 3 } }, - "playwright/e2e/Features/Glossary/GlossaryTermRelatedTerms.spec.ts": { - "om-playwright/no-positional-locator": { - "count": 5 - } - }, "playwright/e2e/Features/Glossary/GlossaryWorkflow.spec.ts": { "om-playwright/no-positional-locator": { "count": 1 @@ -714,7 +709,7 @@ }, "playwright/utils/glossary.ts": { "om-playwright/no-positional-locator": { - "count": 11 + "count": 8 }, "playwright/no-wait-for-selector": { "count": 1 diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkEditEntity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkEditEntity.spec.ts index 7f45c4153672..e8454d94f7d0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkEditEntity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkEditEntity.spec.ts @@ -810,8 +810,11 @@ test.describe('Bulk Edit Entity', { tag: '@quarantine' }, () => { } }); - await glossary.delete(apiContext); await afterAction(); + // Fresh context: the setup one idled past the server keep-alive and its DELETE hangs up. + const cleanup = await getApiContext(page); + await glossary.delete(cleanup.apiContext); + await cleanup.afterAction(); }); test('Glossary Term (Nested)', async ({ page }) => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryTermRelatedTerms.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryTermRelatedTerms.spec.ts index b8217913d07a..2aa8346a9f36 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryTermRelatedTerms.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryTermRelatedTerms.spec.ts @@ -17,8 +17,12 @@ import { GlossaryTerm } from '../../../support/glossary/GlossaryTerm'; import { getApiContext, redirectToHomePage } from '../../../utils/common'; import { addRelatedTermsByRelationType, + addRelationRow, + getOnlyRelationRow, + getRelationRows, selectActiveGlossary, selectActiveGlossaryTerm, + selectRelationType, } from '../../../utils/glossary'; import { pickGlossaryTermInField } from '../../../utils/glossaryPicker'; import { sidebarClick } from '../../../utils/sidebar'; @@ -52,7 +56,7 @@ test.describe('Glossary Term — Related Terms', () => { await selectActiveGlossaryTerm(page, termA.data.displayName); await page.getByTestId('related-term-add-button').click(); - const firstRow = page.locator('[data-testid^="relation-row-"]').first(); + const firstRow = await getOnlyRelationRow(page); const termBName = termB.responseData?.displayName ?? termB.data.displayName; @@ -66,8 +70,7 @@ test.describe('Glossary Term — Related Terms', () => { } ); - await page.getByTestId('add-row-button').click(); - const secondRow = page.locator('[data-testid^="relation-row-"]').last(); + const secondRow = await addRelationRow(page); const termCName = termC.responseData?.displayName ?? termC.data.displayName; @@ -130,14 +133,11 @@ test.describe('Glossary Term — Related Terms', () => { .getByTestId('edit-button') .click(); - const row = page.locator('[data-testid^="relation-row-"]').first(); - await row.getByRole('button').first().click(); - const seeAlsoOption = page.getByRole('option', { - exact: true, - name: 'See Also', - }); - await expect(seeAlsoOption).toBeVisible(); - await seeAlsoOption.click(); + await selectRelationType( + page, + await getOnlyRelationRow(page), + 'See Also' + ); const saveRes = page.waitForResponse('/api/v1/glossaryTerms/*'); await page.getByTestId('save-related-terms').click(); @@ -200,7 +200,15 @@ test.describe('Glossary Term — Related Terms', () => { .getByTestId('edit-button') .click(); - await page.locator('[data-testid^="remove-row-"]').first().click(); + // Match on the trigger: each row's hidden native - -
- {edgeOptions.map((item) => { - const breadcrumb = Fqn.split(item.fullyQualifiedName ?? '').join('/'); - - return ( -
!isOpen && onModalCancel()}> + + + + + +
+ {edgeOptions.map((item) => { + const breadcrumb = Fqn.split( + item.fullyQualifiedName ?? '' + ).join('/'); + + return ( + setEdgeSelection(item)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + setEdgeSelection(item); + } + }}> + {searchClassBase.getEntityIconWithBg( + item.type, + EntityIconSize.Size14 + )} + + + {breadcrumb} + + + {getEntityName(item)} + + + + ); })} - data-testid={`pipeline-entry-${item.fullyQualifiedName}`} - key={item.id} - role="button" - tabIndex={0} - onClick={() => setEdgeSelection(item)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - setEdgeSelection(item); - } - }}> - {searchClassBase.getEntityIconWithBg( - item.type, - EntityIconSize.Size14 - )} -
-
- - {breadcrumb} - -
-
- - {getEntityName(item)} - -
-
-
- ); - })} - {errorPlaceholderEdge} -
- + {errorPlaceholderEdge} +
+ + + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/add-pipeline-modal.less b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/add-pipeline-modal.less deleted file mode 100644 index 5f9fdf48718d..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/add-pipeline-modal.less +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2023 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@import (reference) '../../../../styles/variables.less'; - -.edge-option-container { - margin-top: var(--om-space-6); - width: 100%; - height: 310px; - overflow: scroll; - - .edge-option-item { - display: flex; - align-items: center; - margin: var(--om-space-8) 0; - padding: var(--om-space-8); - border-radius: var(--om-radius-sm); - cursor: pointer; - - &.active { - background: var(--tw-color-bg-brand-primary); - - &:hover { - background: var(--tw-color-bg-brand-primary); - } - } - - &:hover { - background: var(--tw-color-bg-secondary); - } - - .breadcrumb { - color: @text-grey-muted; - font-size: var(--om-font-size-10); - } - } -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CanvasButtonPopover.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CanvasButtonPopover.component.tsx index 281b3f4131d5..3e1956285d94 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CanvasButtonPopover.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CanvasButtonPopover.component.tsx @@ -10,20 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Badge, BadgeColors } from '@openmetadata/ui-core-components'; +import { Badge } from '@openmetadata/ui-core-components'; import React, { MutableRefObject, useCallback } from 'react'; import { Edge, Viewport } from 'reactflow'; +import { PIPELINE_STATUS_BADGE_COLOR } from '../../../constants/Lineage.constants'; import { CanvasButton } from '../../../utils/CanvasButtonUtils'; import { getPipelineStatusClass } from '../../../utils/PipelineStatusUtils'; import { getAbsolutePosition } from '../../../utils/ViewportUtils'; import EntityPopOverCard from '../../common/PopOverCard/EntityPopOverCard'; -const PIPELINE_STATUS_BADGE_COLOR: Record = { - green: 'success', - red: 'error', - amber: 'warning', -}; - export interface CanvasButtonPopoverProps { hoveredButton: CanvasButton; hoveredEdge: Edge; @@ -57,6 +52,9 @@ export const CanvasButtonPopover: React.FC = ({ const pipelineData = hoveredEdge.data?.edge?.pipeline; const pipelineStatus = pipelineData?.pipelineStatus; + const pipelineStatusClass = getPipelineStatusClass( + pipelineStatus?.executionStatus + ); return (
@@ -620,55 +528,46 @@ const CustomControls: FC<{
- - -
{tabsSection} - {activeTab === 'impact_analysis' && ( - - )} - {lineageEditButton} - - - - - + + + + {settingsButton} - - - - updateURLParams({ - [FULLSCREEN_QUERY_PARAM_KEY]: !isFullScreen, - }) - } - /> - - + + + updateURLParams({ + [FULLSCREEN_QUERY_PARAM_KEY]: !isFullScreen, + }) + } + />
{filterSelectionSection} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomControls.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomControls.test.tsx index bae3fd28f115..1dd69741cc87 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomControls.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomControls.test.tsx @@ -10,14 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { EntityType } from '../../../enums/entity.enum'; @@ -60,8 +53,6 @@ const mockDefaultStoreState = { isDQEnabled: false, setLineageConfig: mockOnLineageConfigUpdate, lineageConfig: {}, - toggleEditMode: jest.fn(), - isEditMode: false, platformView: LineagePlatformView.None, sceneBand: undefined as LineageBand | undefined, nodes: [] as unknown[], @@ -285,68 +276,12 @@ describe('CustomControls', () => { expect(screen.getByLabelText('label.full-screen-view')).toBeInTheDocument(); }); - it('opens and dismisses the edit tooltip with the real core trigger', async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - render(, { + it('does not render the Edit Lineage button', () => { + render(, { wrapper: Wrapper, }); - const button = screen.getByRole('button', { name: 'label.edit-entity' }); - - expect(button.parentElement?.closest('button')).toBeNull(); - - fireEvent.mouseMove(document); - await user.hover(button); - - expect(await screen.findByRole('tooltip')).toHaveTextContent( - 'label.edit-entity' - ); - - await user.unhover(button); - await waitFor(() => - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() - ); - }); - - it('shows the zoom-in hint when hovering the disabled Layer edit button', async () => { - const store = useLineageStore as unknown as jest.Mock; - store.mockReturnValue({ ...store(), sceneBand: LineageBand.Layer }); - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - render(, { - wrapper: Wrapper, - }); - const button = screen.getByRole('button', { name: 'label.edit-entity' }); - - expect(button).toBeDisabled(); - expect(button.parentElement?.closest('button')).toBeNull(); - - fireEvent.mouseMove(document); - await user.hover(button); - - expect(await screen.findByRole('tooltip')).toHaveTextContent( - 'label.zoom-in' - ); - - await user.unhover(button); - await waitFor(() => - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() - ); - - const trigger = screen.getByRole('group', { name: 'label.edit-entity' }); - - expect(trigger).toHaveAttribute('tabindex', '0'); - - fireEvent.keyDown(document, { key: 'Tab' }); - act(() => trigger.focus()); - - expect(await screen.findByRole('tooltip')).toHaveTextContent( - 'label.zoom-in' - ); - - act(() => trigger.blur()); - await waitFor(() => - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() - ); + expect(screen.queryByTestId('edit-lineage')).not.toBeInTheDocument(); }); it('shows LineageSearchSelect by default in lineage mode', () => { @@ -355,7 +290,7 @@ describe('CustomControls', () => { }); expect(screen.getByTestId('lineage-search-select')).toBeInTheDocument(); - expect(screen.queryByTestId('lineage-time-filter')).not.toBeInTheDocument(); + expect(screen.getByTestId('lineage-time-filter')).toBeInTheDocument(); }); it('shows SearchBar when in impact analysis mode', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNode.utils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNode.utils.test.tsx index 5037a434ea5e..a836d81ac94f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNode.utils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNode.utils.test.tsx @@ -12,14 +12,12 @@ */ import { fireEvent, render } from '@testing-library/react'; import { ReactFlowProvider } from 'reactflow'; -import { EntityLineageNodeType } from '../../../enums/entity.enum'; import { LineageDirection } from '../../../generated/api/lineage/lineageDirection'; import { Column } from '../../../generated/entity/data/table'; import { useLineageStore } from '../../../hooks/useLineageStore'; import { ColumnContent, getCollapseHandle, - getColumnHandle, getExpandHandle, } from './CustomNode.utils'; @@ -49,7 +47,6 @@ const mockLineageState = { setSelectedColumn: mockSetSelectedColumn, selectedColumn: '', setTracedColumns: mockSetTracedColumns, - isEditMode: false, tracedColumns: new Set(), sceneBand: undefined, columnEdges: [], @@ -71,29 +68,6 @@ describe('Custom Node Utils', () => { ).getState = () => mockLineageState; }); - it('getColumnHandle should return null when nodeType is NOT_CONNECTED', () => { - const result = getColumnHandle( - EntityLineageNodeType.NOT_CONNECTED, - true, - 'test', - '123' - ); - - expect(result).toBeNull(); - }); - - it('getColumnHandle should render handles when nodeType is not NOT_CONNECTED', () => { - const { getByTestId } = render( - -
- {getColumnHandle('CONNECTED', true)} -
-
- ); - - expect(getByTestId('column-handle')).toBeInTheDocument(); - }); - describe('getExpandHandle', () => { it('renders a Button component', () => { const { getByTestId } = render( @@ -177,7 +151,6 @@ describe('Custom Node Utils', () => { const { getByTestId, getByText } = render( { const { getByTestId } = render( { const { getAllByTestId } = render( { const { getByTestId } = render( { const { getByTestId } = render( { const { getByTestId } = render( { expect(onColumnHover).toHaveBeenCalledWith(undefined); expect(onColumnSelect).toHaveBeenCalledWith('test.column'); }); + + it('does not render the column menu when onColumnLineageEdit is not passed', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('lineage-column-menu')).not.toBeInTheDocument(); + }); + + it('renders the column menu and calls onColumnLineageEdit with the column fqn', async () => { + const onColumnLineageEdit = jest.fn(); + const { getByTestId, findByText } = render( + + + + ); + + expect(getByTestId('lineage-column-menu')).toBeInTheDocument(); + + fireEvent.click(getByTestId('lineage-column-menu')); + fireEvent.click(await findByText('label.edit-upstream')); + + expect(onColumnLineageEdit).toHaveBeenCalledWith( + 'test.column', + LineageDirection.Upstream, + expect.anything() + ); + }); + + it('does not select the column when the column menu trigger is clicked', () => { + const onColumnLineageEdit = jest.fn(); + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('lineage-column-menu')); + + expect(mockSetSelectedColumn).not.toHaveBeenCalled(); + }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNode.utils.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNode.utils.tsx index a1e97a40439a..2cfb9cbb5d3f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNode.utils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNode.utils.tsx @@ -13,7 +13,14 @@ import { Button, Skeleton, Typography } from '@openmetadata/ui-core-components'; import { Dataflow01, Plus } from '@openmetadata/ui-core-components/icons'; import classNames from 'classnames'; -import { Fragment, memo, useCallback, useMemo, useState } from 'react'; +import { + Fragment, + memo, + RefObject, + useCallback, + useMemo, + useState, +} from 'react'; import { Handle, HandleProps, HandleType, Position } from 'reactflow'; import { ReactComponent as MinusIcon } from '../../../assets/svg/control-minus.svg'; import { EntityLineageNodeType } from '../../../enums/entity.enum'; @@ -26,6 +33,7 @@ import { getEntityName } from '../../../utils/EntityNameUtils'; import { t } from '../../../utils/i18next/LocalUtil'; import { onColumnMouseEnter } from '../../../utils/Lineage/handlers/columnInteractions'; import { getColumnDataTypeIcon } from '../../../utils/TableUtils'; +import LineageColumnMenu from '../../Lineage/LineageColumnMenu/LineageColumnMenu'; import TestSuiteSummaryWidget from './TestSuiteSummaryWidget/TestSuiteSummaryWidget.component'; const DEPTH_INDENT_PX = 16; @@ -48,6 +56,7 @@ export const getHandleByType = ( ); }; +// Public API: openmetadata-collate builds its column handles with this. export const getColumnHandle = ( nodeType: string, isConnectable: HandleProps['isConnectable'], @@ -56,20 +65,14 @@ export const getColumnHandle = ( ) => { if (nodeType === EntityLineageNodeType.NOT_CONNECTED) { return null; - } else { - return ( - - {getHandleByType(isConnectable, Position.Left, 'target', className, id)} - {getHandleByType( - isConnectable, - Position.Right, - 'source', - className, - id - )} - - ); } + + return ( + + {getHandleByType(isConnectable, Position.Left, 'target', className, id)} + {getHandleByType(isConnectable, Position.Right, 'source', className, id)} + + ); }; const ExpandHandle = ({ @@ -154,7 +157,7 @@ export const getCollapseHandle = ( // border colour and surface bg (!important); these only replace what the // antd button supplied (1px border, no padding). className={classNames( - 'absolute lineage-node-minus lineage-node-handle flex-center nodrag nopan tw:border tw:p-0!', + 'tw:absolute! lineage-node-minus lineage-node-handle flex-center nodrag nopan tw:border tw:p-0!', direction === LineageDirection.Downstream ? 'react-flow__handle-right' : 'react-flow__handle-left' @@ -199,10 +202,9 @@ const getColumnNameContent = (
)} + ellipsis={{ tooltip: true }}> {getEntityName(column)} @@ -211,7 +213,6 @@ const getColumnNameContent = ( interface ColumnContentProps { column: EntityChildrenItem; - isConnectable: boolean; showDataObservabilitySummary: boolean; isLoading: boolean; summary?: ColumnTestSummaryDefinition; @@ -219,11 +220,15 @@ interface ColumnContentProps { className?: string; onColumnHover?: (columnFqn?: string) => void; onColumnSelect?: (columnFqn?: string) => void; + onColumnLineageEdit?: ( + columnFqn: string, + direction: LineageDirection, + triggerRef: RefObject + ) => void; } const ColumnContentInner = ({ column, - isConnectable, showDataObservabilitySummary, isLoading, summary, @@ -231,14 +236,10 @@ const ColumnContentInner = ({ className = '', onColumnHover, onColumnSelect, + onColumnLineageEdit, }: ColumnContentProps) => { - const { - selectedColumn, - setSelectedColumn, - setTracedColumns, - isEditMode, - tracedColumns, - } = useLineageStore(); + const { selectedColumn, setSelectedColumn, setTracedColumns, tracedColumns } = + useLineageStore(); const { fullyQualifiedName } = column; @@ -274,19 +275,6 @@ const ColumnContentInner = ({ [column, isLoading] ); - const handles = useMemo( - () => - isEditMode - ? getColumnHandle( - EntityLineageNodeType.DEFAULT, - isConnectable, - 'lineage-column-node-handle', - fullyQualifiedName ?? '' - ) - : null, - [isEditMode, isConnectable, fullyQualifiedName] - ); - return (
- {handles} -
+
{columnNameContentRender}
+ {onColumnLineageEdit && fullyQualifiedName && ( + + + onColumnLineageEdit(fullyQualifiedName, direction, triggerRef) + } + /> + + )} + {'constraint' in column && column.constraint && (
{ const coreColumnPropsEqual = prev.column === next.column && - prev.isConnectable === next.isConnectable && prev.isLoading === next.isLoading && prev.showDataObservabilitySummary === next.showDataObservabilitySummary; const renderPropsEqual = @@ -344,32 +340,12 @@ export const ColumnContent = memo(ColumnContentInner, (prev, next) => { prev.className === next.className; const columnCallbacksEqual = prev.onColumnHover === next.onColumnHover && - prev.onColumnSelect === next.onColumnSelect; + prev.onColumnSelect === next.onColumnSelect && + prev.onColumnLineageEdit === next.onColumnLineageEdit; return coreColumnPropsEqual && renderPropsEqual && columnCallbacksEqual; }); -/** - * Split across two named booleans so neither expression exceeds - * sonarjs/expression-complexity, and kept out of CustomNodeV1 so its operators - * do not count against that component's cyclomatic-complexity budget. - */ -export function shouldShowNodeRemoveButton({ - isSelected, - isEditMode, - isRootNode, - isNodeRemovable, -}: { - isSelected: boolean; - isEditMode: boolean; - isRootNode: boolean; - isNodeRemovable: boolean; -}) { - const isRemovableSelection = isSelected && isEditMode; - - return isRemovableSelection && !isRootNode && isNodeRemovable; -} - /** * Dark swaps the static grey node/badge/handle borders (custom-node.less * `@lineage-border`) for border-primary. A variable rather than a border class so diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.component.tsx index 96e0ac5525ae..eb2d71387501 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.component.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Button, Tooltip } from '@openmetadata/ui-core-components'; +import { Box, Button, Tooltip } from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { memo, @@ -20,9 +20,10 @@ import { useMemo, useState, type MouseEvent, + type RefObject, } from 'react'; import { useTranslation } from 'react-i18next'; -import { Handle, NodeProps, Position } from 'reactflow'; +import { Handle, NodeProps, Position, useReactFlow } from 'reactflow'; import { ReactComponent as ZoomInIcon } from '../../../assets/svg/ic-zoom-in.svg'; import { NODE_WIDTH } from '../../../constants/Lineage.constants'; import { EntityLineageNodeType } from '../../../enums/entity.enum'; @@ -33,13 +34,13 @@ import { } from '../../../generated/api/lineage/lineageScene'; import { useLineageStore } from '../../../hooks/useLineageStore'; import { useLineageHandlers } from '../../Lineage/Lineage/LineageHandlersContext'; -import LineageNodeRemoveButton from '../../Lineage/LineageNodeRemoveButton'; +import { isContainerSceneNode } from '../../Lineage/LineageMap/LineageMap.utils'; +import LineageNodeMenu from '../../Lineage/LineageNodeMenu/LineageNodeMenu'; import './custom-node.less'; import { getCollapseHandle, getExpandHandle, getNodeClassNames, - shouldShowNodeRemoveButton, } from './CustomNode.utils'; import { ExpandCollapseHandlesProps, @@ -48,66 +49,55 @@ import { import LineageNodeLabelV1 from './LineageNodeLabelV1'; import NodeChildren from './NodeChildren/NodeChildren.component'; +// Scene nodes open through onSceneNodeSelect; classic nodes open the drawer, +// except temp tables, which have no entity page. +const canOpenNodeEntity = ( + hasSceneSelect: boolean, + isSceneNode: boolean, + isTempTable: boolean +) => hasSceneSelect || (!isSceneNode && !isTempTable); + const NodeHandles = memo( ({ nodeType, id, - isConnectable, + showEdgeHandles, expandCollapseHandles, }: NodeHandlesProps) => { - switch (nodeType) { - case EntityLineageNodeType.OUTPUT: - return ( - <> - - {expandCollapseHandles} - - ); - - case EntityLineageNodeType.INPUT: - return ( - <> - - {expandCollapseHandles} - - ); - - case EntityLineageNodeType.NOT_CONNECTED: - return null; - - default: - return ( - <> - - - {expandCollapseHandles} - - ); + if (nodeType === EntityLineageNodeType.NOT_CONNECTED) { + return null; } + // Lineage is added from the node menus, so these only mark where the + // classic graph's edges meet the node; the expand / collapse buttons sit + // over them. Scene map nodes draw no handles. + const showTarget = + showEdgeHandles && nodeType !== EntityLineageNodeType.INPUT; + const showSource = + showEdgeHandles && nodeType !== EntityLineageNodeType.OUTPUT; + + return ( + <> + {showTarget && ( + + )} + {showSource && ( + + )} + {expandCollapseHandles} + + ); } ); @@ -129,10 +119,7 @@ const getHandleVisibility = ({ }); const ExpandCollapseHandles = memo((props: ExpandCollapseHandlesProps) => { - const { isEditMode, onCollapse, onExpand } = props; - if (isEditMode) { - return null; - } + const { onCollapse, onExpand } = props; const { showDownstreamCollapse, @@ -198,14 +185,14 @@ const SceneDrillButton = ({ }; const CustomNodeV1 = (props: NodeProps) => { - const { data, type, isConnectable } = props; + const { data, type } = props; - const { onNodeCollapse, removeNodeHandler, loadChildNodesHandler } = + const { onNodeCollapse, loadChildNodesHandler, onNodeClick } = useLineageHandlers(); + const { getNode } = useReactFlow(); const dataQualityLineage = useLineageStore((s) => s.dataQualityLineage); const { - isEditMode, tracedNodes, selectedNode, isColumnLevelLineage, @@ -217,8 +204,6 @@ const CustomNodeV1 = (props: NodeProps) => { const [columnsExpanded, setColumnsExpanded] = useState(); const { - label, - isNewNode, node = {}, isRootNode, hasOutgoers = false, @@ -235,21 +220,28 @@ const CustomNodeV1 = (props: NodeProps) => { onSceneColumnSelect, onSceneNodeRemove, isNodeRemovable = true, + isNodeEditable, + onSceneLineageEdit, + onSceneShowFields, } = data; - // sync expand state based on edit or column layer active + // sync expand state based on column layer active useEffect(() => { - setColumnsExpanded(isEditMode || isColumnLevelLineage); - }, [isEditMode, isColumnLevelLineage]); + setColumnsExpanded(isColumnLevelLineage); + }, [isColumnLevelLineage]); + // The Asset band has no field list to expand, so asking for a node's fields + // moves the scene to the Field band instead. const toggleColumnsExpanded = useCallback(() => { + if (sceneBand === LineageBand.Asset && onSceneShowFields) { + onSceneShowFields(); + + return; + } setColumnsExpanded((prev) => !prev); - }, []); + }, [onSceneShowFields, sceneBand]); - const nodeType = useMemo( - () => (isEditMode ? EntityLineageNodeType.DEFAULT : type), - [isEditMode, type] - ); + const nodeType = type; const isSelected = useMemo(() => selectedNode === node, [selectedNode, node]); const { id, @@ -292,18 +284,15 @@ const CustomNodeV1 = (props: NodeProps) => { isChildrenListExpanded: columnsExpanded || isColumnLevelLineage, }), { + 'lineage-temp-node': Boolean(node.isTempTable), 'lineage-scene-node': Boolean(sceneNode), + // Container nodes (service, database, schema, domain, data product) use + // the container card, with its centred drill cue, in every band. 'lineage-scene-layer-node tw:bg-surface': - Boolean(sceneNode) && sceneBand === LineageBand.Layer, + Boolean(sceneNode) && + (sceneBand === LineageBand.Layer || isContainerSceneNode(sceneNode)), } ); - const showRemoveButton = shouldShowNodeRemoveButton({ - isSelected, - isEditMode, - isRootNode: Boolean(isRootNode), - isNodeRemovable, - }); - const onExpand = useCallback( (direction: LineageDirection, depth = 1) => { loadChildNodesHandler(node, direction, depth); @@ -325,60 +314,87 @@ const CustomNodeV1 = (props: NodeProps) => { setNodeFilterState(node.id, !showColumnsWithLineageOnly); }, [showColumnsWithLineageOnly, setNodeFilterState, node.id]); - const handleNodeRemove = useCallback(() => { - if (onSceneNodeRemove) { - onSceneNodeRemove(props); + const handleColumnLineageEdit = useCallback( + ( + columnFqn: string, + direction: LineageDirection, + triggerRef: RefObject + ) => { + onSceneLineageEdit?.({ + nodeId: props.id, + columnFqn, + direction, + triggerRef, + }); + }, + [onSceneLineageEdit, props.id] + ); - return; - } - removeNodeHandler(props); - }, [onSceneNodeRemove, props, removeNodeHandler]); + const onColumnLineageEdit = + isNodeEditable && onSceneLineageEdit ? handleColumnLineageEdit : undefined; + const hasLineageNodeMenu = Boolean(isNodeEditable && onSceneLineageEdit); const handleEntityClick = useCallback( (event: MouseEvent) => { event.stopPropagation(); - onSceneNodeSelect?.(props.id); + if (onSceneNodeSelect) { + onSceneNodeSelect(props.id); + + return; + } + // Classic asset lineage: the name opens the same drawer as a node click. + const flowNode = getNode(props.id); + if (flowNode) { + onNodeClick(flowNode); + } }, - [onSceneNodeSelect, props.id] + [getNode, onNodeClick, onSceneNodeSelect, props.id] + ); + const canOpenEntity = canOpenNodeEntity( + Boolean(onSceneNodeSelect), + Boolean(sceneNode), + Boolean(node.isTempTable) ); const nodeLabel = useMemo(() => { - if (isNewNode) { - return label; - } - return ( - <> - - {showRemoveButton && ( - - )} - + onSceneNodeRemove?.(props)} + onEdit={(direction, triggerRef) => + onSceneLineageEdit({ nodeId: props.id, direction, triggerRef }) + } + /> + ) : undefined + } + isChildrenListExpanded={columnsExpanded} + isOnlyShowColumnsWithLineageFilterActive={showColumnsWithLineageOnly} + node={node} + toggleColumnsList={toggleColumnsExpanded} + toggleOnlyShowColumnsWithLineageFilterActive={ + toggleShowColumnsWithLineageOnly + } + onEntityClick={canOpenEntity ? handleEntityClick : undefined} + /> ); }, [ node, onSceneNodeSelect, handleEntityClick, - isNewNode, - label, + canOpenEntity, columnsExpanded, showColumnsWithLineageOnly, toggleShowColumnsWithLineageOnly, - handleNodeRemove, toggleColumnsExpanded, - isEditMode, - showRemoveButton, + isNodeEditable, + onSceneLineageEdit, + isNodeRemovable, + isRootNode, + onSceneNodeRemove, + props, ]); const expandCollapseProps = useMemo( @@ -388,7 +404,6 @@ const CustomNodeV1 = (props: NodeProps) => { hasIncomers, hasOutgoers, isDownstreamNode, - isEditMode, isRootNode, isUpstreamNode, upstreamLineageLength: upstreamLineage.length, @@ -401,7 +416,6 @@ const CustomNodeV1 = (props: NodeProps) => { hasIncomers, hasOutgoers, isDownstreamNode, - isEditMode, isRootNode, isUpstreamNode, upstreamLineage.length, @@ -423,17 +437,17 @@ const CustomNodeV1 = (props: NodeProps) => { return ( ); }, [ columnsExpanded, - isConnectable, node, + onColumnLineageEdit, onSceneColumnHover, onSceneColumnSelect, showColumnsWithLineageOnly, @@ -450,20 +464,29 @@ const CustomNodeV1 = (props: NodeProps) => {
)} -
- {!isEditMode && ( - - )} -
{nodeLabel}
+
+ + + {nodeLabel} + {childElement}
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx index b010fd2ee109..0853757be82f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx @@ -156,22 +156,7 @@ const setColumnsInCurrentPagesMock = jest.fn((updater) => { }); jest.mock('@openmetadata/ui-core-components', () => ({ - Input: ({ - inputDataTestId, - value, - onChange, - }: { - inputDataTestId?: string; - value?: string; - onChange?: (value: string) => void; - }) => ( - onChange?.(e.target.value)} - /> - ), + ...jest.requireActual('@openmetadata/ui-core-components'), ButtonUtility: jest .fn() .mockImplementation( @@ -230,6 +215,7 @@ jest.mock('@openmetadata/ui-core-components', () => ({ ); }), Button: jest.requireActual('@openmetadata/ui-core-components').Button, + Dropdown: jest.requireActual('@openmetadata/ui-core-components').Dropdown, Skeleton: jest.requireActual('@openmetadata/ui-core-components').Skeleton, Tooltip: jest.requireActual('@openmetadata/ui-core-components').Tooltip, Typography: jest @@ -274,7 +260,6 @@ const mockDefaultLineageState = { tracedNodes: new Set(), tracedColumns: new Set(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: new Map(), @@ -330,7 +315,6 @@ describe('CustomNodeV1', () => { tracedColumns: new Set(), tracedNodes: new Set(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -540,7 +524,6 @@ describe('CustomNodeV1', () => { tracedColumns: new Set(), tracedNodes: new Set(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -565,7 +548,6 @@ describe('CustomNodeV1', () => { tracedColumns: new Set(), tracedNodes: new Set(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -601,7 +583,6 @@ describe('CustomNodeV1', () => { tracedNodes: new Set(), updateColumnsInCurrentPages: jest.fn(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -627,7 +608,6 @@ describe('CustomNodeV1', () => { tracedNodes: new Set(), updateColumnsInCurrentPages: jest.fn(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -657,7 +637,6 @@ describe('CustomNodeV1', () => { tracedNodes: new Set(), updateColumnsInCurrentPages: jest.fn(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -681,7 +660,6 @@ describe('CustomNodeV1', () => { tracedNodes: new Set(), updateColumnsInCurrentPages: jest.fn(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -714,7 +692,6 @@ describe('CustomNodeV1', () => { tracedColumns: new Set(), tracedNodes: new Set(), columnsHavingLineage: new Map(), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, updateColumnsInCurrentPages: jest.fn(), @@ -756,7 +733,6 @@ describe('CustomNodeV1', () => { tracedColumns: new Set(), tracedNodes: new Set(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -816,7 +792,6 @@ describe('CustomNodeV1', () => { tracedNodes: new Set(), updateColumnsInCurrentPages: jest.fn(), columnsHavingLineage: new Map([['id', new Set()]]), - isEditMode: false, nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, }) @@ -853,7 +828,6 @@ describe('CustomNodeV1', () => { columnsHavingLineage: new Map([ ['id', new Set(['col0', 'col2', 'col5', 'col7', 'col10'])], ]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), nodeFilterState: new Map(), setNodeFilterState: mockSetNodeFilterState, @@ -937,7 +911,6 @@ describe('CustomNodeV1', () => { columnsHavingLineage: new Map([ ['id', new Set(['col0', 'col2', 'col5', 'col7', 'col10'])], ]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: new Map(), @@ -989,7 +962,6 @@ describe('CustomNodeV1', () => { ]), ], ]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: new Map(), @@ -1044,7 +1016,6 @@ describe('CustomNodeV1', () => { columnsHavingLineage: new Map([ ['id', new Set(['col0', 'col2', 'col5'])], ]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: new Map(), @@ -1086,7 +1057,6 @@ describe('CustomNodeV1', () => { columnsHavingLineage: new Map([ ['khjahjfja', new Set(['col0', 'col2', 'col5', 'col7', 'col10'])], ]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: nodeFilterStateMap, @@ -1146,7 +1116,6 @@ describe('CustomNodeV1', () => { columnsHavingLineage: new Map([ ['khjahjfja', new Set(['col0', 'col2', 'col5'])], ]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: nodeFilterStateMap, @@ -1182,7 +1151,6 @@ describe('CustomNodeV1', () => { columnsHavingLineage: new Map([ ['khjahjfja', new Set(['col0', 'col2', 'col5'])], ]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: nodeFilterStateMap, @@ -1212,37 +1180,6 @@ describe('CustomNodeV1', () => { expect(filterButtonAfterRerender).toHaveClass('active'); }); - - it('should disable turn off and disable the filter in edit mode', () => { - const nodeFilterStateMap = new Map([['khjahjfja', false]]); - (useLineageStore as unknown as jest.Mock).mockImplementation( - withSelector({ - isColumnLevelLineage: false, - isDQEnabled: false, - tracedColumns: new Set(), - tracedNodes: new Set(), - columnsHavingLineage: new Map([ - ['khjahjfja', new Set(['col0', 'col2', 'col5'])], - ]), - isEditMode: true, - updateColumnsInCurrentPages: jest.fn(), - setSelectedColumn: mockSetSelectedColumn, - nodeFilterState: nodeFilterStateMap, - setNodeFilterState: mockSetNodeFilterState, - }) - ); - - render( - - - - ); - - const filterButton = screen.getByTestId('lineage-filter-button'); - - expect(filterButton).not.toHaveClass('active'); - expect(filterButton).toBeDisabled(); - }); }); describe('Nested Columns Display', () => { @@ -1304,7 +1241,6 @@ describe('CustomNodeV1', () => { tracedColumns: new Set(), tracedNodes: new Set(), columnsHavingLineage: columnsLineageMap, - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: nodeFilterStateMap, @@ -1349,7 +1285,6 @@ describe('CustomNodeV1', () => { columnsHavingLineage: new Map([ ['khjahjfja', new Set(['col0', 'col2', 'col5', 'col7', 'col10'])], ]), - isEditMode: false, updateColumnsInCurrentPages: jest.fn(), setSelectedColumn: mockSetSelectedColumn, nodeFilterState: nodeFilterStateMap, @@ -1391,4 +1326,81 @@ describe('CustomNodeV1', () => { }); }); }); + + describe('LineageNodeMenu', () => { + const nodeProps = (dataOverrides: Record) => ({ + ...mockNodeDataProps, + data: { + ...mockNodeDataProps.data, + ...dataOverrides, + }, + }); + + it('shows the node menu only when editable', () => { + const { rerender } = render( + + + + ); + + expect(screen.getByTestId('lineage-node-menu')).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.queryByTestId('lineage-node-menu')).not.toBeInTheDocument(); + }); + + it('keeps the drill button clear of the node menu via a modifier class', () => { + const { container, rerender } = render( + + + + ); + + expect(container.querySelector('.lineage-node-content')).toHaveClass( + 'has-lineage-node-menu' + ); + expect(screen.getByTestId('lineage-node-menu')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'label.zoom-in' }) + ).toBeInTheDocument(); + + rerender( + + + + ); + + expect(container.querySelector('.lineage-node-content')).not.toHaveClass( + 'has-lineage-node-menu' + ); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EdgeInteractionOverlay.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EdgeInteractionOverlay.component.test.tsx deleted file mode 100644 index c42eb27b5b01..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EdgeInteractionOverlay.component.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -/* - * 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 { Edge } from 'reactflow'; -import { EdgeInteractionOverlay } from './EdgeInteractionOverlay.component'; - -const mockUseViewport = jest.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }); -const mockUseLineageStore = { - isEditMode: false, - selectedEdge: undefined, -}; - -jest.mock('reactflow', () => ({ - ...jest.requireActual('reactflow'), - useViewport: () => mockUseViewport(), - useReactFlow: () => ({ getNode: jest.fn() }), -})); - -jest.mock('../../../hooks/useLineageStore', () => ({ - useLineageStore: () => mockUseLineageStore, -})); - -const createEdge = (overrides: Partial = {}): Edge => ({ - id: 'edge-1', - source: 'node-1', - target: 'node-2', - data: { - edge: { - fromEntity: { fullyQualifiedName: 'table1', id: 'id1' }, - toEntity: { fullyQualifiedName: 'table2', id: 'id2' }, - }, - computedPath: { - edgePath: 'M 0,0 C 100,0 100,100 200,100', - edgeCenterX: 100, - edgeCenterY: 50, - }, - isColumnLineage: false, - }, - ...overrides, -}); - -describe('EdgeInteractionOverlay', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockUseLineageStore.isEditMode = false; - mockUseLineageStore.selectedEdge = undefined; - }); - - it('renders nothing when no hovered or selected edge', () => { - const { container } = render(); - - expect( - container.querySelector('.edge-interaction-overlay') - ).toBeEmptyDOMElement(); - }); - - it('renders edit button when edge is selected in edit mode', () => { - const edge = createEdge(); - mockUseLineageStore.isEditMode = true; - mockUseLineageStore.selectedEdge = edge; - - render(); - - const editButton = screen.getByTestId('add-pipeline'); - - expect(editButton).toBeInTheDocument(); - }); - - it('does not render edit button for column lineage', () => { - const edge = createEdge({ - data: { - ...createEdge().data, - isColumnLineage: true, - }, - }); - mockUseLineageStore.isEditMode = true; - mockUseLineageStore.selectedEdge = edge; - - render(); - - expect(screen.queryByTestId('add-pipeline')).not.toBeInTheDocument(); - }); - - it('renders delete button for column lineage in edit mode', () => { - const edge = createEdge({ - data: { - ...createEdge().data, - isColumnLineage: true, - }, - }); - mockUseLineageStore.isEditMode = true; - mockUseLineageStore.selectedEdge = edge; - - render(); - - const deleteButton = screen.getByTestId('delete-button'); - - expect(deleteButton).toBeInTheDocument(); - }); - - it('does not render delete button for non-column lineage', () => { - const edge = createEdge(); - mockUseLineageStore.isEditMode = true; - mockUseLineageStore.selectedEdge = edge; - - render(); - - expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument(); - }); - - it('calls onEdgeRemove when delete button is clicked', () => { - const onEdgeRemove = jest.fn(); - const edge = createEdge({ - data: { - ...createEdge().data, - isColumnLineage: true, - }, - }); - mockUseLineageStore.isEditMode = true; - mockUseLineageStore.selectedEdge = edge; - - render(); - - const deleteButton = screen.getByTestId('delete-button'); - fireEvent.click(deleteButton); - - expect(onEdgeRemove).toHaveBeenCalled(); - }); - - it('does not render when computedPath is missing', () => { - createEdge({ - data: { - edge: { - fromEntity: { fullyQualifiedName: 'table1', id: 'id1' }, - toEntity: { fullyQualifiedName: 'table2', id: 'id2' }, - pipeline: { - fullyQualifiedName: 'pipeline1', - name: 'Pipeline 1', - }, - }, - isColumnLineage: false, - }, - }); - - render(); - - expect( - screen.queryByTestId('pipeline-label-table1-table2') - ).not.toBeInTheDocument(); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EdgeInteractionOverlay.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EdgeInteractionOverlay.component.tsx deleted file mode 100644 index 1430f1a702d1..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EdgeInteractionOverlay.component.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * 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 { Button } from '@openmetadata/ui-core-components'; -import React, { useMemo } from 'react'; -import { Edge, useReactFlow, useViewport } from 'reactflow'; -import { ReactComponent as IconEditCircle } from '../../../assets/svg/ic-edit-circle.svg'; -import { ReactComponent as IconTimesCircle } from '../../../assets/svg/ic-times-circle.svg'; -import { useLineageStore } from '../../../hooks/useLineageStore'; -import { computePathDataForEdge } from '../../../utils/CanvasUtils'; -import { t } from '../../../utils/i18next/LocalUtil'; -import { getAbsolutePosition } from '../../../utils/ViewportUtils'; - -export interface EdgeInteractionOverlayProps { - onPipelineClick?: () => void; - onEdgeRemove?: () => void; -} - -export const EdgeInteractionOverlay: React.FC = ({ - onPipelineClick, - onEdgeRemove, -}) => { - const { isEditMode, selectedEdge, columnsInCurrentPages, isRepositioning } = - useLineageStore(); - const { getNode } = useReactFlow(); - const viewport = useViewport(); - - const pathData = useMemo(() => { - if (!selectedEdge) { - return null; - } - - return computePathDataForEdge( - selectedEdge, - getNode(selectedEdge.source), - getNode(selectedEdge.target), - columnsInCurrentPages - ); - }, [selectedEdge, getNode, columnsInCurrentPages]); - - const buttonPosition = useMemo(() => { - if (!pathData) { - return null; - } - - return getAbsolutePosition( - pathData.edgeCenterX, - pathData.edgeCenterY, - viewport - ); - }, [pathData, viewport]); - - const renderEditButton = (edge: Edge) => { - const { isColumnLineage } = edge.data || {}; - - if (isColumnLineage || !buttonPosition) { - return null; - } - - return ( -
-
- ); - }; - - const renderDeleteButton = (edge: Edge) => { - const { isColumnLineage } = edge.data || {}; - - if (!isColumnLineage || !buttonPosition) { - return null; - } - - return ( -
-
- ); - }; - - if (isRepositioning) { - return null; - } - - return ( -
- {selectedEdge && isEditMode && renderEditButton(selectedEdge)} - {selectedEdge && isEditMode && renderDeleteButton(selectedEdge)} -
- ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineage.interface.ts index f8d8f7003f37..ca9d12226e36 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineage.interface.ts @@ -50,6 +50,21 @@ import type { Edge, LineageConfig } from '../../../interface/lineage.interface'; export type { LineageConfig }; +export type LineageConfigDepthField = + | 'upstreamDepth' + | 'downstreamDepth' + | 'nodesPerLayer'; + +export type LineageConfigFormValues = Record; + +export interface LineageConfigField { + name: LineageConfigDepthField; + label: string; + tooltip: string; + testId: string; + min: number; +} + export interface LineageConfigModalProps { visible: boolean; config: LineageConfig; @@ -60,12 +75,11 @@ export interface LineageConfigModalProps { export interface NodeHandlesProps { nodeType: string; id: string; - isConnectable: boolean; + showEdgeHandles: boolean; expandCollapseHandles: ReactNode; } export interface ExpandCollapseHandlesProps { - isEditMode: boolean; hasOutgoers: boolean; hasIncomers: boolean; isDownstreamNode: boolean; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineageSidebar.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineageSidebar.component.test.tsx deleted file mode 100644 index e13c51ed64da..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineageSidebar.component.test.tsx +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright 2022 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen } from '@testing-library/react'; -import { SearchIndex } from '../../../enums/search.enum'; -import EntityLineageSidebar from './EntityLineageSidebar.component'; - -const mockTheme = { primaryColor: '#1890ff' }; - -jest.mock('../../../hooks/useApplicationStore', () => ({ - useApplicationStore: jest.fn(() => ({ - theme: mockTheme, - })), -})); - -jest.mock('react-i18next', () => ({ - useTranslation: jest.fn(() => ({ - t: (key: string) => key, - })), -})); - -jest.mock('../../../utils/SearchClassBase', () => ({ - __esModule: true, - default: { - getEntityIcon: jest.fn((type: string) =>
{`icon-${type}`}
), - }, -})); - -jest.mock('../../../constants/Lineage.constants', () => ({ - entityData: [ - { type: 'table_search_index', label: 'label.table-plural' }, - { type: 'dashboard_search_index', label: 'label.dashboard-plural' }, - { type: 'topic_search_index', label: 'label.topic-plural' }, - ], -})); - -describe('EntityLineageSidebar component', () => { - const mockNode = { - id: 'test-node', - type: SearchIndex.TABLE, - position: { x: 0, y: 0 }, - data: {}, - }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('should render nothing when show is false', () => { - const { container } = render( - - ); - - expect(container.firstChild).toBeNull(); - }); - - it('should render sidebar when show is true', () => { - const { container } = render( - - ); - - expect( - container.querySelector('.entity-lineage.sidebar') - ).toBeInTheDocument(); - }); - - it('should render sidebar with open class when show is true', () => { - const { container } = render( - - ); - - const sidebar = container.querySelector('.entity-lineage.sidebar'); - - expect(sidebar).toHaveClass('open'); - }); - - it('should render all entity nodes from entityData', () => { - render(); - - expect(screen.getByText('label.table-plural')).toBeInTheDocument(); - expect(screen.getByText('label.dashboard-plural')).toBeInTheDocument(); - expect(screen.getByText('label.topic-plural')).toBeInTheDocument(); - }); - - it('should render entity icons for all nodes', () => { - render(); - - expect(screen.getByText('icon-table_search_index')).toBeInTheDocument(); - expect( - screen.getByText('icon-dashboard_search_index') - ).toBeInTheDocument(); - expect(screen.getByText('icon-topic_search_index')).toBeInTheDocument(); - }); - - it('should apply correct test ids to draggable icons', () => { - render(); - - expect( - screen.getByTestId('table_search_index-draggable-icon') - ).toBeInTheDocument(); - expect( - screen.getByTestId('dashboard_search_index-draggable-icon') - ).toBeInTheDocument(); - expect( - screen.getByTestId('topic_search_index-draggable-icon') - ).toBeInTheDocument(); - }); - }); - - describe('draggability', () => { - it('should make nodes draggable when newAddedNode is undefined', () => { - render(); - - const draggableIcon = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - expect(draggableIcon).toHaveAttribute('draggable', 'true'); - expect(draggableIcon).toHaveClass('tw:cursor-grab'); - }); - - it('should make nodes draggable when newAddedNode is empty object', () => { - render(); - - const draggableIcon = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - expect(draggableIcon).toHaveAttribute('draggable', 'true'); - expect(draggableIcon).toHaveClass('tw:cursor-grab'); - }); - - it('should disable dragging when newAddedNode is provided', () => { - render(); - - const draggableIcon = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - expect(draggableIcon).toHaveAttribute('draggable', 'false'); - expect(draggableIcon).not.toHaveStyle({ cursor: 'grab' }); - }); - - it('should apply disabled styles when newAddedNode is provided', () => { - render(); - - const draggableIcon = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - expect(draggableIcon).toHaveClass('cursor-not-allowed', 'opacity-50'); - }); - - it('should not apply disabled styles when newAddedNode is undefined', () => { - render(); - - const draggableIcon = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - expect(draggableIcon).not.toHaveClass('cursor-not-allowed'); - expect(draggableIcon).not.toHaveClass('opacity-50'); - }); - }); - - describe('drag events', () => { - it('should have drag start handler attached', () => { - render(); - - const draggableIcon = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - expect(draggableIcon).toBeInTheDocument(); - expect(draggableIcon).toHaveAttribute('draggable', 'true'); - }); - - it('should set effectAllowed to move on drag start', () => { - render(); - - const draggableIcon = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - const dragStartHandler = draggableIcon.ondragstart; - - expect(dragStartHandler).toBeDefined(); - }); - - it('should prevent default drag on icon element', () => { - render(); - - const iconContainer = screen - .getByTestId('table_search_index-draggable-icon') - .querySelector('.d-flex'); - - expect(iconContainer).toBeInTheDocument(); - }); - }); - - describe('component memoization', () => { - it('should render same number of entity nodes on multiple renders', () => { - const { rerender } = render( - - ); - - const initialNodes = screen.getAllByText(/label\..+-plural/); - - rerender(); - - const afterRerenderNodes = screen.getAllByText(/label\..+-plural/); - - expect(initialNodes.length).toBe(afterRerenderNodes.length); - expect(initialNodes.length).toBe(3); - }); - }); - - describe('accessibility', () => { - it('should have proper structure for screen readers', () => { - render(); - - const labels = screen.getAllByText(/label\..+-plural/); - - labels.forEach((label) => { - expect(label).toHaveClass('text-grey-body', 'text-xs'); - }); - }); - - it('should maintain semantic structure with proper spacing', () => { - const { container } = render( - - ); - - const entityNodes = container.querySelectorAll('.m-b-sm.text-center'); - - expect(entityNodes.length).toBeGreaterThan(0); - }); - }); - - describe('styling', () => { - it('should apply correct container classes', () => { - const { container } = render( - - ); - - const sidebar = container.querySelector('.entity-lineage.sidebar.open'); - - expect(sidebar).toBeInTheDocument(); - }); - - it('should render with correct icon container classes', () => { - render(); - - const iconContainer = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - expect(iconContainer).toHaveClass('sidebar-icon-container'); - }); - - it('should apply text styles to labels', () => { - render(); - - const label = screen.getByText('label.table-plural'); - - expect(label).toHaveClass('text-grey-body', 'text-xs', 'p-t-xs'); - }); - }); - - describe('edge cases', () => { - it('should toggle visibility correctly', () => { - const { container, rerender } = render( - - ); - - expect( - container.querySelector('.entity-lineage.sidebar') - ).toBeInTheDocument(); - - rerender(); - - expect(container.firstChild).toBeNull(); - }); - - it('should handle empty object as newAddedNode', () => { - render(); - - const draggableIcon = screen.getByTestId( - 'table_search_index-draggable-icon' - ); - - expect(draggableIcon).toHaveAttribute('draggable', 'true'); - expect(draggableIcon).not.toHaveClass('cursor-not-allowed'); - }); - - it('should maintain consistent rendering across re-renders', () => { - const { rerender } = render( - - ); - - const firstRenderCount = screen.getAllByText(/label\..+-plural/).length; - - rerender(); - - const secondRenderCount = screen.getAllByText(/label\..+-plural/).length; - - expect(firstRenderCount).toBe(secondRenderCount); - }); - }); - - describe('integration with theme', () => { - it('should render the drag handle for a draggable node', () => { - // the brand tint comes from --tw-color-fg-brand-primary, which the theme - // provider sets at runtime — there is no value read through JS to assert - render(); - - expect( - screen.getByTestId('table_search_index-draggable-icon') - ).toBeInTheDocument(); - }); - - it('should use translation hook', () => { - const { useTranslation } = require('react-i18next'); - - render(); - - expect(useTranslation).toHaveBeenCalled(); - }); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineageSidebar.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineageSidebar.component.tsx deleted file mode 100644 index f2b2471daf09..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineageSidebar.component.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2022 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Typography } from '@openmetadata/ui-core-components'; -import classNames from 'classnames'; -import { isEmpty, uniqueId } from 'lodash'; -import { FC, HTMLAttributes, memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Node } from 'reactflow'; -import { ReactComponent as DragIconDotted } from '../../../assets/svg/dots-six-bold.svg'; -import { entityData } from '../../../constants/Lineage.constants'; -import searchClassBase from '../../../utils/SearchClassBase'; -import './entity-lineage-sidebar.less'; - -interface SidebarProps extends HTMLAttributes { - show: boolean; - newAddedNode?: Node; -} - -interface EntityNodeProps extends HTMLAttributes { - type: string; - label: string; -} - -const EntityNodeInternal: FC = ({ - type, - label, - draggable, -}) => { - const onDragStart = (event: React.DragEvent, nodeType: string) => { - event.dataTransfer.setData('application/reactflow', nodeType); - event.dataTransfer.effectAllowed = 'move'; - }; - - return ( -
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- drag source; no keyboard equivalent */} -
onDragStart(event, type)}> - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- nested drag source */} - { - e.preventDefault(); - e.stopPropagation(); - }}> - {searchClassBase.getEntityIcon(type ?? '')} - - - - -
- {label} -
- ); -}; - -const EntityNode = memo(EntityNodeInternal); - -const EntityLineageSidebar: FC = ({ show, newAddedNode }) => { - const { t } = useTranslation(); - - if (!show) { - return null; - } - - return ( -
- {entityData.map((d) => ( - - ))} -
- ); -}; - -export default EntityLineageSidebar; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.component.tsx deleted file mode 100644 index 984398aa2e81..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.component.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2025 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Badge } from '@openmetadata/ui-core-components'; -import classNames from 'classnames'; -import { get } from 'lodash'; -import { FC, useMemo } from 'react'; -import { SearchSourceAlias } from '../../../../interface/search.interface'; -import { getEntityIcon } from '../../../../utils/EntityIconUtils'; -import { getEntityName } from '../../../../utils/EntityNameUtils'; -import serviceUtilClassBase from '../../../../utils/ServiceUtilClassBase'; -import { SourceType } from '../../../SearchedData/SearchedData.interface'; -import { EntitySuggestionOptionProps } from './EntitySuggestionOption.interface'; -const EntitySuggestionOption: FC = ({ - entity, - heading, - className, - showEntityTypeBadge = false, -}) => { - const serviceIcon = useMemo(() => { - const serviceType = get(entity, 'serviceType', ''); - - if (serviceType) { - return ( - {entity.name} - ); - } - - return getEntityIcon( - (entity as SearchSourceAlias).entityType ?? '', - 'w-4 h-4 m-r-xs' - ); - }, [entity]); - - return ( -
-
-
- {serviceIcon} -
- {heading && ( -

- {heading} -

- )} -

- {entity.name} -

-

- {getEntityName(entity)} -

-
-
- {showEntityTypeBadge && ( - - {(entity as SourceType)?.entityType} - - )} -
-
- ); -}; - -export default EntitySuggestionOption; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.test.tsx index 3c605543f6a6..8f5105b2e1d8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.test.tsx @@ -20,7 +20,7 @@ const onSave = jest.fn(); const config = { upstreamDepth: 2, downstreamDepth: 3, - nodesPerLayer: 4, + nodesPerLayer: 5, pipelineViewMode: PipelineViewMode.Node, }; @@ -45,9 +45,9 @@ describe('LineageConfigModal', () => { 'field-nodes-per-layer' ); - expect(fieldUpstream).toBeInTheDocument(); - expect(fieldDownstream).toBeInTheDocument(); - expect(fieldNodesPerLayer).toBeInTheDocument(); + expect(fieldUpstream).toHaveValue(2); + expect(fieldDownstream).toHaveValue(3); + expect(fieldNodesPerLayer).toHaveValue(5); }); it('calls onCancel when Cancel button is clicked', () => { @@ -65,7 +65,7 @@ describe('LineageConfigModal', () => { expect(onCancel).toHaveBeenCalledTimes(1); }); - it('calls onSave with updated values when form is submitted', async () => { + it('calls onSave with updated values and keeps the pipeline view mode', async () => { render( { 'field-nodes-per-layer' ); - // Update values fireEvent.change(fieldUpstream, { target: { value: '5' } }); fireEvent.change(fieldDownstream, { target: { value: '6' } }); fireEvent.change(fieldNodesPerLayer, { target: { value: '7' } }); - - // Submit form fireEvent.click(screen.getByRole('button', { name: 'label.ok' })); await waitFor(() => { @@ -94,6 +91,7 @@ describe('LineageConfigModal', () => { upstreamDepth: 5, downstreamDepth: 6, nodesPerLayer: 7, + pipelineViewMode: PipelineViewMode.Node, }); }); }); @@ -110,15 +108,14 @@ describe('LineageConfigModal', () => { const fieldUpstream = await screen.findByTestId('field-upstream'); - // Try to set negative value fireEvent.change(fieldUpstream, { target: { value: '-1' } }); - - // Submit form fireEvent.click(screen.getByRole('button', { name: 'label.ok' })); - await waitFor(() => { - expect(onSave).not.toHaveBeenCalled(); - }); + expect( + await screen.findByText('message.entity-size-less-than') + ).toBeInTheDocument(); + expect(fieldUpstream).toHaveAttribute('aria-invalid', 'true'); + expect(onSave).not.toHaveBeenCalled(); }); it('validates minimum value for downstream depth', async () => { @@ -133,15 +130,14 @@ describe('LineageConfigModal', () => { const fieldDownstream = await screen.findByTestId('field-downstream'); - // Try to set negative value fireEvent.change(fieldDownstream, { target: { value: '-1' } }); - - // Submit form fireEvent.click(screen.getByRole('button', { name: 'label.ok' })); - await waitFor(() => { - expect(onSave).not.toHaveBeenCalled(); - }); + expect( + await screen.findByText('message.entity-size-less-than') + ).toBeInTheDocument(); + expect(fieldDownstream).toHaveAttribute('aria-invalid', 'true'); + expect(onSave).not.toHaveBeenCalled(); }); it('validates minimum value for nodes per layer', async () => { @@ -158,15 +154,14 @@ describe('LineageConfigModal', () => { 'field-nodes-per-layer' ); - // Try to set value less than minimum (5) fireEvent.change(fieldNodesPerLayer, { target: { value: '4' } }); - - // Submit form fireEvent.click(screen.getByRole('button', { name: 'label.ok' })); - await waitFor(() => { - expect(onSave).not.toHaveBeenCalled(); - }); + expect( + await screen.findByText('message.entity-size-less-than') + ).toBeInTheDocument(); + expect(fieldNodesPerLayer).toHaveAttribute('aria-invalid', 'true'); + expect(onSave).not.toHaveBeenCalled(); }); it('validates required fields', async () => { @@ -185,17 +180,15 @@ describe('LineageConfigModal', () => { 'field-nodes-per-layer' ); - // Clear all fields fireEvent.change(fieldUpstream, { target: { value: '' } }); fireEvent.change(fieldDownstream, { target: { value: '' } }); fireEvent.change(fieldNodesPerLayer, { target: { value: '' } }); - - // Submit form fireEvent.click(screen.getByRole('button', { name: 'label.ok' })); - await waitFor(() => { - expect(onSave).not.toHaveBeenCalled(); - }); + expect( + await screen.findAllByText('message.field-text-is-required') + ).toHaveLength(3); + expect(onSave).not.toHaveBeenCalled(); }); it('shows the latest config and drops cancelled edits when reopened', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx index 810acc7da27b..f4c6ea96f370 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx @@ -11,32 +11,23 @@ * limitations under the License. */ import { - FieldProp, - FieldTypes, - FormFields, - HelperTextType, + Button, + Dialog, HookForm, - SimpleModal, + Input, + Modal, + ModalOverlay, } from '@openmetadata/ui-core-components'; import React, { useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { LINEAGE_CONFIG_FIELDS } from '../../../constants/Lineage.constants'; +import { getLineageConfigFormValues } from '../../../utils/EntityLineagePureUtils'; import { - LineageConfig, + LineageConfigFormValues, LineageConfigModalProps, } from './EntityLineage.interface'; -type DepthField = 'upstreamDepth' | 'downstreamDepth' | 'nodesPerLayer'; - -// Number inputs hold strings; the config stores numbers. -type LineageConfigFormValues = Record; - -const toFormValues = (config: LineageConfig): LineageConfigFormValues => ({ - upstreamDepth: String(config.upstreamDepth ?? ''), - downstreamDepth: String(config.downstreamDepth ?? ''), - nodesPerLayer: String(config.nodesPerLayer ?? ''), -}); - const LineageConfigModal: React.FC = ({ visible, config, @@ -45,84 +36,93 @@ const LineageConfigModal: React.FC = ({ }) => { const { t } = useTranslation(); const form = useForm({ - defaultValues: toFormValues(config), + defaultValues: getLineageConfigFormValues(config), }); - // Callers keep the modal mounted and only toggle `visible`, so re-read the - // config on every open: it may have loaded since, or an edit was cancelled. useEffect(() => { if (visible) { - form.reset(toFormValues(config)); + form.reset(getLineageConfigFormValues(config)); } - }, [visible, config, form]); - - const getNumberField = ( - name: DepthField, - label: string, - tooltip: string, - min: number, - testId: string - ): FieldProp => ({ - name, - label, - type: FieldTypes.NUMBER, - required: true, - helperText: tooltip, - helperTextType: HelperTextType.TOOLTIP, - props: { inputDataTestId: testId }, - rules: { - required: t('message.field-text-is-required', { fieldText: label }), - validate: (value) => - Number(value) >= min || - t('message.entity-size-less-than', { entity: label, min }), - }, - }); - - const fields: FieldProp[] = [ - getNumberField( - 'upstreamDepth', - t('label.upstream-depth'), - t('message.upstream-depth-tooltip'), - 0, - 'field-upstream' - ), - getNumberField( - 'downstreamDepth', - t('label.downstream-depth'), - t('message.downstream-depth-tooltip'), - 0, - 'field-downstream' - ), - getNumberField( - 'nodesPerLayer', - t('label.nodes-per-layer'), - t('message.nodes-per-layer-tooltip'), - 5, - 'field-nodes-per-layer' - ), - ]; + }, [visible, config, form.reset, form]); - const handleSave = (values: LineageConfigFormValues) => + // Keep the rest of the config (e.g. pipelineViewMode): callers store the + // saved object as a whole. + const onSubmit = (values: LineageConfigFormValues) => onSave({ + ...config, upstreamDepth: Number(values.upstreamDepth), downstreamDepth: Number(values.downstreamDepth), nodesPerLayer: Number(values.nodesPerLayer), - } as LineageConfig); + }); return ( - - - - - + onOpenChange={(isOpen) => !isOpen && onCancel()}> + + + + + form={form} + onSubmit={form.handleSubmit(onSubmit)}> + {LINEAGE_CONFIG_FIELDS.map( + ({ name, label, tooltip, testId, min }) => ( + ( + + )} + rules={{ + required: t('message.field-text-is-required', { + fieldText: t(label), + }), + min: { + value: min, + message: t('message.entity-size-less-than', { + entity: t(label), + min, + }), + }, + }} + /> + ) + )} + + + + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageControlButtons/LineageControlButtons.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageControlButtons/LineageControlButtons.test.tsx index 38a9871809a9..c95910d33de9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageControlButtons/LineageControlButtons.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageControlButtons/LineageControlButtons.test.tsx @@ -11,43 +11,26 @@ * limitations under the License. */ import { fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import useCustomLocation from '../../../../hooks/useCustomLocation/useCustomLocation'; import LineageControlButtons from './LineageControlButtons'; -const mockNavigate = jest.fn(); const mockZoomIn = jest.fn(); const mockZoomOut = jest.fn(); const mockFitView = jest.fn(); -const mockSetCenter = jest.fn(); -const mockGetNodes = jest.fn(); const mockReactFlowInstance = { zoomIn: mockZoomIn, zoomOut: mockZoomOut, fitView: mockFitView, - setCenter: mockSetCenter, - getNodes: mockGetNodes, }; +let mockZoom = 1; const mockLineageState = { - selectedColumn: null, - setSelectedColumn: jest.fn(), - setTracedColumns: jest.fn(), - isEditMode: false, - tracedColumns: new Set(), reactFlowInstance: mockReactFlowInstance as | typeof mockReactFlowInstance | undefined, }; -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockNavigate, -})); - -jest.mock('../../../../hooks/useCustomLocation/useCustomLocation', () => ({ - __esModule: true, - default: jest.fn(() => ({ search: '' })), +jest.mock('reactflow', () => ({ + useViewport: () => ({ x: 0, y: 0, zoom: mockZoom }), })); jest.mock('../../../../hooks/useLineageStore', () => ({ @@ -57,261 +40,100 @@ jest.mock('../../../../hooks/useLineageStore', () => ({ })); const mockOnToggleMiniMap = jest.fn(); -const mockProps = { - onToggleMiniMap: mockOnToggleMiniMap, - miniMapVisible: false, -}; + +const renderControls = (miniMapVisible = false, onFitView?: () => void) => + render( + + ); describe('LineageControlButtons', () => { beforeEach(() => { + jest.clearAllMocks(); + mockZoom = 1; mockLineageState.reactFlowInstance = mockReactFlowInstance; }); - describe('Rendering', () => { - it('should render all control buttons', () => { - render( - - - - ); - - expect(screen.getByTestId('fit-screen')).toBeInTheDocument(); - expect(screen.getByTestId('toggle-mind-map')).toBeInTheDocument(); - expect(screen.getByTestId('zoom-in')).toBeInTheDocument(); - expect(screen.getByTestId('zoom-out')).toBeInTheDocument(); - expect(screen.getByTestId('full-screen')).toBeInTheDocument(); - }); - - it('should show minimap as selected when miniMapVisible is true', () => { - render( - - - - ); - - const miniMapButton = screen.getByTestId('toggle-mind-map'); - - expect(miniMapButton).toHaveAttribute('data-selected', 'true'); - }); + it('gives every icon button an accessible name', () => { + renderControls(); + + expect( + screen.getByRole('button', { name: 'label.mind-map' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'label.zoom-in' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'label.zoom-out' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'label.fit-to-screen' }) + ).toBeInTheDocument(); }); - describe('MiniMap Toggle', () => { - it('should call onToggleMiniMap when mind map button is clicked', () => { - render( - - - - ); + it('shows the viewport zoom as a percentage', () => { + mockZoom = 0.746; + renderControls(); - fireEvent.click(screen.getByTestId('toggle-mind-map')); - - expect(mockOnToggleMiniMap).toHaveBeenCalledTimes(1); - }); + expect(screen.getByTestId('zoom-level')).toHaveTextContent('75%'); }); - describe('Fullscreen', () => { - it('should navigate to fullscreen view when fullscreen button is clicked', () => { - render( - - - - ); - - fireEvent.click(screen.getByTestId('full-screen')); + it('marks the mini map toggle as pressed only while it is shown', () => { + const { unmount } = renderControls(false); - expect(mockNavigate).toHaveBeenCalledWith({ - search: 'fullscreen=true', - }); - }); + expect(screen.getByTestId('toggle-mind-map')).toHaveAttribute( + 'aria-pressed', + 'false' + ); - it('should exit fullscreen view when already in fullscreen', () => { - (useCustomLocation as jest.Mock).mockReturnValueOnce({ - search: '?fullscreen=true', - }); + unmount(); + renderControls(true); - render( - - - - ); - - expect(screen.getByTestId('exit-full-screen')).toBeInTheDocument(); - - fireEvent.click(screen.getByTestId('exit-full-screen')); - - expect(mockNavigate).toHaveBeenCalledWith({ search: '' }); - }); + expect(screen.getByTestId('toggle-mind-map')).toHaveAttribute( + 'aria-pressed', + 'true' + ); }); - describe('Zoom Controls', () => { - it('should call zoomIn when zoom in button is clicked', () => { - render( - - - - ); - - fireEvent.click(screen.getByTestId('zoom-in')); - - expect(mockZoomIn).toHaveBeenCalledTimes(1); - }); - - it('should call zoomOut when zoom out button is clicked', () => { - render( - - - - ); - - fireEvent.click(screen.getByTestId('zoom-out')); - - expect(mockZoomOut).toHaveBeenCalledTimes(1); - }); - - it('should handle missing reactFlowInstance gracefully', () => { - mockLineageState.reactFlowInstance = undefined; - - render( - - - - ); + it('toggles the mini map', () => { + renderControls(); + fireEvent.click(screen.getByTestId('toggle-mind-map')); - fireEvent.click(screen.getByTestId('zoom-in')); - - expect(mockZoomIn).not.toHaveBeenCalled(); - }); + expect(mockOnToggleMiniMap).toHaveBeenCalledTimes(1); }); - describe('Lineage View Options Menu', () => { - it('should open menu when fit screen button is clicked', () => { - render( - - - - ); - - fireEvent.click(screen.getByTestId('fit-screen')); - - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - - it('should call fitView when "Fit to screen" menu item is clicked', () => { - render( - - - - ); - - fireEvent.click(screen.getByTestId('fit-screen')); - fireEvent.click(screen.getByText('label.fit-to-screen')); - - expect(mockFitView).toHaveBeenCalledWith({ padding: 0.2, maxZoom: 1 }); - }); - - it('should call fitView with selected nodes when "Refocus to selected" is clicked', () => { - const selectedNodes = [ - { - id: '1', - position: { x: 5, y: 5 }, - width: 10, - height: 10, - selected: true, - }, - { - id: '2', - position: { x: 15, y: 15 }, - width: 10, - height: 10, - selected: true, - }, - ]; - mockGetNodes.mockReturnValue(selectedNodes); - - render( - - - - ); - - fireEvent.click(screen.getByTestId('fit-screen')); - fireEvent.click(screen.getByText('label.refocused-to-selected')); - - expect(mockSetCenter).toHaveBeenCalledWith(10, 38, { - duration: 800, - zoom: 0.65, - }); - }); + it('zooms in and out through the flow instance', () => { + renderControls(); + fireEvent.click(screen.getByTestId('zoom-in')); + fireEvent.click(screen.getByTestId('zoom-out')); - it('should call onRearrange when "Rearrange nodes" is clicked', () => { - const mockOnRearrange = jest.fn(); - render( - - - - ); - - fireEvent.click(screen.getByTestId('fit-screen')); - fireEvent.click(screen.getByText('label.rearrange-nodes')); - - expect(mockOnRearrange).toHaveBeenCalledTimes(1); - }); - - it('should not throw when "Rearrange nodes" is clicked without onRearrange', () => { - render( - - - - ); - - fireEvent.click(screen.getByTestId('fit-screen')); - - expect(() => - fireEvent.click(screen.getByText('label.rearrange-nodes')) - ).not.toThrow(); - }); - - it('should call setCenter when "Refocus to home" is clicked', () => { - const selectedNodes = [ - { id: '1', selected: true, data: { isRootNode: false } }, - { - id: '2', - position: { x: 5, y: 5 }, - width: 20, - selected: true, - data: { isRootNode: true }, - }, - ]; - mockGetNodes.mockReturnValue(selectedNodes); - - render( - - - - ); + expect(mockZoomIn).toHaveBeenCalledTimes(1); + expect(mockZoomOut).toHaveBeenCalledTimes(1); + }); - fireEvent.click(screen.getByTestId('fit-screen')); - fireEvent.click(screen.getByText('label.refocused-to-home')); + it('does not throw without a flow instance', () => { + mockLineageState.reactFlowInstance = undefined; + renderControls(); - expect(mockSetCenter).toHaveBeenCalledWith(15, 38, { - duration: 800, - zoom: 0.65, - }); - }); + expect(() => fireEvent.click(screen.getByTestId('zoom-in'))).not.toThrow(); + }); - it('should handle missing reactFlowInstance gracefully', () => { - mockLineageState.reactFlowInstance = undefined; + it('fits the view with the map handler when one is given', () => { + const onFitView = jest.fn(); + renderControls(false, onFitView); + fireEvent.click(screen.getByTestId('fit-screen')); - render( - - - - ); + expect(onFitView).toHaveBeenCalledTimes(1); + expect(mockFitView).not.toHaveBeenCalled(); + }); - fireEvent.click(screen.getByTestId('fit-screen')); - fireEvent.click(screen.getByText('label.fit-to-screen')); + it('falls back to the flow instance fitView', () => { + renderControls(); + fireEvent.click(screen.getByTestId('fit-screen')); - expect(mockFitView).not.toHaveBeenCalled(); - }); + expect(mockFitView).toHaveBeenCalledWith({ padding: 0.2, maxZoom: 1 }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageControlButtons/LineageControlButtons.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageControlButtons/LineageControlButtons.tsx index 050c579fc25c..47034f952e38 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageControlButtons/LineageControlButtons.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageControlButtons/LineageControlButtons.tsx @@ -11,69 +11,39 @@ * limitations under the License. */ import { - ButtonGroup, - ButtonGroupItem, - Dropdown, - Tooltip, + Box, + ButtonUtility, + Card, + Divider, + Typography, } from '@openmetadata/ui-core-components'; -import Qs from 'qs'; -import { FC, useCallback, useMemo } from 'react'; -import type { Key } from 'react-aria-components'; +import { FC, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import type { ReactFlowInstance } from 'reactflow'; -import { ReactComponent as ExitFullScreenIcon } from '../../../../assets/svg/ic-exit-fullscreen.svg'; +import { ReactFlowInstance, useViewport } from 'reactflow'; import { ReactComponent as FitScreenIcon } from '../../../../assets/svg/ic-fit-screen.svg'; -import { ReactComponent as FitViewOptionsIcon } from '../../../../assets/svg/ic-fit-view-options.svg'; -import { ReactComponent as FullscreenIcon } from '../../../../assets/svg/ic-fullscreen.svg'; -import { ReactComponent as HomeIcon } from '../../../../assets/svg/ic-home.svg'; import { ReactComponent as MapIcon } from '../../../../assets/svg/ic-map.svg'; -import { ReactComponent as RearrangeNodesIcon } from '../../../../assets/svg/ic-rearrange-nodes.svg'; import { ReactComponent as ZoomInIcon } from '../../../../assets/svg/ic-zoom-in.svg'; import { ReactComponent as ZoomOutIcon } from '../../../../assets/svg/ic-zoom-out.svg'; -import { FULLSCREEN_QUERY_PARAM_KEY } from '../../../../constants/constants'; -import useCustomLocation from '../../../../hooks/useCustomLocation/useCustomLocation'; import { useLineageStore } from '../../../../hooks/useLineageStore'; -import { centerNodePosition } from '../../../../utils/EntityLineageLayoutUtils'; + +type IconComponent = FC<{ className?: string }>; const LineageControlButtons: FC<{ onToggleMiniMap: () => void; miniMapVisible?: boolean; reactFlowInstance?: ReactFlowInstance; onFitView?: () => void; - onRearrange?: () => void; - onRefocusHome?: () => void; - onRefocusSelected?: () => void; }> = ({ onToggleMiniMap, miniMapVisible = false, reactFlowInstance: controlledReactFlowInstance, onFitView, - onRearrange, - onRefocusHome, - onRefocusSelected, }) => { const { t } = useTranslation(); const providerReactFlowInstance = useLineageStore((s) => s.reactFlowInstance); const reactFlowInstance = controlledReactFlowInstance ?? providerReactFlowInstance; - const navigate = useNavigate(); - const location = useCustomLocation(); - - const isFullscreen = useMemo(() => { - const params = Qs.parse(location.search, { ignoreQueryPrefix: true }); - - return params[FULLSCREEN_QUERY_PARAM_KEY] === 'true'; - }, [location.search]); - - const toggleFullscreenView = useCallback(() => { - navigate({ - search: isFullscreen - ? '' - : Qs.stringify({ [FULLSCREEN_QUERY_PARAM_KEY]: !isFullscreen }), - }); - }, [isFullscreen, navigate]); - + const { zoom } = useViewport(); const handleZoomIn = useCallback(() => { reactFlowInstance?.zoomIn(); }, [reactFlowInstance]); @@ -91,164 +61,64 @@ const LineageControlButtons: FC<{ reactFlowInstance?.fitView({ padding: 0.2, maxZoom: 1 }); }, [onFitView, reactFlowInstance]); - const handleRearrange = useCallback(() => { - onRearrange?.(); - }, [onRearrange]); - - const handleRefocusSelected = useCallback(() => { - if (onRefocusSelected) { - onRefocusSelected(); - - return; - } - const selectedElement = reactFlowInstance - ?.getNodes() - .find((el) => el.selected); - - selectedElement && centerNodePosition(selectedElement, reactFlowInstance); - }, [onRefocusSelected, reactFlowInstance]); - - const handleRefocusHome = useCallback(() => { - if (onRefocusHome) { - onRefocusHome(); - - return; - } - const selectedElement = reactFlowInstance - ?.getNodes() - .find((el) => el.data.isRootNode); - - selectedElement && centerNodePosition(selectedElement, reactFlowInstance); - }, [onRefocusHome, reactFlowInstance]); - - const handleMenuAction = useCallback( - (key: Key) => { - switch (key) { - case 'fit': - handleFitView(); - - break; - case 'refocus-selected': - handleRefocusSelected(); - - break; - case 'rearrange': - handleRearrange(); - - break; - case 'refocus-home': - handleRefocusHome(); - - break; - } - }, - [handleFitView, handleRefocusSelected, handleRearrange, handleRefocusHome] - ); - return ( - void 0}> - - } - id="fit-view" - /> - - - } - id="fit" - label={t('label.fit-to-screen')} - /> - } - id="refocus-selected" - label={t('label.refocused-to-selected')} - /> - } - id="rearrange" - label={t('label.rearrange-nodes')} - /> - } - id="refocus-home" - label={t('label.refocused-to-home')} - /> - - - - - - } - id="mind-map" - onPress={onToggleMiniMap} - /> - - - - } - id="zoom-in" - onPress={handleZoomIn} - /> - - - - } - id="zoom-out" - onPress={handleZoomOut} - /> - - - - - } - id="full-screen" - onPress={toggleFullscreenView} - /> - - + + + + + + + + {`${Math.round(zoom * 100)}%`} + + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.interface.ts index f3f6795c0b2f..b9714c9282d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.interface.ts @@ -1,8 +1,5 @@ import { EntityType } from '../../../../enums/entity.enum'; -import { - LineageBand, - LineageLens, -} from '../../../../generated/api/lineage/lineageScene'; +import { LineageLens } from '../../../../generated/api/lineage/lineageScene'; import { SourceType } from '../../../SearchedData/SearchedData.interface'; /* @@ -20,9 +17,7 @@ import { SourceType } from '../../../SearchedData/SearchedData.interface'; export interface LineageLayersProps { entityType?: EntityType; entity?: SourceType; - sceneBand?: LineageBand; sceneLens?: LineageLens; sceneLevelLabelKey?: string; - onSceneBandChange?: (band: LineageBand) => void; onSceneLensChange?: (lens: LineageLens) => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.test.tsx index 44bafac9b735..d7fae0ee76cc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.test.tsx @@ -10,150 +10,131 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - act, - fireEvent, - queryByText, - render, - screen, -} from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ReactFlowProvider } from 'reactflow'; import { EntityType } from '../../../../enums/entity.enum'; -import { - LineageBand, - LineageLens, -} from '../../../../generated/api/lineage/lineageScene'; +import { LineageLens } from '../../../../generated/api/lineage/lineageScene'; import { LineageLayer } from '../../../../generated/settings/settings'; +import { LineagePlatformView } from '../../../../hooks/lineage/types'; +import { SourceType } from '../../../SearchedData/SearchedData.interface'; import LineageLayers from './LineageLayers'; const mockSetActiveLayer = jest.fn(); const mockSetPlatformView = jest.fn(); +let mockActiveLayer: LineageLayer[] = []; jest.mock('../../../../hooks/useLineageStore', () => ({ useLineageStore: jest.fn().mockImplementation(() => ({ - activeLayer: [], - platformView: [], + activeLayer: mockActiveLayer, + platformView: 'None', setPlatformView: mockSetPlatformView, - isPlatformLineage: false, setActiveLayer: mockSetActiveLayer, })), })); -describe('LineageLayers component', () => { - it('renders LineageLayers component', () => { - const { container } = render( - - - - ); - const layerBtn = screen.getByText('label.layer-plural'); - - expect(layerBtn).toBeInTheDocument(); +const renderLayers = (props: Parameters[0]) => + render( + + + + ); - const columnButton = queryByText(container, 'label.column'); - const pipelineButton = queryByText(container, 'label.pipeline'); - const dataQualityButton = queryByText(container, 'label.data-quality'); - - expect(columnButton).not.toBeInTheDocument(); - expect(pipelineButton).not.toBeInTheDocument(); - expect(dataQualityButton).not.toBeInTheDocument(); +describe('LineageLayers component', () => { + afterEach(() => { + mockActiveLayer = []; + jest.clearAllMocks(); }); - it('calls onUpdateLayerView when a button is clicked', async () => { - render( - - - - ); + it('offers the classic asset layers on an asset page', async () => { + const user = userEvent.setup({ delay: null }); + renderLayers({ + entityType: EntityType.TABLE, + entity: { domains: [{ id: 'd' }] } as unknown as SourceType, + }); - const layerBtn = screen.getByTestId('lineage-layer-btn'); + expect(screen.getByText('label.data-asset-level')).toBeInTheDocument(); - await act(async () => { - fireEvent.click(layerBtn); - }); + await user.click(screen.getByTestId('lineage-layer-btn')); - const columnButton = screen.getByText('label.column'); - const dataObservabilityBtn = screen.getByText('label.observability'); + expect(screen.getByTestId('lineage-layer-column-btn')).toBeInTheDocument(); + expect( + screen.getByTestId('lineage-layer-observability-btn') + ).toBeInTheDocument(); + expect(screen.getByTestId('lineage-layer-service-btn')).toBeInTheDocument(); + expect(screen.getByTestId('lineage-layer-domain-btn')).toBeInTheDocument(); + expect( + screen.queryByTestId('lineage-layer-data-product-btn') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('lineage-layer-lens-service') + ).not.toBeInTheDocument(); + }); - expect(columnButton).toBeInTheDocument(); - expect(dataObservabilityBtn).toBeInTheDocument(); + it('toggles store layers and platform view from the asset menu', async () => { + const user = userEvent.setup({ delay: null }); + renderLayers({ entityType: EntityType.TABLE }); - fireEvent.click(columnButton as HTMLElement); + await user.click(screen.getByTestId('lineage-layer-btn')); + await user.click(screen.getByTestId('lineage-layer-column-btn')); - expect(mockSetActiveLayer).toHaveBeenCalledWith([ + expect(mockSetActiveLayer).toHaveBeenLastCalledWith([ LineageLayer.ColumnLevelLineage, ]); - fireEvent.click(dataObservabilityBtn as HTMLElement); + await user.click(screen.getByTestId('lineage-layer-service-btn')); - expect(mockSetActiveLayer).toHaveBeenCalledWith([ - LineageLayer.DataObservability, - ]); + expect(mockSetPlatformView).toHaveBeenLastCalledWith( + LineagePlatformView.Service + ); }); - it('calls scene lens handler in scene mode', async () => { - const onSceneBandChange = jest.fn(); - const onSceneLensChange = jest.fn(); + it('turns an active asset layer off', async () => { + mockActiveLayer = [LineageLayer.ColumnLevelLineage]; + const user = userEvent.setup({ delay: null }); + renderLayers({ entityType: EntityType.TABLE }); + + expect(screen.getByText('label.column')).toBeInTheDocument(); - render( - - - + await user.click(screen.getByTestId('lineage-layer-btn')); + + expect(screen.getByTestId('lineage-layer-column-btn')).toHaveAttribute( + 'data-selected' ); + await user.click(screen.getByTestId('lineage-layer-column-btn')); + + expect(mockSetActiveLayer).toHaveBeenLastCalledWith([]); + }); + + it('switches the lens from the platform Layers menu', async () => { + const user = userEvent.setup({ delay: null }); + const onSceneLensChange = jest.fn(); + renderLayers({ + sceneLens: LineageLens.Service, + sceneLevelLabelKey: 'label.lineage-map-schema-level', + onSceneLensChange, + }); + expect( screen.getByText('label.lineage-map-schema-level') ).toBeInTheDocument(); - await act(async () => { - fireEvent.click(screen.getByTestId('lineage-layer-btn')); - }); + await user.click(screen.getByTestId('lineage-layer-btn')); expect( - screen.getByText('message.lineage-map-service-lens-description') + await screen.findByText('message.lineage-map-service-lens-description') ).toBeInTheDocument(); + expect(screen.getByTestId('lineage-layer-lens-service')).toHaveAttribute( + 'aria-checked', + 'true' + ); expect( - screen.getByTestId(`lineage-layer-band-${LineageBand.Field}`) - ).toBeInTheDocument(); + screen.queryByTestId('lineage-layer-column-btn') + ).not.toBeInTheDocument(); - fireEvent.click(screen.getByTestId('lineage-layer-lens-domain')); + await user.click(screen.getByTestId('lineage-layer-lens-domain')); expect(onSceneLensChange).toHaveBeenCalledWith(LineageLens.Domain); - expect(onSceneBandChange).not.toHaveBeenCalled(); - }); - - it('calls scene band handler in scene mode', async () => { - const onSceneBandChange = jest.fn(); - const onSceneLensChange = jest.fn(); - - render( - - - - ); - - await act(async () => { - fireEvent.click(screen.getByTestId('lineage-layer-btn')); - }); - - fireEvent.click( - screen.getByTestId(`lineage-layer-band-${LineageBand.Field}`) - ); - - expect(onSceneBandChange).toHaveBeenCalledWith(LineageBand.Field); - expect(onSceneLensChange).not.toHaveBeenCalled(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.tsx index d52b0e6dbd49..3f48ef1ac06b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageLayers/LineageLayers.tsx @@ -11,31 +11,29 @@ * limitations under the License. */ import { + Box, Button, - ButtonGroup, - ButtonGroupItem, - Popover, - PopoverTrigger, + Dropdown, + Typography, } from '@openmetadata/ui-core-components'; +import { + Check, + ChevronDown, + Columns03, + Dataflow03, + Globe01, + LayersThree01, + Package, + ShieldTick, +} from '@openmetadata/ui-core-components/icons'; import classNames from 'classnames'; import { isEmpty, xor } from 'lodash'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { FC, ReactNode, useCallback, useMemo, useState } from 'react'; import type { Selection } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as DropdownIcon } from '../../../../assets/svg/drop-down.svg'; -import { ReactComponent as CheckIcon } from '../../../../assets/svg/ic-check.svg'; -import { ReactComponent as DataQualityIcon } from '../../../../assets/svg/ic-data-contract.svg'; -import { ReactComponent as DataProductIcon } from '../../../../assets/svg/ic-data-product.svg'; -import { ReactComponent as DomainIcon } from '../../../../assets/svg/ic-domain.svg'; -import { ReactComponent as Layers } from '../../../../assets/svg/ic-layers.svg'; -import { ReactComponent as TableIcon } from '../../../../assets/svg/ic-table.svg'; -import { ReactComponent as ServiceView } from '../../../../assets/svg/services.svg'; import { SERVICE_TYPES } from '../../../../constants/Services.constant'; import { EntityType } from '../../../../enums/entity.enum'; -import { - LineageBand, - LineageLens, -} from '../../../../generated/api/lineage/lineageScene'; +import { LineageLens } from '../../../../generated/api/lineage/lineageScene'; import { Table } from '../../../../generated/entity/data/table'; import { LineageLayer } from '../../../../generated/settings/settings'; import { LineagePlatformView } from '../../../../hooks/lineage/types'; @@ -43,28 +41,57 @@ import { useLineageStore } from '../../../../hooks/useLineageStore'; import { AssetsUnion } from '../../../DataAssets/AssetsSelectionModal/AssetSelectionModal.interface'; import { LineageLayersProps } from './LineageLayers.interface'; -const LAYER_BUTTON_CLASSES = [ - 'tw:flex-col tw:gap-1 tw:px-4 tw:py-2 tw:text-[10px] tw:font-medium tw:text-primary', - 'tw:whitespace-normal tw:break-words tw:hover:after:outline-brand tw:hover:z-10', - 'tw:selected:bg-brand-primary tw:selected:text-primary', -].join(' '); +type MenuIcon = FC<{ className?: string }>; -const SCENE_LAYER_MENU_OPTION_CLASSES = [ - 'lineage-scene-layer-menu-option tw:grid! tw:w-full! tw:grid-cols-[34px_minmax(0,1fr)_16px]', - 'tw:items-center tw:justify-start! tw:gap-2.5! tw:rounded-lg! tw:px-3! tw:py-2! tw:text-left', - 'tw:whitespace-normal! tw:after:outline-transparent tw:selected:bg-brand-primary tw:selected:text-brand-tertiary', - 'tw:[&[data-selected]_.lineage-scene-layer-menu-icon]:bg-brand-solid', - 'tw:[&[data-selected]_.lineage-scene-layer-menu-icon]:text-fg-white', -].join(' '); +const SCENE_LENS_OPTIONS = [ + LineageLens.Service, + LineageLens.Domain, + LineageLens.DataProduct, +]; -const SCENE_LAYER_MENU_ICON_CLASSES = - 'lineage-scene-layer-menu-icon tw:size-[34px] tw:rounded-lg tw:bg-tertiary tw:p-2 tw:text-fg-secondary'; +const SCENE_LENS_ICONS: Record = { + [LineageLens.Service]: Dataflow03, + [LineageLens.Domain]: Globe01, + [LineageLens.DataProduct]: Package, +}; -const SCENE_LAYER_TRIGGER_CLASSES = [ - 'lineage-scene-layer-trigger tw:flex! tw:min-h-[62px] tw:min-w-[248px] tw:items-center', - 'tw:justify-start! tw:gap-2.5! tw:rounded-xl! tw:bg-primary tw:px-3! tw:py-2! tw:text-left tw:shadow-lg', - 'tw:[&>[data-text]]:min-w-0 tw:[&>[data-text]]:flex-1 tw:[&>[data-text]]:p-0', -].join(' '); +const ASSET_LAYER_OPTIONS: { + key: LineageLayer | LineagePlatformView; + icon: MenuIcon; + labelKey: string; + testId: string; +}[] = [ + { + key: LineageLayer.ColumnLevelLineage, + icon: Columns03, + labelKey: 'label.column', + testId: 'lineage-layer-column-btn', + }, + { + key: LineageLayer.DataObservability, + icon: ShieldTick, + labelKey: 'label.observability', + testId: 'lineage-layer-observability-btn', + }, + { + key: LineagePlatformView.Service, + icon: Dataflow03, + labelKey: 'label.service', + testId: 'lineage-layer-service-btn', + }, + { + key: LineagePlatformView.Domain, + icon: Globe01, + labelKey: 'label.domain', + testId: 'lineage-layer-domain-btn', + }, + { + key: LineagePlatformView.DataProduct, + icon: Package, + labelKey: 'label.data-product', + testId: 'lineage-layer-data-product-btn', + }, +]; const getSceneLensLabelKey = (lens: LineageLens) => { switch (lens) { @@ -88,99 +115,109 @@ const getSceneLensDescriptionKey = (lens: LineageLens) => { } }; -const getSceneBandLabelKey = (band: LineageBand) => { - switch (band) { - case LineageBand.Layer: - return 'label.lineage-map-layer-view'; - case LineageBand.Field: - return 'label.field-level-lineage'; - default: - return 'label.data-asset-plural'; - } -}; - -const getLegacyLayerVisibility = ( +const getAssetLayerKeys = ( entityType: LineageLayersProps['entityType'], - entity: LineageLayersProps['entity'], - isPlatformLineage: boolean + entity: LineageLayersProps['entity'] ) => { const isServiceType = SERVICE_TYPES.includes(entityType as AssetsUnion); const hasDomainContext = Boolean( entityType && entityType !== EntityType.DOMAIN ); + const keys: string[] = []; - return { - showColumnAndObservability: Boolean(entityType && !isServiceType), - showService: isPlatformLineage || !isServiceType, - showDomain: - isPlatformLineage || (hasDomainContext && !isEmpty(entity?.domains)), - showDataProduct: - isPlatformLineage || - (hasDomainContext && !isEmpty((entity as Table)?.dataProducts)), - }; -}; - -const SceneLensIcon = ({ lens }: { lens: LineageLens }) => { - const icons = { - [LineageLens.Domain]: DomainIcon, - [LineageLens.DataProduct]: DataProductIcon, - [LineageLens.Service]: ServiceView, - }; - const Icon = icons[lens]; + if (entityType && !isServiceType) { + keys.push(LineageLayer.ColumnLevelLineage, LineageLayer.DataObservability); + } + if (!isServiceType) { + keys.push(LineagePlatformView.Service); + } + if (hasDomainContext && !isEmpty(entity?.domains)) { + keys.push(LineagePlatformView.Domain); + } + if (hasDomainContext && !isEmpty((entity as Table)?.dataProducts)) { + keys.push(LineagePlatformView.DataProduct); + } - return ; + return keys; }; +const LayerMenuOption = ({ + icon: Icon, + title, + description, + isSelected, +}: { + icon: MenuIcon; + title: string; + description?: string; + isSelected: boolean; +}) => ( + + + + + + {title} + + {description && ( + + {description} + + )} + + {isSelected && ( + +); + +const MENU_SECTION_HEADER_CLASSES = + 'tw:px-3 tw:pt-1.5 tw:pb-1 tw:text-xs tw:font-semibold tw:uppercase tw:tracking-wider tw:text-quaternary'; + const LineageLayers = ({ entityType, entity, - sceneBand, sceneLens, sceneLevelLabelKey, - onSceneBandChange, onSceneLensChange, }: LineageLayersProps) => { - const { - activeLayer, - platformView, - setPlatformView, - isPlatformLineage, - setActiveLayer, - } = useLineageStore(); + const { activeLayer, platformView, setPlatformView, setActiveLayer } = + useLineageStore(); const { t } = useTranslation(); const [isLayersOpen, setIsLayersOpen] = useState(false); - const hasSceneControls = Boolean( - sceneBand && sceneLens && onSceneBandChange && onSceneLensChange - ); - const handleLayerClick = useCallback( - (layer: LineageLayer) => { - if (activeLayer.indexOf(layer) === -1) { - setActiveLayer([...activeLayer, layer]); - } else { - setActiveLayer(activeLayer.filter((value) => value !== layer)); - } - }, - [activeLayer, setActiveLayer] + const assetLayerKeys = useMemo( + () => getAssetLayerKeys(entityType, entity), + [entityType, entity] ); - const handlePlatformViewChange = useCallback( - (view: string) => { - setPlatformView( - platformView === view - ? LineagePlatformView.None - : (view as LineagePlatformView) - ); - }, - [platformView, setPlatformView] + const assetSelectedKeys = useMemo( + () => + new Set( + [...activeLayer, platformView].filter((value) => + assetLayerKeys.includes(value) + ) + ), + [activeLayer, platformView, assetLayerKeys] ); const handleSceneLensSelection = useCallback( (keys: Selection) => { - if (keys === 'all') { - return; - } - const [lens] = [...keys]; + const [lens] = keys === 'all' ? [] : [...keys]; if (lens && onSceneLensChange) { onSceneLensChange(lens as LineageLens); setIsLayersOpen(false); @@ -189,285 +226,170 @@ const LineageLayers = ({ [onSceneLensChange] ); - const handleSceneBandSelection = useCallback( + // Column and Observability are overlays; Service / Domain / Data Product + // swap the graph for that container's lineage, one at a time. + const handleAssetLayerChange = useCallback( (keys: Selection) => { - if (keys === 'all') { + const next = keys === 'all' ? assetLayerKeys : [...keys].map(String); + const [changed] = xor([...assetSelectedKeys], next); + if (!changed) { return; } - const [band] = [...keys]; - if (band && onSceneBandChange) { - onSceneBandChange(band as LineageBand); - setIsLayersOpen(false); - } - }, - [onSceneBandChange] - ); - - const { - showColumnAndObservability, - showService, - showDomain, - showDataProduct, - } = getLegacyLayerVisibility(entityType, entity, isPlatformLineage); - - const { layerButtons, renderedValues } = useMemo(() => { - const buttons = []; - const values: string[] = []; - - if (showColumnAndObservability) { - values.push( - LineageLayer.ColumnLevelLineage, - LineageLayer.DataObservability - ); - buttons.push( - - - {t('label.column')} - , - - - {t('label.observability')} - - ); - } - - if (showService) { - values.push(LineagePlatformView.Service); - buttons.push( - - - {t('label.service')} - - ); - } - - if (showDomain) { - values.push(LineagePlatformView.Domain); - buttons.push( - - - {t('label.domain')} - - ); - } - - if (showDataProduct) { - values.push(LineagePlatformView.DataProduct); - buttons.push( - - - {t('label.data-product')} - - ); - } - - return { layerButtons: buttons, renderedValues: values }; - }, [t, showColumnAndObservability, showService, showDomain, showDataProduct]); - - const selectedKeys = useMemo( - () => - new Set( - [...activeLayer, platformView].filter((value) => - renderedValues.includes(value as string) + if ( + Object.values(LineagePlatformView).includes( + changed as LineagePlatformView ) - ), - [activeLayer, platformView, renderedValues] - ); - - const handleSelectionChange = useCallback( - (keys: Selection) => { - const nextSelection = - keys === 'all' ? [...renderedValues] : [...keys].map(String); - const [changed] = xor([...selectedKeys], nextSelection); + ) { + setPlatformView( + platformView === changed + ? LineagePlatformView.None + : (changed as LineagePlatformView) + ); - if (changed) { - if ( - Object.values(LineagePlatformView).includes( - changed as LineagePlatformView - ) - ) { - handlePlatformViewChange(changed); - } else { - handleLayerClick(changed as LineageLayer); - } + return; } + const layer = changed as LineageLayer; + setActiveLayer( + activeLayer.includes(layer) + ? activeLayer.filter((value) => value !== layer) + : [...activeLayer, layer] + ); }, - [selectedKeys, renderedValues, handlePlatformViewChange, handleLayerClick] + [ + activeLayer, + assetLayerKeys, + assetSelectedKeys, + platformView, + setActiveLayer, + setPlatformView, + ] ); - const sceneControls = useMemo(() => { - if (!hasSceneControls || !sceneLens || !sceneBand) { - return null; - } - - const sceneLensOptions = [ - LineageLens.Service, - LineageLens.Domain, - LineageLens.DataProduct, - ]; - const sceneBandOptions = [ - LineageBand.Layer, - LineageBand.Asset, - LineageBand.Field, - ]; - - return ( -
-
- + const isSceneMenu = Boolean(sceneLens && onSceneLensChange); + let triggerLabel: ReactNode; + let menu: ReactNode; + + if (sceneLens && isSceneMenu) { + triggerLabel = t(sceneLevelLabelKey ?? getSceneLensLabelKey(sceneLens)); + menu = ( + + + {t('label.lineage-layer')} - - - {sceneLensOptions.map((lens) => ( - - - - - {t(getSceneLensLabelKey(lens))} - - - {t(getSceneLensDescriptionKey(lens))} - - - {sceneLens === lens && ( - - )} - - ))} - -
- -
- - {t('label.level')} - - - {sceneBandOptions.map((band) => ( - - {band === LineageBand.Layer ? ( - - ) : ( - - )} - - - {t(getSceneBandLabelKey(band))} - - - {sceneBand === band && ( - - )} - - ))} - -
-
+ + {SCENE_LENS_OPTIONS.map((lens) => ( + + {({ isSelected }) => ( + + )} + + ))} + + + ); + } else { + const assetOptions = ASSET_LAYER_OPTIONS.filter(({ key }) => + assetLayerKeys.includes(key) + ); + const selectedLabels = assetOptions + .filter(({ key }) => assetSelectedKeys.has(key)) + .map(({ labelKey }) => t(labelKey)); + triggerLabel = isEmpty(selectedLabels) + ? t('label.data-asset-level') + : selectedLabels.join(', '); + menu = ( + + + + {t('label.lineage-layer')} + + {assetOptions.map(({ key, icon, labelKey, testId }) => ( + + {({ isSelected }) => ( + + )} + + ))} + + ); - }, [ - handleSceneBandSelection, - handleSceneLensSelection, - hasSceneControls, - sceneBand, - sceneLens, - t, - ]); + } - const trigger = - hasSceneControls && sceneLens ? ( + return ( + - ) : ( - - ); - - return ( - - {trigger} - - {sceneControls ?? ( - - {layerButtons} - - )} - - + + {menu} + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageNodeLabelV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageNodeLabelV1.test.tsx index 8d8a0092ec69..a49d498d04e5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageNodeLabelV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageNodeLabelV1.test.tsx @@ -26,6 +26,12 @@ jest.mock('../../../rest/testAPI', () => ({ getTestCaseExecutionSummary: jest.fn(), })); +jest.mock('../../../utils/i18next/LocalUtil', () => ({ + Transi18next: ({ values }: { values: { count: number; entity: string } }) => ( + {`${values.count} ${values.entity}`} + ), +})); + jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => { @@ -145,7 +151,6 @@ const mockNodeWithoutChildren = { const defaultLineageStore = { isDQEnabled: false, - isEditMode: false, }; describe('LineageNodeLabelV1', () => { @@ -162,6 +167,37 @@ describe('LineageNodeLabelV1', () => { }); describe('EntityLabel Component', () => { + it('shows the asset count of a container node as a pill', () => { + render( + + ); + + expect(screen.getByTestId('lineage-node-count')).toHaveTextContent( + '146 tables' + ); + expect(screen.getByText('Snowflake · service')).toBeInTheDocument(); + }); + + it('shows no count pill for a node without a count', () => { + render( + + ); + + expect( + screen.queryByTestId('lineage-node-count') + ).not.toBeInTheDocument(); + }); + it('should render entity label with display name', () => { render( { ).toHaveBeenCalledTimes(1); }); - it('should disable filter button in edit mode', () => { - (useLineageStore as unknown as jest.Mock).mockReturnValue({ - ...defaultLineageStore, - isEditMode: true, - }); - - render( - - ); - - const filterButton = screen.getByTestId('lineage-filter-button'); - - expect(filterButton).toBeDisabled(); - }); - it('should expose the filter button tooltip as an accessible label', () => { render( { it('should render complete component with all features enabled', async () => { (useLineageStore as unknown as jest.Mock).mockReturnValue({ isDQEnabled: true, - isEditMode: false, }); render( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageNodeLabelV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageNodeLabelV1.tsx index e974a75b072e..5ba7daeb9426 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageNodeLabelV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageNodeLabelV1.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ import { + Badge, BadgeWithIcon, Box, Breadcrumbs, @@ -18,13 +19,23 @@ import { ButtonUtility, Typography, } from '@openmetadata/ui-core-components'; +import { + Dataflow01, + Table as TableIcon, +} from '@openmetadata/ui-core-components/icons'; import classNames from 'classnames'; import { capitalize, isUndefined } from 'lodash'; -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { + memo, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as IconDBTModel } from '../../../assets/svg/dbt-model.svg'; import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg'; -import { ReactComponent as FilterIcon } from '../../../assets/svg/ic-filter.svg'; import { EntityType } from '../../../enums/entity.enum'; import { ModelType, Table } from '../../../generated/entity/data/table'; import { @@ -39,6 +50,7 @@ import { getEntityIcon } from '../../../utils/EntityIconUtils'; import { getEntityChildrenAndLabel } from '../../../utils/EntityLineageNodeUtils'; import { getEntityName } from '../../../utils/EntityNameUtils'; import { getServiceIcon } from '../../../utils/EntityServiceIconUtils'; +import { Transi18next } from '../../../utils/i18next/LocalUtil'; import TestSuiteSummaryWidget from './TestSuiteSummaryWidget/TestSuiteSummaryWidget.component'; interface LineageNodeLabelProps { @@ -48,12 +60,41 @@ interface LineageNodeLabelProps { toggleColumnsList?: () => void; toggleOnlyShowColumnsWithLineageFilterActive?: () => void; isOnlyShowColumnsWithLineageFilterActive?: boolean; + actions?: ReactNode; } +const getFooterChildrenCount = (node: LineageNodeType) => + getEntityChildrenAndLabel(node).childrenCount || + (node.lineageMapChildrenCount ?? 0); + +const NodeCountBadge = ({ + count, +}: { + count: LineageNodeType['lineageMapCount']; +}) => + count ? ( + + {/* Badge is a flex container, which drops the leading space of a bare text node. */} + + } + values={count} + /> + + + ) : null; + const EntityLabel = ({ + actions, node, onEntityClick, -}: Pick) => { +}: Pick) => { const { showDeletedIcon, showDbtIcon } = useMemo(() => { return { showDbtIcon: @@ -65,23 +106,28 @@ const EntityLabel = ({ }; }, [node]); - const { childrenCount } = useMemo( - () => getEntityChildrenAndLabel(node), - [node] - ); + const childrenCount = useMemo(() => getFooterChildrenCount(node), [node]); + // Temp tables have no service / database / schema to show, so their + // breadcrumb would only be empty separators. const breadcrumbItems = useMemo( () => - getEntityBreadcrumbs( - node as unknown as Table, - node.entityType as EntityType, - false - ).map((breadcrumb, index) => ({ - id: `${index}-${breadcrumb.name}`, - label: ( - {breadcrumb.name} - ), - })), + node.isTempTable + ? [] + : getEntityBreadcrumbs( + node as unknown as Table, + node.entityType as EntityType, + false + ) + .filter((breadcrumb) => Boolean(breadcrumb.name)) + .map((breadcrumb, index) => ({ + id: `${index}-${breadcrumb.name}`, + label: ( + + {breadcrumb.name} + + ), + })), [node] ); const serviceSubtitle = useMemo( @@ -95,27 +141,42 @@ const EntityLabel = ({ return (
0 ? 'with-footer' : '' + 'items-center entity-label-container tw:min-w-0 tw:flex-1', + { + 'with-footer': childrenCount > 0, + // Container nodes have no footer, so centre the name, subtitle and + // count pill instead of the leaf layout's top-weighted padding. + 'tw:flex tw:items-center tw:py-3!': Boolean( + node.lineageMapCount || node.isTempTable + ), + } )}> -
- {!node.isTempTable && ( +
+ {node.isTempTable ? ( + + + ) : (
{getServiceIcon(node)}
)} - + {onEntityClick ? (
)} + {actions}
); }; -const TestSuiteSummaryContainer = ({ node }: LineageNodeLabelProps) => { +const TestSuiteSummaryContainer = ({ + node, +}: Pick) => { const { entityType } = node; const [summary, setSummary] = useState(); const [isLoading, setIsLoading] = useState(true); @@ -222,13 +288,13 @@ const EntityFooter = ({ toggleColumnsList, toggleOnlyShowColumnsWithLineageFilterActive, isOnlyShowColumnsWithLineageFilterActive, -}: LineageNodeLabelProps) => { +}: Omit) => { const { t } = useTranslation(); - const { isEditMode } = useLineageStore(); - const { childrenHeading, childrenCount } = useMemo( + const { childrenHeading } = useMemo( () => getEntityChildrenAndLabel(node), [node] ); + const childrenCount = useMemo(() => getFooterChildrenCount(node), [node]); const childrenInfoDropdownLabel = useMemo( () => `${childrenCount} ${childrenHeading}`, @@ -296,8 +362,7 @@ const EntityFooter = ({ )} color="tertiary" data-testid="lineage-filter-button" - icon={FilterIcon} - isDisabled={isEditMode} + icon={Dataflow01} tooltip={t('message.only-show-columns-with-lineage')} tooltipPlacement="right" onClick={handleOnlyShowColumnsWithLineage} @@ -308,6 +373,7 @@ const EntityFooter = ({ }; const LineageNodeLabelV1 = ({ + actions, node, onEntityClick, isChildrenListExpanded, @@ -317,7 +383,11 @@ const LineageNodeLabelV1 = ({ }: LineageNodeLabelProps) => { return (
- + ({ })); describe('LineageSearchSelect', () => { + let user: ReturnType; + + const openDropdown = () => user.click(screen.getByRole('combobox')); + beforeEach(() => { jest.clearAllMocks(); + jest.useFakeTimers(); + user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + delay: null, + }); (useLineageHandlers as jest.Mock).mockImplementation(() => ({ onNodeClick: mockNodeClick, })); @@ -114,28 +126,25 @@ describe('LineageSearchSelect', () => { ); }); - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + afterEach(() => jest.useRealTimers()); - const openOptions = async () => { + it('should render select with options', async () => { render(); expect(screen.getByTestId('lineage-search')).toBeInTheDocument(); - await user.click(screen.getByRole('combobox')); - }; - - it('should render select with options', async () => { - await openOptions(); + await openDropdown(); expect(await screen.findByTestId('option-test1')).toBeInTheDocument(); + expect(screen.getByTestId('option-column1')).toBeInTheDocument(); }); it('should call onNodeClick and center the node', async () => { - await openOptions(); - + render(); + await openDropdown(); await user.click(await screen.findByTestId('option-test1')); - expect(mockNodeClick).toHaveBeenCalled(); + expect(mockNodeClick).toHaveBeenCalledWith(mockedNodes[0]); // The laid-out position from React Flow, not the origin the provider's copy // still carries: centring on (0,0) leaves the picked node off-viewport, and // onlyRenderVisibleElements then never draws it. @@ -147,11 +156,36 @@ describe('LineageSearchSelect', () => { }); it('should call onColumnClick', async () => { - await openOptions(); - + render(); + await openDropdown(); await user.click(await screen.findByTestId('option-column1')); expect(mockColumnClick).toHaveBeenCalledWith('column1'); + expect(mockNodeClick).not.toHaveBeenCalled(); + }); + + it('should filter options by typed text', async () => { + render(); + await openDropdown(); + await screen.findByTestId('option-test1'); + await user.type(screen.getByRole('combobox'), 'column2'); + await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIMEOUT)); + + expect(screen.getByTestId('option-column2')).toBeInTheDocument(); + expect(screen.queryByTestId('option-test1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('option-column1')).not.toBeInTheDocument(); + }); + + it('should show the selection and clear the column when the input is cleared', async () => { + render(); + await openDropdown(); + await user.click(await screen.findByTestId('option-column1')); + + expect(screen.getByRole('combobox')).toHaveValue('column1'); + + await user.clear(screen.getByRole('combobox')); + + expect(mockColumnClick).toHaveBeenLastCalledWith(''); }); it('should not render when platform lineage is enabled', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx index 9a4ddaa0fcdd..9ffd9d119adc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx @@ -11,21 +11,44 @@ * limitations under the License. */ import { - Autocomplete, - type SelectItemType, + Box, + Select, + SelectItemType, + Typography, } from '@openmetadata/ui-core-components'; -import React, { useCallback, useMemo, useState } from 'react'; +import { ChevronRight } from '@openmetadata/ui-core-components/icons'; +import { debounce } from 'lodash'; +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { Collection, Key, ListBoxLoadMoreItem } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; import { Node } from 'reactflow'; import { useShallow } from 'zustand/react/shallow'; -import { ZOOM_TRANSITION_DURATION } from '../../../../constants/Lineage.constants'; +import { + DEBOUNCE_TIMEOUT, + INITIAL_NODE_ITEMS_LENGTH, + NODE_ITEMS_PAGE_SIZE, + ZOOM_TRANSITION_DURATION, +} from '../../../../constants/Lineage.constants'; import { LineagePlatformView } from '../../../../hooks/lineage/types'; import { useLineageStore } from '../../../../hooks/useLineageStore'; +import { EntityIconSize } from '../../../../utils/EntityIconUtils'; import { getEntityChildrenAndLabel } from '../../../../utils/EntityLineageNodeUtils'; import { getEntityName } from '../../../../utils/EntityNameUtils'; +import searchClassBase from '../../../../utils/SearchClassBase'; import serviceUtilClassBase from '../../../../utils/ServiceUtilClassBase'; import { useLineageHandlers } from '../../../Lineage/Lineage/LineageHandlersContext'; +type LineageSearchOption = SelectItemType & { + label: string; + content: ReactNode; +}; + const LineageSearchSelect = () => { const { t } = useTranslation(); const { onNodeClick } = useLineageHandlers(); @@ -37,36 +60,46 @@ const LineageSearchSelect = () => { ); const { zoomValue, isPlatformLineage, platformView, setSelectedColumn } = useLineageStore(); - // Options are built on first focus: a large graph has thousands of columns. - const [isActivated, setIsActivated] = useState(false); - const [selectedItems, setSelectedItems] = useState([]); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [allOptions, setAllOptions] = useState([]); + const [renderedOptions, setRenderedOptions] = useState( + [] + ); + const [searchValue, setSearchValue] = useState(''); + const [inputValue, setInputValue] = useState(''); + const [selectedOption, setSelectedOption] = + useState(null); - const options = useMemo(() => { - if (!isActivated) { - return []; - } - const optionsMap = new Map(); + const generateNodeOptions = useCallback(() => { + const optionsMap: Map = new Map(); nodes.forEach((nodeObj) => { const { node } = nodeObj.data; - if (!node?.fullyQualifiedName) { + if (!node) { return; } - const icon = ( - {node.serviceType} - ); - const nodeName = getEntityName(node); - optionsMap.set(node.fullyQualifiedName, { + const nodeOption: LineageSearchOption = { id: node.fullyQualifiedName, - label: nodeName, - icon, - }); + label: getEntityName(node), + content: ( + + {node.serviceType} + + {getEntityName(node)} + + + ), + }; + optionsMap.set(nodeOption.id, nodeOption); const { children: childrenFlatten } = getEntityChildrenAndLabel(node); @@ -75,24 +108,111 @@ const LineageSearchSelect = () => { // node itself. Both options key on FQN, so without this the child would // overwrite the node option added above and render the node with the // column-style label. - if ( - !column.fullyQualifiedName || - column.fullyQualifiedName === node.fullyQualifiedName - ) { + if (column.fullyQualifiedName === node.fullyQualifiedName) { return; } - optionsMap.set(column.fullyQualifiedName, { - id: column.fullyQualifiedName, + const columnOption: LineageSearchOption = { + id: column.fullyQualifiedName ?? '', label: getEntityName(column), - supportingText: nodeName, - icon, - }); + content: ( + + + {node.serviceType} + + {getEntityName(node)} + + + + {searchClassBase.getEntityIconWithBg( + node.entityType ?? '', + EntityIconSize.Size14 + )} + + {getEntityName(column)} + + + + ), + }; + optionsMap.set(columnOption.id, columnOption); }); }); return Array.from(optionsMap.values()); - }, [isActivated, nodes]); + }, [nodes]); + + useEffect(() => { + if (isDropdownOpen && allOptions.length === 0) { + const options = generateNodeOptions(); + setAllOptions(options); + setRenderedOptions(options.slice(0, INITIAL_NODE_ITEMS_LENGTH)); + } + }, [isDropdownOpen, allOptions.length, generateNodeOptions]); + + useEffect(() => { + setAllOptions([]); + setRenderedOptions([]); + setSearchValue(''); + }, [nodes]); + + const filterOptions = useCallback( + (value: string) => { + if (value) { + const filteredOptions = allOptions.filter((option) => + option.label.toLowerCase().includes(value.toLowerCase()) + ); + setRenderedOptions(filteredOptions); + } else { + setRenderedOptions(allOptions.slice(0, INITIAL_NODE_ITEMS_LENGTH)); + } + }, + [allOptions] + ); + + const debouncedFilterOptions = useMemo( + () => debounce(filterOptions, DEBOUNCE_TIMEOUT), + [filterOptions] + ); + + useEffect(() => { + return () => { + debouncedFilterOptions.cancel(); + }; + }, [debouncedFilterOptions]); + + const loadMoreData = () => { + if (searchValue || renderedOptions.length >= allOptions.length) { + return; + } + setRenderedOptions( + allOptions.slice(0, renderedOptions.length + NODE_ITEMS_PAGE_SIZE) + ); + }; + + const handleOpenChange = useCallback( + (open: boolean) => { + setIsDropdownOpen(open); + if (!open) { + setSearchValue(''); + setRenderedOptions(allOptions.slice(0, INITIAL_NODE_ITEMS_LENGTH)); + debouncedFilterOptions.cancel(); + } + }, + [allOptions, debouncedFilterOptions] + ); const onOptionSelect = useCallback( (value?: string) => { @@ -129,52 +249,66 @@ const LineageSearchSelect = () => { [onNodeClick, reactFlowInstance, setSelectedColumn, nodes, zoomValue] ); - const handleItemInserted = useCallback( - (key: React.Key) => { - const item = options.find((option) => option.id === key); - setSelectedItems(item ? [item] : []); - onOptionSelect(String(key)); - }, - [options, onOptionSelect] - ); + // Both inputValue and value are controlled, so react-aria never + // rewrites the input itself: it re-emits the current key on blur/close to + // ask for the text to be synced back, and leaves clearing to us. + const handleSelectionChange = (key: Key | null) => { + const id = key === null ? null : String(key); + if (id === (selectedOption?.id ?? null)) { + setInputValue(selectedOption?.label ?? ''); - const handleItemCleared = useCallback(() => { - setSelectedItems([]); - onOptionSelect(undefined); - }, [onOptionSelect]); + return; + } + const option = allOptions.find((opt) => opt.id === id) ?? null; + setSelectedOption(option); + setInputValue(option?.label ?? ''); + onOptionSelect(id ?? undefined); + }; + + const handleInputChange = (value: string) => { + setInputValue(value); + setSearchValue(value); + debouncedFilterOptions(value); + if (!value && selectedOption) { + setSelectedOption(null); + onOptionSelect(); + } + }; if (isPlatformLineage || platformView !== LineagePlatformView.None) { return null; } - const placeholder = t('label.search-entity', { - entity: t('label.lineage'), - }); - return ( -
- setIsActivated(true)} - onItemCleared={handleItemCleared} - onItemInserted={handleItemInserted}> - {(item) => ( - + + + {(option) => ( + + {option.content} + )} - -
+ + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.test.tsx index 4a65736d3396..9e6388dc091b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.test.tsx @@ -125,38 +125,20 @@ describe('NodeChildren Component', () => { describe('Rendering and Visibility', () => { it('should render column container when children list is expanded and columns exist', () => { - render( - - ); + render(); expect(screen.getByTestId('column-container')).toBeInTheDocument(); }); it('should not render when isChildrenListExpanded is false', () => { - render( - - ); + render(); expect(screen.queryByTestId('column-container')).not.toBeInTheDocument(); }); it('should not render when node has no columns', () => { const nodeWithoutColumns = { ...mockNode, columns: [] }; - render( - - ); + render(); expect(screen.queryByTestId('column-container')).not.toBeInTheDocument(); }); @@ -168,13 +150,7 @@ describe('NodeChildren Component', () => { columns: undefined, } as unknown as LineageNodeType; - render( - - ); + render(); expect(screen.queryByTestId('column-container')).not.toBeInTheDocument(); }); @@ -188,13 +164,7 @@ describe('NodeChildren Component', () => { fullyQualifiedName: 'metricService.total_sales', } as unknown as LineageNodeType; - render( - - ); + render(); expect(screen.getByTestId('column-container')).toBeInTheDocument(); expect(screen.getByTestId('column-total_sales')).toBeInTheDocument(); @@ -203,13 +173,7 @@ describe('NodeChildren Component', () => { it('should render when column layer is enabled', () => { mockLineageStoreState.activeLayer = [LineageLayer.ColumnLevelLineage]; - render( - - ); + render(); expect(screen.getByTestId('column-container')).toBeInTheDocument(); }); @@ -217,13 +181,7 @@ describe('NodeChildren Component', () => { it('should render when data observability layer is enabled', () => { mockLineageStoreState.activeLayer = [LineageLayer.DataObservability]; - render( - - ); + render(); expect(screen.getByTestId('column-container')).toBeInTheDocument(); }); @@ -231,13 +189,7 @@ describe('NodeChildren Component', () => { describe('Search Functionality', () => { it('should render search input with correct placeholder', () => { - render( - - ); + render(); const searchInput = screen.getByTestId('search-column-input'); @@ -246,26 +198,14 @@ describe('NodeChildren Component', () => { }); it('should show all columns when search value is empty', () => { - render( - - ); + render(); expect(screen.getByTestId('column-column1')).toBeInTheDocument(); expect(screen.getByTestId('column-column2')).toBeInTheDocument(); }); it('should filter columns based on search input', () => { - render( - - ); + render(); const searchInput = screen.getByTestId('search-column-input'); @@ -276,13 +216,7 @@ describe('NodeChildren Component', () => { }); it('should filter columns case-insensitively', () => { - render( - - ); + render(); const searchInput = screen.getByTestId('search-column-input'); @@ -293,13 +227,7 @@ describe('NodeChildren Component', () => { }); it('should reset to all columns when search is cleared', () => { - render( - - ); + render(); const searchInput = screen.getByTestId('search-column-input'); @@ -314,13 +242,7 @@ describe('NodeChildren Component', () => { }); it('should stop event propagation on search input click', () => { - render( - - ); + render(); const searchInput = screen.getByTestId('search-column-input'); const stopPropagationSpy = jest.fn(); @@ -336,13 +258,7 @@ describe('NodeChildren Component', () => { }); it('should show partial match results', () => { - render( - - ); + render(); const searchInput = screen.getByTestId('search-column-input'); @@ -387,7 +303,6 @@ describe('NodeChildren Component', () => { ); @@ -401,7 +316,6 @@ describe('NodeChildren Component', () => { render( @@ -415,7 +329,6 @@ describe('NodeChildren Component', () => { const { rerender } = render( @@ -428,7 +341,6 @@ describe('NodeChildren Component', () => { ); @@ -470,7 +382,6 @@ describe('NodeChildren Component', () => { ); @@ -482,6 +393,56 @@ describe('NodeChildren Component', () => { expect(screen.getByTestId('column-column1')).toBeInTheDocument(); expect(screen.queryByTestId('column-column3')).not.toBeInTheDocument(); }); + + it('should search all columns when no column with lineage matches', () => { + const columnsMap = new Map(); + columnsMap.set(mockNode.id, new Set(['test.fqn.column1'])); + mockLineageStoreState.columnsHavingLineage = columnsMap; + + render( + + ); + + fireEvent.change(screen.getByTestId('search-column-input'), { + target: { value: 'column2' }, + }); + + expect(screen.getByTestId('column-column2')).toBeInTheDocument(); + expect(screen.queryByTestId('column-column1')).not.toBeInTheDocument(); + }); + + it('should keep the search applied when the lineage filter is toggled', () => { + const columnsMap = new Map(); + columnsMap.set(mockNode.id, new Set(['test.fqn.column1'])); + mockLineageStoreState.columnsHavingLineage = columnsMap; + + const { rerender } = render( + + ); + + fireEvent.change(screen.getByTestId('search-column-input'), { + target: { value: 'column2' }, + }); + + rerender( + + ); + + expect(screen.getByTestId('column-column2')).toBeInTheDocument(); + expect(screen.queryByTestId('column-column1')).not.toBeInTheDocument(); + }); }); describe('Data Observability', () => { @@ -492,11 +453,7 @@ describe('NodeChildren Component', () => { mockGetTestCaseExecutionSummary.mockResolvedValue(mockSummary); render( - + ); await waitFor(() => { @@ -510,11 +467,7 @@ describe('NodeChildren Component', () => { mockLineageStoreState.activeLayer = [LineageLayer.ColumnLevelLineage]; mockLineageStoreState.isDQEnabled = false; render( - + ); expect(mockGetTestCaseExecutionSummary).not.toHaveBeenCalled(); @@ -523,13 +476,7 @@ describe('NodeChildren Component', () => { it('should not fetch test suite summary when node has no test suite', () => { mockLineageStoreState.activeLayer = [LineageLayer.DataObservability]; mockLineageStoreState.isDQEnabled = true; - render( - - ); + render(); expect(mockGetTestCaseExecutionSummary).not.toHaveBeenCalled(); }); @@ -540,11 +487,7 @@ describe('NodeChildren Component', () => { mockGetTestCaseExecutionSummary.mockRejectedValue(new Error('API Error')); render( - + ); await waitFor(() => { @@ -561,11 +504,7 @@ describe('NodeChildren Component', () => { mockGetTestCaseExecutionSummary.mockResolvedValue(mockSummary); const { rerender } = render( - + ); await waitFor(() => { @@ -573,11 +512,7 @@ describe('NodeChildren Component', () => { }); rerender( - + ); expect(mockGetTestCaseExecutionSummary).toHaveBeenCalledTimes(1); @@ -588,13 +523,7 @@ describe('NodeChildren Component', () => { it('should apply "any-column-selected" class when a column is selected', () => { mockLineageStoreState.selectedColumn = 'test.fqn.column1'; - render( - - ); + render(); const container = screen.getByTestId('column-container'); @@ -604,13 +533,7 @@ describe('NodeChildren Component', () => { it('should apply "creating-edge" class when creating edge', () => { mockLineageStoreState.isCreatingEdge = true; - render( - - ); + render(); const container = screen.getByTestId('column-container'); @@ -621,13 +544,7 @@ describe('NodeChildren Component', () => { mockLineageStoreState.selectedColumn = 'test.fqn.column1'; mockLineageStoreState.isCreatingEdge = true; - render( - - ); + render(); const container = screen.getByTestId('column-container'); @@ -646,7 +563,6 @@ describe('NodeChildren Component', () => { render( ); @@ -674,7 +590,6 @@ describe('NodeChildren Component', () => { render( ); @@ -700,7 +615,6 @@ describe('NodeChildren Component', () => { render( ); @@ -725,7 +639,6 @@ describe('NodeChildren Component', () => { render( ); @@ -753,7 +666,6 @@ describe('NodeChildren Component', () => { render( ); @@ -775,7 +687,6 @@ describe('NodeChildren Component', () => { render( ); @@ -784,13 +695,7 @@ describe('NodeChildren Component', () => { }); it('should handle rapid search input changes', () => { - render( - - ); + render(); const searchInput = screen.getByTestId('search-column-input'); @@ -806,13 +711,7 @@ describe('NodeChildren Component', () => { }); it('should handle whitespace in search input', () => { - render( - - ); + render(); const searchInput = screen.getByTestId('search-column-input'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.tsx index 5e4b3476c1ad..869870d171c9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.tsx @@ -13,8 +13,14 @@ import { Input } from '@openmetadata/ui-core-components'; import { Search } from '@openmetadata/ui-core-components/icons'; import classNames from 'classnames'; -import { isEmpty, isEqual, isUndefined } from 'lodash'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isEqual, isUndefined } from 'lodash'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { LINEAGE_CHILD_ITEMS_PER_PAGE, @@ -35,11 +41,11 @@ import VirtualColumnList from './VirtualColumnList.component'; const NodeChildren = ({ node, - isConnectable, isChildrenListExpanded, isOnlyShowColumnsWithLineageFilterActive, onColumnHover, onColumnSelect, + onColumnLineageEdit, }: NodeChildrenProps) => { const { t } = useTranslation(); const { @@ -48,11 +54,9 @@ const NodeChildren = ({ columnsHavingLineage, selectedColumn, isCreatingEdge, - isEditMode, } = useLineageStore(); const { entityType } = node; const [searchValue, setSearchValue] = useState(''); - const [filteredColumns, setFilteredColumns] = useState([]); const [summary, setSummary] = useState(); const [isLoading, setIsLoading] = useState(true); @@ -60,14 +64,14 @@ const NodeChildren = ({ return Boolean( isDQEnabled && entityType === EntityType.TABLE && node.testSuite ); - }, [isDQEnabled, entityType]); + }, [isDQEnabled, entityType, node.testSuite]); const supportsColumns = useMemo(() => { return ( node && LINEAGE_COLUMN_NODE_SUPPORTED.includes(node.entityType as EntityType) ); - }, [node.entityType]); + }, [node]); const { children: entityChildren, childrenHeading } = useMemo( () => getEntityChildrenAndLabel(node), @@ -92,46 +96,29 @@ const NodeChildren = ({ return currentNodeColumnsWithLineage; }, [currentNodeColumnsWithLineage]); - const handleSearchChange = useCallback( - (searchQuery: string) => { - setSearchValue(searchQuery); - const currentNodeColumnsToSearch = - isOnlyShowColumnsWithLineageFilterActive && !isEditMode - ? stableColumnsWithLineage - : entityChildren; - - if (searchQuery.trim() === '') { - setFilteredColumns(currentNodeColumnsToSearch); - } else { - const currentNodeMatchedColumns = currentNodeColumnsToSearch.filter( - (column) => - getEntityName(column) - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - setFilteredColumns(currentNodeMatchedColumns); - } - }, - [ - entityChildren, - stableColumnsWithLineage, - isEditMode, - isOnlyShowColumnsWithLineageFilterActive, - ] - ); - - useEffect(() => { - if (!isEmpty(entityChildren)) { - if (isOnlyShowColumnsWithLineageFilterActive && !isEditMode) { - setFilteredColumns(stableColumnsWithLineage); - } else { - setFilteredColumns(entityChildren); - } + const filteredColumns = useMemo(() => { + const columnsToSearch = isOnlyShowColumnsWithLineageFilterActive + ? stableColumnsWithLineage + : entityChildren; + const query = searchValue.trim().toLowerCase(); + if (!query) { + return columnsToSearch; } + const findMatches = (columns: EntityChildren) => + columns.filter((column) => + getEntityName(column).toLowerCase().includes(query) + ); + const matches = findMatches(columnsToSearch); + + // With the lineage filter on, a column without lineage would be + // unfindable, so fall back to searching every column of the node. + return matches.length === 0 && isOnlyShowColumnsWithLineageFilterActive + ? findMatches(entityChildren) + : matches; }, [ entityChildren, + searchValue, stableColumnsWithLineage, - isEditMode, isOnlyShowColumnsWithLineageFilterActive, ]); @@ -157,17 +144,13 @@ const NodeChildren = ({ } else { setIsLoading(false); } - }, [node, showDataObservabilitySummary, summary]); + }, [fetchTestSuiteSummary, node, showDataObservabilitySummary, summary]); const pageSize = useMemo(() => { - return isOnlyShowColumnsWithLineageFilterActive || isEditMode + return isOnlyShowColumnsWithLineageFilterActive ? filteredColumns.length : LINEAGE_CHILD_ITEMS_PER_PAGE; - }, [ - isOnlyShowColumnsWithLineageFilterActive, - isEditMode, - filteredColumns.length, - ]); + }, [isOnlyShowColumnsWithLineageFilterActive, filteredColumns.length]); // No need to render if there's no children if (entityChildren.length === 0) { @@ -187,33 +170,31 @@ const NodeChildren = ({ )} data-testid="column-container">
- {/* The field sits inside a React Flow node; a click must not select the node. */} -
e.stopPropagation()}> - -
+ e.stopPropagation()} + />
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.interface.ts index a1859508e44e..3966574a0a11 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/NodeChildren.interface.ts @@ -10,15 +10,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { RefObject } from 'react'; +import { LineageDirection } from '../../../../generated/api/lineage/lineageDirection'; import { LineageNodeType } from '../../../../interface/lineage.interface'; export interface NodeChildrenProps { node: LineageNodeType; - isConnectable: boolean; isChildrenListExpanded: boolean; isOnlyShowColumnsWithLineageFilterActive?: boolean; onColumnHover?: (columnFqn?: string) => void; onColumnSelect?: (columnFqn?: string) => void; + onColumnLineageEdit?: ( + columnFqn: string, + direction: LineageDirection, + triggerRef: RefObject + ) => void; } // Re-exported because consumers outside this repository import it from this path. diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/VirtualColumnList.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/VirtualColumnList.component.test.tsx index 88dd8188cd9f..a1f7cd0ac942 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/VirtualColumnList.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/VirtualColumnList.component.test.tsx @@ -73,7 +73,6 @@ const createFlatItem = (column: Column, depth = 0) => ({ describe('VirtualColumnList', () => { const defaultProps: VirtualColumnListProps = { flatItems: [], - isConnectable: true, isLoading: false, nodeId: 'test-node', showDataObservabilitySummary: false, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/VirtualColumnList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/VirtualColumnList.component.tsx index 5724566dddbd..f8787b8cf8f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/VirtualColumnList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeChildren/VirtualColumnList.component.tsx @@ -14,6 +14,7 @@ import { ButtonUtility } from '@openmetadata/ui-core-components'; import { ChevronDown, ChevronUp } from '@openmetadata/ui-core-components/icons'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { LINEAGE_CHILD_ITEMS_PER_PAGE } from '../../../../constants/Lineage.constants'; +import { LineageDirection } from '../../../../generated/api/lineage/lineageDirection'; import { TestSummary } from '../../../../generated/tests/testCase'; import { useLineageStore } from '../../../../hooks/useLineageStore'; import { @@ -25,11 +26,15 @@ import { ColumnContent } from '../CustomNode.utils'; export interface VirtualColumnListProps { flatItems: EntityChildren; - isConnectable: boolean; isLoading: boolean; nodeId?: string; onColumnHover?: (columnFqn?: string) => void; onColumnSelect?: (columnFqn?: string) => void; + onColumnLineageEdit?: ( + columnFqn: string, + direction: LineageDirection, + triggerRef: React.RefObject + ) => void; showDataObservabilitySummary: boolean; summary?: TestSummary; pageSize?: number; @@ -37,11 +42,11 @@ export interface VirtualColumnListProps { const VirtualColumnList = ({ flatItems, - isConnectable, isLoading, nodeId, onColumnHover, onColumnSelect, + onColumnLineageEdit, showDataObservabilitySummary, summary, pageSize = LINEAGE_CHILD_ITEMS_PER_PAGE, @@ -138,12 +143,12 @@ const VirtualColumnList = ({ className={className} column={column} depth={depth} - isConnectable={isConnectable} isLoading={isLoading} key={column.fullyQualifiedName} showDataObservabilitySummary={showDataObservabilitySummary} summary={columnSummary} onColumnHover={onColumnHover} + onColumnLineageEdit={onColumnLineageEdit} onColumnSelect={onColumnSelect} /> ); @@ -151,9 +156,9 @@ const VirtualColumnList = ({ // selectedColumn triggers re-render when column selection changes [ getColumnSummary, - isConnectable, isLoading, onColumnHover, + onColumnLineageEdit, onColumnSelect, showDataObservabilitySummary, selectedColumn, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.test.tsx deleted file mode 100644 index ee337d23bdfb..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2022 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { SearchIndex } from '../../../enums/search.enum'; -import { searchQuery } from '../../../rest/searchAPI'; -import NodeSuggestions from './NodeSuggestions.component'; - -const mockProps = { - onSelectHandler: jest.fn(), - entityType: SearchIndex.TABLE, -}; - -const entityType = [ - SearchIndex.TABLE, - SearchIndex.STORED_PROCEDURE, - SearchIndex.TOPIC, - SearchIndex.DASHBOARD, - SearchIndex.PIPELINE, - SearchIndex.MLMODEL, - SearchIndex.CONTAINER, - SearchIndex.PIPELINE, - SearchIndex.SEARCH_INDEX, - SearchIndex.DASHBOARD_DATA_MODEL, -]; - -jest.mock('../../../rest/searchAPI', () => ({ - searchQuery: jest.fn().mockImplementation(() => Promise.resolve()), -})); - -describe('Test NodeSuggestions Component', () => { - it('component should render properly', async () => { - await act(async () => { - render(); - }); - - const suggestionNode = await screen.findByTestId('suggestion-node'); - - expect(suggestionNode).toBeInTheDocument(); - }); - - entityType.forEach((value) => { - it(`Suggest & Suggest API for ${value} should work properly`, async () => { - jest.useFakeTimers('modern'); - const mockSearchQueryData = searchQuery as jest.Mock; - const searchValue = 'sale'; - await act(async () => { - render(); - }); - - // 1st call on page load with empty search string and respective searchIndex - expect(mockSearchQueryData.mock.calls[0][0]).toStrictEqual({ - includeDeleted: false, - pageNumber: 1, - pageSize: 10, - query: '', - queryFilter: undefined, - searchIndex: value, - }); - - const suggestionNode = await screen.findByTestId('suggestion-node'); - const searchInput = await screen.findByRole('combobox'); - - await act(async () => { - fireEvent.change(searchInput, { target: { value: searchValue } }); - }); - - expect(suggestionNode).toBeInTheDocument(); - expect(searchInput).toBeInTheDocument(); - expect(searchInput).toHaveValue(searchValue); - - act(() => { - jest.runAllTimers(); - }); - - expect(mockSearchQueryData.mock.instances).toHaveLength(2); - }); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/TestSuiteSummaryWidget/test-suite-summary-widget.less b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/TestSuiteSummaryWidget/test-suite-summary-widget.less index 234efc74a054..3b39375d7be6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/TestSuiteSummaryWidget/test-suite-summary-widget.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/TestSuiteSummaryWidget/test-suite-summary-widget.less @@ -10,8 +10,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@import '@/styles/variables.less'; - .profiler-item { width: 100px; height: 80px; @@ -23,12 +21,12 @@ cursor: pointer; border-radius: var(--om-radius-10); &.green { - background: @green-2; + background: var(--tw-background-color-success-primary); } &.amber { - background: @yellow-1; + background: var(--tw-background-color-warning-primary); } &.red { - background: @red-2; + background: var(--tw-background-color-error-primary); } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/custom-node.less b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/custom-node.less index 5f14d7f2d3da..3386372d2f3f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/custom-node.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/custom-node.less @@ -10,20 +10,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@import (reference) '../../../styles/variables.less'; - // Node, badge and handle borders read --lineage-node-border so dark can swap // it for border-primary via LINEAGE_NODE_DARK_CLASS on the node element; light // keeps these static greys. -@lineage-border: var(--lineage-node-border, var(--om-legacy-color-b1b1b7)); -@lineage-collapse-header: var(--om-color-gray-50); .ant-btn.ant-btn-background-ghost.expand-btn { - background-color: var(--om-color-bg-surface); + background-color: var(--tw-background-color-surface); box-shadow: none; &:hover { - background-color: var(--om-color-bg-surface); + background-color: var(--tw-background-color-surface); } } @@ -31,48 +27,71 @@ box-shadow: none; &.lineage-path-highlight > .lineage-node { - border-color: @primary-color; - box-shadow: 0 0 0 3px @primary-1, @box-shadow-base; + border-color: var(--tw-color-fg-brand-primary); + box-shadow: 0 0 0 3px var(--tw-color-utility-brand-100), var(--om-shadow-md); } &.lineage-path-highlight > .lineage-scene-layer-node { - border-color: @primary-color; - box-shadow: 0 0 0 1px @primary-color, + border-color: var(--tw-color-fg-brand-primary); + box-shadow: 0 0 0 1px var(--tw-color-fg-brand-primary), 0 2px 8px var(--om-legacy-color-16-24-40-0-12); } } +// Temp tables are transient lineage hops (no service, no columns): a lighter, +// dashed card rather than a full asset node. +.lineage-node.lineage-temp-node { + border-style: dashed; +} + +// Keep an asset node's header row clear of the drill button. Only the header +// row: the footer below it still spans the full node. Container +// cards reserve the cue's space in their own padding instead. +.lineage-node:not(.lineage-scene-layer-node) + .lineage-node-content.has-scene-drill-button:not(.has-lineage-node-menu) + .lineage-node-label-row { + margin-right: var(--om-space-36); +} + // Node default state .lineage-node { width: 100%; max-width: 400px; // Max width of Lineage Node, in case of long columns names - border: 1px solid @lineage-border; + border: 1px solid var(--lineage-node-border, var(--tw-border-color-primary)); border-radius: var(--om-radius-10); overflow: hidden; transition: border-color var(--om-duration-150) ease, box-shadow var(--om-duration-150) ease, opacity var(--om-duration-150) ease; &.lineage-path-highlight { - border-color: @primary-color; - box-shadow: 0 0 0 3px @primary-1, @box-shadow-base; + border-color: var(--tw-color-fg-brand-primary); + box-shadow: 0 0 0 3px var(--tw-color-utility-brand-100), var(--om-shadow-md); } &.lineage-scene-layer-node { min-height: 66px; - border-color: var(--lineage-node-border, @grey-200); + border-color: var(--lineage-node-border, var(--tw-border-color-secondary)); border-radius: var(--om-radius-xl); box-shadow: 0 1px 3px var(--om-legacy-color-16-24-40-0-12); &.lineage-path-highlight, &.custom-node-header-active { - border-color: @primary-color; - box-shadow: 0 0 0 1px @primary-color, + border-color: var(--tw-color-fg-brand-primary); + box-shadow: 0 0 0 1px var(--tw-color-fg-brand-primary), 0 2px 8px var(--om-legacy-color-16-24-40-0-12); } .lineage-node-content { min-height: 66px; + // The Layer-band drill button is always shown, 14px in and 22px wide. + &.has-scene-drill-button .label-container { + padding-right: var(--om-space-44); + } + + // No .has-lineage-node-menu offset needed here: Layer-band scene nodes + // are never editable (canEditScene excludes LineageBand.Layer), so + // LineageNodeMenu never renders alongside this drill button variant. .lineage-scene-drill-button { top: 50%; right: 14px; @@ -80,7 +99,7 @@ height: 22px; border: none; border-radius: var(--om-radius-md); - background: @primary-1; + background: var(--tw-background-color-tertiary); box-shadow: none; transform: translateY(-50%); opacity: 1; @@ -120,8 +139,8 @@ .entity-header-display-name { width: auto !important; - max-width: 170px; - color: @grey-900; + max-width: 100%; + color: var(--tw-text-color-primary); font-size: var(--om-font-size-sm); font-weight: var(--om-font-weight-semibold); line-height: 18px; @@ -139,7 +158,7 @@ .lineage-service-subtitle { display: block; - color: @grey-600; + color: var(--tw-text-color-tertiary); font-size: var(--om-font-size-11_5); line-height: 16px; } @@ -176,16 +195,21 @@ } &.amber { - border: 1px solid var(--om-color-warning-600); + border: 1px solid var(--tw-color-fg-warning-primary); } &.red { - border: 1px solid var(--om-color-error-600); + border: 1px solid var(--tw-color-fg-error-primary); } } .lineage-node-content { - position: relative; + // Anchor only the scene drill button. A positioned content box inside the + // node's overflow: hidden would also anchor, and so clip, the expand / + // collapse handles that sit half outside the node edge. + &.has-scene-drill-button { + position: relative; + } .lineage-scene-drill-button { position: absolute; @@ -198,13 +222,13 @@ width: 24px; height: 24px; padding: 0; - border: 1px solid @grey-200; + border: 1px solid var(--tw-border-color-secondary); border-radius: var(--om-radius-md); - background: @primary-1; - color: @primary-color; + background: var(--tw-background-color-tertiary); + color: var(--tw-color-fg-tertiary); cursor: pointer; opacity: 0; - box-shadow: @box-shadow-base; + box-shadow: var(--om-shadow-md); transition: opacity var(--om-duration-150) ease, background-color var(--om-duration-150) ease; @@ -215,7 +239,7 @@ &:hover, &:focus-visible { - background: @white; + background: var(--tw-background-color-surface); opacity: 1; } } @@ -227,6 +251,17 @@ } } + // The node ⋮ menu sits in the same top-right spot as the absolutely + // positioned drill button, so shift the drill button clear of the 24px + // menu with enough gap that the two hit areas never overlap. + &.has-lineage-node-menu .lineage-scene-drill-button { + right: 34px; + } + + &.has-scene-drill-button .label-container .lineage-node-menu { + margin-left: var(--om-space-36); + } + .label-container { .custom-node-label-container { width: 100%; @@ -239,7 +274,7 @@ svg { width: 18px; - color: @grey-4; + color: var(--tw-text-color-quaternary); } } @@ -257,15 +292,6 @@ width: 32px; margin-bottom: var(--om-space-6); } - - &.with-footer::after { - display: block; - width: 100%; - height: 1px; - - background: @grey-200; - content: ''; - } } .entity-footer { @@ -291,13 +317,14 @@ .children-info-dropdown-label { min-width: fit-content; - padding: var(--om-space-2) @size-xs var(--om-space-2) @size-sm; + padding: var(--om-space-2) var(--om-space-8) var(--om-space-2) + var(--om-space-12); border: none; border-radius: var(--om-radius-lg); box-shadow: none; - background-color: @primary-1; - color: @blue-7; + background-color: var(--tw-color-utility-brand-100); + color: var(--tw-color-fg-brand-primary); font-size: var(--om-font-size-xs); font-weight: var(--om-font-weight-medium); line-height: 18px; @@ -306,14 +333,14 @@ &:hover { box-shadow: none; - background-color: @primary-1; - color: @blue-7; + background-color: var(--tw-color-utility-brand-100); + color: var(--tw-color-fg-brand-primary); } &::after { - margin-left: @size-xxs; - width: @size-sm; - height: @size-sm; + margin-left: var(--om-space-4); + width: var(--om-space-12); + height: var(--om-space-12); content: ''; } @@ -339,8 +366,8 @@ } &.active { - background-color: @primary-1; - color: @blue-7; + background-color: var(--tw-color-utility-brand-100); + color: var(--tw-color-fg-brand-primary); } &:disabled:hover { @@ -355,7 +382,7 @@ min-height: 48px; padding: var(--om-space-12); padding-top: 0; - background-color: @grey-9; + background-color: var(--tw-background-color-secondary); .inside-current-page-item { margin-top: 0; @@ -376,7 +403,6 @@ } .outside-current-page-items:has(.custom-node-header-column-tracing) { - border-top: 1px solid silver; margin-top: var(--om-space-8); padding-top: var(--om-space-8); } @@ -387,8 +413,8 @@ position: relative; font-size: var(--om-font-size-xs); font-weight: var(--om-font-weight-regular); - border: 1px solid @grey-1; - border-left: 6px solid transparent; + border: 1px solid transparent; + border-left-width: 6px; border-radius: var(--om-radius-sm); border-top-left-radius: var(--om-radius-md); border-bottom-left-radius: var(--om-radius-md); @@ -415,11 +441,6 @@ width: 14px; display: flex; } - - .custom-node-column-label { - width: 100%; - display: block; - } } .custom-node-constraint { @@ -431,8 +452,8 @@ &:not(.any-column-selected) { .custom-node-column-container { &:hover { - background-color: @primary-1; - border-left-color: @primary-6; + background-color: var(--tw-color-utility-brand-100); + border-left-color: var(--tw-color-fg-brand-primary); } } } @@ -444,7 +465,7 @@ .label-container { .custom-node-label-container { .entity-footer { - background-color: @grey-9; + background-color: var(--tw-background-color-secondary); } } } @@ -452,89 +473,89 @@ } &.custom-node-header-active { - background-color: var(--om-color-brand-50); + background-color: var(--tw-background-color-brand-primary); .lineage-node-content { .label-container { - background-color: var(--om-color-brand-50); + background-color: var(--tw-background-color-brand-primary); .custom-node-label-container { .entity-footer { - background-color: var(--om-color-brand-50); + background-color: var(--tw-background-color-brand-primary); } } } } .lineage-node-badge-container { - background-color: var(--om-color-brand-50); + background-color: var(--tw-background-color-brand-primary); } &.columns-expanded { - background-color: @primary-1; + background-color: var(--tw-color-utility-brand-100); .lineage-node-content { .label-container { - background-color: @primary-1; + background-color: var(--tw-color-utility-brand-100); .custom-node-label-container { .entity-footer { - background-color: var(--om-color-brand-50); + background-color: var(--tw-background-color-brand-primary); } } } .column-container { - background-color: var(--om-color-brand-50); + background-color: var(--tw-background-color-brand-primary); } } .lineage-node-badge-container { - background-color: @primary-1; + background-color: var(--tw-color-utility-brand-100); } } } &.custom-node-header-active.data-quality-failed-custom-node-header { - background-color: var(--om-color-error-25); + background-color: var(--tw-background-color-error-primary); .lineage-node-content { .label-container { - background-color: var(--om-color-error-25); + background-color: var(--tw-background-color-error-primary); .custom-node-label-container { .entity-footer { - background-color: var(--om-color-error-25); + background-color: var(--tw-background-color-error-primary); } } } } .lineage-node-badge-container { - background-color: var(--om-color-error-25); - border-color: var(--om-color-error-600); + background-color: var(--tw-background-color-error-primary); + border-color: var(--tw-color-fg-error-primary); } &.columns-expanded { - background-color: var(--om-color-error-100); + background-color: var(--tw-background-color-error-secondary); .lineage-node-badge-container { - background-color: var(--om-color-error-100); + background-color: var(--tw-background-color-error-secondary); } .lineage-node-content { .label-container { - background-color: var(--om-color-error-100); + background-color: var(--tw-background-color-error-secondary); .custom-node-label-container { .entity-footer { - background-color: var(--om-color-error-25); + background-color: var(--tw-background-color-error-primary); } } } .column-container { - background-color: var(--om-color-error-25); + background-color: var(--tw-background-color-error-primary); } } } @@ -542,7 +563,7 @@ &.custom-node-header-tracing { .lineage-node-badge-container { - border-color: @primary-color; + border-color: var(--tw-color-fg-brand-primary); } } @@ -559,22 +580,22 @@ .column-container { .custom-node-column-container.custom-node-header-column-tracing { - background-color: @primary-1; - border-left-color: @primary-6; + background-color: var(--tw-color-utility-brand-100); + border-left-color: var(--tw-color-fg-brand-primary); &:hover { - background-color: @primary-1; + background-color: var(--tw-color-utility-brand-100); } } &.any-column-selected { .custom-node-column-container.custom-node-header-column-tracing { - background-color: @indigo-100; - border-left-color: @indigo-600; + background-color: var(--tw-color-utility-indigo-100); + border-left-color: var(--tw-color-utility-indigo-600); &:hover { - background-color: @indigo-100; - border-left-color: @indigo-600; + background-color: var(--tw-color-utility-indigo-100); + border-left-color: var(--tw-color-utility-indigo-600); } } } @@ -583,14 +604,32 @@ .custom-node-column-container:not( .custom-node-header-column-tracing ):hover { - background-color: @primary-1; - border-left-color: @primary-6; + background-color: var(--tw-color-utility-brand-100); + border-left-color: var(--tw-color-fg-brand-primary); } } } } } +// The base node is a light brand card; selected / DQ states above still win. +.lineage-node.lineage-base-node { + --lineage-node-border: var(--tw-border-color-brand); + background-color: var(--tw-background-color-brand-primary); + + &:not(.custom-node-header-active) { + .lineage-node-badge-container, + .lineage-node-content .label-container, + .lineage-node-content .column-container, + .lineage-node-content + .label-container + .custom-node-label-container + .entity-footer { + background-color: var(--tw-background-color-brand-primary); + } + } +} + .lineage-base-node { border-top-left-radius: var(--om-radius-none); @@ -601,7 +640,7 @@ width: 68px; height: 15px; - border: 1px solid @lineage-border; + border: 1px solid var(--lineage-node-border, var(--tw-border-color-primary)); border-bottom: 0px; border-top-left-radius: var(--om-radius-10); border-top-right-radius: var(--om-radius-10); @@ -620,8 +659,8 @@ padding: var(--om-space-2); border-radius: var(--om-radius-7); - background-color: var(--om-color-brand-700); - color: white; + background-color: var(--tw-background-color-brand-solid_hover); + color: var(--tw-text-color-primary_on-brand); font-size: var(--om-font-size-xs); font-weight: var(--om-font-weight-medium); @@ -656,7 +695,7 @@ padding: 0 !important; border: none !important; box-shadow: none !important; - background-color: var(--om-color-bg-surface) !important; + background-color: var(--tw-background-color-surface) !important; } .react-flow__node-load-more { @@ -673,11 +712,11 @@ .react-flow__node-output.selectable:hover, .react-flow__node-group.selectable:hover { .lineage-node-handle { - border-color: @primary-color; + border-color: var(--tw-color-fg-brand-primary); } .lineage-node { - border-color: @primary-color; + border-color: var(--tw-color-fg-brand-primary); .lineage-node-badge-container { border-color: var(--om-legacy-color-356fe7); @@ -685,22 +724,22 @@ } .data-quality-failed-custom-node-header { - border-color: @red-3; + border-color: var(--tw-color-fg-error-primary); .lineage-node-badge-container { - border-color: @red-3; + border-color: var(--tw-color-fg-error-primary); } .lineage-node-content { .column-container { .custom-node-column-container { &:hover { - background-color: var(--om-color-error-50); - color: var(--om-color-orange-dark-700); - border-left-color: var(--om-color-error-600); + background-color: var(--tw-background-color-error-primary); + color: var(--tw-color-utility-orange-dark-700); + border-left-color: var(--tw-color-fg-error-primary); .profiler-item { - color: black; + color: var(--tw-text-color-primary); } } } @@ -713,8 +752,8 @@ .column-container { .custom-node-column-container { &:hover { - background-color: var(--om-color-error-50); - border-left-color: var(--om-color-error-600); + background-color: var(--tw-background-color-error-primary); + border-left-color: var(--tw-color-fg-error-primary); } } } @@ -728,9 +767,9 @@ .lineage-node-content { .column-container { .custom-node-column-container.custom-node-header-column-tracing { - border-color: var(--om-color-error-600); - background-color: var(--om-color-white); - color: black; + border-color: var(--tw-color-fg-error-primary); + background-color: var(--tw-background-color-surface); + color: var(--tw-text-color-primary); } } } @@ -738,7 +777,7 @@ } .react-flow__handle.connectable { - background-color: @text-color; + background-color: var(--tw-background-color-quaternary); } .react-flow { @@ -757,8 +796,11 @@ min-width: 20px !important; height: 20px !important; border-radius: var(--om-radius-sm) !important; - border-color: @lineage-border !important; - background: var(--om-color-bg-surface) !important; + border-color: var( + --lineage-node-border, + var(--tw-border-color-primary) + ) !important; + background: var(--tw-background-color-surface) !important; top: min(58px, 50%) !important; gap: var(--om-space-1); @@ -784,7 +826,7 @@ } svg { - color: @grey-4; + color: var(--tw-text-color-quaternary); cursor: pointer; padding: var(--om-space-1); width: 18px; @@ -792,8 +834,8 @@ border-radius: var(--om-radius-3); &:hover { - color: @primary-color; - background-color: var(--om-color-brand-50); + color: var(--tw-color-fg-brand-primary); + background-color: var(--tw-background-color-brand-primary); } } @@ -804,7 +846,7 @@ top: 2px; width: 1px; height: 14px; - background: @blue-20; + background: var(--tw-background-color-quaternary); transform: translateX(-50%); } } @@ -815,20 +857,23 @@ min-width: 20px !important; height: 20px !important; border-radius: 20% !important; - border-color: @lineage-border !important; - background: var(--om-color-bg-surface) !important; + border-color: var( + --lineage-node-border, + var(--tw-border-color-primary) + ) !important; + background: var(--tw-background-color-surface) !important; top: 60px !important; // Need to show handles on top half top: min(58px, 50%) !important; svg { - color: @text-grey-muted; + color: var(--tw-text-color-quaternary); } } .react-flow .lineage-column-node-handle { width: 30px; border-radius: 0; - border-color: @lineage-border; + border-color: var(--lineage-node-border, var(--tw-border-color-primary)); background: transparent; top: 5px; height: 25px; @@ -857,7 +902,7 @@ } svg:first-child { - border-right: 1px solid @grey-15; + border-right: 1px solid var(--tw-border-color-secondary); width: 22px; padding-right: var(--om-space-6); } @@ -865,8 +910,8 @@ .custom-node-deleted-icon { border-radius: var(--om-radius-sm); - border: 1px solid @error-color; - background-color: var(--tw-color-bg-primary); + border: 1px solid var(--tw-color-fg-error-primary); + background-color: var(--tw-background-color-surface); } .manage-node { @@ -876,5 +921,24 @@ } .react-flow__edge :hover { - stroke: @primary-color; + stroke: var(--tw-color-fg-brand-primary); +} + +// Asset lineage handles and their expand / collapse controls appear only while +// the node is hovered, focused or selected, so idle cards stay clean. +.react-flow__node { + .lineage-node-handle, + .lineage-node-handle-expand-all { + opacity: 0; + transition: opacity var(--om-duration-150) ease; + } + + &:hover, + &:focus-within, + &.selected { + .lineage-node-handle, + .lineage-node-handle-expand-all { + opacity: 1; + } + } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/entity-lineage.style.less b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/entity-lineage.style.less index c8984747420b..98b4c345b510 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/entity-lineage.style.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/entity-lineage.style.less @@ -11,11 +11,6 @@ * limitations under the License. */ -@import (reference) '../../../styles/variables.less'; - -@lineage-breadcrumb-panel: 50px; -@lineage-toolbar-height: 12px; - .custom-react-flow { .react-flow__node-input.selectable.selected { box-shadow: none; @@ -33,79 +28,61 @@ } .custom-node-header-tracing { - border: 1px solid @primary-color; + border: 1px solid var(--tw-color-fg-brand-primary); .react-flow__handle { - border-color: @primary-color; + border-color: var(--tw-color-fg-brand-primary); } .lineage-node-handle { - border: 1px solid @primary-color; + border: 1px solid var(--tw-color-fg-brand-primary); svg { - color: @primary-color; + color: var(--tw-color-fg-brand-primary); } } } .data-quality-failed-custom-node-header { .custom-node-header-column-tracing { - border-color: @red-14; - color: @red-20; + border-color: var(--tw-color-fg-error-primary); + color: var(--tw-text-color-error-primary); .custom-node-column-label { color: inherit; } .profiler-item { - color: black; + color: var(--tw-text-color-primary); } } } .data-quality-failed-custom-node-header { &.custom-node-header-normal { - border: 1px solid @red-3; + border: 1px solid var(--tw-color-fg-error-primary); .lineage-node-badge-container { - border-color: @red-3; + border-color: var(--tw-color-fg-error-primary); } } .react-flow__handle { - border-color: @red-3; + border-color: var(--tw-color-fg-error-primary); } .lineage-node-handle { - border: 1px solid @red-3; + border: 1px solid var(--tw-color-fg-error-primary); svg { - color: @red-3; + color: var(--tw-text-color-error-primary); } } &.custom-node-header-active { - border-color: @red-3; + border-color: var(--tw-color-fg-error-primary); } } .custom-node-header-active { - border-color: @primary-color; -} - -.custom-node-column-lineage { - background: var(--tw-color-utility-gray-blue-50); - border: 10px solid; - border-radius: var(--om-radius-sm); - - .custom-node-column-lineage-body { - display: flex; - flex-direction: column; - row-gap: var(--om-space-10); - position: relative; - - .custom-node-column-container { - padding: var(--om-space-4); - position: relative; - } - } + border-color: var(--tw-color-fg-brand-primary); } .custom-node { @@ -115,14 +92,14 @@ } .custom-lineage-heading { - color: var(--tw-color-text-white); + color: var(--tw-text-color-primary_on-brand); } } } .lineage-card { // 20px bottom spacing - height: calc(@asset-details-tab-pane-height - 20px); + height: calc(100vh - var(--ant-navbar-height) - 300px); // lineage and it's children's always should be ltr direction: ltr; @@ -132,101 +109,23 @@ } } -.lineage-header-edit-mode { - margin-left: @lineage-sidebar-width; -} - -.custom-control-zoom-slide { - display: flex; - column-gap: var(--om-space-8); - height: 32px; - width: 150px; -} - -.custom-node-expand-button { - top: -14px; - left: -12px; - cursor: pointer; - z-index: var(--om-z-1); -} - .load-more-node { - border: 3.5px dashed @primary-color; + border: 3.5px dashed var(--tw-color-fg-brand-primary); border-radius: var(--om-radius-18); text-align: center; - color: @primary-color; + color: var(--tw-color-fg-brand-primary); font-weight: var(--om-font-weight-semibold); padding: var(--om-space-8); .load-more-node-sizes { - color: @text-color; - } -} - -.control-button { - display: flex; - align-items: center; - justify-content: center; -} - -.custom-control-edit-button { - display: block !important; - border: 1px solid @border-color; - background-color: var(--tw-color-utility-gray-blue-50); - - &.active { - background-color: @primary-color; - } - - &.active:hover { - background-color: @primary-color; - } - - &.active:focus { - background-color: @primary-color; + color: var(--tw-text-color-primary); } } -.custom-lineage-heading { - color: @text-grey-muted; -} - -.suggestion-node-item { - position: absolute; - left: -10px; - min-width: 230px; - width: max-content; - z-index: var(--om-z-20); - background-color: var(--tw-color-bg-primary); - color: @text-color; -} - .lineage-card .react-flow__edge.animated path { animation: none; } -.entity-lineage.sidebar { - height: calc(100% - @om-navbar-height); - background: var(--tw-color-bg-primary); - position: absolute; - top: 0; - left: 0; - width: @lineage-sidebar-width; - z-index: var(--om-z-200); - overflow-y: auto; - padding: var(--om-space-8); - transform: translateX(100%); - display: none; - border-right: 1px solid @border-color; - transition: transform var(--om-duration-300) ease-out; -} - -.entity-lineage.sidebar.open { - transform: translateX(0); - display: block; - padding-top: var(--om-space-20); -} - .loading-card { position: relative; height: 100%; @@ -246,40 +145,28 @@ } .full-screen-lineage { + // top / left / width / height come from useFullScreenBounds. position: fixed; - top: @om-navbar-height; - left: @sidebar-width; - width: calc(100% - @sidebar-width); - z-index: var(--om-z-sticky); - - &.sidebar-collapsed { - left: calc(72px + @padding-mlg); - width: calc(100% - 72px - @size-xl); - } + display: flex; + flex-direction: column; + padding-bottom: var(--om-space-16); - &.sidebar-expanded { - left: calc(228px + @padding-mlg); - width: calc(100% - 228px - @size-xl); + // The lineage view's wrapper, ahead of the overlays, takes the full height. + > :first-child { + flex: 1; + min-height: 0; } .lineage-card { - // Navbar - breadcrumb - bottom spacing - height: calc(100vh - @om-navbar-height - 36px - 20px); + height: 100%; } .entity-panel-container { - top: calc(@lineage-breadcrumb-panel + @lineage-toolbar-height); + top: calc(var(--om-space-50) + var(--om-space-12)); margin-top: 0; } } -.lineage-node-remove-btn { - position: absolute !important; - top: -20px; - right: -20px; - cursor: pointer; -} - .react-flow__attribution { display: none; } @@ -289,109 +176,7 @@ height: 20px; } -.custom-edge-pipeline-button { - background-color: var(--tw-color-utility-gray-blue-50); - color: @primary-color; - - &.green { - background: @green-6; - border: 1px solid @green-7; - color: @green-7; - } - - &.amber { - background: @yellow-5; - border: 1px solid @yellow-6; - color: @yellow-6; - } - - &.red { - background: @red-6; - border: 1px solid @red-7; - color: @red-7; - } - - svg { - color: inherit; - } -} - -@keyframes blink { - 0% { - border: 1px solid @primary-color; - } - - 50% { - border: 3px solid @primary-color; - } - - 100% { - border: 1px solid @primary-color; - } -} - -@keyframes blink-success { - 0% { - border: 1px solid @green-7; - } - - 50% { - border: 3px solid @green-7; - } - - 100% { - border: 1px solid @green-7; - } -} - -@keyframes blink-amber { - 0% { - border: 1px solid @yellow-6; - } - - 50% { - border: 3px solid @yellow-6; - } - - 100% { - border: 1px solid @yellow-6; - } -} - -@keyframes blink-red { - 0% { - border: 1px solid @red-7; - } - - 50% { - border: 3px solid @red-7; - } - - 100% { - border: 1px solid @red-7; - } -} - -.blinking-border { - animation: blink var(--om-duration-1000) infinite; -} - -.blinking-green-border { - animation: blink-success var(--om-duration-1000) infinite; -} - -.blinking-amber-border { - animation: blink-amber var(--om-duration-1000) infinite; -} - -.blinking-red-border { - animation: blink-red var(--om-duration-1000) infinite; -} - .react-flow__panel.left { - &.edit-mode { - left: @lineage-sidebar-width; - } left: 8px; } @@ -416,9 +201,29 @@ margin: 0 !important; } +// Canvas overlays (breadcrumb, status, layers) sit inset from the canvas +// corners, clear of its edge. +.react-flow__panel.lineage-map-breadcrumb-panel, +.react-flow__panel.lineage-map-status-panel, +.react-flow__panel.lineage-layers-panel { + margin: var(--om-space-16) !important; +} + +// The level rail (138px tall) is centred, but never lower than the controls +// stacked in the bottom-right corner: the zoom bar, and the minimap (60px up, +// 150px tall) above it when shown. +.lineage-map-rail { + // Escaped so LESS leaves min() to the browser. + top: ~'min(50%, calc(100% - 68px - 69px))'; + + &.with-minimap { + top: ~'min(50%, calc(100% - 226px - 69px))'; + } +} + .react-flow__minimap { height: 150px; - border: 1px solid @border-color; + border: 1px solid var(--tw-border-color-secondary); border-radius: var(--om-radius-10); overflow: hidden; } @@ -433,8 +238,7 @@ z-index: var(--om-z-0); } -.edge-interaction-overlay, -.pipeline-edge-buttons-overlay { +.edge-interaction-overlay { position: absolute; top: 0; left: 0; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/LineageTab/LineageTabContent.less b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/LineageTab/LineageTabContent.less index e869b69fa90d..447d78faf8d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/LineageTab/LineageTabContent.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/LineageTab/LineageTabContent.less @@ -10,14 +10,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@import (reference) '../../../../styles/variables.less'; - .lineage-tab-content { .lineage-info-container { padding-top: var(--om-space-12); } .right-arrow-icon { - color: @grey-400; + color: var(--tw-color-fg-quaternary); font-size: var(--om-font-size-10) !important; } .right-arrow-icon-container { @@ -30,7 +28,7 @@ .right-arrow-icon-dot { width: 2px; height: 2px; - background-color: @grey-500; + background-color: var(--tw-text-color-quaternary); border-radius: 50%; } .lineage-items-list.empty-state { @@ -71,22 +69,22 @@ } .lineage-items-list { .lineage-item-card { - border: 1px solid @grey-200; + border: 1px solid var(--tw-border-color-secondary); border-radius: var(--om-radius-lg); margin-bottom: var(--om-space-16); cursor: pointer; &:hover { cursor: pointer !important; - background-color: @grey-50; + background-color: var(--tw-background-color-secondary); } .item-name-text { - color: @grey-900; + color: var(--tw-text-color-primary); font-size: var(--om-font-size-sm); font-weight: var(--om-font-weight-semibold); padding-top: var(--om-space-8); } .item-direction-text { - color: @grey-900; + color: var(--tw-text-color-primary); font-size: var(--om-font-size-9); text-transform: uppercase; font-weight: var(--om-font-weight-semibold); @@ -120,25 +118,25 @@ width: fit-content; } .item-path-text { - color: @grey-500; + color: var(--tw-text-color-quaternary); font-size: var(--om-font-size-xs) !important; } } } .entity-type-icon { - color: @grey-500 !important; + color: var(--tw-text-color-quaternary) !important; } .item-entity-type-text, .item-owner-label-text { - color: @grey-500 !important; + color: var(--tw-text-color-quaternary) !important; font-size: var(--om-font-size-10) !important; line-height: 18px; align-content: center; font-weight: var(--om-font-weight-regular) !important; } .item-bullet-separator { - color: @grey-400 !important; + color: var(--tw-color-fg-quaternary) !important; align-content: center; margin-right: var(--om-space-4); line-height: 18px; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/LineageTab/LineageTabContent.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/LineageTab/LineageTabContent.test.tsx index 90551293191f..02ddb6ab5b5c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/LineageTab/LineageTabContent.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/LineageTab/LineageTabContent.test.tsx @@ -34,7 +34,6 @@ jest.mock('@openmetadata/ui-core-components/icons', () => ({ // Mock react-i18next jest.mock('@openmetadata/ui-core-components', () => ({ Tabs: jest.requireActual('@openmetadata/ui-core-components').Tabs, - Typography: jest.requireActual('@openmetadata/ui-core-components').Typography, Tooltip: jest .fn() .mockImplementation(({ children }) =>
{children}
), @@ -42,6 +41,31 @@ jest.mock('@openmetadata/ui-core-components', () => ({ .fn() .mockImplementation(({ children }) => {children}), Owner: jest.fn().mockReturnValue(<>), + Button: jest + .fn() + .mockImplementation(({ children, onClick, className, size, ...props }) => ( + + )), + Typography: jest + .fn() + .mockImplementation( + ({ children, className, as: Component = 'span', ...props }) => ( + + {children} + + ) + ), Breadcrumbs: jest .fn() .mockImplementation( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.interface.ts new file mode 100644 index 000000000000..b29d61489f1f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.interface.ts @@ -0,0 +1,36 @@ +/* + * 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 { RefObject } from 'react'; +import { LineageDirection } from '../../../generated/api/lineage/lineageDirection'; +import { EdgeFromToData } from '../../../interface/lineage.interface'; + +export interface LineageEditRequest { + nodeId: string; + direction: LineageDirection; + columnFqn?: string; + triggerRef: RefObject; +} + +export interface AddLineageSelection { + entity: EdgeFromToData; + columnFqn?: string; +} + +export interface AddLineagePopoverProps { + request?: LineageEditRequest; + /** Display name of the node the lineage is added to. */ + nodeName?: string; + excludeEntityId?: string; + onClose: () => void; + onSubmit: (selection: AddLineageSelection) => Promise; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.test.tsx new file mode 100644 index 000000000000..48a2cb174bf3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.test.tsx @@ -0,0 +1,227 @@ +/* + * 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 { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { LineageDirection } from '../../../generated/api/lineage/lineageDirection'; +import { getTableDetailsByFQN } from '../../../rest/tableAPI'; +import { getAggregationOptions } from '../../../utils/ExploreUtils'; +import { DataAssetOption } from '../../DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList.interface'; +import AddLineagePopover from './AddLineagePopover'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +jest.mock('../../../rest/tableAPI', () => ({ + getTableDetailsByFQN: jest.fn(), +})); +jest.mock('../../../rest/dataModelsAPI', () => ({ + getDataModelByFqn: jest.fn(), +})); +jest.mock('../../../utils/ExploreUtils', () => ({ + getAggregationOptions: jest.fn(), +})); +jest.mock('../../../utils/ToastUtils', () => ({ showErrorToast: jest.fn() })); + +const ORDERS: DataAssetOption = { + displayName: 'orders', + name: 'orders', + value: 's.d.sc.orders', + reference: { id: 'b', type: 'table', fullyQualifiedName: 's.d.sc.orders' }, +}; + +// The asset list is its own tested component; here it only has to hand back a pick. +jest.mock( + '../../DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList', + () => ({ + __esModule: true, + default: ({ + onChange, + searchIndex, + queryFilter, + }: { + onChange: (option: DataAssetOption) => void; + searchIndex: string; + queryFilter?: unknown; + }) => ( + + ), + }) +); + +const triggerRef = createRef() as { current: HTMLElement }; +triggerRef.current = document.createElement('button'); + +const baseProps = { + excludeEntityId: 'self', + nodeName: 'customers', + onClose: jest.fn(), + onSubmit: jest.fn().mockResolvedValue(true), +}; + +// FilterSelect's menu does not settle under userEvent's pointer sequence in +// jsdom; a plain click opens it and picks a row. +const pickType = async (index = 'table') => { + fireEvent.click(screen.getByTestId('add-lineage-type-select')); + fireEvent.click( + within(await screen.findByTestId('drop-down-menu')).getByTestId(index) + ); +}; + +describe('AddLineagePopover', () => { + let user: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + user = userEvent.setup({ delay: null, pointerEventsCheck: 0 }); + (getAggregationOptions as jest.Mock).mockResolvedValue({ + data: { + aggregations: { + 'sterms#entityType': { + buckets: [{ key: 'table', doc_count: 528 }], + }, + }, + }, + }); + }); + + const open = (direction = LineageDirection.Downstream, columnFqn?: string) => + render( + + ); + + it('renders nothing without a request', () => { + render(); + + expect(screen.queryByTestId('add-lineage-popover')).not.toBeInTheDocument(); + }); + + it('shows the direction title', () => { + open(LineageDirection.Upstream); + + expect(screen.getAllByText('label.add-upstream').length).toBeGreaterThan(0); + }); + + it('lists asset types with their counts', async () => { + open(); + fireEvent.click(screen.getByTestId('add-lineage-type-select')); + const menu = await screen.findByTestId('drop-down-menu'); + + await waitFor(() => + expect(within(menu).getByTestId('table')).toHaveTextContent('528') + ); + }); + + it('searches the chosen type without the current entity and submits on Add', async () => { + open(); + + expect(screen.getByTestId('add-lineage-submit')).toBeDisabled(); + + await pickType(); + const list = screen.getByTestId('pick-orders'); + + expect(list).toHaveAttribute('data-search-index', 'table'); + expect(list.getAttribute('data-query-filter')).toContain('self'); + + fireEvent.click(list); + + expect(baseProps.onSubmit).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('add-lineage-submit')); + + await waitFor(() => + expect(baseProps.onSubmit).toHaveBeenCalledWith({ + entity: { id: 'b', type: 'table', fullyQualifiedName: 's.d.sc.orders' }, + }) + ); + }); + + it('previews the new edge with the current node on the matching side', async () => { + open(LineageDirection.Upstream); + await pickType(); + fireEvent.click(screen.getByTestId('pick-orders')); + + expect(screen.getByTestId('add-lineage-preview').textContent).toMatch( + /orders.*customers/ + ); + }); + + it('asks for a column when opened from a column and submits the column', async () => { + (getTableDetailsByFQN as jest.Mock).mockResolvedValue({ + columns: [{ name: 'id', fullyQualifiedName: 's.d.sc.orders.id' }], + }); + open(LineageDirection.Downstream, 's.d.sc.self.id'); + await pickType(); + fireEvent.click(screen.getByTestId('pick-orders')); + + expect(screen.getByTestId('add-lineage-submit')).toBeDisabled(); + + await user.click( + within(screen.getByTestId('add-lineage-column-select')).getByRole( + 'button' + ) + ); + await user.click(await screen.findByRole('option', { name: 'id' })); + fireEvent.click(screen.getByTestId('add-lineage-submit')); + + await waitFor(() => + expect(baseProps.onSubmit).toHaveBeenCalledWith({ + entity: { id: 'b', type: 'table', fullyQualifiedName: 's.d.sc.orders' }, + columnFqn: 's.d.sc.orders.id', + }) + ); + + expect(getTableDetailsByFQN).toHaveBeenCalledWith('s.d.sc.orders', { + fields: 'columns', + }); + }); + + it('stays open when submit fails, and ignores duplicate submits', async () => { + let resolveSubmit: (value: boolean) => void = (_value: boolean) => + undefined; + baseProps.onSubmit.mockImplementation( + () => + new Promise((resolve) => { + resolveSubmit = resolve; + }) + ); + open(); + await pickType(); + fireEvent.click(screen.getByTestId('pick-orders')); + fireEvent.click(screen.getByTestId('add-lineage-submit')); + fireEvent.click(screen.getByTestId('add-lineage-submit')); + + expect(baseProps.onSubmit).toHaveBeenCalledTimes(1); + + await act(async () => resolveSubmit(false)); + + expect(baseProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByTestId('add-lineage-popover')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.tsx new file mode 100644 index 000000000000..c7d4e0c568b1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/AddLineagePopover/AddLineagePopover.tsx @@ -0,0 +1,457 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Box, + Button, + Card, + FilterSelect, + Select, + SelectItemType, + SlideoutMenu, + Typography, +} from '@openmetadata/ui-core-components'; +import { ArrowRight } from '@openmetadata/ui-core-components/icons'; +import { AxiosError } from 'axios'; +import classNames from 'classnames'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { entityData } from '../../../constants/Lineage.constants'; +import { EntityFields } from '../../../enums/AdvancedSearch.enum'; +import { TabSpecificField } from '../../../enums/entity.enum'; +import { SearchIndex } from '../../../enums/search.enum'; +import { LineageDirection } from '../../../generated/api/lineage/lineageDirection'; +import { + EdgeFromToData, + LineageNodeType, +} from '../../../interface/lineage.interface'; +import { getDataModelByFqn } from '../../../rest/dataModelsAPI'; +import { getTableDetailsByFQN } from '../../../rest/tableAPI'; +import { EntityIconSize } from '../../../utils/EntityIconUtils'; +import { getEntityChildrenAndLabel } from '../../../utils/EntityLineageNodeUtils'; +import { getEntityName } from '../../../utils/EntityNameUtils'; +import { getCanonicalEntityType } from '../../../utils/ExplorePureUtils'; +import { getAggregationOptions } from '../../../utils/ExploreUtils'; +import { getNameFromFQN } from '../../../utils/FqnUtils'; +import searchClassBase from '../../../utils/SearchClassBase'; +import { getEntityTypeFromSearchIndex } from '../../../utils/SearchPureUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import DataAssetAsyncSelectList from '../../DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList'; +import { DataAssetOption } from '../../DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList.interface'; +import { AddLineagePopoverProps } from './AddLineagePopover.interface'; + +// Only entity types whose search hits carry columns via getEntityChildrenAndLabel's +// getTableEntityChildren resolver (reads `entityType` + `columns` directly, no +// extra nesting) can be flattened into a column picker from a raw search hit. +const COLUMN_CAPABLE_SEARCH_INDEXES = new Set([ + SearchIndex.TABLE, + SearchIndex.DASHBOARD_DATA_MODEL, +]); + +type EntityHit = { + id: string; + name?: string; + displayName?: string; + fullyQualifiedName?: string; + entityType?: string; + columns?: { name: string; fullyQualifiedName?: string }[]; +}; + +const LineagePreviewCard = ({ + label, + name, + placeholder, + isCurrent, +}: { + label: string; + name?: string; + placeholder?: string; + isCurrent?: boolean; +}) => ( + + + + {label} + + + {name ?? placeholder} + + + +); + +const joinNames = (...names: (string | undefined | false)[]) => + names.filter(Boolean).join('.') || undefined; + +// Shows the edge about to be created, so the panel reads as a new input rather +// than another node on the canvas. +const LineageEdgePreview = ({ + isUpstream, + currentName, + pickedName, +}: { + isUpstream: boolean; + currentName?: string; + pickedName?: string; +}) => { + const { t } = useTranslation(); + const currentCard = ( + + ); + const pickedCard = ( + + ); + + return ( + + ); +}; + +const AddLineagePopover = ({ + request, + nodeName, + excludeEntityId, + onClose, + onSubmit, +}: AddLineagePopoverProps) => { + const { t } = useTranslation(); + const [type, setType] = useState(); + const [typeCounts, setTypeCounts] = useState>({}); + const [entity, setEntity] = useState(); + const [columnFqn, setColumnFqn] = useState(); + const [isSubmitting, setIsSubmitting] = useState(false); + const isColumnTarget = Boolean(request?.columnFqn); + + useEffect(() => { + setType(undefined); + setEntity(undefined); + setColumnFqn(undefined); + }, [request]); + + // Asset counts per type, as the Explore "Data Assets" filter shows them. + useEffect(() => { + if (!request) { + return; + } + // One bucket per listed type; the default page of 10 drops the rest. + getAggregationOptions( + entityData.map(({ type: index }) => index), + EntityFields.ENTITY_TYPE, + '', + '', + true, + false, + entityData.length + ) + .then((response) => + setTypeCounts( + Object.fromEntries( + ( + response.data.aggregations[`sterms#${EntityFields.ENTITY_TYPE}`] + ?.buckets ?? [] + ).map(({ key, doc_count }) => [ + getCanonicalEntityType(key), + doc_count, + ]) + ) + ) + ) + .catch(() => setTypeCounts({})); + }, [request]); + + // The search hit for the picked asset carries the columns to map to. + // Columns come from the picked asset itself, fetched by FQN, so names shared + // across services or special characters cannot miss it. + const loadColumns = useCallback( + async (picked: EntityHit, index: SearchIndex) => { + const fqn = picked.fullyQualifiedName ?? ''; + try { + const { columns } = + index === SearchIndex.DASHBOARD_DATA_MODEL + ? await getDataModelByFqn(fqn, { fields: TabSpecificField.COLUMNS }) + : await getTableDetailsByFQN(fqn, { + fields: TabSpecificField.COLUMNS, + }); + setEntity((current) => + current?.id === picked.id ? { ...current, columns } : current + ); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, + [] + ); + + const handleAssetChange = useCallback( + (option?: DataAssetOption | DataAssetOption[]) => { + const picked = Array.isArray(option) ? option[0] : option; + setColumnFqn(undefined); + if (!picked?.reference.id) { + setEntity(undefined); + + return; + } + const hit: EntityHit = { + id: picked.reference.id, + name: picked.name, + displayName: picked.displayName, + fullyQualifiedName: picked.reference.fullyQualifiedName, + entityType: picked.reference.type, + }; + setEntity(hit); + if (isColumnTarget && type) { + void loadColumns(hit, type); + } + }, + [isColumnTarget, loadColumns, type] + ); + + const excludeQueryFilter = useMemo( + () => + excludeEntityId + ? { query: { bool: { must_not: [{ term: { id: excludeEntityId } }] } } } + : undefined, + [excludeEntityId] + ); + + const submit = useCallback( + async (picked: EntityHit, columnFqn?: string) => { + if (isSubmitting) { + return; + } + const ref: EdgeFromToData = { + id: picked.id, + type: picked.entityType ?? '', + fullyQualifiedName: picked.fullyQualifiedName, + }; + setIsSubmitting(true); + const ok = await onSubmit( + columnFqn ? { entity: ref, columnFqn } : { entity: ref } + ); + setIsSubmitting(false); + if (ok) { + onClose(); + } + }, + [isSubmitting, onClose, onSubmit] + ); + + const typeOptions = useMemo( + () => + (isColumnTarget + ? entityData.filter(({ type: index }) => + COLUMN_CAPABLE_SEARCH_INDEXES.has(index) + ) + : entityData + ).map(({ type: index, label }) => { + const entityType = getEntityTypeFromSearchIndex(index) ?? index; + + return { + value: index, + label: t(label), + textValue: t(label), + count: typeCounts[entityType], + icon: + searchClassBase.getEntityIconWithBg( + entityType, + EntityIconSize.Size14 + ) ?? undefined, + }; + }), + [isColumnTarget, t, typeCounts] + ); + const columnItems: SelectItemType[] = useMemo(() => { + if (!entity) { + return []; + } + const { children } = getEntityChildrenAndLabel( + entity as unknown as LineageNodeType + ); + + return children.map((column) => ({ + id: column.fullyQualifiedName ?? column.name ?? '', + label: column.name ?? '', + })); + }, [entity]); + + if (!request) { + return null; + } + + const isUpstream = request.direction === LineageDirection.Upstream; + const canSubmit = Boolean(entity && (!isColumnTarget || columnFqn)); + const currentName = joinNames( + nodeName, + request.columnFqn && getNameFromFQN(request.columnFqn) + ); + const pickedName = + entity && + joinNames(getEntityName(entity), columnFqn && getNameFromFQN(columnFqn)); + + return ( + + !isPanelOpen && !isSubmitting && onClose() + }> + {({ close }) => ( +
+ + + {t(isUpstream ? 'label.add-upstream' : 'label.add-downstream')} + + + {t( + isUpstream + ? 'message.add-upstream-lineage-description' + : 'message.add-downstream-lineage-description', + { entity: currentName } + )} + + + + + + + {t('label.select-type')} + + { + setType(value as SearchIndex | undefined); + setEntity(undefined); + setColumnFqn(undefined); + }} + /> + + {type && ( + + + {t('label.search-entity', { entity: t('label.asset') })} + + + + )} + {isColumnTarget && entity && ( + + )} + + + + + + + +
+ )} +
+ ); +}; + +export default AddLineagePopover; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Edges/CanvasLayerWrapper/CanvasLayerWrapper.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Edges/CanvasLayerWrapper/CanvasLayerWrapper.test.tsx index cc59c7fa277b..172ce76ded46 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Edges/CanvasLayerWrapper/CanvasLayerWrapper.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Edges/CanvasLayerWrapper/CanvasLayerWrapper.test.tsx @@ -49,29 +49,6 @@ jest.mock('../../../Entity/EntityLineage/CanvasEdgeRenderer.component', () => ({ }, })); -jest.mock( - '../../../Entity/EntityLineage/EdgeInteractionOverlay.component', - () => ({ - EdgeInteractionOverlay: ({ - onPipelineClick, - onEdgeRemove, - }: { - hoveredEdge?: Edge | null; - onPipelineClick?: () => void; - onEdgeRemove?: () => void; - }) => ( -
{ - onPipelineClick?.(); - onEdgeRemove?.(); - }} - /> - ), - }) -); - describe('CanvasLayerWrapper', () => { const mockEdge: Edge = { id: 'edge-1', @@ -83,8 +60,6 @@ describe('CanvasLayerWrapper', () => { dqHighlightedEdges: new Set(), onEdgeClick: jest.fn(), onEdgeHover: jest.fn(), - onPipelineClick: jest.fn(), - onEdgeRemove: jest.fn(), hoverEdge: null, }; @@ -98,12 +73,6 @@ describe('CanvasLayerWrapper', () => { expect(getByTestId('canvas-edge-renderer')).toBeInTheDocument(); }); - it('renders EdgeInteractionOverlay component', () => { - const { getByTestId } = render(); - - expect(getByTestId('edge-interaction-overlay')).toBeInTheDocument(); - }); - it('passes dqHighlightedEdges to CanvasEdgeRenderer', () => { const dqHighlightedEdges = new Set(['edge-1', 'edge-2']); @@ -173,32 +142,6 @@ describe('CanvasLayerWrapper', () => { expect(onEdgeHover).toHaveBeenCalled(); }); - it('passes onPipelineClick callback to EdgeInteractionOverlay', () => { - const onPipelineClick = jest.fn(); - - const { getByTestId } = render( - - ); - - const overlay = getByTestId('edge-interaction-overlay'); - overlay.click(); - - expect(onPipelineClick).toHaveBeenCalled(); - }); - - it('passes onEdgeRemove callback to EdgeInteractionOverlay', () => { - const onEdgeRemove = jest.fn(); - - const { getByTestId } = render( - - ); - - const overlay = getByTestId('edge-interaction-overlay'); - overlay.click(); - - expect(onEdgeRemove).toHaveBeenCalled(); - }); - it('handles null hoverEdge correctly', () => { const { getByTestId } = render( @@ -263,11 +206,4 @@ describe('CanvasLayerWrapper', () => { ); }).not.toThrow(); }); - - it('renders both components simultaneously', () => { - const { getByTestId } = render(); - - expect(getByTestId('canvas-edge-renderer')).toBeInTheDocument(); - expect(getByTestId('edge-interaction-overlay')).toBeInTheDocument(); - }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Edges/CanvasLayerWrapper/CanvasLayerWrapper.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Edges/CanvasLayerWrapper/CanvasLayerWrapper.tsx index bb8b2d251ca6..c22156925dee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Edges/CanvasLayerWrapper/CanvasLayerWrapper.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Edges/CanvasLayerWrapper/CanvasLayerWrapper.tsx @@ -12,14 +12,11 @@ */ import type { Edge, Node } from 'reactflow'; import { CanvasEdgeRenderer } from '../../../Entity/EntityLineage/CanvasEdgeRenderer.component'; -import { EdgeInteractionOverlay } from '../../../Entity/EntityLineage/EdgeInteractionOverlay.component'; export const CanvasLayerWrapper = ({ dqHighlightedEdges, onEdgeClick, onEdgeHover, - onPipelineClick, - onEdgeRemove, hoverEdge, edges, nodes, @@ -29,8 +26,6 @@ export const CanvasLayerWrapper = ({ dqHighlightedEdges: Set; onEdgeClick?: (edge: Edge, event: MouseEvent) => void; onEdgeHover?: (edge: Edge | null) => void; - onPipelineClick?: () => void; - onEdgeRemove?: () => void; hoverEdge: Edge | null; edges?: Edge[]; nodes?: Node[]; @@ -38,21 +33,15 @@ export const CanvasLayerWrapper = ({ isPathHighlightActive?: boolean; }) => { return ( - <> - - - + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/EntityLineageCanvas/EntityLineageCanvas.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/EntityLineageCanvas/EntityLineageCanvas.tsx new file mode 100644 index 000000000000..79711c609c32 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/EntityLineageCanvas/EntityLineageCanvas.tsx @@ -0,0 +1,353 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AxiosError } from 'axios'; +import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import ReactFlow, { + Background, + Edge, + MiniMap, + Node, + Panel, + ReactFlowProvider, +} from 'reactflow'; +import { + MAX_ZOOM_VALUE, + MIN_ZOOM_VALUE, +} from '../../../constants/Lineage.constants'; +import { SERVICE_TYPES } from '../../../constants/Services.constant'; +import { EntityType } from '../../../enums/entity.enum'; +import { LineageDirection } from '../../../generated/api/lineage/lineageDirection'; +import { LineagePlatformView } from '../../../hooks/lineage/types'; +import { useLineageStore } from '../../../hooks/useLineageStore'; +import { EdgeFromToData } from '../../../interface/lineage.interface'; +import { + dragHandle, + onNodeContextMenu, +} from '../../../utils/EntityLineagePureUtils'; +import { nodeTypes } from '../../../utils/EntityLineageUtils'; +import { getEntityName } from '../../../utils/EntityNameUtils'; +import { + onColumnEdgeRemove, + onEdgeClick, + saveLineageEdge, +} from '../../../utils/Lineage/handlers/edgeMutations'; +import { onPaneClick } from '../../../utils/Lineage/handlers/nodeMutations'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import LineageControlButtons from '../../Entity/EntityLineage/LineageControlButtons/LineageControlButtons'; +import LineageLayers from '../../Entity/EntityLineage/LineageLayers/LineageLayers'; +import { SourceType } from '../../SearchedData/SearchedData.interface'; +import AddLineagePopover from '../AddLineagePopover/AddLineagePopover'; +import { + AddLineageSelection, + LineageEditRequest, +} from '../AddLineagePopover/AddLineagePopover.interface'; +import { CanvasLayerWrapper } from '../Edges/CanvasLayerWrapper/CanvasLayerWrapper'; +import { useLineageHandlers } from '../Lineage/LineageHandlersContext'; +import { + getDeleteKeyAction, + getLineageEditColumnPair, +} from '../LineageMap/LineageMap.utils'; +import LineageNodeDeleteModal from '../LineageNodeDeleteModal/LineageNodeDeleteModal'; +import LineageSkeleton from '../LineageSkeleton.component'; + +interface EntityLineageCanvasProps { + deleted?: boolean; + entity?: SourceType; + entityType: EntityType; + hasEditAccess?: boolean; +} + +// React Flow padding is a share of the viewport, not pixels. +const FIT_VIEW_OPTIONS = { padding: 0.2, maxZoom: 1 }; + +const toEdgeEntity = (node?: Node): EdgeFromToData | undefined => { + const entity = node?.data?.node; + + return entity?.id + ? { + id: entity.id, + type: entity.entityType ?? entity.type, + fullyQualifiedName: entity.fullyQualifiedName, + } + : undefined; +}; + +/** + * The asset Lineage tab: the classic lineage graph (`getLineageDataByFQN`) + * loaded by the `` provider and drawn with a canvas edge layer. + * The hierarchical scene map is reserved for the main Lineage page. + */ +const EntityLineageCanvas = ({ + deleted, + entity, + entityType, + hasEditAccess, +}: EntityLineageCanvasProps) => { + const { + onNodeClick, + onInitReactFlow, + onNodesChange, + updateEntityData, + removeNodeHandler, + refetchLineage, + } = useLineageHandlers(); + const { + nodes, + init, + isDQEnabled, + dqHighlightedEdges, + platformView, + reactFlowInstance, + selectedEdge, + selectedNode, + openDeleteModal, + setCanEditLineage, + setIsCreatingEdge, + } = useLineageStore(); + const [showMiniMap, setShowMiniMap] = useState(true); + const [hoveredEdge, setHoveredEdge] = useState(null); + const [editRequest, setEditRequest] = useState(); + const [nodePendingDelete, setNodePendingDelete] = useState(); + const [isDeletingNode, setIsDeletingNode] = useState(false); + + const canEdit = + Boolean(hasEditAccess) && + !deleted && + platformView === LineagePlatformView.None && + !SERVICE_TYPES.includes(entityType); + + useEffect(() => { + updateEntityData(entityType, entity, false); + }, [entity, entityType]); + + useEffect(() => { + setCanEditLineage(canEdit); + }, [canEdit, setCanEditLineage]); + + const requestNodeDelete = useCallback( + (node: { id: string }) => + setNodePendingDelete(nodes.find((item) => item.id === node.id)), + [nodes] + ); + + // The node ⋮ menu and column menu read their edit callbacks from node data. + const flowNodes = useMemo( + () => + canEdit + ? nodes.map((node) => + // Temp tables are plain hops in the graph: no menu, no edits. + node.data?.node?.isTempTable + ? node + : { + ...node, + data: { + ...node.data, + isNodeEditable: true, + onSceneLineageEdit: setEditRequest, + onSceneNodeRemove: requestNodeDelete, + }, + } + ) + : nodes, + [canEdit, nodes, requestNodeDelete] + ); + + // Delete / Backspace removes the selected node or edge, as on the platform map. + useEffect(() => { + const handleDeleteKey = (event: KeyboardEvent) => { + const deletableNode = selectedNode?.id + ? nodes.find( + (node) => + node.data?.node?.id === selectedNode.id && + !node.data?.isRootNode && + !node.data?.node?.isTempTable + ) + : undefined; + const action = getDeleteKeyAction(event, { + canEdit, + hasSelectedNode: Boolean(deletableNode), + hasSelectedEdge: Boolean(selectedEdge?.data?.isEditable), + }); + if (!action) { + return; + } + event.preventDefault(); + if (action === 'node' && deletableNode) { + setNodePendingDelete(deletableNode); + + return; + } + if (selectedEdge?.data?.isColumnLineage) { + onColumnEdgeRemove(); + } else { + openDeleteModal(); + } + }; + window.addEventListener('keydown', handleDeleteKey); + + return () => window.removeEventListener('keydown', handleDeleteKey); + }, [canEdit, nodes, openDeleteModal, selectedEdge, selectedNode]); + + const handleNodeClick = useCallback( + (event: MouseEvent, node: Node) => { + onNodeClick(node); + event.stopPropagation(); + }, + [onNodeClick] + ); + + const handleCanvasEdgeClick = useCallback( + (edge: Edge, event: globalThis.MouseEvent) => { + // The edge drawer offers Edit Pipeline / Delete only for editable edges; + // an edge into or out of a temp table stays read-only. + const touchesTempTable = nodes.some( + (node) => + (node.id === edge.source || node.id === edge.target) && + node.data?.node?.isTempTable + ); + onEdgeClick( + canEdit && !touchesTempTable + ? { ...edge, data: { ...edge.data, isEditable: true } } + : edge + ); + event.stopPropagation(); + }, + [canEdit, nodes] + ); + + const editNode = nodes.find((node) => node.id === editRequest?.nodeId); + + const handleAddLineageSubmit = useCallback( + async ({ entity: picked, columnFqn }: AddLineageSelection) => { + const current = toEdgeEntity(editNode); + if (!editRequest || !current) { + return false; + } + const isUpstream = editRequest.direction === LineageDirection.Upstream; + const columnPair = getLineageEditColumnPair( + isUpstream, + editRequest.columnFqn, + columnFqn + ); + setIsCreatingEdge(true); + try { + const saved = isUpstream + ? await saveLineageEdge(picked, current, columnPair) + : await saveLineageEdge(current, picked, columnPair); + if (saved) { + refetchLineage(); + } + + return saved; + } catch (error) { + showErrorToast(error as AxiosError); + + return false; + } finally { + setIsCreatingEdge(false); + } + }, + [editNode, editRequest, refetchLineage, setIsCreatingEdge] + ); + + const confirmNodeDelete = useCallback(async () => { + if (!nodePendingDelete) { + return; + } + setIsDeletingNode(true); + try { + await removeNodeHandler(nodePendingDelete); + } catch { + // removeLineageHandler already toasted; a partial delete needs a resync. + refetchLineage(); + } finally { + setIsDeletingNode(false); + setNodePendingDelete(undefined); + } + }, [nodePendingDelete, refetchLineage, removeNodeHandler]); + + // Edges are drawn by CanvasLayerWrapper, not as ReactFlow DOM edges. + const memoizedEdgeTypes = useMemo(() => ({}), []); + const memoizedEdges = useMemo(() => [], []); + const highlightedDqEdges = useMemo( + () => (isDQEnabled ? dqHighlightedEdges : new Set()), + [dqHighlightedEdges, isDQEnabled] + ); + + if (!init) { + return ; + } + + return ( + + + + {showMiniMap && } + + + + + + setShowMiniMap((show) => !show)} + /> + + + setNodePendingDelete(undefined)} + onConfirm={confirmNodeDelete} + /> + setEditRequest(undefined)} + onSubmit={handleAddLineageSubmit} + /> + + ); +}; + +export default EntityLineageCanvas; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.component.tsx index 0a1413c01142..1fe47bc270a9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.component.tsx @@ -12,9 +12,8 @@ */ import { Card } from '@openmetadata/ui-core-components'; -import classNames from 'classnames'; -import { useLineageStore } from '../../hooks/useLineageStore'; import CustomControlsComponent from '../Entity/EntityLineage/CustomControls.component'; +import EntityLineageCanvas from './EntityLineageCanvas/EntityLineageCanvas'; import type { LineageProps } from './Lineage.interface'; import LineageMap from './LineageMap/LineageMap.component'; @@ -27,17 +26,9 @@ const Lineage = ({ platformHeader, showControls = true, }: LineageProps) => { - const { isEditMode } = useLineageStore(); - const entityHeader = showControls ? ( -
- +
+
) : null; const headerContent = isPlatformLineage ? platformHeader : entityHeader; @@ -55,14 +46,23 @@ const Lineage = ({ className="lineage-container tw:flex-1 tw:min-h-0 tw:overflow-hidden" data-testid="lineage-container" id="lineage-container"> - + {isPlatformLineage ? ( + + ) : ( + + )}
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.test.tsx index 1006aff43aa3..6909877fa15a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage.test.tsx @@ -15,6 +15,7 @@ import { EntityType } from '../../enums/entity.enum'; import { Table } from '../../generated/entity/data/table'; import { useLineageStore } from '../../hooks/useLineageStore'; import { MOCK_EXPLORE_SEARCH_RESULTS } from '../Explore/Explore.mock'; +import EntityLineageCanvas from './EntityLineageCanvas/EntityLineageCanvas'; import Lineage from './Lineage.component'; import LineageMap from './LineageMap/LineageMap.component'; @@ -29,6 +30,11 @@ jest.mock('../Entity/EntityLineage/CustomControls.component', () => ({ default: jest.fn(() =>
), })); +jest.mock('./EntityLineageCanvas/EntityLineageCanvas', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); + jest.mock('./LineageMap/LineageMap.component', () => ({ __esModule: true, default: jest.fn(() =>
), @@ -44,12 +50,10 @@ describe('Lineage Component', () => { beforeEach(() => { jest.clearAllMocks(); - (useLineageStore as unknown as jest.Mock).mockReturnValue({ - isEditMode: false, - }); + (useLineageStore as unknown as jest.Mock).mockReturnValue({}); }); - it('renders the scene-backed map in the lineage container', () => { + it('renders the classic lineage canvas on an asset page', () => { render(); expect(screen.getByTestId('lineage-details')).toBeInTheDocument(); @@ -58,21 +62,22 @@ describe('Lineage Component', () => { 'lineage-container' ); expect(screen.getByTestId('custom-controls')).toBeInTheDocument(); - expect(screen.getByTestId('lineage-map')).toBeInTheDocument(); + expect(screen.getByTestId('entity-lineage-canvas')).toBeInTheDocument(); + expect(screen.queryByTestId('lineage-map')).not.toBeInTheDocument(); }); - it('passes entity context to the scene map', () => { + it('passes entity context to the asset canvas', () => { render(); - const lineageMapMock = LineageMap as jest.MockedFunction; - const mapProps = lineageMapMock.mock.calls[0][0]; + const canvasMock = EntityLineageCanvas as jest.MockedFunction< + typeof EntityLineageCanvas + >; - expect(mapProps).toEqual( + expect(canvasMock.mock.calls[0][0]).toEqual( expect.objectContaining({ entity: mockEntity, entityType: EntityType.TABLE, hasEditAccess: true, - isPlatformLineage: undefined, }) ); }); @@ -88,24 +93,20 @@ describe('Lineage Component', () => { expect(screen.getByTestId('platform-header')).toBeInTheDocument(); expect(screen.queryByTestId('custom-controls')).not.toBeInTheDocument(); - }); - - it('applies edit mode class when edit mode is active', () => { - (useLineageStore as unknown as jest.Mock).mockReturnValue({ - isEditMode: true, - }); - - render(); - - expect(screen.getByTestId('custom-controls').parentElement).toHaveClass( - 'lineage-header-edit-mode' + expect(screen.getByTestId('lineage-map')).toBeInTheDocument(); + expect( + screen.queryByTestId('entity-lineage-canvas') + ).not.toBeInTheDocument(); + expect(LineageMap).toHaveBeenCalledWith( + expect.objectContaining({ isPlatformLineage: true }), + expect.anything() ); }); - it('keeps the scene map mounted when controls are hidden', () => { + it('keeps the canvas mounted when controls are hidden', () => { render(); expect(screen.queryByTestId('custom-controls')).not.toBeInTheDocument(); - expect(screen.getByTestId('lineage-map')).toBeInTheDocument(); + expect(screen.getByTestId('entity-lineage-canvas')).toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.integration.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.integration.test.tsx index 6d1572cf78d8..226257eda7c3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.integration.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.integration.test.tsx @@ -63,11 +63,6 @@ jest.mock( }) ); -jest.mock('../../Entity/EntityLineage/EntityLineageSidebar.component', () => ({ - __esModule: true, - default: () =>
, -})); - jest.mock('@openmetadata/ui-core-components', () => { type ChildrenProps = { children?: ReactNode }; @@ -438,6 +433,35 @@ describe('Lineage integration', () => { upstreamEdges: {}, }; + it('fetches the lineage once when the same config is re-applied mid-request', async () => { + let resolveFetch: (value: typeof emptyLineageResponse) => void = jest.fn(); + (getLineageDataByFQN as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + renderMounted(); + + await waitFor(() => expect(getLineageDataByFQN).toHaveBeenCalledTimes(1)); + + // Lineage settings load after mount and re-apply an equal config object, + // which re-runs the fetch effect for the same graph. + act(() => { + useLineageStore + .getState() + .setLineageConfig({ ...useLineageStore.getState().lineageConfig }); + }); + await act(async () => { + resolveFetch(emptyLineageResponse); + }); + await waitFor(() => expect(useLineageStore.getState().init).toBe(true)); + + // A duplicate response landing after the user switched layers would + // redraw with the layers captured at its call and drop the column rows. + expect(getLineageDataByFQN).toHaveBeenCalledTimes(1); + }); + it('seeds the store time filter from the URL and fetches with it', async () => { mockLocation.search = '?lineageStartTime=100&lineageEndTime=200'; (getLineageDataByFQN as jest.Mock).mockResolvedValue(emptyLineageResponse); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.test.tsx index 902a3f7cd3e6..adfa6c18a30a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { EntityType } from '../../../enums/entity.enum'; @@ -42,11 +42,6 @@ jest.mock( }) ); -jest.mock('../../Entity/EntityLineage/EntityLineageSidebar.component', () => ({ - __esModule: true, - default: () =>
, -})); - jest.mock('@openmetadata/ui-core-components', () => { type ChildrenProps = { children?: ReactNode }; @@ -102,7 +97,6 @@ describe('Lineage', () => { ); expect(useLineageStore.getState().entityFqn).toBe('svc.db.s.t'); - expect(screen.getByTestId('entity-lineage-sidebar')).toBeInTheDocument(); }); it('mirrors entityFqn to the store on mount with default props', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.tsx index 349703a2f6ee..44d8e9ecd21b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/Lineage.tsx @@ -12,11 +12,10 @@ */ import { AxiosError } from 'axios'; import classNames from 'classnames'; -import { isEmpty, isEqual, isUndefined, uniqueId, uniqWith } from 'lodash'; +import { isEmpty, isEqual, isUndefined, uniqWith } from 'lodash'; import { LoadingState } from 'Models'; import QueryString from 'qs'; import { - DragEvent, ReactNode, useCallback, useEffect, @@ -36,7 +35,6 @@ import { Node, NodeProps, ReactFlowInstance, - useKeyPress, } from 'reactflow'; import 'reactflow/dist/style.css'; import { @@ -64,11 +62,11 @@ import { EntityReference, LineageDetails, } from '../../../generated/type/entityLineage'; -import { useCurrentUserPreferences } from '../../../hooks/currentUserStore/useCurrentUserStore'; import { LineagePlatformView, LineageTimeRange, } from '../../../hooks/lineage/types'; +import { useFullScreenBounds } from '../../../hooks/lineage/useFullScreenBounds'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import { useDomainStore } from '../../../hooks/useDomainStore'; @@ -90,7 +88,6 @@ import { exportLineageAsync, getDataQualityLineage, getLineageDataByFQN, - getPlatformLineage, updateLineageEdge, } from '../../../rest/lineageAPI'; import { drawEdgesForExport } from '../../../utils/CanvasUtils'; @@ -116,8 +113,6 @@ import { import { createNodes, getConnectedNodesEdges, - getEntityTypeFromPlatformView, - getNodeLineageData, getUpstreamDownstreamNodesEdges, removeUnconnectedNodes, } from '../../../utils/EntityLineageNodeUtils'; @@ -125,6 +120,7 @@ import { addLineageHandler, parseLineageData, removeLineageHandler, + withoutLineageEdges, } from '../../../utils/EntityLineagePureUtils'; import { updateNodeType } from '../../../utils/EntityPureUtils'; import { getEntityReferenceFromEntity } from '../../../utils/EntityReferenceUtils'; @@ -136,11 +132,8 @@ import { showErrorToast } from '../../../utils/ToastUtils'; import { useEntityExportModalProvider } from '../../Entity/EntityExportModalProvider/EntityExportModalProvider.component'; import '../../Entity/EntityLineage/entity-lineage.style.less'; import { LineageConfig } from '../../Entity/EntityLineage/EntityLineage.interface'; -import EntityLineageSidebar from '../../Entity/EntityLineage/EntityLineageSidebar.component'; -import NodeSuggestions from '../../Entity/EntityLineage/NodeSuggestions.component'; import { SourceType } from '../../SearchedData/SearchedData.interface'; import { getRealEntityRef } from '../LineageMap/LineageMapEdit.utils'; -import LineageNodeRemoveButton from '../LineageNodeRemoveButton'; import { LineageOverlays, LineageOverlaysHandlers, @@ -479,15 +472,15 @@ export const Lineage = ({ const { isTourOpen, isTourPage, tourMockDatasetData } = useTourProvider(); const { appPreferences } = useApplicationStore(); const { activeDomain, isDomainRestricted } = useDomainStore(); - const { preferences } = useCurrentUserPreferences(); const defaultLineageConfig = appPreferences?.lineageConfig as LineageSettings; const isLineageSettingsLoaded = !isUndefined(defaultLineageConfig); - const [reactFlowInstance] = useState(); + const [reactFlowInstance, setReactFlowInstance] = + useState(); const reactFlowInstanceRef = useRef(); const lastFetchedLineageKeyRef = useRef(); + const lineageFetchIdRef = useRef(0); const { - isEditMode, lineageConfig, setLineageConfig, tracedColumns, @@ -545,21 +538,20 @@ export const Lineage = ({ removeEdgeById, removeEdgesBySourceTarget, removeEdgesByDocId, - addNodes, updateEdge, + onNodesChange, } = useMapBasedNodesEdges([], []); const [loading, setLoading] = useState(true); const [init, setInit] = useState(false); const [status, setStatus] = useState('initial'); - const [newAddedNode, setNewAddedNode] = useState({} as Node); const selectedQuickFilters = useLineageStore((s) => s.selectedQuickFilters); const [entityType, setEntityType] = useState( entityTypeProp ); const queryParams = new URLSearchParams(location.search); const isFullScreen = queryParams.get(FULLSCREEN_QUERY_PARAM_KEY) === 'true'; - const deletePressed = useKeyPress('Delete'); - const backspacePressed = useKeyPress('Backspace'); + const { ref: fullScreenRef, bounds: fullScreenBounds } = + useFullScreenBounds(isFullScreen); const { showModal } = useEntityExportModalProvider(); const [dqHighlightedEdges, setDqHighlightedEdges] = useState>(); @@ -894,52 +886,26 @@ export const Lineage = ({ [redrawLineage] ); - const fetchPlatformLineage = useCallback( - async (view: string, config?: LineageConfig) => { - try { - setLoading(true); - setInit(false); - const res = await getPlatformLineage({ - config, - view, - }); - - setLineageData(res); - - const { nodes, edges, entity } = parseLineageData( - res, - '', - entityFqn, - config?.pipelineViewMode - ); - const updatedEntityLineage = { - nodes, - edges, - entity, - }; - - setEntityLineage(updatedEntityLineage); - } catch (err) { - showErrorToast( - err as AxiosError, - t('server.entity-fetch-error', { - entity: t('label.lineage-data-lowercase'), - }) - ); - } finally { - setInit(true); - setLoading(false); - } - }, - [entityFqn, t] - ); - const fetchLineageData = useCallback( async (fqn: string, entityType: string, config?: LineageConfig) => { if (isTourOpen || lineageMode === 'impact_analysis') { return; } + // Claim the key before the request so a second trigger for the same + // graph while this one is in flight does not fetch it again. A late + // duplicate response redraws with the layers captured at its call and + // wipes column rows the user has switched on since. + lastFetchedLineageKeyRef.current = getLineageFetchKey( + fqn, + entityType, + config, + queryFilter, + timeFilter + ); + const fetchId = ++lineageFetchIdRef.current; + const isSuperseded = () => fetchId !== lineageFetchIdRef.current; + setLoading(true); setInit(false); @@ -955,13 +921,9 @@ export const Lineage = ({ startTime: timeFilter.startTime, endTime: timeFilter.endTime, }); - lastFetchedLineageKeyRef.current = getLineageFetchKey( - fqn, - entityType, - config, - queryFilter, - timeFilter - ); + if (isSuperseded()) { + return; + } setLineageData(res); const { nodes, edges, entity } = parseLineageData( @@ -979,6 +941,10 @@ export const Lineage = ({ setEntityLineage(updatedEntityLineage); await redrawLineage(updatedEntityLineage, true); } catch (err) { + if (isSuperseded()) { + return; + } + lastFetchedLineageKeyRef.current = undefined; showErrorToast( err as AxiosError, t('server.entity-fetch-error', { @@ -986,8 +952,10 @@ export const Lineage = ({ }) ); } finally { - setInit(true); - setLoading(false); + if (!isSuperseded()) { + setInit(true); + setLoading(false); + } } }, [ @@ -1456,27 +1424,21 @@ export const Lineage = ({ const edgesToRemove = getConnectedEdges([node as Node], nodeEdges); - const filteredEdges: EdgeDetails[] = []; - - await Promise.all( + const removedEdges = await Promise.all( edgesToRemove.map(async (edge) => { const edgeData = getEdgeDataFromEdge(edge); await removeLineageHandler(edgeData); - - filteredEdges.push( - ...(entityLineage.edges ?? []).filter( - (item) => - !( - item.fromEntity.id === edgeData.fromId && - item.toEntity.id === edgeData.toId - ) - ) - ); - removeEdgesBySourceTarget(edgeData.fromId, edgeData.toId); + + return edgeData; }) ); + const filteredEdges = withoutLineageEdges( + entityLineage.edges ?? [], + removedEdges + ); + const updatedNodes = (entityLineage.nodes ?? []).filter( (previousNode) => previousNode.id !== node.id ); @@ -1490,100 +1452,10 @@ export const Lineage = ({ nodes: updatedNodes, }; }); - - setNewAddedNode({} as Node); }, [entityLineage, edges, removeNodeById, removeEdgesBySourceTarget] ); - const onEntitySelect = useCallback( - (selectedEntity: EntityReference, nodeId: string) => { - const isExistingNode = nodes.some( - (n) => - n.data.node.fullyQualifiedName === selectedEntity.fullyQualifiedName - ); - if (isExistingNode) { - setNodes((es) => - es - .map((n) => - n.id.includes(nodeId) - ? { - ...n, - selectable: true, - className: `${n.className} selected`, - } - : n - ) - .filter((es) => es.id !== nodeId) - ); - setNewAddedNode({} as Node); - } else { - setNodes((es) => { - return es.map((el) => { - if (el.id === nodeId) { - return { - ...el, - connectable: true, - selectable: true, - id: selectedEntity.id, - data: { - saved: false, - node: getNodeLineageData(selectedEntity), - }, - }; - } else { - return el; - } - }); - }); - } - }, - [nodes, setNodes] - ); - - const onNodeDrop = useCallback( - (event: DragEvent, reactFlowBounds: DOMRect) => { - event.preventDefault(); - const entityType = event.dataTransfer.getData('application/reactflow'); - if (entityType) { - const position = reactFlowInstance?.project({ - x: event.clientX - (reactFlowBounds?.left ?? 0), - y: event.clientY - (reactFlowBounds?.top ?? 0), - }); - const nodeId = uniqueId(); - const newNode = { - id: nodeId, - nodeType: EntityLineageNodeType.DEFAULT, - position, - className: '', - connectable: false, - selectable: false, - type: EntityLineageNodeType.DEFAULT, - data: { - label: ( - <> - removeNodeHandler(newNode as Node)} - /> - - onEntitySelect(value, nodeId)} - /> - - ), - isEditMode, - isNewNode: true, - }, - }; - addNodes([newNode as Node]); - - setNewAddedNode(newNode as Node); - } - }, - [addNodes, isEditMode, onEntitySelect, reactFlowInstance, removeNodeHandler] - ); - const selectLoadMoreNode = useCallback( async (node: Node) => { const { pagination_data, direction } = node.data.node; @@ -1764,10 +1636,9 @@ export const Lineage = ({ await removeEdgeHandler(selectedEdge as Edge, true); } - // Close the modal and drop the selection in the same batch so the - // floating edit/delete button in EdgeInteractionOverlay unmounts - // right after removal. Doing this here (rather than inside the - // handlers) keeps `selectedEdge` populated while the confirmation + // Close the modal and drop the selection in the same batch. Doing + // this here (rather than inside the handlers) keeps `selectedEdge` + // populated while the confirmation // modal is still mounted — getModalBodyText() destructures it // during render and would crash the tree if `selectedEdge` were // cleared before the modal unmounts. @@ -1888,8 +1759,6 @@ export const Lineage = ({ ); setEdges(createdEdges); setColumnsHavingLineage(columnsHavingLineage); - - setNewAddedNode({} as Node); }) .catch((err) => { showErrorToast(err); @@ -2136,7 +2005,9 @@ export const Lineage = ({ }, [isColumnLevelLineage]); const onPlatformViewUpdate = useCallback(() => { - if (lineageMode === 'impact_analysis') { + // The main Lineage page is drawn from the scene API by LineageMap; this + // provider only loads the classic lineage graph for asset pages. + if (lineageMode === 'impact_analysis' || isPlatformLineage) { return; } @@ -2174,15 +2045,6 @@ export const Lineage = ({ lineageConfig ); } - - return; - } - - if (isPlatformLineage) { - fetchPlatformLineage( - getEntityTypeFromPlatformView(platformView), - lineageConfig - ); } }, [ lineageMode, @@ -2195,7 +2057,6 @@ export const Lineage = ({ lineageConfig, queryFilter, timeFilter, - fetchPlatformLineage, ]); useEffect(() => { @@ -2215,8 +2076,7 @@ export const Lineage = ({ }, [defaultLineageConfig, setActiveLayer, setLineageConfig]); useEffect(() => { - if (!isEditMode && updatedEntityLineage !== null) { - // On exit of edit mode, use updatedEntityLineage and update data. + if (updatedEntityLineage !== null) { const { downstreamEdges, upstreamEdges } = getUpstreamDownstreamNodesEdges( updatedEntityLineage.edges ?? [], @@ -2238,33 +2098,7 @@ export const Lineage = ({ nodes: updatedNodes, }); } - }, [isEditMode, updatedEntityLineage, entityFqn]); - - useEffect(() => { - if (isEditMode) { - setUpdatedEntityLineage(null); - if (deletePressed || backspacePressed) { - if (activeNode) { - removeNodeHandler(activeNode); - } else if (selectedEdge) { - if (selectedEdge.data?.isColumnLineage) { - removeColumnEdge(selectedEdge, true); - } else { - removeEdgeHandler(selectedEdge, true); - } - } - } - } - }, [ - isEditMode, - deletePressed, - backspacePressed, - activeNode, - selectedEdge, - removeColumnEdge, - removeEdgeHandler, - removeNodeHandler, - ]); + }, [updatedEntityLineage, entityFqn]); useEffect(() => { if (reactFlowInstance?.viewportInitialized) { @@ -2367,30 +2201,45 @@ export const Lineage = ({ }); }, [dataQualityLineage, dqHighlightedEdges]); + const onInitReactFlow = useCallback((instance: ReactFlowInstance) => { + reactFlowInstanceRef.current = instance; + setReactFlowInstance(instance); + useLineageStore.getState().setReactFlowInstance(instance); + }, []); + + const refetchLineage = useCallback(() => { + lastFetchedLineageKeyRef.current = undefined; + onPlatformViewUpdateRef.current(); + }, []); + const handlers = useMemo( () => ({ loadChildNodesHandler, removeNodeHandler, onNodeClick, - onNodeDrop, onNodeCollapse, onConnect, onEdgeDetailsUpdate, updateEntityData, handleEntityUpdate, onExportClick, + onInitReactFlow, + onNodesChange, + refetchLineage, }), [ loadChildNodesHandler, removeNodeHandler, onNodeClick, - onNodeDrop, onNodeCollapse, onConnect, onEdgeDetailsUpdate, updateEntityData, handleEntityUpdate, onExportClick, + onInitReactFlow, + onNodesChange, + refetchLineage, ] ); @@ -2418,11 +2267,10 @@ export const Lineage = ({
+ })} + ref={fullScreenRef} + style={fullScreenBounds}> {children} -
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/LineageHandlersContext.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/LineageHandlersContext.tsx index 46a0ad466659..64fec95e0940 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/LineageHandlersContext.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/Lineage/LineageHandlersContext.tsx @@ -10,8 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createContext, DragEvent, useContext } from 'react'; -import type { Connection, Edge, Node, NodeProps } from 'reactflow'; +import { createContext, useContext } from 'react'; +import type { + Connection, + Edge, + Node, + NodeProps, + OnNodesChange, + ReactFlowInstance, +} from 'reactflow'; import { ExportTypes } from '../../../constants/Export.constants'; import { EntityType } from '../../../enums/entity.enum'; import { AddLineage } from '../../../generated/api/lineage/addLineage'; @@ -33,7 +40,6 @@ export type LineageHandlersValue = { ) => Promise; removeNodeHandler: (node: Node | NodeProps) => Promise; onNodeClick: (node: Node) => void; - onNodeDrop: (event: DragEvent, reactFlowBounds: DOMRect) => void; onNodeCollapse: (node: Node | NodeProps, direction: LineageDirection) => void; onConnect: (connection: Edge | Connection) => void; onEdgeDetailsUpdate: (updatedEdgeDetails: AddLineage) => Promise; @@ -47,6 +53,9 @@ export type LineageHandlersValue = { exportTypes?: ExportTypes[], onExportCallback?: (_: string) => Promise ) => void; + onInitReactFlow: (instance: ReactFlowInstance) => void; + onNodesChange: OnNodesChange; + refetchLineage: () => void; }; const noop = () => undefined; @@ -56,13 +65,15 @@ export const LineageHandlersContext = createContext({ loadChildNodesHandler: noopAsync, removeNodeHandler: noopAsync, onNodeClick: noop, - onNodeDrop: noop, onNodeCollapse: noop, onConnect: noop, onEdgeDetailsUpdate: noopAsync, updateEntityData: noop, handleEntityUpdate: noop, onExportClick: noop, + onInitReactFlow: noop, + onNodesChange: noop, + refetchLineage: noop, }); export const useLineageHandlers = () => useContext(LineageHandlersContext); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.interface.ts similarity index 66% rename from openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.interface.ts rename to openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.interface.ts index a5c663f279b1..31d59ce45d8f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.interface.ts @@ -1,5 +1,5 @@ /* - * Copyright 2025 Collate. + * 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 @@ -10,11 +10,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EntityReference } from '../../../../generated/entity/type'; +import { RefObject } from 'react'; +import { LineageDirection } from '../../../generated/api/lineage/lineageDirection'; -export interface EntitySuggestionOptionProps { - entity: EntityReference; - heading?: string; - className?: string; - showEntityTypeBadge?: boolean; +export interface LineageColumnMenuProps { + onEdit: ( + direction: LineageDirection, + triggerRef: RefObject + ) => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.test.tsx new file mode 100644 index 000000000000..5925c0068526 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.test.tsx @@ -0,0 +1,83 @@ +/* + * 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 { LineageDirection } from '../../../generated/api/lineage/lineageDirection'; +import LineageColumnMenu from './LineageColumnMenu'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe('LineageColumnMenu', () => { + const props = { onEdit: jest.fn() }; + + it('edits upstream and downstream', async () => { + render(); + fireEvent.click(screen.getByTestId('lineage-column-menu')); + fireEvent.click(await screen.findByText('label.edit-upstream')); + + expect(props.onEdit).toHaveBeenCalledWith( + LineageDirection.Upstream, + expect.anything() + ); + + fireEvent.click(screen.getByTestId('lineage-column-menu')); + fireEvent.click(await screen.findByText('label.edit-downstream')); + + expect(props.onEdit).toHaveBeenCalledWith( + LineageDirection.Downstream, + expect.anything() + ); + }); + + it('anchors the popover trigger ref to a real, mounted element', async () => { + const onEdit = jest.fn(); + render(); + fireEvent.click(screen.getByTestId('lineage-column-menu')); + fireEvent.click(await screen.findByText('label.edit-upstream')); + + const triggerRef = onEdit.mock.calls[0][1]; + + expect(triggerRef.current).toBeInstanceOf(HTMLElement); + expect( + triggerRef.current.contains(screen.getByTestId('lineage-column-menu')) + ).toBe(true); + }); + + it('does not render a delete item', async () => { + render(); + fireEvent.click(screen.getByTestId('lineage-column-menu')); + + await screen.findByText('label.edit-upstream'); + + expect(screen.queryByText('label.delete')).not.toBeInTheDocument(); + }); + + it('does not propagate trigger or menu clicks to the column row', async () => { + const onParentClick = jest.fn(); + const onParentMouseDown = jest.fn(); + render( + // stand-in for the column row, which selects the column on click + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
+ +
+ ); + fireEvent.mouseDown(screen.getByTestId('lineage-column-menu')); + fireEvent.click(screen.getByTestId('lineage-column-menu')); + fireEvent.click(await screen.findByText('label.edit-upstream')); + + expect(onParentMouseDown).not.toHaveBeenCalled(); + expect(onParentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.tsx new file mode 100644 index 000000000000..085daeb3b104 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageColumnMenu/LineageColumnMenu.tsx @@ -0,0 +1,58 @@ +/* + * 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 { Dropdown } from '@openmetadata/ui-core-components'; +import { ArrowLeft, ArrowRight } from '@openmetadata/ui-core-components/icons'; +import { RefObject, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LineageDirection } from '../../../generated/api/lineage/lineageDirection'; +import { LineageColumnMenuProps } from './LineageColumnMenu.interface'; + +const LineageColumnMenu = ({ onEdit }: LineageColumnMenuProps) => { + const { t } = useTranslation(); + // The popover for editing lineage anchors to the ⋮ button. + const triggerRef = useRef(null); + + return ( + + + + { + onEdit( + key === 'upstream' + ? LineageDirection.Upstream + : LineageDirection.Downstream, + triggerRef as RefObject + ); + }}> + + {t('label.edit-upstream')} + + + {t('label.edit-downstream')} + + + + + ); +}; + +export default LineageColumnMenu; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageMap/LineageMap.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageMap/LineageMap.component.tsx index 3b484fd49ccf..ab06effe7f95 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageMap/LineageMap.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Lineage/LineageMap/LineageMap.component.tsx @@ -13,7 +13,7 @@ import { Alert, - Badge, + Box, Breadcrumbs, Button, ButtonUtility, @@ -25,27 +25,20 @@ import { import { ArrowsUp, Home02, + InfoCircle, LayersThree01, } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; import classNames from 'classnames'; import { CookieStorage } from 'cookie-storage'; import type { LayoutOptions } from 'elkjs/lib/elk.bundled.js'; -import { debounce, uniqueId } from 'lodash'; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react'; +import { debounce } from 'lodash'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import ReactFlow, { applyNodeChanges, Background, - Connection, Edge, MiniMap, Node, @@ -72,14 +65,12 @@ import { EntityLineageNodeType, EntityType } from '../../../enums/entity.enum'; import { LineageBand, LineageLens, - LineageLevelKind, LineageScene, LineageSceneBreadcrumb, LineageSceneEdge, LineageSceneNode, } from '../../../generated/api/lineage/lineageScene'; import { PipelineViewMode } from '../../../generated/configuration/lineageSettings'; -import { EntityReference } from '../../../generated/entity/type'; import { LineageLayer } from '../../../generated/settings/settings'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import { useDomainStore } from '../../../hooks/useDomainStore'; @@ -98,16 +89,8 @@ import { getLineageEdgeDetails, getLineageScene, } from '../../../rest/lineageAPI'; -import { - addLineageHandler, - removeLineageHandler, -} from '../../../utils/EntityLineagePureUtils'; import { getQuickFilterQuery } from '../../../utils/ExplorePureUtils'; -import { - onAddPipelineClick, - onColumnEdgeRemove, - onEdgeClick, -} from '../../../utils/Lineage/handlers/edgeMutations'; +import { onEdgeClick } from '../../../utils/Lineage/handlers/edgeMutations'; import { onPaneClick } from '../../../utils/Lineage/handlers/nodeMutations'; import ELKLayout from '../../../utils/Lineage/Layout/ELKUtil/ELKUtil'; import { showErrorToast, showInfoToast } from '../../../utils/ToastUtils'; @@ -116,71 +99,85 @@ import Loader from '../../common/Loader/Loader'; import CustomNodeV1 from '../../Entity/EntityLineage/CustomNodeV1.component'; import LineageControlButtons from '../../Entity/EntityLineage/LineageControlButtons/LineageControlButtons'; import LineageLayers from '../../Entity/EntityLineage/LineageLayers/LineageLayers'; -import NodeSuggestions from '../../Entity/EntityLineage/NodeSuggestions.component'; import { SourceType } from '../../SearchedData/SearchedData.interface'; +import { LineageEditRequest } from '../AddLineagePopover/AddLineagePopover.interface'; import { CanvasLayerWrapper } from '../Edges/CanvasLayerWrapper/CanvasLayerWrapper'; import { LineageProps } from '../Lineage.interface'; import { useLineageHandlers } from '../Lineage/LineageHandlersContext'; -import LineageNodeRemoveButton from '../LineageNodeRemoveButton'; import LineageSkeleton from '../LineageSkeleton.component'; -import type { LineageSceneRequest } from './LineageMap.utils'; import { buildLineagePathHighlightIndex, - getBandLabelKey, getBreadcrumbSceneRequest, getConnectedFieldLineagePathHighlight, getConnectedLineagePathHighlight, getDrillBand, getLensRootLabelKey, getParentSceneRequest, + getRailStop, + getRailStopLabelKey, getSceneFocus, getSceneLandingViewport, getSceneLevelLabelKey, - getSceneNodeCountSubtitle, + getSceneNodeCount, + getSceneNodeTypeSubtitle, getSceneOriginFocus, getSceneRequestFromSearch, getSceneSearch, + getSemanticZoomDirection, + isContainerSceneNode, + LINEAGE_MAP_RAIL_STOPS, + type LineageSceneRequest, } from './LineageMap.utils'; import { - buildConnectPayload, getEndpointHandle, getEndpointNodeId, getRealEntityRef, - hasSceneEntityConnection, hydrateSelectedEdge, isEditableSceneEdge, - isEditableSceneNode, - isRemovableSceneNode, toFlowEdges, type LineageMapEdgeData, } from './LineageMapEdit.utils'; import { LineageSceneCache } from './LineageSceneCache.utils'; +type ZoomGestureEvent = MouseEvent | TouchEvent; + const LINEAGE_MAP_ONBOARDING_COOKIE = 'lineageMapsOnboardingSeen'; -const ZOOM_IN_THRESHOLD = 1.9; -const ZOOM_OUT_THRESHOLD = 0.5; const SEMANTIC_ZOOM_COOLDOWN = 450; const PROGRAMMATIC_ZOOM_SUPPRESSION_MS = 1200; const SCENE_MUTATION_MAX_ATTEMPTS = 5; const SCENE_MUTATION_RETRY_DELAY_MS = 500; const MAX_SCENE_DEPTH = 3; const CONTROL_INSET_PADDING = 0.2; +// The lowest zoom a scene still reads at; a scene that would fit only below it +// opens there on its focus node instead (getSceneLandingViewport). const SCENE_LAYER_FIT_VIEW_MIN_ZOOM = MIN_ZOOM_VALUE; const SCENE_ASSET_FIT_VIEW_MIN_ZOOM = 0.55; const SCENE_FIELD_FIT_VIEW_MIN_ZOOM = 0.9; const SCENE_FIT_VIEW_MAX_ZOOM = 1; -const SCENE_LAYER_NODE_WIDTH = 262; +// Containers carry a name, a subtitle and a count pill, so they take one width +// in every band; assets keep the wider default for their column lists. +const CONTAINER_NODE_WIDTH = 340; const SCENE_LAYER_NODE_HEIGHT = 66; const LINEAGE_MAP_EMPTY_CLASSES = 'lineage-map-empty tw:absolute tw:inset-0 tw:z-1 tw:grid tw:place-items-center tw:bg-transparent tw:text-tertiary'; const LINEAGE_MAP_RAIL_CLASSES = [ - 'lineage-map-rail tw:absolute tw:top-1/2 tw:right-6 tw:z-10 tw:flex tw:w-8 tw:-translate-y-1/2', - 'tw:flex-col tw:items-center tw:rounded-full tw:border tw:border-secondary tw:bg-primary tw:py-1.5 tw:shadow-lg', + 'lineage-map-rail tw:group tw:absolute tw:right-6 tw:z-10 tw:flex tw:-translate-y-1/2', + 'tw:items-stretch tw:gap-2', +].join(' '); +const LINEAGE_MAP_RAIL_TRACK_CLASSES = [ + 'tw:relative tw:flex tw:h-[138px] tw:w-[30px] tw:flex-col tw:items-center tw:justify-between tw:py-1', + 'tw:rounded-full tw:border tw:border-secondary tw:bg-surface tw:shadow-sm', ].join(' '); +// Hidden labels slide in while the rail is hovered or holds keyboard focus; +// a mouse click's lingering focus does not keep them open. const LINEAGE_MAP_RAIL_LABEL_CLASSES = [ - 'lineage-map-rail-label tw:absolute tw:right-10 tw:max-w-[164px] tw:whitespace-nowrap tw:rounded-full', - 'tw:border tw:border-brand tw:bg-primary tw:px-2.5 tw:py-1 tw:text-sm tw:font-semibold tw:leading-normal', - 'tw:text-brand-tertiary', + 'lineage-map-rail-label tw:h-auto! tw:whitespace-nowrap tw:rounded-full! tw:px-2.5! tw:py-0.5!', + 'tw:text-xs tw:shadow-xs tw:transition-[opacity,transform] tw:duration-150', +].join(' '); +const LINEAGE_MAP_RAIL_LABEL_HIDDEN_CLASSES = [ + 'tw:pointer-events-none tw:translate-x-1.5 tw:opacity-0', + 'tw:group-hover:pointer-events-auto tw:group-hover:translate-x-0 tw:group-hover:opacity-100', + 'tw:group-has-[:focus-visible]:pointer-events-auto tw:group-has-[:focus-visible]:translate-x-0 tw:group-has-[:focus-visible]:opacity-100', ].join(' '); const FIELD_NODE_HEIGHT = NODE_HEIGHT_WITH_CHILDREN + @@ -228,7 +225,7 @@ interface SceneFlowNodeData { sceneNode: LineageSceneNode; sceneBand: LineageBand; nodeWidth: number; - onSceneDrill: (node: LineageSceneNode) => void; + onSceneDrill?: (node: LineageSceneNode) => void; onSceneNodeSelect?: (nodeId: string) => void; sceneDrillLabel: string; isRootNode: boolean; @@ -239,10 +236,11 @@ interface SceneFlowNodeData { isPathHighlighted?: boolean; onSceneColumnHover?: (columnFqn?: string) => void; onSceneColumnSelect?: (columnFqn?: string) => void; - isNewNode?: boolean; isNodeRemovable?: boolean; - label?: ReactNode; + isNodeEditable?: boolean; onSceneNodeRemove?: (node: { id: string }) => void; + onSceneLineageEdit?: (request: LineageEditRequest) => void; + onSceneShowFields?: () => void; } interface SceneNodeBounds { @@ -275,18 +273,21 @@ const getNodeHeight = ( if (sceneBand === LineageBand.Layer) { return SCENE_LAYER_NODE_HEIGHT; } + if ( + sceneBand === LineageBand.Asset && + !isContainerSceneNode(node) && + (node.childrenCount ?? 0) > 0 + ) { + return NODE_HEIGHT_WITH_CHILDREN; + } return sceneBand === LineageBand.Field && (node.fields ?? []).length > 0 ? FIELD_NODE_HEIGHT : NODE_HEIGHT; }; -const getNodeWidth = ( - node: LineageSceneNode, - sceneBand: LineageBand = node.band -) => { - return sceneBand === LineageBand.Layer ? SCENE_LAYER_NODE_WIDTH : NODE_WIDTH; -}; +const getNodeWidth = (node: LineageSceneNode) => + isContainerSceneNode(node) ? CONTAINER_NODE_WIDTH : NODE_WIDTH; const getSceneNodeBounds = ( flowNodes: Node[], @@ -303,8 +304,7 @@ const getSceneNodeBounds = ( const bounds = selectedNodes.reduce( (nodeBounds, node) => { - const width = - node.width ?? getNodeWidth(node.data.sceneNode, node.data.sceneBand); + const width = node.width ?? getNodeWidth(node.data.sceneNode); const height = node.height ?? getNodeHeight(node.data.sceneNode, node.data.sceneBand); @@ -363,12 +363,11 @@ const getSceneFitViewMinZoom = (band?: LineageBand) => { : SCENE_ASSET_FIT_VIEW_MIN_ZOOM; }; -const getSceneFitViewOptions = ( - band?: LineageBand, - nodeIds?: string[] -): FitViewOptions => ({ +// Fit may zoom out as far as the graph needs: semantic zoom reads screen fill, +// so a small fit zoom no longer switches level by itself. +const getSceneFitViewOptions = (nodeIds?: string[]): FitViewOptions => ({ maxZoom: SCENE_FIT_VIEW_MAX_ZOOM, - minZoom: getSceneFitViewMinZoom(band), + minZoom: MIN_ZOOM_VALUE, nodes: nodeIds?.map((id) => ({ id })), padding: CONTROL_INSET_PADDING, }); @@ -445,7 +444,6 @@ const prefetchSceneBands = ( const fitSceneBounds = ( instance: ReactFlowInstance, bounds: SceneNodeBounds, - band?: LineageBand, landing?: Viewport ) => { window.requestAnimationFrame(() => { @@ -455,13 +453,7 @@ const fitSceneBounds = ( return; } - instance.fitBounds(bounds, getSceneFitViewOptions(band)); - window.requestAnimationFrame(() => { - const minZoom = getSceneFitViewMinZoom(band); - if (instance.getZoom() < minZoom) { - instance.zoomTo(minZoom); - } - }); + instance.fitBounds(bounds, getSceneFitViewOptions()); }); }); }; @@ -485,41 +477,39 @@ const getHydratedSceneEdge = async ( return hydrateSelectedEdge(edge, sceneEdge, nodeById, details); }; -const getExistingEdgeDetails = async (fromId: string, toId: string) => { - try { - return await getLineageEdgeDetails(fromId, toId); - } catch (error) { - if ((error as AxiosError).response?.status !== 404) { - throw error; - } - - return undefined; - } -}; - -const getSceneConnectionHandles = ({ - source, - sourceHandle, - target, - targetHandle, -}: Connection) => ({ - sourceHandle: sourceHandle === source ? undefined : sourceHandle ?? undefined, - targetHandle: targetHandle === target ? undefined : targetHandle ?? undefined, -}); - const getSemanticZoomBand = ( band: LineageBand, previousZoom: number, - zoom: number + zoom: number, + flowNodes: Node[], + screen?: DOMRect ) => { - if (zoom >= ZOOM_IN_THRESHOLD && previousZoom < ZOOM_IN_THRESHOLD) { - return getNextZoomBand(band); + const graph = getSceneNodeBounds(flowNodes); + if (!graph || !screen) { + return band; } - if (zoom <= ZOOM_OUT_THRESHOLD && previousZoom > ZOOM_OUT_THRESHOLD) { - return getPreviousZoomBand(band); + const card = { + width: Math.max( + ...flowNodes.map( + (node) => node.width ?? getNodeWidth(node.data.sceneNode) + ) + ), + height: 0, + }; + const direction = getSemanticZoomDirection({ + previousZoom, + zoom, + graph, + card, + screen, + minZoom: MIN_ZOOM_VALUE, + maxZoom: MAX_ZOOM_VALUE, + }); + if (direction === 'in') { + return getNextZoomBand(band); } - return band; + return direction === 'out' ? getPreviousZoomBand(band) : band; }; const getSceneChildren = (node: LineageSceneNode): EntityChildren => @@ -598,6 +588,9 @@ const getSceneChildrenPatch = ( } }; +const getLeafChildrenCount = (node: LineageSceneNode) => + isContainerSceneNode(node) ? undefined : node.childrenCount; + const toLineageNode = ( node: LineageSceneNode, t: ReturnType['t'] @@ -617,7 +610,9 @@ const toLineageNode = ( type: sourceEntity.type ?? entityType ?? node.levelKind, entityType: entityType as EntityType, deleted: sourceEntity.deleted ?? false, - lineageMapSubtitle: getSceneNodeCountSubtitle(node, t), + lineageMapSubtitle: getSceneNodeTypeSubtitle(node, t), + lineageMapCount: getSceneNodeCount(node, t), + lineageMapChildrenCount: getLeafChildrenCount(node), serviceType: sourceEntity.serviceType ?? node.serviceType, upstreamExpandPerformed: true, downstreamExpandPerformed: true, @@ -660,7 +655,7 @@ const layoutNodes = async ( const layoutedGraph = await ELKLayout.layoutGraph( nodes.map((node) => ({ id: node.id, - width: getNodeWidth(node.data.sceneNode, node.data.sceneBand), + width: getNodeWidth(node.data.sceneNode), height: getNodeHeight(node.data.sceneNode, node.data.sceneBand), })), edges.map((edge) => ({ @@ -723,7 +718,7 @@ const LineageMapOnboardingDialog = ({ {t('message.lineage-map-onboarding-description')}
-
+
-
+
{t('message.lineage-map-onboarding-hint')} @@ -771,73 +766,93 @@ const LineageMapOnboardingDialog = ({ const LineageMapControls = ({ canDrill, - isEditMode, + miniMapVisible, scene, - onBandChange, + onStopSelect, }: { canDrill: boolean; - isEditMode: boolean; + miniMapVisible: boolean; scene: LineageScene; - onBandChange: (band: LineageBand) => void; + onStopSelect: (stop: LineageBand) => void; }) => { const { t } = useTranslation(); - const bandOptions = [LineageBand.Layer, LineageBand.Asset, LineageBand.Field]; + const activeStop = getRailStop(scene); + const stops = LINEAGE_MAP_RAIL_STOPS.map((stop) => ({ + stop, + label: t(getRailStopLabelKey(stop, scene.lens)), + isActive: stop === activeStop, + isDisabled: isDeeperBand(activeStop, stop) && !canDrill, + })); return ( -
-