From 4d79dfee4859e2b8d4aab0d939f24651cb54ab61 Mon Sep 17 00:00:00 2001 From: Akshay Ambekar Date: Thu, 2 Jul 2026 18:37:23 -0700 Subject: [PATCH 1/3] feat(glossary): surface deprecation badge on glossary term tree nodes Adds the deprecation aspect to the childGlossaryTerm fragment and renders the existing DeprecationIcon next to term names in the glossary sidebar tree, following the same pattern already used for tags, domains, and data products. --- .../glossaryV2/GlossaryBrowser/TermItem.tsx | 28 ++++- .../__tests__/TermItem.test.tsx | 100 ++++++++++++++++++ .../src/graphql/glossaryNode.graphql | 3 + 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 datahub-web-react/src/app/glossaryV2/GlossaryBrowser/__tests__/TermItem.test.tsx diff --git a/datahub-web-react/src/app/glossaryV2/GlossaryBrowser/TermItem.tsx b/datahub-web-react/src/app/glossaryV2/GlossaryBrowser/TermItem.tsx index ca814fd58193..93f0586eeb94 100644 --- a/datahub-web-react/src/app/glossaryV2/GlossaryBrowser/TermItem.tsx +++ b/datahub-web-react/src/app/glossaryV2/GlossaryBrowser/TermItem.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { useHistory } from 'react-router-dom'; +import styled from 'styled-components/macro'; +import { DeprecationIcon } from '@app/entityV2/shared/components/styled/DeprecationIcon'; import { useGlossaryEntityData } from '@app/entityV2/shared/GlossaryEntityContext'; import { EDITING_DOCUMENTATION_URL_PARAM } from '@app/entityV2/shared/constants'; import { useGlossaryActiveTabPath } from '@app/entityV2/shared/containers/profile/utils'; @@ -22,6 +24,16 @@ import { EntityType } from '@types'; // Row chrome (RowContainer/LeftContent/IconSlot/Title) lives in `treeRow.styles.ts` // — shared with `NodeItem` so the two leaf-row types stay visually identical. +const DeprecationSlot = styled.span` + display: inline-flex; + align-items: center; + margin-left: 6px; + & svg { + width: 12px; + height: 12px; + } +`; + interface Props { term: ChildGlossaryTermFragment; isSelecting?: boolean; @@ -75,6 +87,17 @@ function TermItem(props: Props) { const displayName = entityRegistry.getDisplayName(term.type, isOnEntityPage ? entityData : term); + // Prefer the profile page's live (post-mutation) deprecation state over the sidebar's own + // fetch when this row is the currently-open entity, mirroring the same isOnEntityPage + // pattern already used above for the display name. + const deprecation = isOnEntityPage ? entityData?.deprecation : term.deprecation; + + const deprecationBadge = deprecation?.deprecated && ( + + + + ); + return ( - {displayName} + + {displayName} + {deprecationBadge} + {isMultiSelected && } diff --git a/datahub-web-react/src/app/glossaryV2/GlossaryBrowser/__tests__/TermItem.test.tsx b/datahub-web-react/src/app/glossaryV2/GlossaryBrowser/__tests__/TermItem.test.tsx new file mode 100644 index 000000000000..e75ec90b0477 --- /dev/null +++ b/datahub-web-react/src/app/glossaryV2/GlossaryBrowser/__tests__/TermItem.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; + +import { useGlossaryEntityData } from '@app/entityV2/shared/GlossaryEntityContext'; +import TermItem from '@app/glossaryV2/GlossaryBrowser/TermItem'; +import themeV2 from '@conf/theme/themeV2'; + +import { EntityType } from '@types'; + +vi.mock('@app/useEntityRegistry', () => ({ + useEntityRegistry: () => ({ + getDisplayName: vi.fn(() => 'Adoptions'), + getEntityUrl: vi.fn(() => '/glossaryTerm/test'), + }), +})); + +vi.mock('@app/entityV2/shared/components/styled/DeprecationIcon', () => ({ + DeprecationIcon: () =>
, +})); + +vi.mock('@app/entityV2/shared/GlossaryEntityContext', () => ({ + useGlossaryEntityData: vi.fn(() => ({ entityData: null })), +})); + +const baseTerm = { + urn: 'urn:li:glossaryTerm:test', + type: EntityType.GlossaryTerm, + name: 'Adoptions', + hierarchicalName: 'Adoptions.Adoptions', + properties: { name: 'Adoptions', description: null }, + domain: null, +} as any; + +const renderTermItem = (term: any) => + render( + + + + + , + ); + +describe('TermItem — deprecation badge', () => { + it('renders deprecation icon when term is deprecated', () => { + renderTermItem({ + ...baseTerm, + deprecation: { deprecated: true, note: 'Replaced', actor: null, decommissionTime: null }, + }); + expect(screen.getByTestId('deprecation-icon')).toBeInTheDocument(); + }); + + it('does not render deprecation icon when deprecated is false', () => { + renderTermItem({ + ...baseTerm, + deprecation: { deprecated: false, note: null, actor: null, decommissionTime: null }, + }); + expect(screen.queryByTestId('deprecation-icon')).not.toBeInTheDocument(); + }); + + it('does not render deprecation icon when deprecation is null', () => { + renderTermItem({ ...baseTerm, deprecation: null }); + expect(screen.queryByTestId('deprecation-icon')).not.toBeInTheDocument(); + }); + + it('does not render deprecation icon when deprecation is absent', () => { + renderTermItem({ ...baseTerm }); + expect(screen.queryByTestId('deprecation-icon')).not.toBeInTheDocument(); + }); +}); + +describe('TermItem — live deprecation state when this row is the open entity', () => { + it('shows the badge from entityData even when the sidebar-fetched term is not deprecated', () => { + vi.mocked(useGlossaryEntityData).mockReturnValueOnce({ + entityData: { urn: baseTerm.urn, deprecation: { deprecated: true, note: '', decommissionTime: null } }, + } as any); + renderTermItem({ ...baseTerm, deprecation: { deprecated: false, note: null, actor: null, decommissionTime: null } }); + expect(screen.getByTestId('deprecation-icon')).toBeInTheDocument(); + }); + + it('clears a stale badge when entityData shows un-deprecated but the sidebar fetch is still deprecated', () => { + vi.mocked(useGlossaryEntityData).mockReturnValueOnce({ + entityData: { urn: baseTerm.urn, deprecation: { deprecated: false, note: null, decommissionTime: null } }, + } as any); + renderTermItem({ ...baseTerm, deprecation: { deprecated: true, note: 'stale', actor: null, decommissionTime: null } }); + expect(screen.queryByTestId('deprecation-icon')).not.toBeInTheDocument(); + }); + + it('falls back to the term prop when entityData belongs to a different entity', () => { + vi.mocked(useGlossaryEntityData).mockReturnValueOnce({ + entityData: { + urn: 'urn:li:glossaryTerm:someOtherTerm', + deprecation: { deprecated: false, note: null, decommissionTime: null }, + }, + } as any); + renderTermItem({ ...baseTerm, deprecation: { deprecated: true, note: null, actor: null, decommissionTime: null } }); + expect(screen.getByTestId('deprecation-icon')).toBeInTheDocument(); + }); +}); diff --git a/datahub-web-react/src/graphql/glossaryNode.graphql b/datahub-web-react/src/graphql/glossaryNode.graphql index d53eadf66cf3..76cba9af99e5 100644 --- a/datahub-web-react/src/graphql/glossaryNode.graphql +++ b/datahub-web-react/src/graphql/glossaryNode.graphql @@ -13,6 +13,9 @@ fragment childGlossaryTerm on GlossaryTerm { domain { ...entityDomain } + deprecation { + ...deprecationFields + } } fragment glossaryNodeFields on GlossaryNode { From 2e60c459b1fbf09208bd5005ae774a325be7ce54 Mon Sep 17 00:00:00 2001 From: Anna Everhart Date: Tue, 7 Jul 2026 15:40:51 -0700 Subject: [PATCH 2/3] style(glossary): align tree badge and migrate deprecation UI to alchemy Align the glossary sidebar deprecation icon with row text, and replace antd/MUI deprecation modal, menu, and toast feedback with alchemy components. Co-authored-by: Cursor --- .../shared/EntityDropdown/EntityDropdown.tsx | 36 +-- .../UpdateDeprecationMenuAction.tsx | 39 +-- .../EntityDropdown/UpdateDeprecationModal.tsx | 237 +++++++++--------- .../glossaryV2/GlossaryBrowser/TermItem.tsx | 21 +- .../__tests__/TermItem.test.tsx | 15 +- 5 files changed, 183 insertions(+), 165 deletions(-) diff --git a/datahub-web-react/src/app/entityV2/shared/EntityDropdown/EntityDropdown.tsx b/datahub-web-react/src/app/entityV2/shared/EntityDropdown/EntityDropdown.tsx index ea49b3902b1e..f0a7b19dfb07 100644 --- a/datahub-web-react/src/app/entityV2/shared/EntityDropdown/EntityDropdown.tsx +++ b/datahub-web-react/src/app/entityV2/shared/EntityDropdown/EntityDropdown.tsx @@ -1,7 +1,7 @@ -import { Menu } from '@components'; -import MoreVertOutlinedIcon from '@mui/icons-material/MoreVertOutlined'; +import { Menu, toast } from '@components'; import { ClockCounterClockwise } from '@phosphor-icons/react/dist/csr/ClockCounterClockwise'; import { Copy } from '@phosphor-icons/react/dist/csr/Copy'; +import { DotsThreeVertical } from '@phosphor-icons/react/dist/csr/DotsThreeVertical'; import { Envelope } from '@phosphor-icons/react/dist/csr/Envelope'; import { FolderOpen } from '@phosphor-icons/react/dist/csr/FolderOpen'; import { FolderPlus } from '@phosphor-icons/react/dist/csr/FolderPlus'; @@ -15,12 +15,11 @@ import { Plus } from '@phosphor-icons/react/dist/csr/Plus'; import { Share } from '@phosphor-icons/react/dist/csr/Share'; import { Trash } from '@phosphor-icons/react/dist/csr/Trash'; import { Warning } from '@phosphor-icons/react/dist/csr/Warning'; -import { message } from 'antd'; import qs from 'query-string'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Redirect, useHistory } from 'react-router'; -import styled, { useTheme } from 'styled-components'; +import { useTheme } from 'styled-components'; import { EventType } from '@app/analytics'; import analytics from '@app/analytics/analytics'; @@ -61,18 +60,6 @@ import DeprecatedIcon from '@images/deprecated-status.svg?react'; // Tab path segment passed to getEntityPath — a route identifier, not user-visible copy. const INCIDENTS_TAB_NAME = 'Incidents'; -const StyledMoreIcon = styled(MoreVertOutlinedIcon)` - &&& { - display: flex; - font-size: 20px; - padding: 2px; - - :hover { - color: ${(p) => p.theme.colors.textBrand}; - } - } -`; - interface Options { hideDeleteMessage?: boolean; skipDeleteWait?: boolean; @@ -144,7 +131,7 @@ const EntityDropdown = (props: Props) => { const [isChangeHistoryOpen, setIsChangeHistoryOpen] = useState(false); const handleUpdateDeprecation = async (deprecatedStatus: boolean) => { - message.loading({ content: tcf('updating') }); + toast.loading(tcf('updating')); try { await updateDeprecation({ variables: { @@ -156,20 +143,17 @@ const EntityDropdown = (props: Props) => { }, }, }); - message.destroy(); - message.success({ content: t('deprecation.updated'), duration: 2 }); + toast.destroy(); + toast.success(t('deprecation.updated'), { duration: 2 }); analytics.event({ type: EventType.SetDeprecation, entityUrns: [urn], deprecated: deprecatedStatus, }); } catch (e: unknown) { - message.destroy(); + toast.destroy(); if (e instanceof Error) { - message.error({ - content: t('deprecation.updateError', { errorMessage: e.message || '' }), - duration: 2, - }); + toast.error(t('deprecation.updateError', { errorMessage: e.message || '' }), { duration: 2 }); } } refetchForEntity?.(); @@ -197,7 +181,7 @@ const EntityDropdown = (props: Props) => { icon: Link, onClick: () => { navigator.clipboard.writeText(pageUrl); - message.info(t('menuItem.copiedUrl'), 1.2); + toast.info(t('menuItem.copiedUrl'), { duration: 1.2 }); }, }); } @@ -516,7 +500,7 @@ const EntityDropdown = (props: Props) => { return ( <> - + {isCreateTermModalVisible && ( { - message.loading({ content: tcf('updating') }); + toast.loading(tcf('updating')); try { await updateDeprecation({ variables: { @@ -34,20 +42,17 @@ export default function UpdateDeprecationMenuAction() { }, }, }); - message.destroy(); - message.success({ content: t('deprecation.updated'), duration: 2 }); + toast.destroy(); + toast.success(t('deprecation.updated'), { duration: 2 }); analytics.event({ type: EventType.SetDeprecation, entityUrns: [urn], deprecated: deprecatedStatus, }); } catch (e: unknown) { - message.destroy(); + toast.destroy(); if (e instanceof Error) { - message.error({ - content: t('deprecation.updateError', { errorMessage: e.message || '' }), - duration: 2, - }); + toast.error(t('deprecation.updateError', { errorMessage: e.message || '' }), { duration: 2 }); } } refetchForEntity?.(); @@ -62,8 +67,12 @@ export default function UpdateDeprecationMenuAction() { : t('deprecation.markUnTooltip', { entityName: entityRegistry.getEntityName(entityType) }) } > - !entityData?.deprecation?.deprecated ? setIsDeprecationModalVisible(true) @@ -71,8 +80,8 @@ export default function UpdateDeprecationMenuAction() { } data-testid="entity-menu-deprecate-button" > - - + + {isDeprecationModalVisible && ( void; - refetch?: () => void; + refetch?: (result?: DeprecationModalResult) => void; zIndexOverride?: number; }; const SCHEMA_FIELD_PREFIX = 'urn:li:schemaField:'; +const FieldGroup = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const ReplacementControls = styled.div` + align-self: flex-start; +`; + export const UpdateDeprecationModal = ({ urns, resourceRefs, onClose, refetch, zIndexOverride }: Props) => { const { t } = useTranslation('entity.shared.entityDropdown'); const { t: tc } = useTranslation('common.actions'); const { t: tcf } = useTranslation('common.feedback'); const { entityWithSchema } = useGetEntityWithSchema(); const schemaMetadata: any = entityWithSchema?.schemaMetadata || undefined; + const entityRegistry = useEntityRegistry(); const [batchUpdateDeprecation] = useBatchUpdateDeprecationMutation(); - const [isReplacementModalVisible, setIsReplacementModalVisible] = React.useState(false); - const [replacementUrn, setReplacementUrn] = React.useState(null); - const entityRegistry = useEntityRegistry(); + const [isReplacementModalVisible, setIsReplacementModalVisible] = useState(false); + const [replacementUrn, setReplacementUrn] = useState(null); + const [note, setNote] = useState(''); + const [decommissionTime, setDecommissionTime] = useState(undefined); const isDeprecatingFields = !!resourceRefs && resourceRefs.length > 0 && resourceRefs[0].subResourceType === SubResourceType.DatasetField; @@ -55,23 +71,16 @@ export const UpdateDeprecationModal = ({ urns, resourceRefs, onClose, refetch, z skip: !replacementUrn || replacementUrn?.startsWith(SCHEMA_FIELD_PREFIX), }); - const [form] = Form.useForm(); - - const handleClose = () => { - form.resetFields(); - onClose(); - }; - - const handleOk = async (formData: any) => { - message.loading({ content: tcf('updating') }); + const handleSubmit = async () => { + toast.loading(tcf('updating')); try { await batchUpdateDeprecation({ variables: { input: { resources: resourceRefs || urns.map((resourceUrn) => ({ resourceUrn })), deprecated: true, - note: formData.note, - decommissionTime: formData.decommissionTime && formData.decommissionTime.unix() * 1000, + note, + decommissionTime: decommissionTime ? decommissionTime.unix() * 1000 : null, replacement: replacementUrn, }, }, @@ -82,136 +91,132 @@ export const UpdateDeprecationModal = ({ urns, resourceRefs, onClose, refetch, z deprecated: true, resources: isDeprecatingFields ? resourceRefs : undefined, }); - message.destroy(); - message.success({ content: t('deprecation.updated'), duration: 2 }); + toast.destroy(); + toast.success(t('deprecation.updated'), { duration: 2 }); } catch (e: unknown) { - message.destroy(); + toast.destroy(); if (e instanceof Error) { - message.error( - handleBatchError(urns, e, { - content: t('deprecation.updateError', { errorMessage: e.message || '' }), - duration: 2, - }), - ); + const fallback = { + content: t('deprecation.updateError', { errorMessage: e.message || '' }), + duration: 2, + }; + const { content, duration } = handleBatchError(urns, e, fallback); + toast.error(content, { duration }); } } - refetch?.(); - handleClose(); + refetch?.({ + note: note || null, + decommissionTime: decommissionTime ? decommissionTime.unix() * 1000 : null, + replacement: replacementData?.entities?.[0] ?? null, + }); + onClose(); }; return ( -
- +