feat(homePageRedesign): add header (#13904)

This commit is contained in:
v-tarasevich-blitz-brain 2025-06-30 22:34:59 +03:00 committed by GitHub
parent 73cb3621e0
commit 31ee414008
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 383 additions and 91 deletions

View File

@ -95,6 +95,10 @@ const meta = {
'Determine whether the dropdown menu and the select input are the same width.' +
'Default set min-width same as input. Will ignore when value less than select width.',
},
clickOutsideWidth: {
description:
'Customize width of the wrapper to handle outside clicks. This wrapper is fit-content by default',
},
},
// Define defaults

View File

@ -19,6 +19,7 @@ export default function AutoComplete({
onChange,
onClear,
value,
clickOutsideWidth,
...props
}: React.PropsWithChildren<AutoCompleteProps>) {
const { open } = props;
@ -60,10 +61,18 @@ export default function AutoComplete({
}
};
// Automatically close the dropdown on resize to avoid the dropdown's misalignment
useEffect(() => {
const onResize = () => setInternalOpen(false);
window.addEventListener('resize', onResize, true);
return () => window.removeEventListener('resize', onResize);
}, []);
return (
<ClickOutside
ignoreSelector={AUTOCOMPLETE_WRAPPER_CLASS_CSS_SELECTOR}
onClickOutside={() => setInternalOpen(false)}
width={clickOutsideWidth}
>
<AntdAutoComplete
open={internalOpen}

View File

@ -35,4 +35,6 @@ export interface AutoCompleteProps {
style?: React.CSSProperties;
dropdownStyle?: React.CSSProperties;
dropdownMatchSelectWidth?: boolean | number;
clickOutsideWidth?: string;
}

View File

@ -27,11 +27,19 @@ const meta = {
description: 'Optional CSS-selector to ignore handling of clicks as outside clicks',
},
outsideSelector: {
description: 'Optional CSS-selector to cosider clicked element as outside click',
description: 'Optional CSS-selector to consider clicked element as outside click',
},
ignoreWrapper: {
description: 'Enable to ignore clicking outside of wrapper',
},
width: {
description: 'Customize the width of the wrapper',
table: {
defaultValue: {
summary: 'fit-content',
},
},
},
},
// Define defaults

View File

@ -7,11 +7,16 @@ import useClickOutside from '@components/components/Utils/ClickOutside/useClickO
export default function ClickOutside({
children,
onClickOutside,
width,
...options
}: React.PropsWithChildren<ClickOutsideProps>) {
const wrapperRef = useRef<HTMLDivElement>(null);
useClickOutside(onClickOutside, { ...options, wrappers: [wrapperRef] });
return <Wrapper ref={wrapperRef}>{children}</Wrapper>;
return (
<Wrapper ref={wrapperRef} $width={width}>
{children}
</Wrapper>
);
}

View File

@ -1,5 +1,5 @@
import styled from 'styled-components';
export const Wrapper = styled.div({
width: 'fit-content',
});
export const Wrapper = styled.div<{ $width?: string }>(({ $width }) => ({
width: $width || 'fit-content',
}));

View File

@ -9,4 +9,5 @@ export interface ClickOutsideOptions {
export interface ClickOutsideProps extends Omit<ClickOutsideOptions, 'wrappers'> {
onClickOutside: ClickOutsideCallback;
width?: string;
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import PersonalizationLoadingModal from '@app/homeV2/persona/PersonalizationLoadingModal';
import HomePageContent from '@app/homepageV2/HomePageContent';
import HomePageHeader from '@app/homepageV2/HomePageHeader';
import Header from '@app/homepageV2/header/Header';
import { PageWrapper } from '@app/homepageV2/styledComponents';
import { SearchablePage } from '@app/searchV2/SearchablePage';
@ -11,7 +11,7 @@ export const HomePage = () => {
<>
<SearchablePage hideSearchBar>
<PageWrapper>
<HomePageHeader />
<Header />
<HomePageContent />
</PageWrapper>
</SearchablePage>

View File

@ -0,0 +1,35 @@
import { colors } from '@components';
import React from 'react';
import styled from 'styled-components';
import GreetingText from '@app/homepageV2/header/components/GreetingText';
import SearchBar from '@app/homepageV2/header/components/SearchBar';
export const HeaderWrapper = styled.div`
display: flex;
justify-content: center;
padding: 27px 0 24px 0;
width: 100%;
overflow: hidden;
background: linear-gradient(180deg, #f8fcff 0%, #fafafb 100%);
border: 1px solid ${colors.gray[100]};
border-radius: 12px 12px 0 0;
`;
const CenteredContainer = styled.div`
max-width: 1016px;
width: 100%;
`;
const Header = () => {
return (
<HeaderWrapper>
<CenteredContainer>
<GreetingText />
<SearchBar />
</CenteredContainer>
</HeaderWrapper>
);
};
export default Header;

View File

@ -0,0 +1,32 @@
import { PageTitle } from '@components';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { useUserContext } from '@app/context/useUserContext';
import { getGreetingText } from '@app/homeV2/reference/header/getGreetingText';
import { useEntityRegistryV2 } from '@app/useEntityRegistry';
import { EntityType } from '@types';
const Container = styled.div`
// FYI: horizontal 8px padding to align with the search bar's input as it has a wrapper on focus.
// bottom 8px to add gap between the greeting text and the search bar. Flex gap breaks the views popover
padding: 0 8px 8px 8px;
`;
export default function GreetingText() {
const greetingText = getGreetingText();
const { user } = useUserContext();
const entityRegistry = useEntityRegistryV2();
const finalText = useMemo(() => {
if (!user) return `${greetingText}!`;
return `${greetingText}, ${entityRegistry.getDisplayName(EntityType.CorpUser, user)}!`;
}, [greetingText, user, entityRegistry]);
return (
<Container>
<PageTitle title={finalText} />
</Container>
);
}

View File

@ -0,0 +1,51 @@
import { Button, Icon } from '@components';
import React from 'react';
import styled, { useTheme } from 'styled-components';
import { SearchBarV2 } from '@app/searchV2/searchBarV2/SearchBarV2';
import useGoToSearchPage from '@app/searchV2/useGoToSearchPage';
import useSearchViewAll from '@app/searchV2/useSearchViewAll';
import { useEntityRegistryV2 } from '@app/useEntityRegistry';
const Container = styled.div`
display: flex;
flex-direction: column;
`;
const ViewAllContainer = styled.div`
display: flex;
justify-content: flex-end;
`;
const StyledButton = styled(Button)`
padding: 0 8px;
`;
export default function SearchBar() {
const entityRegistry = useEntityRegistryV2();
const searchViewAll = useSearchViewAll();
const search = useGoToSearchPage(null);
const themeConfig = useTheme();
return (
<Container>
<SearchBarV2
placeholderText={themeConfig.content.search.searchbarMessage}
onSearch={search}
entityRegistry={entityRegistry}
width="100%"
fixAutoComplete
viewsEnabled
isShowNavBarRedesign
showViewAllResults
combineSiblings
showCommandK
/>
<ViewAllContainer>
<StyledButton variant="text" color="gray" size="sm" onClick={searchViewAll}>
Discover <Icon icon="ArrowRight" source="phosphor" size="sm" />
</StyledButton>
</ViewAllContainer>
</Container>
);
}

View File

@ -152,7 +152,7 @@ export interface SearchBarProps {
isLoading?: boolean;
initialQuery?: string;
placeholderText: string;
suggestions: Array<AutoCompleteResultForEntity>;
suggestions?: Array<AutoCompleteResultForEntity>;
onSearch: (query: string, filters?: FacetFilterInput[]) => void;
onQueryChange?: (query: string) => void;
style?: React.CSSProperties;
@ -284,7 +284,7 @@ export const SearchBar = ({
}, [effectiveQuery, showViewAllResults]);
const autoCompleteEntityOptions = useMemo(() => {
return suggestions.map((suggestion: AutoCompleteResultForEntity) => {
return (suggestions ?? []).map((suggestion: AutoCompleteResultForEntity) => {
const combinedSuggestion = combineSiblingsInAutoComplete(suggestion, {
combineSiblings: finalCombineSiblings,
});

View File

@ -129,6 +129,7 @@ type Props = {
onSearch: (query: string) => void;
onQueryChange: (query: string) => void;
entityRegistry: EntityRegistry;
hideSearchBar?: boolean;
};
/**
@ -141,6 +142,7 @@ export const SearchHeader = ({
onSearch,
onQueryChange,
entityRegistry,
hideSearchBar,
}: Props) => {
const [, setIsSearchBarFocused] = useState(false);
const appConfig = useAppConfig();
@ -164,34 +166,36 @@ export const SearchHeader = ({
<NavBarToggler />
</NavBarTogglerWrapper>
)}
<SearchBarContainer $isShowNavBarRedesign={isShowNavBarRedesign}>
<FinalSearchBar
isLoading={isUserInitializing || !appConfig.loaded}
id={V2_SEARCH_BAR_ID}
style={styles.searchBoxContainer}
autoCompleteStyle={styles.searchBox}
inputStyle={styles.input}
initialQuery={initialQuery}
placeholderText={placeholderText}
suggestions={suggestions}
onSearch={onSearch}
onQueryChange={onQueryChange}
entityRegistry={entityRegistry}
setIsSearchBarFocused={setIsSearchBarFocused}
viewsEnabled={viewsEnabled}
isShowNavBarRedesign={isShowNavBarRedesign}
combineSiblings
fixAutoComplete
showQuickFilters
showViewAllResults
showCommandK
/>
{isShowNavBarRedesign && (
<StyledButton type="link" onClick={searchViewAll}>
View all <ArrowRight />
</StyledButton>
)}
</SearchBarContainer>
{!hideSearchBar && (
<SearchBarContainer $isShowNavBarRedesign={isShowNavBarRedesign}>
<FinalSearchBar
isLoading={isUserInitializing || !appConfig.loaded}
id={V2_SEARCH_BAR_ID}
style={styles.searchBoxContainer}
autoCompleteStyle={styles.searchBox}
inputStyle={styles.input}
initialQuery={initialQuery}
placeholderText={placeholderText}
suggestions={suggestions}
onSearch={onSearch}
onQueryChange={onQueryChange}
entityRegistry={entityRegistry}
setIsSearchBarFocused={setIsSearchBarFocused}
viewsEnabled={viewsEnabled}
isShowNavBarRedesign={isShowNavBarRedesign}
combineSiblings
fixAutoComplete
showQuickFilters
showViewAllResults
showCommandK
/>
{isShowNavBarRedesign && (
<StyledButton type="link" onClick={searchViewAll}>
View all <ArrowRight />
</StyledButton>
)}
</SearchBarContainer>
)}
</Header>
</Wrapper>
</>

View File

@ -1,18 +1,15 @@
import { debounce } from 'lodash';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router';
import styled, { useTheme } from 'styled-components';
import analytics, { EventType } from '@app/analytics';
import { useUserContext } from '@app/context/useUserContext';
import { REDESIGN_COLORS } from '@app/entityV2/shared/constants';
import { NavSidebar } from '@app/homeV2/layout/NavSidebar';
import { NavSidebar as NavSidebarRedesign } from '@app/homeV2/layout/navBarRedesign/NavSidebar';
import { useSelectedSortOption } from '@app/search/context/SearchContext';
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 { navigateToSearchUrl } from '@app/searchV2/utils/navigateToSearchUrl';
import { useAppConfig } from '@app/useAppConfig';
import { useEntityRegistry } from '@app/useEntityRegistry';
import { useShowNavBarRedesign } from '@app/useShowNavBarRedesign';
@ -23,7 +20,6 @@ import {
GetAutoCompleteMultipleResultsQuery,
useGetAutoCompleteMultipleResultsLazyQuery,
} from '@graphql/search.generated';
import { FacetFilterInput } from '@types';
const Body = styled.div`
display: flex;
@ -72,11 +68,9 @@ type Props = React.PropsWithChildren<{
export const SearchablePage = ({ children, hideSearchBar }: Props) => {
const appConfig = useAppConfig();
const showSearchBarAutocompleteRedesign = appConfig.config.featureFlags?.showSearchBarAutocompleteRedesign;
const { filters, query: currentQuery } = useQueryAndFiltersFromLocation();
const selectedSortOption = useSelectedSortOption();
const { query: currentQuery } = useQueryAndFiltersFromLocation();
const isShowNavBarRedesign = useShowNavBarRedesign();
const history = useHistory();
const entityRegistry = useEntityRegistry();
const themeConfig = useTheme();
const { selectedQuickFilter } = useQuickFiltersContext();
@ -92,29 +86,7 @@ export const SearchablePage = ({ children, hideSearchBar }: Props) => {
}
}, [suggestionsData]);
const search = (query: string, newFilters?: FacetFilterInput[]) => {
analytics.event({
type: EventType.SearchEvent,
query,
pageNumber: 1,
originPath: window.location.pathname,
selectedQuickFilterTypes: selectedQuickFilter ? [selectedQuickFilter.field] : undefined,
selectedQuickFilterValues: selectedQuickFilter ? [selectedQuickFilter.value] : undefined,
});
let newAppliedFilters: FacetFilterInput[] | undefined = filters;
if (newFilters && newFilters?.length > 0) {
newAppliedFilters = newFilters;
}
navigateToSearchUrl({
query,
filters: newAppliedFilters,
history,
selectedSortOption,
});
};
const search = useGoToSearchPage(selectedQuickFilter);
const autoComplete = debounce((query: string) => {
if (query && query.trim() !== '') {
@ -148,21 +120,20 @@ export const SearchablePage = ({ children, hideSearchBar }: Props) => {
return (
<>
{!hideSearchBar && (
<SearchHeader
initialQuery={currentQuery as string}
placeholderText={themeConfig.content.search.searchbarMessage}
suggestions={
(newSuggestionData &&
newSuggestionData?.autoCompleteForMultiple &&
newSuggestionData.autoCompleteForMultiple.suggestions) ||
[]
}
onSearch={search}
onQueryChange={autoComplete}
entityRegistry={entityRegistry}
/>
)}
<SearchHeader
initialQuery={currentQuery as string}
placeholderText={themeConfig.content.search.searchbarMessage}
suggestions={
(newSuggestionData &&
newSuggestionData?.autoCompleteForMultiple &&
newSuggestionData.autoCompleteForMultiple.suggestions) ||
[]
}
onSearch={search}
onQueryChange={autoComplete}
entityRegistry={entityRegistry}
hideSearchBar={hideSearchBar}
/>
<BodyBackground $isShowNavBarRedesign={isShowNavBarRedesign} />
<Body>
<Navigation $isShowNavBarRedesign={isShowNavBarRedesign}>

View File

@ -0,0 +1,107 @@
import { renderHook } from '@testing-library/react-hooks';
import { useSelectedSortOption } from '@app/search/context/SearchContext';
import useGoToSearchPage from '@app/searchV2/useGoToSearchPage';
import useQueryAndFiltersFromLocation from '@app/searchV2/useQueryAndFiltersFromLocation';
import { navigateToSearchUrl } from '@app/searchV2/utils/navigateToSearchUrl';
vi.mock('@app/search/context/SearchContext', () => ({
useSelectedSortOption: vi.fn(),
}));
vi.mock('@app/searchV2/useQueryAndFiltersFromLocation', () => ({
default: vi.fn(() => ({ filters: [] })),
}));
vi.mock('@app/searchV2/utils/navigateToSearchUrl', () => ({
navigateToSearchUrl: vi.fn(),
}));
describe('useGoToSearchPage Hook', () => {
const mockQuickFilter = {
field: 'type',
value: 'dataset',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should return a function that navigates to search url', () => {
const mockSortOption = 'relevance';
vi.mocked(useSelectedSortOption).mockReturnValue(mockSortOption);
const { result } = renderHook(() => useGoToSearchPage(mockQuickFilter));
const query = 'testQuery';
const filters = [{ field: 'origin', values: ['urn:li:dataPlatform:bigquery'] }];
result.current(query, filters);
expect(vi.mocked(navigateToSearchUrl)).toHaveBeenCalledWith(
expect.objectContaining({
query,
filters,
selectedSortOption: mockSortOption,
}),
);
});
it('should use existing filters if no newFilters provided', () => {
const existingFilters = [{ field: 'platform', values: ['urn:li:dataPlatform:mysql'] }];
vi.mocked(useQueryAndFiltersFromLocation).mockReturnValue({ filters: existingFilters, query: '' });
vi.mocked(useSelectedSortOption).mockReturnValue(undefined);
const { result } = renderHook(() => useGoToSearchPage(null));
const query = 'hello';
result.current(query);
expect(vi.mocked(navigateToSearchUrl)).toHaveBeenCalledWith(
expect.objectContaining({
query,
filters: existingFilters,
selectedSortOption: undefined,
}),
);
});
it('should always pass newFilters if newFilters is provided', () => {
vi.mocked(useQueryAndFiltersFromLocation).mockReturnValue({ filters: [], query: '' });
vi.mocked(useSelectedSortOption).mockReturnValue(undefined);
const newFilters = [{ field: 'platform', values: ['urn:li:dataPlatform:hive'] }];
const { result } = renderHook(() => useGoToSearchPage(null));
const query = 'featureFlagTest';
result.current(query, newFilters);
expect(vi.mocked(navigateToSearchUrl)).toHaveBeenCalledWith(
expect.objectContaining({
query,
filters: newFilters,
selectedSortOption: undefined,
}),
);
});
it('should not override filters if newFilters is not provided', () => {
const existingFilters = [{ field: 'platform', values: ['urn:li:dataPlatform:hive'] }];
vi.mocked(useQueryAndFiltersFromLocation).mockReturnValue({ filters: existingFilters, query: '' });
const { result } = renderHook(() => useGoToSearchPage(null));
const query = 'noOverride';
result.current(query);
expect(vi.mocked(navigateToSearchUrl)).toHaveBeenCalledWith(
expect.objectContaining({
filters: existingFilters,
}),
);
});
});

View File

@ -27,6 +27,14 @@ import { SearchBarApi } from '@src/types.generated';
const Wrapper = styled.div``;
const StyledAutocomplete = styled(AutoComplete)`
width: 100%;
`;
interface SearchBarV2Props extends SearchBarProps {
width?: string;
}
/**
* Represents the search bar appearing in the default header view.
*/
@ -44,7 +52,8 @@ export const SearchBarV2 = ({
onBlur,
showViewAllResults = false,
isShowNavBarRedesign,
}: SearchBarProps) => {
width,
}: SearchBarV2Props) => {
const appConfig = useAppConfig();
const showAutoCompleteResults = appConfig?.config?.featureFlags?.showAutoCompleteResults;
const isShowSeparateSiblingsEnabled = useIsShowSeparateSiblingsEnabled();
@ -139,7 +148,7 @@ export const SearchBarV2 = ({
return (
<Wrapper id={id} className={SEARCH_BAR_CLASS_NAME}>
<AutoComplete
<StyledAutocomplete
dataTestId="search-bar"
defaultActiveFirstOption={false}
options={options}
@ -162,6 +171,7 @@ export const SearchBarV2 = ({
onClearFilters={onClearFiltersAndSelectedViewHandler}
/>
}
dropdownMatchSelectWidth
onSelect={onSelectHandler}
defaultValue={initialQuery || undefined}
value={searchQuery}
@ -186,6 +196,7 @@ export const SearchBarV2 = ({
onDropdownVisibleChange={onDropdownVisibilityChangeHandler}
open={isDropdownVisible}
dropdownContentHeight={480}
clickOutsideWidth={width === '100%' ? '100%' : undefined}
>
<SearchBarInput
placeholder={placeholderText}
@ -199,8 +210,9 @@ export const SearchBarV2 = ({
showCommandK={showCommandK}
isDropdownOpened={isDropdownVisible}
viewsEnabled={viewsEnabled}
width={width}
/>
</AutoComplete>
</StyledAutocomplete>
</Wrapper>
);
};

View File

@ -38,6 +38,8 @@ const ViewSelectContainer = styled.div``;
export const Wrapper = styled.div<{ $open?: boolean; $isShowNavBarRedesign?: boolean }>`
background: transparent;
width: 100%;
min-width: 500px;
${(props) =>
props.$isShowNavBarRedesign &&
@ -77,6 +79,7 @@ interface Props {
placeholder?: string;
showCommandK?: boolean;
viewsEnabled?: boolean;
width?: string;
}
const SearchBarInput = forwardRef<InputRef, Props>(
@ -93,6 +96,7 @@ const SearchBarInput = forwardRef<InputRef, Props>(
placeholder,
showCommandK,
viewsEnabled,
width,
},
ref,
) => {
@ -158,7 +162,7 @@ const SearchBarInput = forwardRef<InputRef, Props>(
)}
</SuffixWrapper>
}
width={isShowNavBarRedesign ? '664px' : '620px'}
width={width ?? (isShowNavBarRedesign ? '664px' : '620px')}
height="44px"
$isShowNavBarRedesign={isShowNavBarRedesign}
/>

View File

@ -2,10 +2,14 @@ import { AutocompleteDropdownAlign } from '@src/alchemy-components/components/Au
// Adjusted aligning to show dropdown in the correct place
export const AUTOCOMPLETE_DROPDOWN_ALIGN_WITH_NEW_NAV_BAR: AutocompleteDropdownAlign = {
// bottom-center of input (search) and top-center of dropdown
points: ['bl', 'tl'],
// additional offset
offset: [0, 6],
// top-left of dropdown and bottom-left of input (search)
points: ['tl', 'bl'],
overflow: {
adjustX: 1,
adjustY: 1,
},
offset: [0, -6],
targetOffset: [0, 0],
};
export const AUTOCOMPLETE_DROPDOWN_ALIGN: AutocompleteDropdownAlign = {
// bottom-center of input (search) and top-center of dropdown

View File

@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router';
import analytics, { EventType } from '@app/analytics';
import { useSelectedSortOption } from '@app/search/context/SearchContext';
import useQueryAndFiltersFromLocation from '@app/searchV2/useQueryAndFiltersFromLocation';
import { navigateToSearchUrl } from '@app/searchV2/utils/navigateToSearchUrl';
import { FacetFilterInput, QuickFilter } from '@types';
export default function useGoToSearchPage(quickFilter: QuickFilter | null) {
const history = useHistory();
const selectedSortOption = useSelectedSortOption();
const { filters } = useQueryAndFiltersFromLocation();
return useCallback(
(query: string, newFilters?: FacetFilterInput[]) => {
analytics.event({
type: EventType.SearchEvent,
query,
pageNumber: 1,
originPath: window.location.pathname,
selectedQuickFilterTypes: quickFilter ? [quickFilter.field] : undefined,
selectedQuickFilterValues: quickFilter ? [quickFilter.value] : undefined,
});
let newAppliedFilters: FacetFilterInput[] | undefined = filters;
if (newFilters && newFilters?.length > 0) {
newAppliedFilters = newFilters;
}
navigateToSearchUrl({
query,
filters: newAppliedFilters,
history,
selectedSortOption,
});
},
[filters, history, quickFilter, selectedSortOption],
);
}