feat(ui): Adding release pop up in disabled state for v0.1.2 (#14066)

Co-authored-by: John Joyce <john@Mac-900.lan>
Co-authored-by: John Joyce <john@Mac-923.lan>
Co-authored-by: John Joyce <john@Mac-1136.lan>
Co-authored-by: John Joyce <john@Mac-2320.lan>
Co-authored-by: John Joyce <john@Mac.lan>
Co-authored-by: John Joyce <john@ip-192-168-1-63.us-west-2.compute.internal>
Co-authored-by: John Joyce <john@Mac-4711.lan>
This commit is contained in:
John Joyce 2025-07-14 18:07:32 -07:00 committed by GitHub
parent 4b764ed55f
commit ff40a94908
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 446 additions and 2 deletions

View File

@ -281,6 +281,7 @@ public class AppConfigResolver implements DataFetcher<CompletableFuture<AppConfi
.setShowIngestionPageRedesign(_featureFlags.isShowIngestionPageRedesign())
.setShowLineageExpandMore(_featureFlags.isShowLineageExpandMore())
.setShowHomePageRedesign(_featureFlags.isShowHomePageRedesign())
.setShowProductUpdates(_featureFlags.isShowProductUpdates())
.setLineageGraphV3(_featureFlags.isLineageGraphV3())
.build();

View File

@ -748,6 +748,11 @@ type FeatureFlagsConfig {
"""
showHomePageRedesign: Boolean!
"""
Whether product updates on the sidebar is enabled. Will go to oss.
"""
showProductUpdates: Boolean!
"""
Enables the redesign of the lineage v2 graph
"""

View File

@ -119,6 +119,7 @@ export enum EventType {
ShowAllVersionsEvent,
HomePageClick,
SearchBarFilter,
ClickProductUpdate,
}
/**
@ -882,6 +883,12 @@ export interface SearchBarFilterEvent extends BaseEvent {
values: string[]; // the values being filtered for
}
export interface ClickProductUpdateEvent extends BaseEvent {
type: EventType.ClickProductUpdate;
id: string;
url: string;
}
/**
* Event consisting of a union of specific event types.
*/
@ -985,4 +992,5 @@ export type Event =
| UnlinkAssetVersionEvent
| ShowAllVersionsEvent
| HomePageClickEvent
| SearchBarFilterEvent;
| SearchBarFilterEvent
| ClickProductUpdateEvent;

View File

@ -10,6 +10,7 @@ import { SearchHeader } from '@app/searchV2/SearchHeader';
import useGoToSearchPage from '@app/searchV2/useGoToSearchPage';
import useQueryAndFiltersFromLocation from '@app/searchV2/useQueryAndFiltersFromLocation';
import { getAutoCompleteInputFromQuickFilter } from '@app/searchV2/utils/filterUtils';
import ProductUpdates from '@app/shared/product/update/ProductUpdates';
import { useAppConfig } from '@app/useAppConfig';
import { useEntityRegistry } from '@app/useEntityRegistry';
import { useShowNavBarRedesign } from '@app/useShowNavBarRedesign';
@ -141,6 +142,7 @@ export const SearchablePage = ({ children, hideSearchBar }: Props) => {
</Navigation>
<Content $isShowNavBarRedesign={isShowNavBarRedesign}>{children}</Content>
</Body>
<ProductUpdates />
</>
);
};

View File

@ -0,0 +1,139 @@
import { Tooltip, colors } from '@components';
import { X } from '@phosphor-icons/react';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import analytics, { EventType } from '@app/analytics';
import {
useDismissProductAnnouncement,
useGetLatestProductAnnouncementData,
useIsProductAnnouncementEnabled,
useIsProductAnnouncementVisible,
} from '@app/shared/product/update/hooks';
const CardWrapper = styled.div`
position: fixed;
bottom: 24px;
left: 16px;
width: 240px;
border-radius: 12px;
box-shadow: 0px 0px 6px 0px #5d668b33;
background: white;
z-index: 1000;
overflow: hidden;
`;
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px 0px 12px;
`;
const Title = styled.h3`
font-size: 14px;
font-family: Mulish;
font-weight: 700;
color: ${colors.gray[600]};
`;
const CloseButton = styled.button`
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
&:hover {
color: #4b5563;
}
`;
const ImageSection = styled.div`
margin: 0px 12px 12px 12px;
`;
const Image = styled.img`
width: 100%;
height: auto;
`;
const Content = styled.div`
padding: 0px 12px 12px 12px;
`;
const Description = styled.p`
font-size: 14px;
color: ${colors.gray[1700]};
`;
const CTA = styled.a`
font-size: 14px;
color: ${colors.primary[500]};
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
`;
const StyledCloseRounded = styled(X)`
font-size: 16px;
`;
export default function ProductUpdates() {
const isFeatureEnabled = useIsProductAnnouncementEnabled();
const latestUpdate = useGetLatestProductAnnouncementData();
const { title, image, description, ctaText, ctaLink } = latestUpdate;
const { visible, refetch } = useIsProductAnnouncementVisible(latestUpdate);
const dismiss = useDismissProductAnnouncement(latestUpdate, refetch);
// Local state to hide immediately on dismiss
const [isLocallyVisible, setIsLocallyVisible] = useState(false);
useEffect(() => {
setIsLocallyVisible(visible);
}, [visible]);
const handleDismiss = () => {
setIsLocallyVisible(false);
dismiss();
};
const trackClick = () => {
analytics.event({
type: EventType.ClickProductUpdate,
id: latestUpdate.id,
url: latestUpdate.ctaLink,
});
};
if (!isFeatureEnabled || !isLocallyVisible || !latestUpdate.enabled) return null;
return (
<CardWrapper>
<Header>
<Title>{title}</Title>
<Tooltip title="Dismiss" placement="right">
<CloseButton onClick={handleDismiss}>
<StyledCloseRounded />
</CloseButton>
</Tooltip>
</Header>
{image && (
<ImageSection>
<Image src={image} alt="" />
</ImageSection>
)}
<Content>
{description && <Description>{description}</Description>}
{ctaText && ctaLink && (
<CTA href={ctaLink} onClick={trackClick} target="_blank" rel="noreferrer noopener">
{ctaText}
</CTA>
)}
</Content>
</CardWrapper>
);
}

View File

@ -0,0 +1,166 @@
import { MockedProvider } from '@apollo/client/testing';
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import * as useUserContextModule from '@app/context/useUserContext';
import {
useGetLatestProductAnnouncementData,
useIsProductAnnouncementEnabled,
useIsProductAnnouncementVisible,
} from '@app/shared/product/update/hooks';
import { latestUpdate } from '@app/shared/product/update/latestUpdate';
import * as useAppConfigModule from '@app/useAppConfig';
import { BatchGetStepStatesDocument } from '@graphql/step.generated';
const STEP_ID = 'urn:li:user:123-product_updates-test';
const TEST_UPDATE = {
enabled: true,
id: 'test',
title: "What's New In DataHub",
description: 'Explore version v0.3.12',
ctaText: 'Read updates',
ctaLink: 'https://docs.yourapp.com/releases/v0.3.12',
};
const BATCH_GET_STEP_STATES_MOCK_PRESENT = {
request: {
query: BatchGetStepStatesDocument,
variables: {
input: {
ids: [STEP_ID],
},
},
},
result: {
data: {
batchGetStepStates: {
results: [
{
id: STEP_ID,
properties: [],
},
],
},
},
},
};
const BATCH_GET_STEP_STATES_MOCK_NOT_PRESENT = {
request: {
query: BatchGetStepStatesDocument,
variables: {
input: {
ids: [STEP_ID],
},
},
},
result: {
data: {
batchGetStepStates: {
results: [],
},
},
},
};
const BATCH_GET_STEP_STATES_MOCK_LOADING = {
request: {
query: BatchGetStepStatesDocument,
variables: {
input: {
ids: [STEP_ID],
},
},
},
result: {
data: undefined,
loading: true,
},
};
describe('product update hooks', () => {
describe('useIsProductAnnouncementEnabled', () => {
it('returns true when feature flag is enabled', () => {
vi.spyOn(useAppConfigModule, 'useAppConfig').mockReturnValue({
config: {
featureFlags: {
showProductUpdates: true,
},
},
} as any);
const { result } = renderHook(() => useIsProductAnnouncementEnabled());
expect(result.current).toBe(true);
});
it('returns false when feature flag is disabled', () => {
vi.spyOn(useAppConfigModule, 'useAppConfig').mockReturnValue({
config: {
featureFlags: {
showProductUpdates: false,
},
},
} as any);
const { result } = renderHook(() => useIsProductAnnouncementEnabled());
expect(result.current).toBe(false);
});
});
describe('useGetLatestProductAnnouncementData', () => {
it('returns latest update object', () => {
const { result } = renderHook(() => useGetLatestProductAnnouncementData());
expect(result.current).toBe(latestUpdate);
});
});
describe('useIsProductAnnouncementVisible', () => {
beforeEach(() => {
vi.spyOn(useUserContextModule, 'useUserContext').mockReturnValue({
user: { urn: 'urn:li:user:123' },
} as any);
});
it('returns visible=false when step state exists', async () => {
const { result } = renderHook(() => useIsProductAnnouncementVisible(TEST_UPDATE), {
wrapper: ({ children }) => (
<MockedProvider mocks={[BATCH_GET_STEP_STATES_MOCK_PRESENT]} addTypename={false}>
{children}
</MockedProvider>
),
});
await waitFor(() => {
expect(result.current.visible).toBe(false);
});
});
it('returns visible=true when step state does not exist', async () => {
const { result } = renderHook(() => useIsProductAnnouncementVisible(TEST_UPDATE), {
wrapper: ({ children }) => (
<MockedProvider mocks={[BATCH_GET_STEP_STATES_MOCK_NOT_PRESENT]} addTypename={false}>
{children}
</MockedProvider>
),
});
await waitFor(() => {
expect(result.current.visible).toBe(true);
});
});
it('returns visible=false when query is loading', () => {
const { result } = renderHook(() => useIsProductAnnouncementVisible(TEST_UPDATE), {
wrapper: ({ children }) => (
<MockedProvider mocks={[BATCH_GET_STEP_STATES_MOCK_LOADING]} addTypename={false}>
{children}
</MockedProvider>
),
});
expect(result.current.visible).toBe(false);
});
});
});

View File

@ -0,0 +1,94 @@
import { useCallback } from 'react';
import { useUserContext } from '@app/context/useUserContext';
import { ProductUpdate, latestUpdate } from '@app/shared/product/update/latestUpdate';
import { useAppConfig } from '@app/useAppConfig';
import { useBatchGetStepStatesQuery, useBatchUpdateStepStatesMutation } from '@graphql/step.generated';
const PRODUCT_UPDATE_STEP_PREFIX = 'product_updates';
function buildProductUpdateStepId(userUrn: string, updateId: string): string {
return `${userUrn}-${PRODUCT_UPDATE_STEP_PREFIX}-${updateId}`;
}
/**
* Determine whether product announcements feature is enabled and viewabled.
*/
export function useIsProductAnnouncementEnabled() {
const appConfig = useAppConfig();
const { showProductUpdates } = appConfig.config.featureFlags;
return showProductUpdates;
}
/**
* Hook to fetch the announcement data (eventually can replace with fetch).
*/
export function useGetLatestProductAnnouncementData() {
return latestUpdate;
}
export type ProductAnnouncementResult = {
visible: boolean;
refetch: () => void;
};
/**
* Hook to check if the announcement should be shown based on dismissal state
*/
export function useIsProductAnnouncementVisible(update: ProductUpdate): ProductAnnouncementResult {
const userUrn = useUserContext()?.user?.urn;
const productUpdateStepId = userUrn ? buildProductUpdateStepId(userUrn, update.id) : null;
const productUpdateStepIds = productUpdateStepId ? [productUpdateStepId] : [];
const { data, loading, error, refetch } = useBatchGetStepStatesQuery({
skip: !userUrn,
variables: { input: { ids: productUpdateStepIds } },
fetchPolicy: 'cache-first',
});
if (loading || error) {
return {
visible: false,
refetch,
};
}
const visible =
(data?.batchGetStepStates?.results &&
!data?.batchGetStepStates?.results?.some((result) => result?.id === productUpdateStepId)) ||
false;
return {
visible,
refetch,
};
}
/**
* Optional helper to dismiss the announcement (can also inline in `onClose`)
*/
export function useDismissProductAnnouncement(update: ProductUpdate, refetch: () => void): () => void {
const userUrn = useUserContext()?.user?.urn;
const productUpdateStepId = userUrn ? buildProductUpdateStepId(userUrn, update.id) : null;
const [batchUpdateStepStates] = useBatchUpdateStepStatesMutation();
return useCallback(() => {
if (!productUpdateStepId) return;
const stepStates = [
{
id: productUpdateStepId,
properties: [],
},
];
batchUpdateStepStates({
variables: { input: { states: stepStates } },
})
.catch((error) => {
console.error('Failed to dismiss product announcement:', error);
})
.finally(() => refetch());
}, [productUpdateStepId, batchUpdateStepStates, refetch]);
}

View File

@ -0,0 +1,24 @@
import SampleImage from '@images/sample-product-update-image.png';
export type ProductUpdate = {
enabled: boolean;
id: string;
title: string;
image?: string;
description?: string;
ctaText: string;
ctaLink: string;
};
// NOTE: This is a place that OSS and Cloud diverge.
/* Important: Change this section to adjust the system announcement shown in the bottom left corner of the product! */
// TODO: Migrate this to be served via an aspect!
export const latestUpdate: ProductUpdate = {
enabled: true,
id: 'v1.2.0', // Very important, when changed it will trigger the announcement to be re-displayed for a user.
title: "What's New In DataHub",
description: 'Explore version v1.2.0',
image: SampleImage, // Import and use image.,
ctaText: 'Read updates',
ctaLink: 'https://docs.datahub.com/docs/releases#v1-2-0',
};

View File

@ -81,6 +81,7 @@ export const DEFAULT_APP_CONFIG = {
showIngestionPageRedesign: false,
showLineageExpandMore: false,
showHomePageRedesign: false,
showProductUpdates: false,
lineageGraphV3: false,
},
chromeExtensionConfig: {

View File

@ -103,6 +103,7 @@ query appConfig {
showIngestionPageRedesign
showLineageExpandMore
showHomePageRedesign
showProductUpdates
lineageGraphV3
}
chromeExtensionConfig {

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -44,4 +44,5 @@ public class FeatureFlags {
private boolean showLineageExpandMore = true;
private boolean showHomePageRedesign = false;
private boolean lineageGraphV3 = true;
private boolean showProductUpdates = false;
}

View File

@ -629,6 +629,7 @@ featureFlags:
showLineageExpandMore: ${SHOW_LINEAGE_EXPAND_MORE:true} # If turned on, show the expand more button (>>) in the lineage graph
showHomePageRedesign: ${SHOW_HOME_PAGE_REDESIGN:false} # If turned on, show the re-designed home page
lineageGraphV3: ${LINEAGE_GRAPH_V3:false} # Enables the redesign of the lineage v2 graph
showProductUpdates: ${SHOW_PRODUCT_UPDATES:true} # Whether to show in-product update popover on new updates.
entityChangeEvents:
enabled: ${ENABLE_ENTITY_CHANGE_EVENTS_HOOK:true}

View File

@ -111,7 +111,8 @@ public enum DataHubUsageEventType {
UPDATE_ASPECT_EVENT("UpdateAspectEvent"),
ENTITY_EVENT("EntityEvent"),
FAILED_LOGIN_EVENT("FailedLogInEvent"),
DELETE_POLICY_EVENT("DeletePolicyEvent");
DELETE_POLICY_EVENT("DeletePolicyEvent"),
CLICK_PRODUCT_UPDATE_EVENT("ClickProductUpdateEvent");
private final String type;