diff --git a/datahub-web-react/src/alchemy-components/components/AvatarStack/AvatarStack.tsx b/datahub-web-react/src/alchemy-components/components/AvatarStack/AvatarStack.tsx index 47d109df6f..d71654a3c6 100644 --- a/datahub-web-react/src/alchemy-components/components/AvatarStack/AvatarStack.tsx +++ b/datahub-web-react/src/alchemy-components/components/AvatarStack/AvatarStack.tsx @@ -14,7 +14,7 @@ export const avatarListDefaults: AvatarStackProps = { }; export const AvatarStack = ({ avatars, size = 'md', showRemainingNumber = true, maxToShow = 4 }: AvatarStackProps) => { - if (avatars?.length === 0) return
-
; + if (!avatars?.length) return
-
; const remainingNumber = avatars.length - maxToShow; const renderAvatarStack = avatars?.slice(0, maxToShow).map((avatar: AvatarItemProps) => ( diff --git a/datahub-web-react/src/alchemy-components/components/AvatarStack/types.ts b/datahub-web-react/src/alchemy-components/components/AvatarStack/types.ts index 79f8f5d675..96d099ec05 100644 --- a/datahub-web-react/src/alchemy-components/components/AvatarStack/types.ts +++ b/datahub-web-react/src/alchemy-components/components/AvatarStack/types.ts @@ -13,7 +13,7 @@ export interface AvatarItemProps { } export type AvatarStackProps = { - avatars: AvatarItemProps[]; + avatars?: AvatarItemProps[]; size?: AvatarSizeOptions; showRemainingNumber?: boolean; maxToShow?: number; diff --git a/datahub-web-react/src/alchemy-components/components/Button/utils.ts b/datahub-web-react/src/alchemy-components/components/Button/utils.ts index 8090f755c8..8e0d51f637 100644 --- a/datahub-web-react/src/alchemy-components/components/Button/utils.ts +++ b/datahub-web-react/src/alchemy-components/components/Button/utils.ts @@ -122,17 +122,22 @@ const getButtonColorStyles = (variant: ButtonVariant, color: ColorOptions, theme }; // Generate color styles for button -const getButtonVariantStyles = (variant: ButtonVariant, colorStyles: ColorStyles, color: ColorOptions): CSSObject => { - const isViolet = color === 'violet'; - const violetGradient = `radial-gradient(115.48% 144.44% at 50% -44.44%, var(--buttons-bg-2-for-gradient, #705EE4) 38.97%, var(--buttons-bg, #533FD1) 100%)`; +const getButtonVariantStyles = ( + variant: ButtonVariant, + colorStyles: ColorStyles, + color: ColorOptions, + theme?: Theme, +): CSSObject => { + const isPrimary = color === 'violet' || color === 'primary'; + const primaryGradient = `radial-gradient(115.48% 144.44% at 50% -44.44%, ${theme?.styles?.['primary-color-gradient'] || '#705EE4'} 38.97%, ${theme?.styles?.['primary-color'] || '#533FD1'} 100%)`; const variantStyles = { filled: { - background: isViolet ? violetGradient : colorStyles.bgColor, + background: isPrimary ? primaryGradient : colorStyles.bgColor, border: `1px solid ${colorStyles.borderColor}`, color: colorStyles.textColor, '&:hover': { - background: isViolet ? violetGradient : colorStyles.hoverBgColor, + background: isPrimary ? primaryGradient : colorStyles.hoverBgColor, border: `1px solid ${colorStyles.hoverBgColor}`, boxShadow: shadows.sm, }, @@ -276,7 +281,7 @@ export const getButtonStyle = (props: ButtonStyleProps): CSSObject => { const colorStyles = getButtonColorStyles(variant, color, theme); // Define styles for button - const variantStyles = getButtonVariantStyles(variant, colorStyles, color); + const variantStyles = getButtonVariantStyles(variant, colorStyles, color, theme); const fontStyles = getButtonFontStyles(size); const radiiStyles = getButtonRadiiStyles(isCircle); const paddingStyles = getButtonPadding(size, hasChildren, isCircle, variant); diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx index 7ab62e5c08..3fec5c8840 100644 --- a/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx @@ -126,6 +126,16 @@ export const states = () => ( ); +export const sizes = () => ( + + + + + + + +); + export const intermediate = () => { return ( diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts index dba3716fa2..50eec87274 100644 --- a/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts @@ -21,7 +21,7 @@ export function getCheckboxColor(checked: boolean, error: string, disabled: bool } if (error) return checkboxBackgroundDefault.error; if (checked) return checkboxBackgroundDefault.checked; - return mode === 'background' ? checkboxBackgroundDefault.default : colors.gray[500]; + return mode === 'background' ? checkboxBackgroundDefault.default : colors.gray[1800]; } export function getCheckboxHoverBackgroundColor(checked: boolean, error: string) { @@ -36,7 +36,7 @@ const sizeMap: Record = { md: '20px', lg: '22px', xl: '24px', - inherit: '', + inherit: 'inherit', }; export function getCheckboxSize(size: SizeOptions) { diff --git a/datahub-web-react/src/alchemy-components/components/GraphCard/GraphCard.tsx b/datahub-web-react/src/alchemy-components/components/GraphCard/GraphCard.tsx index 78daca5237..ae83113714 100644 --- a/datahub-web-react/src/alchemy-components/components/GraphCard/GraphCard.tsx +++ b/datahub-web-react/src/alchemy-components/components/GraphCard/GraphCard.tsx @@ -79,7 +79,7 @@ export function GraphCard({ )} {emptyMessage} {moreInfoModalContent && ( - setShowInfoModal(true)}> + setShowInfoModal(true)}> More info )} diff --git a/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx b/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx index a10898f892..450ff603c9 100644 --- a/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx +++ b/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx @@ -6,6 +6,8 @@ import { IconProps, IconPropsDefaults } from '@components/components/Icon/types' import { getIconComponent, getIconNames } from '@components/components/Icon/utils'; import { getColor, getFontSize, getRotationTransform } from '@components/theme/utils'; +import { useCustomTheme } from '@src/customThemeContext'; + export const iconDefaults: IconPropsDefaults = { source: 'material', variant: 'outline', @@ -28,6 +30,7 @@ export const Icon = ({ ...props }: IconProps) => { const { filled, outlined } = getIconNames(); + const { theme } = useCustomTheme(); // Return early if no icon is provided if (!icon) return null; @@ -60,9 +63,9 @@ export const Icon = ({ diff --git a/datahub-web-react/src/alchemy-components/components/Input/Input.tsx b/datahub-web-react/src/alchemy-components/components/Input/Input.tsx index 1fd3b440c7..c5d87140f4 100644 --- a/datahub-web-react/src/alchemy-components/components/Input/Input.tsx +++ b/datahub-web-react/src/alchemy-components/components/Input/Input.tsx @@ -36,6 +36,10 @@ const SearchIcon = styled(Icon)` margin-left: 8px; `; +const ClearIcon = styled(Icon)` + cursor: pointer; +`; + export const Input = ({ value = inputDefaults.value, setValue = inputDefaults.setValue, @@ -55,6 +59,7 @@ export const Input = ({ id, inputStyles, inputTestId, + onClear, ...props }: InputProps) => { // Invalid state is always true if error is present @@ -104,6 +109,7 @@ export const Input = ({ {warning && } )} + {!!onClear && value && } {isPassword && setShowPassword(!showPassword)} icon={passwordIcon} size="lg" />} {invalid && error && !errorOnHover && {error}} diff --git a/datahub-web-react/src/alchemy-components/components/Input/types.ts b/datahub-web-react/src/alchemy-components/components/Input/types.ts index 872bc672f5..cbfa3608aa 100644 --- a/datahub-web-react/src/alchemy-components/components/Input/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Input/types.ts @@ -22,4 +22,5 @@ export interface InputProps extends InputHTMLAttributes { styles?: React.CSSProperties; inputStyles?: React.CSSProperties; inputTestId?: string; + onClear?: () => void; } diff --git a/datahub-web-react/src/alchemy-components/components/Modal/Modal.stories.tsx b/datahub-web-react/src/alchemy-components/components/Modal/Modal.stories.tsx index d4352c74e9..c1215762d8 100644 --- a/datahub-web-react/src/alchemy-components/components/Modal/Modal.stories.tsx +++ b/datahub-web-react/src/alchemy-components/components/Modal/Modal.stories.tsx @@ -77,13 +77,13 @@ function Render({ buttons, buttonExamples, content, ...props }: ModalWithExample buttonExamples?.map((text) => { switch (text) { case 'Cancel': - return { text, variant: 'text', onClick: () => setIsOpen(false) }; + return { text, variant: 'text', onClick: () => setIsOpen(false), key: 'cancel' }; case 'Propose': // TODO: Replace with secondary variant once it's supported - return { text, variant: 'outline', onClick: () => setIsOpen(false) }; + return { text, variant: 'outline', onClick: () => setIsOpen(false), key: 'propose' }; case 'Submit': default: - return { text, variant: 'filled', onClick: () => setIsOpen(false) }; + return { text, variant: 'filled', onClick: () => setIsOpen(false), key: 'submit' }; } }) || []; @@ -91,7 +91,7 @@ function Render({ buttons, buttonExamples, content, ...props }: ModalWithExample <> {isOpen && ( - setIsOpen(false)}> + setIsOpen(false)}> {content && {content}} )} diff --git a/datahub-web-react/src/alchemy-components/components/Modal/Modal.tsx b/datahub-web-react/src/alchemy-components/components/Modal/Modal.tsx index b907ddca48..6284ac66e2 100644 --- a/datahub-web-react/src/alchemy-components/components/Modal/Modal.tsx +++ b/datahub-web-react/src/alchemy-components/components/Modal/Modal.tsx @@ -62,8 +62,8 @@ export interface ModalButton extends ButtonProps { } export interface ModalProps { - buttons: ModalButton[]; - title: string; + buttons?: ModalButton[]; + title: React.ReactNode; subtitle?: string; titlePill?: React.ReactNode; children?: React.ReactNode; @@ -105,7 +105,7 @@ export function Modal({ } footer={ - !!buttons.length && ( + !!buttons?.length && ( {buttons.map(({ text, variant, onClick, key, buttonDataTestId, ...buttonProps }, index) => ( )} diff --git a/datahub-web-react/src/alchemy-components/components/Pills/types.ts b/datahub-web-react/src/alchemy-components/components/Pills/types.ts index dc26bbaaa2..f0afe5a65b 100644 --- a/datahub-web-react/src/alchemy-components/components/Pills/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Pills/types.ts @@ -1,5 +1,6 @@ import { HTMLAttributes } from 'react'; +import { IconSource } from '@src/alchemy-components/components/Icon/types'; import { ColorOptions, PillVariantOptions, SizeOptions } from '@src/alchemy-components/theme/config'; import { Theme } from '@src/conf/theme/types'; @@ -16,6 +17,7 @@ export interface PillProps extends Partial, Omit void; diff --git a/datahub-web-react/src/alchemy-components/components/Pills/utils.ts b/datahub-web-react/src/alchemy-components/components/Pills/utils.ts index f1284559f0..46e30d0cd8 100644 --- a/datahub-web-react/src/alchemy-components/components/Pills/utils.ts +++ b/datahub-web-react/src/alchemy-components/components/Pills/utils.ts @@ -25,7 +25,7 @@ function getPillColorStyles(variant: PillVariantOptions, color: ColorOptions, th } return { - primaryColor: getColor(color, 500, theme), + primaryColor: getColor(color, 700, theme), bgColor: color === 'gray' ? getColor(color, 100, theme) : getColor(color, 0, theme), hoverColor: color === 'gray' ? getColor(color, 100, theme) : getColor(color, 1100, theme), borderColor: getColor('gray', 1800, theme), diff --git a/datahub-web-react/src/alchemy-components/components/Radio/components.ts b/datahub-web-react/src/alchemy-components/components/Radio/components.ts index 3d278ab7f2..bfe0a10966 100644 --- a/datahub-web-react/src/alchemy-components/components/Radio/components.ts +++ b/datahub-web-react/src/alchemy-components/components/Radio/components.ts @@ -4,7 +4,7 @@ import { getRadioBorderColor, getRadioCheckmarkColor } from '@components/compone import { formLabelTextStyles } from '@components/components/commonStyles'; import { borders, colors, radius, spacing } from '@components/theme'; -export const RadioWrapper = styled.div<{ disabled: boolean; error: string }>(({ disabled, error }) => ({ +export const RadioWrapper = styled.div<{ disabled: boolean; error: string }>(({ disabled, error, theme }) => ({ position: 'relative', margin: '20px', width: '20px', @@ -19,7 +19,7 @@ export const RadioWrapper = styled.div<{ disabled: boolean; error: string }>(({ cursor: !disabled ? 'pointer' : 'none', transition: 'border 0.3s ease, outline 0.3s ease', '&:hover': { - border: `${borders['2px']} ${!disabled && !error ? colors.violet[500] : getRadioBorderColor(disabled, error)}`, + border: `${borders['2px']} ${!disabled && !error ? theme.styles['primary-color'] : getRadioBorderColor(disabled, error)}`, outline: !disabled && !error ? `${borders['2px']} ${colors.gray[200]}` : 'none', }, })); @@ -45,7 +45,7 @@ export const Required = styled.span({ }); export const RadioHoverState = styled.div({ - border: `${borders['2px']} ${colors.violet[500]}`, + border: `${borders['2px']} ${(props) => props.theme.styles['primary-color']}`, width: 'calc(100% - -3px)', height: 'calc(100% - -3px)', display: 'flex', diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx index 60ad95380c..cbb3e04f8c 100644 --- a/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx +++ b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx @@ -23,22 +23,30 @@ export const SearchBar = forwardRef { + // Override value handling when forceUncontrolled is true + const inputValue = forceUncontrolled ? undefined : value; + return ( onChange?.(e.target.value, e)} - value={value} + value={inputValue} prefix={} allowClear={clearIcon ? allowClear && { clearIcon } : allowClear} $width={width} $height={height} data-testid="search-bar-input" ref={ref} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} {...props} /> ); diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts b/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts index f8b72061df..6e580c040d 100644 --- a/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts +++ b/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts @@ -11,15 +11,40 @@ export const StyledSearchBar = styled(Input)<{ $width?: string; $height?: string display: flex; align-items: center; border-radius: 8px; + border: 1px solid ${colors.gray[100]}; + box-shadow: 0px 1px 2px 0px rgba(33, 23, 95, 0.07); + transition: all 0.1s ease; + + &.ant-input-affix-wrapper { + border: 1px solid ${colors.gray[100]}; + + &:not(.ant-input-affix-wrapper-disabled) { + &:hover { + border-color: ${colors.gray[100]}; + } + } + + &:focus, + &-focused { + border-color: ${(props) => props.theme.styles['primary-color']}; + box-shadow: 0px 0px 0px 2px ${colors.violet[100]}; + } + } input { color: ${colors.gray[600]}; font-size: ${typography.fontSizes.md} !important; + background-color: transparent; + + &::placeholder { + color: ${colors.gray[400]}; + } } .ant-input-prefix { width: 20px; - color: ${colors.gray[1800]}; + color: ${colors.gray[400]}; + margin-right: 4px; svg { height: 16px; @@ -33,4 +58,9 @@ export const StyledSearchBar = styled(Input)<{ $width?: string; $height?: string border-color: ${({ theme }) => getColor('primary', 300, theme)} !important; box-shadow: none !important; } + + &.ant-input-affix-wrapper-focused { + border-color: ${(props) => props.theme.styles['primary-color']}; + box-shadow: 0px 0px 0px 2px ${colors.violet[100]}; + } `; diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts b/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts index 9021e8cb05..455eb07ac0 100644 --- a/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts +++ b/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts @@ -10,4 +10,7 @@ export interface SearchBarProps { clearIcon?: React.ReactNode; disabled?: boolean; suffix?: React.ReactNode; + forceUncontrolled?: boolean; + onCompositionStart?: React.CompositionEventHandler; + onCompositionEnd?: React.CompositionEventHandler; } diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/__tests__/useNestedSelectOptionChildren.test.ts b/datahub-web-react/src/alchemy-components/components/Select/Nested/__tests__/useNestedSelectOptionChildren.test.ts new file mode 100644 index 0000000000..9401d986e4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/__tests__/useNestedSelectOptionChildren.test.ts @@ -0,0 +1,48 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import useNestedSelectOptionChildren from '@components/components/Select/Nested/useNestedSelectOptionChildren'; + +const option1 = { value: '1', label: '1', isParent: true }; +const option2 = { value: '5', label: '5', isParent: true }; +const children1 = [{ value: '2', label: '2' }, { value: '3', label: '3' }, { value: '4', label: '4' }, option2]; +const children2 = [ + { value: '6', label: '6' }, + { value: '7', label: '7' }, + { value: '8', label: '8' }, +]; + +const parentValueToOptions = { + [option1.value]: children1, + [option2.value]: children2, +}; + +const defaultProps = { + option: option1, + parentValueToOptions, + areParentsSelectable: true, + addOptions: () => {}, +}; + +describe('useNestedSelectOptionChildren', () => { + it('should return props properly when parents are selectable', () => { + const { result } = renderHook(() => useNestedSelectOptionChildren(defaultProps)); + + const { children, selectableChildren, directChildren } = result.current; + + expect(children).toMatchObject([...children1, ...children2]); + expect(selectableChildren).toMatchObject([...children1, ...children2]); + expect(directChildren).toMatchObject(children1); + }); + + it('should return props properly when parents are not selectable', () => { + const { result } = renderHook(() => + useNestedSelectOptionChildren({ ...defaultProps, areParentsSelectable: false }), + ); + + const { children, selectableChildren, directChildren } = result.current; + + expect(children).toMatchObject([...children1, ...children2]); + expect(selectableChildren).toMatchObject([...children1, ...children2].filter((o) => o.value !== option2.value)); + expect(directChildren).toMatchObject(children1); + }); +}); diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/__tests__/useSelectOption.test.ts b/datahub-web-react/src/alchemy-components/components/Select/Nested/__tests__/useSelectOption.test.ts new file mode 100644 index 0000000000..89d11f9887 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/__tests__/useSelectOption.test.ts @@ -0,0 +1,220 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import useNestedOption from '@components/components/Select/Nested/useSelectOption'; + +const option = { value: '1', label: '1', isParent: true }; +const children = [ + { value: '2', label: '2' }, + { value: '3', label: '3' }, + { value: '4', label: '4' }, +]; + +const defaultProps = { + selectedOptions: [], + option, + children, + selectableChildren: children, + areParentsSelectable: true, + implicitlySelectChildren: false, + addOptions: () => {}, + removeOptions: () => {}, + setSelectedOptions: () => {}, + handleOptionChange: () => {}, +}; + +describe('useSelectChildren - areParentsSelectable is true', () => { + it('should return props properly when parent is not selected and no children are selected', () => { + const mockAddOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + addOptions: mockAddOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(false); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(false); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockAddOptions).toHaveBeenCalledWith([option, ...children]); + }); + + it('should return props properly when parent is selected and no children are selected', () => { + const mockAddOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + selectedOptions: [option], + addOptions: mockAddOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(true); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(true); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockAddOptions).toHaveBeenCalledWith([option, ...children]); + }); + + it('should return props properly when parent is not selected and some children are selected', () => { + const mockAddOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + selectedOptions: [children[0]], + addOptions: mockAddOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(true); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(false); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockAddOptions).toHaveBeenCalledWith([option, ...children]); + }); + + it('should return props properly when parent and children are selected', () => { + const mockRemoveOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + selectedOptions: [option, ...children], + removeOptions: mockRemoveOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(false); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(true); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockRemoveOptions).toHaveBeenCalledWith([option, ...children]); + }); +}); + +describe('useSelectChildren - areParentsSelectable is false', () => { + it('should return props properly when parent is not selected and no children are selected', () => { + const mockAddOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + areParentsSelectable: false, + addOptions: mockAddOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(false); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(false); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockAddOptions).toHaveBeenCalledWith(children); + }); + + it('should return props properly when some children are selected', () => { + const mockAddOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + areParentsSelectable: false, + selectedOptions: [children[0]], + addOptions: mockAddOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(true); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(false); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockAddOptions).toHaveBeenCalledWith(children); + }); + + it('should return props properly when all children are selected', () => { + const mockRemoveOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + areParentsSelectable: false, + selectedOptions: [option, ...children], + removeOptions: mockRemoveOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(false); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(true); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockRemoveOptions).toHaveBeenCalledWith([option, ...children]); + }); +}); + +describe('useSelectChildren - areParentsSelectable is true & implicitlySelectChildren is true', () => { + it('should return props properly when parent is not selected and no children are selected', () => { + const mockSetSelectedOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + implicitlySelectChildren: true, + setSelectedOptions: mockSetSelectedOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(false); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(false); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockSetSelectedOptions).toHaveBeenCalledWith([option]); + }); + + it('should return props properly when parent is selected and no children are selected', () => { + const mockRemoveOptions = vi.fn(); + const { result } = renderHook(() => + useNestedOption({ + ...defaultProps, + implicitlySelectChildren: true, + selectedOptions: [option], + removeOptions: mockRemoveOptions, + }), + ); + + const { selectOption, isPartialSelected, isParentMissingChildren, isSelected, isImplicitlySelected } = + result.current; + + expect(isPartialSelected).toBe(true); + expect(isParentMissingChildren).toBe(false); + expect(isSelected).toBe(true); + expect(isImplicitlySelected).toBe(false); + selectOption(); + expect(mockRemoveOptions).toHaveBeenCalledWith([option]); + }); +}); diff --git a/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx b/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx index 146a5e6218..a2ed4d6ccf 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx @@ -187,6 +187,12 @@ const meta: Meta = { defaultValue: { summary: 'undefined' }, }, }, + emptyState: { + description: 'Custom empty state component to render when no options are available', + table: { + defaultValue: { summary: 'undefined' }, + }, + }, }, // Define defaults diff --git a/datahub-web-react/src/alchemy-components/components/Select/private/DropdownSearchBar.tsx b/datahub-web-react/src/alchemy-components/components/Select/private/DropdownSearchBar.tsx index 7c62ad6171..b1b887bd83 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/private/DropdownSearchBar.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/private/DropdownSearchBar.tsx @@ -16,9 +16,10 @@ interface DropdownSearchBarProps { value?: string; size?: SelectSizeOptions; onChange?: (value: string) => void; + onClear?: () => void; } -export default function DropdownSearchBar({ placeholder, value, size, onChange }: DropdownSearchBarProps) { +export default function DropdownSearchBar({ placeholder, value, size, onChange, onClear }: DropdownSearchBarProps) { return ( onChange?.(e.target.value)} style={{ fontSize: size || 'md' }} + onClear={onClear} /> ); diff --git a/datahub-web-react/src/alchemy-components/components/SelectItemsPopover/hooks.tsx b/datahub-web-react/src/alchemy-components/components/SelectItemsPopover/hooks.tsx index 4787ff7ad4..f87dae503a 100644 --- a/datahub-web-react/src/alchemy-components/components/SelectItemsPopover/hooks.tsx +++ b/datahub-web-react/src/alchemy-components/components/SelectItemsPopover/hooks.tsx @@ -49,8 +49,7 @@ export const useEntityOperations = ({ useGetAutoCompleteResultsLazyQuery(); // Handles search input for entity autocomplete - const handleSearchEntities = (e: React.ChangeEvent) => { - const { value } = e.target; + const handleSearchEntities = (value: string) => { setSearchText(value); }; diff --git a/datahub-web-react/src/alchemy-components/components/Table/components.ts b/datahub-web-react/src/alchemy-components/components/Table/components.ts index 3065ffaa62..e9eabeedc4 100644 --- a/datahub-web-react/src/alchemy-components/components/Table/components.ts +++ b/datahub-web-react/src/alchemy-components/components/Table/components.ts @@ -30,7 +30,6 @@ export const BaseTable = styled.table({ export const TableHeader = styled.thead({ backgroundColor: colors.gray[1500], - boxShadow: '0px 1px 1px rgba(0, 0, 0, 0.1)', borderRadius: radius.lg, borderBottom: `1px solid ${colors.gray[1400]}`, position: 'sticky', diff --git a/datahub-web-react/src/alchemy-components/components/Table/useRowSelection.ts b/datahub-web-react/src/alchemy-components/components/Table/useRowSelection.ts index b5cec5fd9f..20cb7e9cf4 100644 --- a/datahub-web-react/src/alchemy-components/components/Table/useRowSelection.ts +++ b/datahub-web-react/src/alchemy-components/components/Table/useRowSelection.ts @@ -14,30 +14,49 @@ export const useRowSelection = ( [data, getCheckboxProps], ); - const isSelectAll = useMemo( + const selectableRowsKeys = useMemo( () => - data.length > 0 && - selectedRowKeys.length !== 0 && - selectedRowKeys.length === data.length - disabledRows.length, - [selectedRowKeys, data, disabledRows], + rowSelection + ? data + .filter((row) => !rowSelection.getCheckboxProps?.(row).disabled) + .map((row, index) => getRowKey(row, index, rowKey)) + : [], + [data, rowSelection, rowKey], + ); + + const isSelectAll = useMemo( + () => selectableRowsKeys.length > 0 && selectableRowsKeys.every((key) => selectedRowKeys.includes(key)), + [selectedRowKeys, selectableRowsKeys], ); const isSelectAllDisabled = useMemo(() => data.length === disabledRows.length, [data, disabledRows]); const isIntermediate = useMemo( - () => selectedRowKeys.length > 0 && selectedRowKeys.length < data.length - disabledRows.length, - [selectedRowKeys, data, disabledRows], + () => + selectableRowsKeys.some((key) => selectedRowKeys.includes(key)) && + !selectableRowsKeys.every((key) => selectedRowKeys.includes(key)), + [selectedRowKeys, selectableRowsKeys], ); const handleSelectAll = useCallback(() => { if (!rowSelection) return; - const newSelectedKeys = isSelectAll - ? [] - : data - .filter((row) => !rowSelection.getCheckboxProps?.(row).disabled) - .map((row, index) => getRowKey(row, index, rowKey)); - onChange?.(newSelectedKeys, data); - }, [rowSelection, isSelectAll, data, onChange, rowKey]); + + const currentPageKeys = selectableRowsKeys; + let newSelectedKeys; + + if (isSelectAll) { + // Deselect only the current page's rows + newSelectedKeys = selectedRowKeys.filter((key) => !currentPageKeys.includes(key)); + } else { + // Add current page's rows to existing selection (removing duplicates) + const keysToAdd = currentPageKeys.filter((key) => !selectedRowKeys.includes(key)); + newSelectedKeys = [...selectedRowKeys, ...keysToAdd]; + } + + const selectedRows = data.filter((row, idx) => newSelectedKeys.includes(getRowKey(row, idx, rowKey))); + + onChange?.(newSelectedKeys, selectedRows); + }, [rowSelection, isSelectAll, selectedRowKeys, selectableRowsKeys, data, onChange, rowKey]); const handleRowSelect = (record: T, index: number) => { if (!rowSelection) return; diff --git a/datahub-web-react/src/alchemy-components/components/Tabs/Tabs.stories.tsx b/datahub-web-react/src/alchemy-components/components/Tabs/Tabs.stories.tsx index c4f9b43e99..b61a24957c 100644 --- a/datahub-web-react/src/alchemy-components/components/Tabs/Tabs.stories.tsx +++ b/datahub-web-react/src/alchemy-components/components/Tabs/Tabs.stories.tsx @@ -70,6 +70,7 @@ const meta = { argTypes: { tabs: { description: 'The tabs you want to display', + control: 'object', }, selectedTab: { description: 'A controlled pieces of state for which tab is selected. This is the key of the tab', @@ -90,6 +91,13 @@ const meta = { getCurrentUrl: { description: 'A custom function to get the current URL. Defaults to window.location.pathname', }, + secondary: { + description: 'Whether to render the tabs in a secondary style', + }, + styleOptions: { + description: 'Style options for the tabs component', + control: 'object', + }, scrollToTopOnChange: { description: 'Whether to scroll to the top of the tabs container when switching tabs', control: { type: 'boolean' }, @@ -104,11 +112,13 @@ const meta = { control: { type: 'boolean' }, }, }, - - // Args for the story - args: { tabs: exampleTabs, + styleOptions: { + navMarginBottom: 16, + navMarginTop: 0, + containerHeight: 'auto', + }, }, } satisfies Meta; diff --git a/datahub-web-react/src/alchemy-components/components/Tabs/Tabs.tsx b/datahub-web-react/src/alchemy-components/components/Tabs/Tabs.tsx index 8aaa8b2033..a8d5bc9ef1 100644 --- a/datahub-web-react/src/alchemy-components/components/Tabs/Tabs.tsx +++ b/datahub-web-react/src/alchemy-components/components/Tabs/Tabs.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components'; import { Pill } from '@components/components/Pills'; import { Tooltip } from '@components/components/Tooltip'; +import { ErrorBoundary } from '@app/sharedV2/ErrorHandling/ErrorBoundary'; import { colors } from '@src/alchemy-components/theme'; const ScrollableTabsContainer = styled.div<{ $maxHeight?: string }>` @@ -14,13 +15,24 @@ const ScrollableTabsContainer = styled.div<{ $maxHeight?: string }>` position: relative; `; -const StyledTabs = styled(AntTabs)<{ +const StyledTabsPrimary = styled(AntTabs)<{ + $navMarginBottom?: number; + $navMarginTop?: number; + $containerHeight?: 'full' | 'auto'; $addPaddingLeft?: boolean; $hideTabsHeader: boolean; $scrollable?: boolean; $stickyHeader?: boolean; }>` - ${({ $scrollable }) => !$scrollable && 'flex: 1;'} + ${({ $scrollable, $containerHeight }) => { + if (!$scrollable) { + if ($containerHeight === 'full') { + return 'height: 100%;'; + } + return 'flex: 1;'; + } + return ''; + }} ${({ $scrollable }) => !$scrollable && 'overflow: hidden;'} .ant-tabs-tab { @@ -79,7 +91,85 @@ const StyledTabs = styled(AntTabs)<{ } .ant-tabs-nav { - margin-bottom: 0px; + margin-bottom: ${(props) => props.$navMarginBottom ?? 8}px; + margin-top: ${(props) => props.$navMarginTop ?? 0}px; + } +`; + +const StyledTabsSecondary = styled(AntTabs)<{ + $navMarginBottom?: number; + $navMarginTop?: number; + $containerHeight?: 'full' | 'auto'; + $addPaddingLeft?: boolean; + $hideTabsHeader: boolean; + $scrollable?: boolean; + $stickyHeader?: boolean; +}>` + ${(props) => + props.$containerHeight === 'full' + ? ` + height: 100%; + ` + : ` + flex: 1; + `} + overflow: hidden; + + .ant-tabs-tab { + padding: 8px 8px; + border-radius: 4px; + font-size: 14px; + color: ${colors.gray[600]}; + } + + ${({ $addPaddingLeft }) => + $addPaddingLeft + ? ` + .ant-tabs-tab { + margin-left: 8px; + } + ` + : ` + .ant-tabs-tab + .ant-tabs-tab { + margin-left: 8px; + } + `} + + ${({ $hideTabsHeader }) => + $hideTabsHeader && + ` + .ant-tabs-nav { + display: none; + } + `} + + .ant-tabs-tab-active { + background-color: ${(props) => props.theme.styles['primary-color-light']}80; + } + + .ant-tabs-tab-active .ant-tabs-tab-btn { + color: ${(props) => props.theme.styles['primary-color']}; + font-weight: 600; + } + + .ant-tabs-ink-bar { + background-color: transparent; + } + + .ant-tabs-content-holder { + display: flex; + } + + .ant-tabs-tabpane { + height: 100%; + } + + .ant-tabs-nav { + margin-bottom: ${(props) => props.$navMarginBottom ?? 0}px; + margin-top: ${(props) => props.$navMarginTop ?? 0}px; + &::before { + display: none; + } } ${({ $stickyHeader }) => @@ -126,7 +216,7 @@ function TabView({ tab }: { tab: Tab }) { {tab.name} - {!!tab.count && } + {!!tab.count && } ); @@ -152,6 +242,12 @@ export interface Props { onUrlChange?: (url: string) => void; defaultTab?: string; getCurrentUrl?: () => string; + secondary?: boolean; + styleOptions?: { + containerHeight?: 'full' | 'auto'; + navMarginBottom?: number; + navMarginTop?: number; + }; addPaddingLeft?: boolean; hideTabsHeader?: boolean; scrollToTopOnChange?: boolean; @@ -167,6 +263,8 @@ export function Tabs({ onUrlChange = (url) => window.history.replaceState({}, '', url), defaultTab, getCurrentUrl = () => window.location.pathname, + secondary, + styleOptions, addPaddingLeft, hideTabsHeader, scrollToTopOnChange = false, @@ -208,6 +306,8 @@ export function Tabs({ } }, [getCurrentUrl, onChange, onUrlChange, selectedTab, urlMap, urlToTabMap, defaultTab]); + const StyledTabs = secondary ? StyledTabsSecondary : StyledTabsPrimary; + const tabsContent = ( { return ( } key={tab.key} disabled={tab.disabled}> - {tab.component} + + {tab.component} + ); })} diff --git a/datahub-web-react/src/alchemy-components/components/ToggleCard/ToggleCard.tsx b/datahub-web-react/src/alchemy-components/components/ToggleCard/ToggleCard.tsx new file mode 100644 index 0000000000..a6cda6f9bf --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/ToggleCard/ToggleCard.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { + CardContainer, + SubTitle, + SubTitleContainer, + Title, + TitleContainer, +} from '@components/components/Card/components'; +import { Switch } from '@components/components/Switch'; +import { Header } from '@components/components/ToggleCard/components'; +import { ToggleCardProps } from '@components/components/ToggleCard/types'; + +export const ToggleCard = ({ + title, + subTitle, + value, + disabled, + onToggle, + toggleDataTestId, + children, +}: ToggleCardProps) => { + return ( + +
+ + {title} + + {subTitle} + + + onToggle(e.target.checked)} + colorScheme="primary" + label="" + labelStyle={{ display: 'none' }} + data-testid={toggleDataTestId} + /> +
+ {children} +
+ ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/ToggleCard/components.ts b/datahub-web-react/src/alchemy-components/components/ToggleCard/components.ts new file mode 100644 index 0000000000..405a8765d5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/ToggleCard/components.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +export const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; diff --git a/datahub-web-react/src/alchemy-components/components/ToggleCard/index.ts b/datahub-web-react/src/alchemy-components/components/ToggleCard/index.ts new file mode 100644 index 0000000000..d16e7cd571 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/ToggleCard/index.ts @@ -0,0 +1 @@ +export { ToggleCard } from './ToggleCard'; diff --git a/datahub-web-react/src/alchemy-components/components/ToggleCard/types.ts b/datahub-web-react/src/alchemy-components/components/ToggleCard/types.ts new file mode 100644 index 0000000000..afbd1550c5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/ToggleCard/types.ts @@ -0,0 +1,11 @@ +import React from 'react'; + +export interface ToggleCardProps { + title: string; + value: boolean; + disabled?: boolean; + subTitle?: React.ReactNode; + children?: React.ReactNode; + onToggle: (value: boolean) => void; + toggleDataTestId?: string; +} diff --git a/datahub-web-react/src/alchemy-components/components/WhiskerChart/WhiskerChart.tsx b/datahub-web-react/src/alchemy-components/components/WhiskerChart/WhiskerChart.tsx index 603d4b56c4..e0bc2c18f3 100644 --- a/datahub-web-react/src/alchemy-components/components/WhiskerChart/WhiskerChart.tsx +++ b/datahub-web-react/src/alchemy-components/components/WhiskerChart/WhiskerChart.tsx @@ -1,12 +1,15 @@ import { Axis } from '@visx/axis'; import { GridColumns } from '@visx/grid'; +import { Group } from '@visx/group'; import { ParentSize } from '@visx/responsive'; import { scaleLinear } from '@visx/scale'; import { BoxPlot } from '@visx/stats'; import { useTooltip } from '@visx/tooltip'; -import React, { useMemo } from 'react'; +import { Margin } from '@visx/xychart'; +import React, { useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; +import DynamicMarginSetter from '@components/components/BarChart/components/DynamicMarginSetter'; import { AXIS_LABEL_MARGIN_OFFSET, AXIS_LABEL_PROPS, @@ -20,6 +23,7 @@ import { WhiskerTooltipDatum, } from '@components/components/WhiskerChart/types'; import { computeWhiskerOffset } from '@components/components/WhiskerChart/utils'; +import { abbreviateNumber } from '@components/components/dataviz/utils'; import { colors } from '@src/alchemy-components/theme'; @@ -29,6 +33,8 @@ const ChartWrapper = styled.div` position: relative; `; +const NUMBER_OF_TICKS = 7; + function InternalWhiskerChart({ data, width, @@ -40,16 +46,27 @@ function InternalWhiskerChart({ renderTooltip = whiskerChartDefaults.renderTooltip, renderWhisker = whiskerChartDefaults.renderWhisker, }: InternalWhiskerChartProps) { - const axisLabelMarginOffset = axisLabel !== undefined ? AXIS_LABEL_MARGIN_OFFSET : 0; - const margin = { left: 10, top: 0, right: 10, bottom: 20 + axisLabelMarginOffset }; + const wrapperRef = useRef(null); + + const defaultMargin = useMemo(() => { + const axisLabelMarginOffset = axisLabel !== undefined ? AXIS_LABEL_MARGIN_OFFSET : 0; + return { + top: 0, + right: 0, + bottom: 20 + axisLabelMarginOffset, + left: 0, + }; + }, [axisLabel]); + + const [dynamicMargin, setDynamicMargin] = useState(defaultMargin); const finalBoxSize = boxSize ?? DEFAULT_BOX_SIZE; const finalGap = gap ?? DEFAULT_GAP_BETWEEN_WHISKERS; const minY = 0; - const maxY = height - margin.bottom; - const minX = margin.left; - const maxX = width - margin.right; + const maxY = height - dynamicMargin.bottom; + const minX = dynamicMargin.left; + const maxX = width - dynamicMargin.right; const chartHeight = maxY - minY; const chartWidth = maxX - minX; @@ -75,61 +92,74 @@ function InternalWhiskerChart({ }, [minX, maxX, minValue, maxValue]); return ( - - +
+ + + - {dataWithOffsets.map(({ datum, offset }) => ( - - {renderWhisker ? (props) => renderWhisker({ datum, tooltip, ...props }) : undefined} - - ))} + {dataWithOffsets.map(({ datum, offset }) => ( + + {renderWhisker ? (props) => renderWhisker({ datum, tooltip, ...props }) : undefined} + + ))} - - + + + - {tooltip.tooltipOpen && - renderTooltip?.({ - x: tooltip.tooltipLeft, - y: tooltip.tooltipTop, - minY, - maxY, - datum: tooltip.tooltipData, - })} - + + + {tooltip.tooltipOpen && + renderTooltip?.({ + x: tooltip.tooltipLeft, + y: tooltip.tooltipTop, + minY, + maxY, + datum: tooltip.tooltipData, + })} + +
); } diff --git a/datahub-web-react/src/alchemy-components/index.ts b/datahub-web-react/src/alchemy-components/index.ts index 8748a60830..71b87d58f8 100644 --- a/datahub-web-react/src/alchemy-components/index.ts +++ b/datahub-web-react/src/alchemy-components/index.ts @@ -40,6 +40,7 @@ export * from './components/Table'; export * from './components/Text'; export * from './components/TextArea'; export * from './components/Timeline'; +export * from './components/ToggleCard'; export * from './components/Tooltip'; export * from './components/Utils'; export * from './components/WhiskerChart'; diff --git a/datahub-web-react/src/app/entityV2/shared/components/search/InlineListSearch.tsx b/datahub-web-react/src/app/entityV2/shared/components/search/InlineListSearch.tsx index 1a849bf9d6..270108ffc5 100644 --- a/datahub-web-react/src/app/entityV2/shared/components/search/InlineListSearch.tsx +++ b/datahub-web-react/src/app/entityV2/shared/components/search/InlineListSearch.tsx @@ -1,11 +1,12 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useDebounce } from 'react-use'; import { MatchLabelText, SearchContainer, StyledInput } from '@app/entityV2/shared/components/search/styledComponents'; import { pluralize } from '@src/app/shared/textUtil'; interface InlineListSearchProps { searchText: string; - debouncedSetFilterText: (event: React.ChangeEvent) => void; + debouncedSetFilterText: (value: string) => void; matchResultCount: number; numRows: number; options?: { @@ -25,12 +26,20 @@ export const InlineListSearch: React.FC = ({ entityTypeName, options, }) => { + const [debouncedSearchText, setDebouncedSearchText] = useState(searchText); + useDebounce( + () => { + debouncedSetFilterText(debouncedSearchText); + }, + 500, + [debouncedSearchText], + ); return ( setDebouncedSearchText(e.target.value)} icon={options?.hidePrefix ? undefined : { icon: 'MagnifyingGlass', source: 'phosphor' }} label="" /> diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListFilters.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListFilters.tsx index 082e9e2dd6..dc5ff41a8c 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListFilters.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListFilters.tsx @@ -63,8 +63,7 @@ export const AcrylAssertionListFilters: React.FC }) => { const [appliedRecommendedFilters, setAppliedRecommendedFilters] = useState([]); - const handleSearchTextChange = (event: React.ChangeEvent) => { - const searchText = event.target.value; + const handleSearchTextChange = (searchText: string) => { handleFilterChange({ ...selectedFilters, filterCriteria: { ...selectedFilters.filterCriteria, searchText }, diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx index 8deeb917a7..f239dd4018 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx @@ -34,8 +34,7 @@ export const IncidentFilterContainer: React.FC = ({ handleFilterChange, selectedFilters, }) => { - const handleSearchTextChange = (event: React.ChangeEvent) => { - const searchText = event.target.value; + const handleSearchTextChange = (searchText: string) => { handleFilterChange({ ...selectedFilters, filterCriteria: { ...selectedFilters.filterCriteria, searchText }, diff --git a/datahub-web-react/src/app/sharedV2/ErrorHandling/ErrorBoundary.tsx b/datahub-web-react/src/app/sharedV2/ErrorHandling/ErrorBoundary.tsx new file mode 100644 index 0000000000..af1a5e1e3b --- /dev/null +++ b/datahub-web-react/src/app/sharedV2/ErrorHandling/ErrorBoundary.tsx @@ -0,0 +1,44 @@ +import React, { ReactNode } from 'react'; +import { FallbackProps, ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; + +import ErrorFallback, { ErrorVariant } from '@app/sharedV2/ErrorHandling/ErrorFallback'; + +type ErrorBoundaryProps = { + variant?: ErrorVariant; + children: ReactNode; + fallback?: React.ComponentType; + resetKeys?: string[]; +}; + +const logError = (error: Error, info: { componentStack: string }) => { + console.group('🔴 UI Crash Error Report'); + console.error('Error:', error); + console.error('Component Info:', info); + console.error('URL:', window.location.href); + + console.warn('🔧 ACTION REQUIRED: Please report this error to your Datahub Administrator'); + console.warn('📧 Include the above error details in your report'); + console.groupEnd(); +}; + +export const ErrorBoundary = ({ children, variant = 'route', fallback, resetKeys }: ErrorBoundaryProps) => { + const FallbackComponent = + fallback || + (() => ( + + )); + + return ( + logError(e, i)} + resetKeys={resetKeys} + > + {children} + + ); +}; diff --git a/datahub-web-react/src/app/sharedV2/ErrorHandling/ErrorFallback.tsx b/datahub-web-react/src/app/sharedV2/ErrorHandling/ErrorFallback.tsx new file mode 100644 index 0000000000..8e1aa5fbfb --- /dev/null +++ b/datahub-web-react/src/app/sharedV2/ErrorHandling/ErrorFallback.tsx @@ -0,0 +1,94 @@ +import { Button, Text } from '@components'; +import React from 'react'; +import { useHistory } from 'react-router'; +import styled, { css } from 'styled-components'; + +type ErrorFallbackProps = { + variant?: ErrorVariant; + actionMessage?: string; +}; +const DEFAULT_MESSAGE = 'Our team has been notified of this unexpected error and is working on a resolution.'; +export type ErrorVariant = 'route' | 'tab' | 'sidebar'; + +const getVariantStyles = (variant: ErrorVariant) => { + // only route needs the border shadows and margins + if (variant === 'route') { + return css` + border-radius: ${(props) => props.theme.styles['border-radius-navbar-redesign']}; + box-shadow: ${(props) => props.theme.styles['box-shadow-navbar-redesign']}; + margin: 5px; + `; + } + return ''; +}; + +const Container = styled.div<{ variant?: ErrorVariant }>` + background-color: white; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + gap: 16px; + + ${(props) => getVariantStyles(props.variant || 'route')} + + svg { + width: 160px; + height: 160px; + } +`; + +const ButtonContainer = styled.div` + display: flex; + gap: 8px; +`; + +const TextContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 0 20px; + text-align: center; +`; + +const ErrorFallback: React.FC = ({ variant, actionMessage = DEFAULT_MESSAGE }) => { + const history = useHistory(); + return ( + + + + + + + + Whoops! + + + + Something didn't go as planned. + + + {actionMessage} + + + + {variant !== 'sidebar' && ( + + + + + )} + + ); +}; + +export default ErrorFallback;