diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java index 3932f135e3fcb6..f4d17b747ad448 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java @@ -2,9 +2,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.datahub.graphql.generated.ProductUpdate; +import com.linkedin.datahub.graphql.generated.ProductUpdateFeature; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -64,23 +67,20 @@ public static ProductUpdate parseProductUpdate( String id = json.get("id").asText(); String title = json.get("title").asText(); - String ctaText = json.has("ctaText") ? json.get("ctaText").asText() : "Learn more"; - String ctaLink = json.has("ctaLink") ? json.get("ctaLink").asText() : ""; - - // Decorate ctaLink with clientId if provided - if (clientId != null && !clientId.trim().isEmpty() && !ctaLink.isEmpty()) { - ctaLink = decorateUrlWithClientId(ctaLink, clientId); - } // Build the ProductUpdate response ProductUpdate productUpdate = new ProductUpdate(); productUpdate.setEnabled(enabled); productUpdate.setId(id); productUpdate.setTitle(title); - productUpdate.setCtaText(ctaText); - productUpdate.setCtaLink(ctaLink); // Optional fields + if (json.has("header")) { + productUpdate.setHeader(json.get("header").asText()); + } + if (json.has("requiredVersion")) { + productUpdate.setRequiredVersion(json.get("requiredVersion").asText()); + } if (json.has("description")) { productUpdate.setDescription(json.get("description").asText()); } @@ -88,9 +88,117 @@ public static ProductUpdate parseProductUpdate( productUpdate.setImage(json.get("image").asText()); } + // Parse primary CTA (new format) - preferred over legacy ctaText/ctaLink + if (json.has("primaryCtaText") && json.has("primaryCtaLink")) { + String primaryCtaText = json.get("primaryCtaText").asText(); + String primaryCtaLink = maybeDecorateUrl(json.get("primaryCtaLink").asText(), clientId); + + productUpdate.setPrimaryCtaText(primaryCtaText); + productUpdate.setPrimaryCtaLink(primaryCtaLink); + } + + // Parse secondary CTA (optional) + if (json.has("secondaryCtaText") && json.has("secondaryCtaLink")) { + String secondaryCtaText = json.get("secondaryCtaText").asText(); + String secondaryCtaLink = maybeDecorateUrl(json.get("secondaryCtaLink").asText(), clientId); + + productUpdate.setSecondaryCtaText(secondaryCtaText); + productUpdate.setSecondaryCtaLink(secondaryCtaLink); + } + + // Parse legacy CTA fields (backward compatibility) + // Only use if primary CTA is not provided + if (!json.has("primaryCtaText") || !json.has("primaryCtaLink")) { + String ctaText = json.has("ctaText") ? json.get("ctaText").asText() : "Learn more"; + String ctaLink = + maybeDecorateUrl(json.has("ctaLink") ? json.get("ctaLink").asText() : "", clientId); + + productUpdate.setCtaText(ctaText); + productUpdate.setCtaLink(ctaLink); + } + + // Parse features array if present + if (json.has("features") && json.get("features").isArray()) { + List features = parseFeatures(json.get("features")); + if (!features.isEmpty()) { + productUpdate.setFeatures(features); + } + } + return productUpdate; } + /** + * Parse features array from JSON. + * + * @param featuresArray JSON array node containing feature objects + * @return List of parsed ProductUpdateFeature objects (may be empty) + */ + @Nonnull + private static List parseFeatures(@Nonnull JsonNode featuresArray) { + List features = new ArrayList<>(); + + for (JsonNode featureNode : featuresArray) { + ProductUpdateFeature feature = parseFeature(featureNode); + if (feature != null) { + features.add(feature); + } + } + + return features; + } + + /** + * Parse a single feature from JSON. + * + * @param featureNode JSON node containing a feature object + * @return Parsed ProductUpdateFeature, or null if parsing fails or required fields are missing + */ + @Nullable + private static ProductUpdateFeature parseFeature(@Nonnull JsonNode featureNode) { + // Validate required fields + if (!featureNode.has("title") || !featureNode.has("description")) { + log.warn("Skipping invalid feature entry: missing required fields (title or description)"); + return null; + } + + try { + ProductUpdateFeature feature = new ProductUpdateFeature(); + feature.setTitle(featureNode.get("title").asText()); + feature.setDescription(featureNode.get("description").asText()); + + // Icon is optional + if (featureNode.has("icon")) { + feature.setIcon(featureNode.get("icon").asText()); + } + + // Availability is optional + if (featureNode.has("availability")) { + feature.setAvailability(featureNode.get("availability").asText()); + } + + return feature; + } catch (Exception e) { + log.warn("Failed to parse feature entry, skipping: {}", e.getMessage()); + return null; + } + } + + /** + * Conditionally decorates a URL with clientId if the clientId is valid and URL is non-empty. + * + * @param url The URL to potentially decorate (may be empty) + * @param clientId The client ID to append (may be null or empty) + * @return The decorated URL if conditions are met, otherwise the original URL + */ + @Nonnull + private static String maybeDecorateUrl(@Nonnull String url, @Nullable String clientId) { + if (clientId != null && !clientId.trim().isEmpty() && !url.isEmpty()) { + return decorateUrlWithClientId(url, clientId); + } + return url; + } + /** * Decorates a URL with a clientId query parameter. * diff --git a/datahub-graphql-core/src/main/resources/app.graphql b/datahub-graphql-core/src/main/resources/app.graphql index f86b13798489bb..4b05caca8b082a 100644 --- a/datahub-graphql-core/src/main/resources/app.graphql +++ b/datahub-graphql-core/src/main/resources/app.graphql @@ -921,6 +921,32 @@ type GlobalHomePageSettings { defaultTemplate: DataHubPageTemplate } +""" +Feature information for a product update +""" +type ProductUpdateFeature { + """ + Title of the feature (subheader) + """ + title: String! + + """ + Description text for the feature (bullet text) + """ + description: String! + + """ + Phosphor icon name (PascalCase, e.g., "Lightning", "Sparkle", "Settings", "Domain") + Optional - if not provided, a default bullet will be shown + """ + icon: String + + """ + Optional availability text (e.g., "Available in DataHub Cloud") + """ + availability: String +} + """ Product update information fetched from remote JSON source """ @@ -941,6 +967,16 @@ type ProductUpdate { """ title: String! + """ + Optional header text (displayed instead of title if provided) + """ + header: String + + """ + Optional minimum required version for this update + """ + requiredVersion: String + """ Optional URL to an image to display with the update """ @@ -952,12 +988,40 @@ type ProductUpdate { description: String """ - Call-to-action button text (e.g., "Read updates") + Primary call-to-action button text (required if primaryCtaLink is provided) + """ + primaryCtaText: String + + """ + Primary call-to-action link URL, with telemetry client ID appended (required if primaryCtaText is provided) + Relative URLs will be prefixed with baseUrl + """ + primaryCtaLink: String + + """ + Secondary call-to-action button text (optional) + """ + secondaryCtaText: String + + """ + Secondary call-to-action link URL, with telemetry client ID appended (optional) + """ + secondaryCtaLink: String + + """ + Call-to-action button text (deprecated, use primaryCtaText instead) + Kept for backward compatibility + """ + ctaText: String + + """ + Call-to-action link URL, with telemetry client ID appended (deprecated, use primaryCtaLink instead) + Kept for backward compatibility """ - ctaText: String! + ctaLink: String """ - Call-to-action link URL, with telemetry client ID appended + Optional list of features (up to 3) to display with icons and descriptions """ - ctaLink: String! + features: [ProductUpdateFeature!] } diff --git a/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx b/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx index 7b4c127da50b0b..8363d16fa5cf14 100644 --- a/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx +++ b/datahub-web-react/src/app/homeV2/layout/navBarRedesign/NavSidebar.tsx @@ -32,6 +32,7 @@ import useSelectedKey from '@app/homeV2/layout/navBarRedesign/useSelectedKey'; import { useShowHomePageRedesign } from '@app/homeV3/context/hooks/useShowHomePageRedesign'; import OnboardingContext from '@app/onboarding/OnboardingContext'; import { useOnboardingTour } from '@app/onboarding/OnboardingTourContext.hooks'; +import { NAV_SIDEBAR_ID, NAV_SIDEBAR_WIDTH_COLLAPSED, NAV_SIDEBAR_WIDTH_EXPANDED } from '@app/shared/constants'; import { useIsHomePage } from '@app/shared/useIsHomePage'; import { useAppConfig, useBusinessAttributesFlag } from '@app/useAppConfig'; import { colors } from '@src/alchemy-components'; @@ -60,7 +61,7 @@ const Content = styled.div<{ isCollapsed: boolean }>` flex-direction: column; padding: 17px 8px 17px 16px; height: 100%; - width: ${(props) => (props.isCollapsed ? '60px' : '264px')}; + width: ${(props) => (props.isCollapsed ? `${NAV_SIDEBAR_WIDTH_COLLAPSED}px` : `${NAV_SIDEBAR_WIDTH_EXPANDED}px`)}; transition: width 250ms ease-in-out; overflow-x: hidden; `; @@ -347,7 +348,7 @@ export const NavSidebar = () => { return ( {renderSvgSelectedGradientForReusingInIcons()} - + {showSkeleton ? ( ) : ( diff --git a/datahub-web-react/src/app/onboarding/WelcomeToDataHubModal.tsx b/datahub-web-react/src/app/onboarding/WelcomeToDataHubModal.tsx index 7429e703aa6396..37814d143bbf6f 100644 --- a/datahub-web-react/src/app/onboarding/WelcomeToDataHubModal.tsx +++ b/datahub-web-react/src/app/onboarding/WelcomeToDataHubModal.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'; import analytics, { EventType } from '@app/analytics'; import { useOnboardingTour } from '@app/onboarding/OnboardingTourContext.hooks'; import { ANT_NOTIFICATION_Z_INDEX } from '@app/shared/constants'; +import { checkShouldSkipWelcomeModal, setSkipWelcomeModal } from '@app/shared/localStorageUtils'; import { LoadingContainer, SlideContainer, @@ -17,7 +18,6 @@ import welcomeModalHomeScreenshot from '@images/welcome-modal-home-screenshot.pn const SLIDE_DURATION_MS = 10000; const DATAHUB_DOCS_URL = 'https://docs.datahub.com/docs/category/features'; const WELCOME_TO_DATAHUB_MODAL_TITLE = 'Welcome to DataHub'; -const SKIP_WELCOME_MODAL_KEY = 'skipWelcomeModal'; interface VideoSources { search: string; @@ -26,10 +26,6 @@ interface VideoSources { aiDocs?: string; } -function checkShouldSkipWelcomeModal() { - return localStorage.getItem(SKIP_WELCOME_MODAL_KEY) === 'true'; -} - export const WelcomeToDataHubModal = () => { const [shouldShow, setShouldShow] = useState(false); const [currentSlide, setCurrentSlide] = useState(0); @@ -147,7 +143,7 @@ export const WelcomeToDataHubModal = () => { closeModalTour(); } else { // Only set localStorage for automatic first-time tours, not manual triggers - localStorage.setItem(SKIP_WELCOME_MODAL_KEY, 'true'); + setSkipWelcomeModal(true); } } diff --git a/datahub-web-react/src/app/shared/constants.ts b/datahub-web-react/src/app/shared/constants.ts index e2e828c0da673b..77e9ba0c751998 100644 --- a/datahub-web-react/src/app/shared/constants.ts +++ b/datahub-web-react/src/app/shared/constants.ts @@ -22,3 +22,12 @@ export const ANT_NOTIFICATION_Z_INDEX = 1010; // S3 folder to store product assets export const PRODUCT_ASSETS_FOLDER = 'product_assets'; + +// LocalStorage keys for dismissal/skip states +export const SKIP_WELCOME_MODAL_KEY = 'skipWelcomeModal'; +export const DISMISSED_PRODUCT_UPDATES_KEY = 'dismissedProductUpdates'; + +// Navigation sidebar +export const NAV_SIDEBAR_ID = 'nav-sidebar'; +export const NAV_SIDEBAR_WIDTH_EXPANDED = 264; +export const NAV_SIDEBAR_WIDTH_COLLAPSED = 60; diff --git a/datahub-web-react/src/app/shared/localStorageUtils.test.ts b/datahub-web-react/src/app/shared/localStorageUtils.test.ts new file mode 100644 index 00000000000000..ddbb325aecd508 --- /dev/null +++ b/datahub-web-react/src/app/shared/localStorageUtils.test.ts @@ -0,0 +1,100 @@ +import { + checkProductUpdateDismissed, + checkShouldSkipWelcomeModal, + dismissProductUpdate, + setSkipWelcomeModal, +} from '@app/shared/localStorageUtils'; + +describe('localStorageUtils', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('checkShouldSkipWelcomeModal', () => { + it('returns false when key is not set', () => { + expect(checkShouldSkipWelcomeModal()).toBe(false); + }); + + it('returns true when key is set to "true"', () => { + localStorage.setItem('skipWelcomeModal', 'true'); + expect(checkShouldSkipWelcomeModal()).toBe(true); + }); + + it('returns false when key is set to "false"', () => { + localStorage.setItem('skipWelcomeModal', 'false'); + expect(checkShouldSkipWelcomeModal()).toBe(false); + }); + + it('returns false when key is set to other value', () => { + localStorage.setItem('skipWelcomeModal', 'something'); + expect(checkShouldSkipWelcomeModal()).toBe(false); + }); + }); + + describe('setSkipWelcomeModal', () => { + it('sets the key to "true" by default', () => { + setSkipWelcomeModal(); + expect(localStorage.getItem('skipWelcomeModal')).toBe('true'); + }); + + it('sets the key to "true" when passed true', () => { + setSkipWelcomeModal(true); + expect(localStorage.getItem('skipWelcomeModal')).toBe('true'); + }); + + it('sets the key to "false" when passed false', () => { + setSkipWelcomeModal(false); + expect(localStorage.getItem('skipWelcomeModal')).toBe('false'); + }); + }); + + describe('checkProductUpdateDismissed', () => { + it('returns false when key is not set', () => { + expect(checkProductUpdateDismissed('v1.0.0')).toBe(false); + }); + + it('returns false when JSON is invalid', () => { + localStorage.setItem('dismissedProductUpdates', 'invalid json'); + expect(checkProductUpdateDismissed('v1.0.0')).toBe(false); + }); + + it('returns false when update ID is not in the list', () => { + localStorage.setItem('dismissedProductUpdates', JSON.stringify(['v1.0.0', 'v2.0.0'])); + expect(checkProductUpdateDismissed('v3.0.0')).toBe(false); + }); + + it('returns true when update ID is in the list', () => { + localStorage.setItem('dismissedProductUpdates', JSON.stringify(['v1.0.0', 'v2.0.0'])); + expect(checkProductUpdateDismissed('v1.0.0')).toBe(true); + }); + }); + + describe('dismissProductUpdate', () => { + it('creates a new array with the update ID', () => { + dismissProductUpdate('v1.0.0'); + const stored = JSON.parse(localStorage.getItem('dismissedProductUpdates') || '[]'); + expect(stored).toEqual(['v1.0.0']); + }); + + it('appends to existing array', () => { + localStorage.setItem('dismissedProductUpdates', JSON.stringify(['v1.0.0'])); + dismissProductUpdate('v2.0.0'); + const stored = JSON.parse(localStorage.getItem('dismissedProductUpdates') || '[]'); + expect(stored).toEqual(['v1.0.0', 'v2.0.0']); + }); + + it('does not add duplicate IDs', () => { + dismissProductUpdate('v1.0.0'); + dismissProductUpdate('v1.0.0'); + const stored = JSON.parse(localStorage.getItem('dismissedProductUpdates') || '[]'); + expect(stored).toEqual(['v1.0.0']); + }); + + it('handles invalid JSON by creating new array', () => { + localStorage.setItem('dismissedProductUpdates', 'invalid json'); + dismissProductUpdate('v1.0.0'); + const stored = JSON.parse(localStorage.getItem('dismissedProductUpdates') || '[]'); + expect(stored).toEqual(['v1.0.0']); + }); + }); +}); diff --git a/datahub-web-react/src/app/shared/localStorageUtils.ts b/datahub-web-react/src/app/shared/localStorageUtils.ts new file mode 100644 index 00000000000000..f2b99e8684eab4 --- /dev/null +++ b/datahub-web-react/src/app/shared/localStorageUtils.ts @@ -0,0 +1,50 @@ +import { DISMISSED_PRODUCT_UPDATES_KEY, SKIP_WELCOME_MODAL_KEY } from '@app/shared/constants'; + +/** + * Check if the welcome modal has been dismissed/skipped by the user + */ +export function checkShouldSkipWelcomeModal(): boolean { + return localStorage.getItem(SKIP_WELCOME_MODAL_KEY) === 'true'; +} + +/** + * Mark the welcome modal as dismissed/skipped + */ +export function setSkipWelcomeModal(skip = true): void { + localStorage.setItem(SKIP_WELCOME_MODAL_KEY, String(skip)); +} + +/** + * Check if a specific product update has been dismissed + * @deprecated Use server-side step state instead (see useIsProductAnnouncementVisible) + */ +export function checkProductUpdateDismissed(updateId: string): boolean { + const dismissedUpdates = localStorage.getItem(DISMISSED_PRODUCT_UPDATES_KEY); + if (!dismissedUpdates) return false; + try { + const dismissed: string[] = JSON.parse(dismissedUpdates); + return dismissed.includes(updateId); + } catch { + return false; + } +} + +/** + * Mark a product update as dismissed + * @deprecated Use server-side step state instead (see useDismissProductAnnouncement) + */ +export function dismissProductUpdate(updateId: string): void { + const dismissedUpdates = localStorage.getItem(DISMISSED_PRODUCT_UPDATES_KEY); + let dismissed: string[] = []; + if (dismissedUpdates) { + try { + dismissed = JSON.parse(dismissedUpdates); + } catch { + dismissed = []; + } + } + if (!dismissed.includes(updateId)) { + dismissed.push(updateId); + localStorage.setItem(DISMISSED_PRODUCT_UPDATES_KEY, JSON.stringify(dismissed)); + } +} diff --git a/datahub-web-react/src/app/shared/product/update/ProductUpdates.components.tsx b/datahub-web-react/src/app/shared/product/update/ProductUpdates.components.tsx new file mode 100644 index 00000000000000..1f49e2caec624f --- /dev/null +++ b/datahub-web-react/src/app/shared/product/update/ProductUpdates.components.tsx @@ -0,0 +1,153 @@ +import { colors } from '@components'; +import { X } from '@phosphor-icons/react'; +import styled from 'styled-components'; + +export const ToastContainer = styled.div<{ $sidebarWidth: number }>` + display: inline-flex; + flex-direction: column; + align-items: flex-start; + position: fixed; + bottom: 18px; + left: calc(${(props) => props.$sidebarWidth}px + 10px); + width: 452px; + max-width: calc(100vw - ${(props) => props.$sidebarWidth}px - 47px); + max-height: calc(100vh - 48px); + padding: 0; + border-radius: 12px; + background: linear-gradient(180deg, #f9fafc 0%, #f1f3fd 100%); + box-shadow: 0 4px 28px 0 rgba(9, 1, 61, 0.14); + z-index: 1000; + transition: + left 250ms ease-in-out, + max-width 250ms ease-in-out; + animation: slideUpScale 500ms cubic-bezier(0.34, 1.56, 0.64, 1); + + @keyframes slideUpScale { + from { + opacity: 0; + transform: translateY(24px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +`; + +export const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 16px 16px 12px 16px; + border-bottom: 1px solid ${colors.gray[200]}; + background: white; + border-radius: 12px 12px 0 0; +`; + +export const CloseButton = styled.button` + background: none; + border: none; + color: ${colors.gray[400]}; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; + + &:hover { + color: ${colors.gray[600]}; + } +`; + +export const StyledCloseIcon = styled(X)` + font-size: 16px; +`; + +export const Content = styled.div` + width: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px 16px 0 16px; + flex: 1; + min-height: 0; +`; + +export const HeroSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const ImageSection = styled.div``; + +export const Image = styled.img` + width: 100%; + height: auto; +`; + +export const SectionHeaderContainer = styled.div` + display: flex; + align-items: center; + gap: 12px; + width: 100%; +`; + +export const SectionHeaderLine = styled.div` + flex: 1; + height: 1px; + background: ${colors.gray[200]}; +`; + +export const FeaturesSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const FeatureList = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const FeatureItem = styled.div<{ $hasIcon: boolean }>` + display: flex; + align-items: flex-start; + gap: ${(props) => (props.$hasIcon ? '10px' : '0')}; +`; + +export const FeatureIconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + min-width: 20px; + min-height: 20px; + color: ${colors.gray[500]}; + flex-shrink: 0; + margin-top: 2px; +`; + +export const FeatureContent = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +`; + +export const CTAContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding: 16px; + border-top: 1px solid ${colors.gray[200]}; + width: calc(100% + 32px); + background: white; + border-radius: 0 0 12px 12px; + margin: 0 -16px; +`; diff --git a/datahub-web-react/src/app/shared/product/update/ProductUpdates.tsx b/datahub-web-react/src/app/shared/product/update/ProductUpdates.tsx index ec1180b7af528c..8659381111d4e5 100644 --- a/datahub-web-react/src/app/shared/product/update/ProductUpdates.tsx +++ b/datahub-web-react/src/app/shared/product/update/ProductUpdates.tsx @@ -1,142 +1,300 @@ -import { Tooltip, colors } from '@components'; -import { X } from '@phosphor-icons/react'; -import React, { useEffect, useState } from 'react'; -import styled from 'styled-components'; +import { Button, Heading, Text, Tooltip } from '@components'; +import * as phosphorIcons from '@phosphor-icons/react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; import analytics, { EventType } from '@app/analytics'; +import { NAV_SIDEBAR_ID, NAV_SIDEBAR_WIDTH_COLLAPSED, NAV_SIDEBAR_WIDTH_EXPANDED } from '@app/shared/constants'; +import { + CTAContainer, + CloseButton, + Content, + FeatureContent, + FeatureIconWrapper, + FeatureItem, + FeatureList, + FeaturesSection, + Header, + HeroSection, + Image, + ImageSection, + SectionHeaderContainer, + SectionHeaderLine, + StyledCloseIcon, + ToastContainer, +} from '@app/shared/product/update/ProductUpdates.components'; 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; -`; +import { isVersionMatch } from '@app/shared/product/update/versionUtils'; +import { convertToPascalCase } from '@app/shared/stringUtils'; +import { useIsHomePage } from '@app/shared/useIsHomePage'; +import { useAppConfig } from '@app/useAppConfig'; +import { getRuntimeBasePath } from '@utils/runtimeBasePath'; export default function ProductUpdates() { + const history = useHistory(); const isFeatureEnabled = useIsProductAnnouncementEnabled(); const latestUpdate = useGetLatestProductAnnouncementData(); + const appConfig = useAppConfig(); + const isOnHomePage = useIsHomePage(); const { visible, refetch } = useIsProductAnnouncementVisible(latestUpdate?.id); const dismiss = useDismissProductAnnouncement(latestUpdate?.id, refetch); // Local state to hide immediately on dismiss const [isLocallyVisible, setIsLocallyVisible] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(NAV_SIDEBAR_WIDTH_EXPANDED); + + // Check if current version matches required version + const currentVersion = appConfig.config.appVersion; + const versionMatches = isVersionMatch(currentVersion, latestUpdate?.requiredVersion); useEffect(() => { setIsLocallyVisible(visible); }, [visible]); - const handleDismiss = () => { + // Measure sidebar width - use MutationObserver for instant updates + useEffect(() => { + const sidebar = document.getElementById(NAV_SIDEBAR_ID); + if (!sidebar) return undefined; + + const updateWidth = () => { + // Read the target width from data-collapsed attribute (instant, no animation delay) + const isCollapsed = sidebar.getAttribute('data-collapsed') === 'true'; + const targetWidth = isCollapsed ? NAV_SIDEBAR_WIDTH_COLLAPSED : NAV_SIDEBAR_WIDTH_EXPANDED; + setSidebarWidth(targetWidth); + }; + + // Set initial width + updateWidth(); + + // Watch for data-collapsed attribute changes on the sidebar element + const observer = new MutationObserver(() => { + updateWidth(); + }); + + observer.observe(sidebar, { + attributes: true, + attributeFilter: ['data-collapsed'], + }); + + return () => { + observer.disconnect(); + }; + }, []); + + const handleDismiss = useCallback(() => { setIsLocallyVisible(false); dismiss(); - }; + }, [dismiss]); - const trackClick = () => { + const trackClick = (url: string) => { if (!latestUpdate) return; analytics.event({ type: EventType.ClickProductUpdate, id: latestUpdate.id, - url: latestUpdate.ctaLink, + url, }); }; - // Don't show if feature disabled, not locally visible, update not loaded, or update not enabled - if (!isFeatureEnabled || !isLocallyVisible || !latestUpdate || !latestUpdate.enabled) return null; + // Helper to build URL with baseUrl for relative paths + const buildUrl = (url: string | null | undefined): string | null => { + if (!url) return null; + // If it's an external URL, use as-is + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + // If it's a relative URL (starts with /), prepend baseUrl + if (url.startsWith('/')) { + const basePath = getRuntimeBasePath(); + return basePath ? `${basePath}${url}` : url; + } + // Otherwise return as-is + return url; + }; + + // Don't show if: + if ( + !isFeatureEnabled || + !isLocallyVisible || + !latestUpdate || + !latestUpdate.enabled || + !versionMatches || + !isOnHomePage + ) { + return null; + } + + const { title, header, image, description, primaryCtaText, primaryCtaLink, ctaText, ctaLink, features } = + latestUpdate; + + // Helper to check if value is actually present (not null, undefined, or string "null") + const isPresent = (value: any) => value && value !== 'null' && value !== null; + + // Use header if available, otherwise fall back to title + const displayTitle = isPresent(header) ? header : title; + + // Only show title in content if header exists (to avoid duplication) + const showTitleInContent = isPresent(header); + + // Determine primary CTA (prefer new format, fall back to legacy) + const primaryText = primaryCtaText || ctaText; + const primaryLink = buildUrl(primaryCtaLink || ctaLink); - const { title, image, description, ctaText, ctaLink } = latestUpdate; + // Secondary CTA (only if both text and link are present) + const secondaryText = isPresent(latestUpdate.secondaryCtaText) ? latestUpdate.secondaryCtaText : null; + const secondaryLink = isPresent(latestUpdate.secondaryCtaLink) ? buildUrl(latestUpdate.secondaryCtaLink) : null; + + // Limit features to 3 max + const displayFeatures = features && features.length > 0 ? features.slice(0, 3) : null; return ( - +
- {title} - + + {displayTitle} + + - +
- {image && ( - - - - )} - {description && {description}} - {ctaText && ctaLink && ( - - {ctaText} → - + {description && ( + + {showTitleInContent && ( + + {title} + + )} + + {description} + + + )} + {image && ( + + {title} + + )} + {displayFeatures && displayFeatures.length > 0 && ( + + {displayFeatures.length > 1 && ( + + + + more in this release + + + + )} + + {displayFeatures.map((feature) => { + // Try the icon name as-is first, then try converting from kebab-case + const iconName = feature.icon; + let IconComponent = iconName + ? (phosphorIcons[iconName as keyof typeof phosphorIcons] as + | React.ComponentType<{ size?: number; weight?: string }> + | undefined) + : undefined; + + // If not found and contains hyphens, try converting to PascalCase + if (!IconComponent && iconName?.includes('-')) { + const pascalCaseName = convertToPascalCase(iconName); + IconComponent = phosphorIcons[pascalCaseName as keyof typeof phosphorIcons] as + | React.ComponentType<{ size?: number; weight?: string }> + | undefined; + } + + // Debug logging for icon resolution + if (feature.icon && !IconComponent) { + // eslint-disable-next-line no-console + console.warn(`[ProductUpdates] Icon "${feature.icon}" not found in phosphor-icons`); + } + + const hasIcon = !!IconComponent; + + return ( + + {hasIcon && IconComponent && ( + + + + )} + + + {feature.title} + + + {feature.description} + + {feature.availability && feature.availability !== 'null' && ( + + {feature.availability} + + )} + + + ); + })} + + )} + {(primaryText && primaryLink) || (secondaryText && secondaryLink) ? ( + + {secondaryText && secondaryLink && ( + + )} + {primaryText && primaryLink && ( + + )} + + ) : null} -
+ ); } diff --git a/datahub-web-react/src/app/shared/product/update/__tests__/hooks.test.tsx b/datahub-web-react/src/app/shared/product/update/__tests__/hooks.test.tsx index 0159e5a895eeb5..146b500398eca0 100644 --- a/datahub-web-react/src/app/shared/product/update/__tests__/hooks.test.tsx +++ b/datahub-web-react/src/app/shared/product/update/__tests__/hooks.test.tsx @@ -109,6 +109,12 @@ describe('product update hooks', () => { vi.spyOn(useUserContextModule, 'useUserContext').mockReturnValue({ user: { urn: 'urn:li:user:123' }, } as any); + // Mock localStorage to indicate welcome modal has been seen + localStorage.setItem('skipWelcomeModal', 'true'); + }); + + afterEach(() => { + localStorage.clear(); }); it('returns visible=false when step state exists', async () => { @@ -150,5 +156,26 @@ describe('product update hooks', () => { expect(result.current.visible).toBe(false); }); + + it('returns visible=false when welcome modal has not been seen', async () => { + // Clear localStorage to simulate welcome modal not seen + localStorage.clear(); + + const { result } = renderHook(() => useIsProductAnnouncementVisible(TEST_UPDATE.id), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Should be false immediately since welcome modal hasn't been seen + expect(result.current.visible).toBe(false); + + // Should still be false after query completes + await waitFor(() => { + expect(result.current.visible).toBe(false); + }); + }); }); }); diff --git a/datahub-web-react/src/app/shared/product/update/hooks.ts b/datahub-web-react/src/app/shared/product/update/hooks.ts index 4537e25b10fe40..009bd2e47c988b 100644 --- a/datahub-web-react/src/app/shared/product/update/hooks.ts +++ b/datahub-web-react/src/app/shared/product/update/hooks.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useUserContext } from '@app/context/useUserContext'; +import { checkShouldSkipWelcomeModal } from '@app/shared/localStorageUtils'; import { useAppConfig } from '@app/useAppConfig'; import { useGetLatestProductUpdateQuery } from '@graphql/app.generated'; @@ -71,6 +72,15 @@ export function useIsProductAnnouncementVisible(updateId: string | null | undefi }; } + // If welcome modal hasn't been seen/dismissed, don't show product update + const hasSeenWelcomeModal = checkShouldSkipWelcomeModal(); + if (!hasSeenWelcomeModal) { + return { + visible: false, + refetch, + }; + } + // If query is loading or has an error, don't show yet if (loading || error) { return { diff --git a/datahub-web-react/src/app/shared/product/update/versionUtils.test.ts b/datahub-web-react/src/app/shared/product/update/versionUtils.test.ts new file mode 100644 index 00000000000000..8666e71127ccb5 --- /dev/null +++ b/datahub-web-react/src/app/shared/product/update/versionUtils.test.ts @@ -0,0 +1,49 @@ +import { isVersionMatch } from '@app/shared/product/update/versionUtils'; + +describe('isVersionMatch', () => { + it('returns true for exact version match', () => { + expect(isVersionMatch('v1.4.0', 'v1.4.0')).toBe(true); + expect(isVersionMatch('1.3.15', '1.3.15')).toBe(true); + }); + + it('returns true for exact match with different prefix styles', () => { + expect(isVersionMatch('1.4.0', 'v1.4.0')).toBe(true); + expect(isVersionMatch('v1.4.0', '1.4.0')).toBe(true); + }); + + it('returns false for version mismatch', () => { + expect(isVersionMatch('v1.3.10', 'v1.3.15')).toBe(false); + expect(isVersionMatch('v1.4.0', 'v1.3.15')).toBe(false); + expect(isVersionMatch('v2.0.0', 'v1.4.0')).toBe(false); + }); + + it('returns true when no required version is specified', () => { + expect(isVersionMatch('v1.0.0', null)).toBe(true); + expect(isVersionMatch('v1.0.0', undefined)).toBe(true); + expect(isVersionMatch('v1.0.0', '')).toBe(true); + expect(isVersionMatch('v1.0.0', 'null')).toBe(true); + }); + + it('returns false when no current version is available', () => { + expect(isVersionMatch(null, 'v1.0.0')).toBe(false); + expect(isVersionMatch(undefined, 'v1.0.0')).toBe(false); + expect(isVersionMatch('', 'v1.0.0')).toBe(false); + }); + + it('handles whitespace in version strings', () => { + expect(isVersionMatch(' v1.4.0 ', 'v1.4.0')).toBe(true); + expect(isVersionMatch('v1.4.0', ' 1.4.0 ')).toBe(true); + }); + + it('returns false for different major versions', () => { + expect(isVersionMatch('v2.0.0', 'v1.0.0')).toBe(false); + }); + + it('returns false for different minor versions', () => { + expect(isVersionMatch('v1.5.0', 'v1.4.0')).toBe(false); + }); + + it('returns false for different patch versions', () => { + expect(isVersionMatch('v1.4.1', 'v1.4.0')).toBe(false); + }); +}); diff --git a/datahub-web-react/src/app/shared/product/update/versionUtils.ts b/datahub-web-react/src/app/shared/product/update/versionUtils.ts new file mode 100644 index 00000000000000..5027a7b8ca5ce5 --- /dev/null +++ b/datahub-web-react/src/app/shared/product/update/versionUtils.ts @@ -0,0 +1,33 @@ +/** + * Compare versions for exact match + * Returns true if currentVersion === requiredVersion (normalized) + * + * Examples: + * isVersionMatch("v1.4.0", "v1.4.0") => true + * isVersionMatch("1.4.0", "v1.4.0") => true + * isVersionMatch("v1.3.10", "v1.3.15") => false + * isVersionMatch("v1.4.0", null) => true (no required version = show to all) + * isVersionMatch(null, "v1.4.0") => false (no current version = can't verify) + */ +export function isVersionMatch( + currentVersion: string | null | undefined, + requiredVersion: string | null | undefined, +): boolean { + // If no required version specified (including string "null" or JSON null), show update to all versions + if (!requiredVersion || requiredVersion === 'null' || requiredVersion === null) return true; + + // If no current version, don't show (can't verify compatibility) + if (!currentVersion) return false; + + // Normalize versions (remove whitespace first, then 'v' prefix) + const normalize = (version: string) => version.trim().replace(/^v/, ''); + + const current = normalize(currentVersion); + const required = normalize(requiredVersion); + + // If normalized current version is empty, can't verify + if (!current) return false; + + // Exact match comparison + return current === required; +} diff --git a/datahub-web-react/src/app/shared/stringUtils.test.ts b/datahub-web-react/src/app/shared/stringUtils.test.ts new file mode 100644 index 00000000000000..1f4239450f634b --- /dev/null +++ b/datahub-web-react/src/app/shared/stringUtils.test.ts @@ -0,0 +1,64 @@ +import { convertToCamelCase, convertToKebabCase, convertToPascalCase } from '@app/shared/stringUtils'; + +describe('stringUtils', () => { + describe('convertToPascalCase', () => { + it('converts kebab-case to PascalCase', () => { + expect(convertToPascalCase('magnifying-glass')).toBe('MagnifyingGlass'); + expect(convertToPascalCase('user-profile')).toBe('UserProfile'); + expect(convertToPascalCase('api-key-manager')).toBe('ApiKeyManager'); + }); + + it('handles single word', () => { + expect(convertToPascalCase('user')).toBe('User'); + expect(convertToPascalCase('profile')).toBe('Profile'); + }); + + it('handles already capitalized words', () => { + expect(convertToPascalCase('User-Profile')).toBe('UserProfile'); + }); + + it('handles empty string', () => { + expect(convertToPascalCase('')).toBe(''); + }); + }); + + describe('convertToCamelCase', () => { + it('converts kebab-case to camelCase', () => { + expect(convertToCamelCase('magnifying-glass')).toBe('magnifyingGlass'); + expect(convertToCamelCase('user-profile')).toBe('userProfile'); + expect(convertToCamelCase('api-key-manager')).toBe('apiKeyManager'); + }); + + it('handles single word', () => { + expect(convertToCamelCase('user')).toBe('user'); + expect(convertToCamelCase('profile')).toBe('profile'); + }); + + it('handles empty string', () => { + expect(convertToCamelCase('')).toBe(''); + }); + }); + + describe('convertToKebabCase', () => { + it('converts PascalCase to kebab-case', () => { + expect(convertToKebabCase('MagnifyingGlass')).toBe('magnifying-glass'); + expect(convertToKebabCase('UserProfile')).toBe('user-profile'); + expect(convertToKebabCase('APIKeyManager')).toBe('api-key-manager'); + }); + + it('converts camelCase to kebab-case', () => { + expect(convertToKebabCase('magnifyingGlass')).toBe('magnifying-glass'); + expect(convertToKebabCase('userProfile')).toBe('user-profile'); + expect(convertToKebabCase('apiKeyManager')).toBe('api-key-manager'); + }); + + it('handles single word', () => { + expect(convertToKebabCase('User')).toBe('user'); + expect(convertToKebabCase('user')).toBe('user'); + }); + + it('handles empty string', () => { + expect(convertToKebabCase('')).toBe(''); + }); + }); +}); diff --git a/datahub-web-react/src/app/shared/stringUtils.ts b/datahub-web-react/src/app/shared/stringUtils.ts new file mode 100644 index 00000000000000..7578dfdf369b97 --- /dev/null +++ b/datahub-web-react/src/app/shared/stringUtils.ts @@ -0,0 +1,41 @@ +/** + * Converts kebab-case string to PascalCase + * @example convertToPascalCase("magnifying-glass") → "MagnifyingGlass" + * @example convertToPascalCase("user-profile") → "UserProfile" + */ +export function convertToPascalCase(str: string): string { + return str + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); +} + +/** + * Converts kebab-case string to camelCase + * @example convertToCamelCase("magnifying-glass") → "magnifyingGlass" + * @example convertToCamelCase("user-profile") → "userProfile" + */ +export function convertToCamelCase(str: string): string { + const parts = str.split('-'); + if (parts.length === 0) return str; + + return ( + parts[0] + + parts + .slice(1) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join('') + ); +} + +/** + * Converts PascalCase or camelCase string to kebab-case + * @example convertToKebabCase("MagnifyingGlass") → "magnifying-glass" + * @example convertToKebabCase("userProfile") → "user-profile" + */ +export function convertToKebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') + .toLowerCase(); +} diff --git a/datahub-web-react/src/graphql/app.graphql b/datahub-web-react/src/graphql/app.graphql index 70630fd4ba031b..df56eccab62585 100644 --- a/datahub-web-react/src/graphql/app.graphql +++ b/datahub-web-react/src/graphql/app.graphql @@ -187,9 +187,21 @@ query getLatestProductUpdate { enabled id title + header + requiredVersion description image + primaryCtaText + primaryCtaLink + secondaryCtaText + secondaryCtaLink ctaText ctaLink + features { + title + description + icon + availability + } } } diff --git a/metadata-service/configuration/src/main/resources/product-update.json b/metadata-service/configuration/src/main/resources/product-update.json index 0f4cfab7eea5b9..4c62c6ca880f01 100644 --- a/metadata-service/configuration/src/main/resources/product-update.json +++ b/metadata-service/configuration/src/main/resources/product-update.json @@ -1,9 +1,30 @@ { "enabled": true, - "id": "v1.2.1", - "title": "What's New In DataHub", - "description": "Explore version v1.2.1", - "image": "https://raw.githubusercontent.com/datahub-project/datahub/master/datahub-web-react/src/images/sample-product-update-image.png", - "ctaText": "Read updates", - "ctaLink": "https://docs.datahub.com/docs/releases#v1-2-1" + "id": "v0.3.16", + "requiredVersion": "v1.3.0rc1", + "header": "Welcome to your free trial of DataHub!", + "title": "Get to know your Demo Environment", + "description": "We've pre-loaded your environment with sample data and entities to help you get started. Whether you're interested in learning more about AI-powered data discovery, data lineage, data quality, or something else, we've made sure you have real-world examples to explore.", + "image": "link-to-highlight-image", + "ctaText": "Let's dig in!", + "ctaLink": "link-to-pre-seeded-search-page", + "secondaryCtaText": "Learn more", + "secondaryCtaLink": "link-to-loom-video", + "features": [ + { + "title": "Get familiar with Search", + "description": "See how DataHub makes it easy to find your data, no matter where it's stored.", + "icon": "magnifying-glass" + }, + { + "title": "View technical and business context in one place", + "description": "DataHub makes it easy to find your data, no matter where it's stored.", + "icon": "magnifying-glass" + }, + { + "title": "Go deeper with Ask DataHub", + "description": "Try out our new AI-powered assistant to get a deeper understanding of your metadata.", + "icon": "ai-assistant" + } + ] }