diff --git a/datahub-web-react/src/alchemy-components/components/DatePicker/DatePicker.tsx b/datahub-web-react/src/alchemy-components/components/DatePicker/DatePicker.tsx index 51e679f686..b77a03fa38 100644 --- a/datahub-web-react/src/alchemy-components/components/DatePicker/DatePicker.tsx +++ b/datahub-web-react/src/alchemy-components/components/DatePicker/DatePicker.tsx @@ -16,6 +16,7 @@ export function DatePicker({ variant = datePickerDefault.variant, disabled = datePickerDefault.disabled, disabledDate, + placeholder, }: DatePickerProps) { const [internalValue, setInternalValue] = useState(value); @@ -38,8 +39,9 @@ export function DatePicker({ open: isOpen, setValue: setInternalValue, }, + placeholder, }); - }, [disabled, isOpen, inputRender]); + }, [disabled, placeholder, isOpen, inputRender]); return ( boolean; variant?: DatePickerVariant; + placeholder?: string; }; export type DatePickerState = { diff --git a/datahub-web-react/src/alchemy-components/components/DatePicker/variants/common/components.tsx b/datahub-web-react/src/alchemy-components/components/DatePicker/variants/common/components.tsx new file mode 100644 index 0000000000..990fce704a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/DatePicker/variants/common/components.tsx @@ -0,0 +1,23 @@ +import { Input } from '@components'; +import React from 'react'; + +import { ExtendedInputRenderProps } from '@components/components/DatePicker/types'; + +export function DefaultDatePickerInput({ datePickerProps, ...props }: ExtendedInputRenderProps) { + const { disabled } = datePickerProps; + return ( + + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/DatePicker/variants/common/props.tsx b/datahub-web-react/src/alchemy-components/components/DatePicker/variants/common/props.tsx index c92d7958d0..3a29269d9b 100644 --- a/datahub-web-react/src/alchemy-components/components/DatePicker/variants/common/props.tsx +++ b/datahub-web-react/src/alchemy-components/components/DatePicker/variants/common/props.tsx @@ -2,7 +2,14 @@ import React from 'react'; import { StyledCalendarWrapper } from '@components/components/DatePicker/components'; import { VariantProps } from '@components/components/DatePicker/types'; +import { DefaultDatePickerInput } from '@components/components/DatePicker/variants/common/components'; export const CommonVariantProps: VariantProps = { panelRender: (panel) => {panel}, + inputRender: (props) => , + bordered: false, + allowClear: false, + format: 'll', + suffixIcon: null, + $noDefaultPaddings: true, }; diff --git a/datahub-web-react/src/alchemy-components/components/DatePicker/variants/dateSwitcher/components.tsx b/datahub-web-react/src/alchemy-components/components/DatePicker/variants/dateSwitcher/components.tsx index c568b188c4..bd5aa3ce49 100644 --- a/datahub-web-react/src/alchemy-components/components/DatePicker/variants/dateSwitcher/components.tsx +++ b/datahub-web-react/src/alchemy-components/components/DatePicker/variants/dateSwitcher/components.tsx @@ -49,7 +49,7 @@ const CaretWrapper = styled.div<{ $disabled?: boolean }>` & svg { color: ${colors.gray[1800]}; display: flex; - align-items: center; + align-items: start; cursor: ${(props) => (props.$disabled ? 'not-allowed' : 'pointer')}; :hover { diff --git a/datahub-web-react/src/alchemy-components/components/DatePicker/variants/dateSwitcher/props.tsx b/datahub-web-react/src/alchemy-components/components/DatePicker/variants/dateSwitcher/props.tsx index 8dc8d3ff63..112a106f43 100644 --- a/datahub-web-react/src/alchemy-components/components/DatePicker/variants/dateSwitcher/props.tsx +++ b/datahub-web-react/src/alchemy-components/components/DatePicker/variants/dateSwitcher/props.tsx @@ -6,10 +6,5 @@ import { DateSwitcherInput } from '@components/components/DatePicker/variants/da export const DateSwitcherVariantProps: VariantProps = { ...CommonVariantProps, - bordered: false, - allowClear: false, - format: 'll', - suffixIcon: null, inputRender: (props) => , - $noDefaultPaddings: true, }; diff --git a/datahub-web-react/src/alchemy-components/components/Input/components.ts b/datahub-web-react/src/alchemy-components/components/Input/components.ts index 752bcf62ca..eea4d55233 100644 --- a/datahub-web-react/src/alchemy-components/components/Input/components.ts +++ b/datahub-web-react/src/alchemy-components/components/Input/components.ts @@ -31,7 +31,7 @@ export const InputWrapper = styled.div({ export const InputContainer = styled.div( ({ isSuccess, warning, isDisabled, isInvalid }: InputProps) => ({ border: `${borders['1px']} ${getStatusColors(isSuccess, warning, isInvalid)}`, - backgroundColor: isDisabled ? colors.gray[100] : colors.white, + backgroundColor: isDisabled ? colors.gray[1500] : colors.white, paddingRight: spacing.md, }), { @@ -67,6 +67,10 @@ export const InputField = styled.input({ '&:focus': { outline: 'none', }, + + '&:disabled': { + backgroundColor: colors.gray[1500], + }, }); export const Required = styled.span({ diff --git a/datahub-web-react/src/alchemy-components/components/Select/components.ts b/datahub-web-react/src/alchemy-components/components/Select/components.ts index 57002d4128..f4ec1a8167 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/components.ts +++ b/datahub-web-react/src/alchemy-components/components/Select/components.ts @@ -1,9 +1,15 @@ -import { Button, Icon } from '@components'; +import { Button } from '@components'; import { Checkbox } from 'antd'; import styled from 'styled-components'; +import { Icon } from '@components/components/Icon'; import { SelectLabelVariants, SelectSizeOptions, SelectStyleProps } from '@components/components/Select/types'; -import { getOptionLabelStyle, getSelectFontStyles, getSelectStyle } from '@components/components/Select/utils'; +import { + getDropdownStyle, + getOptionLabelStyle, + getSelectFontStyles, + getSelectStyle, +} from '@components/components/Select/utils'; import { formLabelTextStyles, inputPlaceholderTextStyles, @@ -40,7 +46,7 @@ export const SelectLabelContainer = styled.div({ gap: spacing.xsm, lineHeight: typography.lineHeights.none, alignItems: 'center', - maxWidth: 'calc(100% - 54px)', + maxWidth: 'calc(100% - 10px)', }); /** @@ -89,6 +95,7 @@ export const Container = styled.div(({ size, width, $selectLabel }); export const DropdownContainer = styled.div<{ ignoreMaxHeight?: boolean }>(({ ignoreMaxHeight }) => ({ + ...getDropdownStyle(), borderRadius: radius.md, background: colors.white, zIndex: zIndices.dropdown, @@ -220,8 +227,8 @@ export const ArrowIcon = styled.span<{ isOpen: boolean }>(({ isOpen }) => ({ export const StyledCheckbox = styled(Checkbox)({ '.ant-checkbox-checked:not(.ant-checkbox-disabled) .ant-checkbox-inner': { - backgroundColor: colors.violet[500], - borderColor: `${colors.violet[500]} !important`, + backgroundColor: `${(props) => props.theme.styles['primary-color']}`, + borderColor: `${(props) => props.theme.styles['primary-color']} !important`, }, }); diff --git a/datahub-web-react/src/alchemy-components/components/Select/utils.ts b/datahub-web-react/src/alchemy-components/components/Select/utils.ts index 240223dc88..3a6bcd8740 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/utils.ts +++ b/datahub-web-react/src/alchemy-components/components/Select/utils.ts @@ -109,11 +109,11 @@ export const getSelectStyle = (props: SelectStyleProps) => { const baseStyle = { borderRadius: radius.md, - border: `1px solid ${isDisabled ? colors.gray[1800] : colors.gray[100]}`, + border: `1px solid ${colors.gray[100]}`, fontFamily: typography.fonts.body, + backgroundColor: isDisabled ? colors.gray[1500] : colors.white, color: isDisabled ? colors.gray[300] : colors.gray[600], cursor: isDisabled || isReadOnly ? 'not-allowed' : 'pointer', - backgroundColor: isDisabled ? colors.gray[1500] : 'initial', boxShadow: '0px 1px 2px 0px rgba(33, 23, 95, 0.07)', textWrap: 'nowrap', @@ -125,7 +125,7 @@ export const getSelectStyle = (props: SelectStyleProps) => { ...(isOpen ? { borderColor: colors.gray[1800], - outline: `1px solid ${colors.violet[300]}`, + outline: `1px solid ${colors.violet[200]}`, } : {}), @@ -150,3 +150,11 @@ export const getSelectStyle = (props: SelectStyleProps) => { ...minHeightStyles, }; }; + +export const getDropdownStyle = () => { + const baseStyle = { + fontFamily: typography.fonts.body, + }; + + return { ...baseStyle }; +}; diff --git a/datahub-web-react/src/alchemy-components/components/TextArea/components.ts b/datahub-web-react/src/alchemy-components/components/TextArea/components.ts index d173183660..34164a6a1d 100644 --- a/datahub-web-react/src/alchemy-components/components/TextArea/components.ts +++ b/datahub-web-react/src/alchemy-components/components/TextArea/components.ts @@ -38,7 +38,7 @@ export const StyledIcon = styled(Icon)({ export const TextAreaContainer = styled.div( ({ isSuccess, warning, isDisabled, isInvalid }: TextAreaProps) => ({ border: `${borders['1px']} ${getStatusColors(isSuccess, warning, isInvalid)}`, - backgroundColor: isDisabled ? colors.gray[100] : colors.white, + backgroundColor: isDisabled ? colors.gray[1500] : colors.white, }), { ...defaultFlexStyles, @@ -78,6 +78,10 @@ export const TextAreaField = styled.textarea<{ icon?: IconNames }>(({ icon }) => '&::placeholder': { ...inputPlaceholderTextStyles, }, + + '&:disabled': { + backgroundColor: colors.gray[1500], + }, })); export const Label = styled.div({ diff --git a/datahub-web-react/src/alchemy-components/index.ts b/datahub-web-react/src/alchemy-components/index.ts index e25de588d3..84d0c6bdd5 100644 --- a/datahub-web-react/src/alchemy-components/index.ts +++ b/datahub-web-react/src/alchemy-components/index.ts @@ -11,6 +11,7 @@ export * from './components/Button'; export * from './components/CalendarChart'; export * from './components/Card'; export * from './components/Checkbox'; +export * from './components/ColorPicker'; export * from './components/DatePicker'; export * from './components/Drawer'; export * from './components/Dropdown'; @@ -29,6 +30,7 @@ export * from './components/Pills'; export * from './components/Popover'; export * from './components/SearchBar'; export * from './components/Select'; +export * from './components/StructuredPopover'; export * from './components/Switch'; export * from './components/Tabs'; export * from './components/Table'; @@ -38,5 +40,3 @@ export * from './components/Timeline'; export * from './components/Tooltip'; export * from './components/Utils'; export * from './components/WhiskerChart'; -export * from './components/ColorPicker'; -export * from './components/StructuredPopover'; diff --git a/datahub-web-react/src/alchemy-components/theme/utils.ts b/datahub-web-react/src/alchemy-components/theme/utils.ts index f9dcaf871d..7c54f5f329 100644 --- a/datahub-web-react/src/alchemy-components/theme/utils.ts +++ b/datahub-web-react/src/alchemy-components/theme/utils.ts @@ -5,7 +5,6 @@ import { Theme } from '@conf/theme/types'; import { ColorOptions, DEFAULT_VALUE, FontSizeOptions, MiscColorOptions, RotationOptions } from './config'; import { foundations } from './foundations'; -import { semanticTokens } from './semantic-tokens'; const { colors, typography, transform } = foundations; /* @@ -67,5 +66,5 @@ export const getStatusColors = (isSuccess?: boolean, warning?: string, isInvalid if (warning) { return colors.yellow[600]; } - return semanticTokens.colors['border-color']; + return colors.gray[100]; }; diff --git a/datahub-web-react/src/app/entityV2/shared/EntitySearchInput/EntitySearchInput.tsx b/datahub-web-react/src/app/entityV2/shared/EntitySearchInput/EntitySearchInput.tsx index bae355b5ba..4451d8d2a3 100644 --- a/datahub-web-react/src/app/entityV2/shared/EntitySearchInput/EntitySearchInput.tsx +++ b/datahub-web-react/src/app/entityV2/shared/EntitySearchInput/EntitySearchInput.tsx @@ -2,7 +2,7 @@ import { Tooltip } from '@components'; import { Select, Tag } from 'antd'; import React, { useEffect, useState } from 'react'; -import { EntitySearchInputResult } from '@app/entityV2/shared/EntitySearchInput/EntitySearchInputResult'; +import EntitySearchInputResultV2 from '@app/entityV2/shared/EntitySearchInput/EntitySearchInputResultV2'; import { useEntityRegistry } from '@app/useEntityRegistry'; import { useGetEntitiesLazyQuery } from '@graphql/entity.generated'; @@ -173,7 +173,7 @@ export const EntitySearchInput = ({ style={optionStyle} data-testid={`${result.entity.urn}-entity-search-input-result`} > - + ))} diff --git a/datahub-web-react/src/app/entityV2/shared/EntitySearchInput/EntitySearchInputResultV2.tsx b/datahub-web-react/src/app/entityV2/shared/EntitySearchInput/EntitySearchInputResultV2.tsx index 642d0b0855..45c7b52ed5 100644 --- a/datahub-web-react/src/app/entityV2/shared/EntitySearchInput/EntitySearchInputResultV2.tsx +++ b/datahub-web-react/src/app/entityV2/shared/EntitySearchInput/EntitySearchInputResultV2.tsx @@ -1,7 +1,8 @@ -import { Icon, Text } from '@components'; +import { Text } from '@components'; import React from 'react'; import styled from 'styled-components'; +import EntityRegistry from '@app/entityV2/EntityRegistry'; import { getDisplayedEntityType } from '@app/entityV2/shared/containers/profile/header/utils'; import ContextPath from '@app/previewV2/ContextPath'; import { useEntityRegistry } from '@app/useEntityRegistry'; @@ -17,6 +18,7 @@ const Wrapper = styled.div` const TextWrapper = styled.div` display: flex; flex-direction: column; + // TODO: Add this as a prop if needed max-width: 600px; `; @@ -30,7 +32,7 @@ type Props = { }; export default function EntitySearchInputResultV2({ entity }: Props) { - const entityRegistry = useEntityRegistry(); + const entityRegistry = useEntityRegistry() as EntityRegistry; const properties = entityRegistry.getGenericEntityProperties(entity.type, entity); const platformIcon = properties?.platform?.properties?.logoUrl; @@ -38,10 +40,9 @@ export default function EntitySearchInputResultV2({ entity }: Props) { return ( - {!platformIcon && } {platformIcon && } - {entityRegistry.getDisplayName(entity.type, entity)} + {entityRegistry.getDisplayName(entity.type, entity)} void; + showClear?: boolean; + isRequired?: boolean; + icon?: any; +} + +interface EntityOption extends SelectOption { + entity: Entity; +} + +const addToCache = (cache: Map, entity: Entity) => { + cache.set(entity.urn, entity); + return cache; +}; + +const buildCache = (entities: Entity[]) => { + const cache = new Map(); + entities.forEach((entity) => cache.set(entity.urn, entity)); + return cache; +}; + +const isResolutionRequired = (urns: string[], cache: Map) => { + const uncachedUrns = urns.filter((urn) => !cache.has(urn)); + return uncachedUrns.length > 0; +}; + +/** + * A standardized entity search and selection component that allows users to search + * for DataHub entities and select one or multiple entities. Built on top of the + * Select component library infrastructure for consistency. + */ +export const EntitySearchSelect: React.FC = ({ + selectedUrns = [], + entityTypes, + placeholder = 'Search for entities...', + size = 'md', + isMultiSelect = false, + isDisabled = false, + isReadOnly = false, + label, + width = 255, + onUpdate, + showClear = true, + isRequired = false, + icon, +}) => { + const entityRegistry = useEntityRegistry(); + const [entityCache, setEntityCache] = useState>(new Map()); + const [searchQuery, setSearchQuery] = useState(''); + const selectRef = useRef(null); + const dropdownRef = useRef(null); + + const { + isOpen, + isVisible, + close: closeDropdown, + toggle: toggleDropdown, + } = useSelectDropdown(false, selectRef, dropdownRef); + + /** + * Bootstrap by resolving all URNs that are not in the cache yet. + */ + const [getEntities, { data: resolvedEntitiesData, loading: entitiesLoading }] = useGetEntitiesLazyQuery(); + useEffect(() => { + if (isResolutionRequired(selectedUrns, entityCache)) { + getEntities({ variables: { urns: selectedUrns } }); + } + }, [selectedUrns, entityCache, getEntities]); + + /** + * Build cache from resolved entities + */ + useEffect(() => { + if (resolvedEntitiesData && resolvedEntitiesData.entities?.length) { + const entities: Entity[] = (resolvedEntitiesData?.entities as Entity[]) || []; + setEntityCache(buildCache(entities)); + } + }, [resolvedEntitiesData]); + + /** + * Search functionality + */ + const [searchResources, { data: resourcesSearchData, loading: searchLoading }] = + useGetSearchResultsForMultipleLazyQuery(); + + const entityOptions: EntityOption[] = useMemo(() => { + const results = resourcesSearchData?.searchAcrossEntities?.searchResults || []; + return results.map((result) => ({ + label: entityRegistry.getDisplayName(result.entity.type, result.entity), + value: result.entity.urn, + entity: result.entity as Entity, + })); + }, [resourcesSearchData, entityRegistry]); + + const handleSelectClick = useCallback(() => { + if (!isDisabled && !isReadOnly) { + toggleDropdown(); + } + }, [toggleDropdown, isDisabled, isReadOnly]); + + const handleOptionClick = useCallback( + (option: EntityOption) => { + // Add entity to cache + setEntityCache(addToCache(entityCache, option.entity)); + + let newUrns: string[]; + if (isMultiSelect) { + newUrns = selectedUrns.includes(option.value) + ? selectedUrns.filter((urn) => urn !== option.value) + : [...selectedUrns, option.value]; + } else { + newUrns = [option.value]; + closeDropdown(); + } + + onUpdate?.(newUrns); + }, + [selectedUrns, isMultiSelect, onUpdate, closeDropdown, entityCache], + ); + + const handleSearchChange = useCallback( + (value: string) => { + setSearchQuery(value); + searchResources({ + variables: { + input: { + types: entityTypes, + query: value || '*', + start: 0, + count: 10, + }, + }, + }); + }, + [entityTypes, searchResources], + ); + + const handleClearSelection = useCallback(() => { + onUpdate?.([]); + }, [onUpdate]); + + const removeOption = useCallback( + (option: SelectOption) => { + const newUrns = selectedUrns.filter((urn) => urn !== option.value); + onUpdate?.(newUrns); + }, + [selectedUrns, onUpdate], + ); + + /** + * Issue a default search on component mount + */ + useEffect(() => { + searchResources({ + variables: { + input: { + types: entityTypes, + query: '*', + start: 0, + count: 10, + }, + }, + }); + }, [entityTypes, searchResources]); + + // Create options for selected values from cache + const selectedOptions: SelectOption[] = useMemo(() => { + return selectedUrns.map((urn) => { + const entity = entityCache.get(urn); + return { + label: entity ? entityRegistry.getDisplayName(entity.type, entity) : urn, + value: urn, + }; + }); + }, [selectedUrns, entityCache, entityRegistry]); + + const isLoading = entitiesLoading || searchLoading; + + return ( + + {label && {label}} + {isVisible && ( + ( + + + { + const newValue = typeof value === 'function' ? value(searchQuery) : value; + handleSearchChange(newValue); + }} + placeholder="Search..." + icon={{ icon: 'Search' }} + data-testid="entity-search-select-input" + /> + + + {isLoading && ( + + + + )} + {!isLoading && entityOptions.length === 0 && No entities found} + {!isLoading && + entityOptions.map((option) => ( + !isMultiSelect && handleOptionClick(option)} + isSelected={selectedUrns.includes(option.value)} + isMultiSelect={isMultiSelect} + data-testid={`entity-search-option-${option.entity.urn.split(':').pop()}`} + > + {isMultiSelect ? ( + + + + + handleOptionClick(option)} + checked={selectedUrns.includes(option.value)} + /> + + ) : ( + + + + )} + + ))} + + + )} + > + + + {icon && } + + + 0} + isOpen={isOpen} + isDisabled={!!isDisabled} + isReadOnly={!!isReadOnly} + handleClearSelection={handleClearSelection} + fontSize={size} + showClear={!!showClear} + /> + + + )} + + ); +};