feat(components) Consolidate SaaS and OSS component libraries (#14504)

This commit is contained in:
Chris Collins 2025-08-20 18:37:02 -04:00 committed by GitHub
parent a9fbd6154c
commit 8ade946f76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 836 additions and 119 deletions

View File

@ -14,7 +14,7 @@ export const avatarListDefaults: AvatarStackProps = {
};
export const AvatarStack = ({ avatars, size = 'md', showRemainingNumber = true, maxToShow = 4 }: AvatarStackProps) => {
if (avatars?.length === 0) return <div>-</div>;
if (!avatars?.length) return <div>-</div>;
const remainingNumber = avatars.length - maxToShow;
const renderAvatarStack = avatars?.slice(0, maxToShow).map((avatar: AvatarItemProps) => (
<AvatarContainer key={avatar.name}>

View File

@ -13,7 +13,7 @@ export interface AvatarItemProps {
}
export type AvatarStackProps = {
avatars: AvatarItemProps[];
avatars?: AvatarItemProps[];
size?: AvatarSizeOptions;
showRemainingNumber?: boolean;
maxToShow?: number;

View File

@ -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);

View File

@ -126,6 +126,16 @@ export const states = () => (
</GridList>
);
export const sizes = () => (
<GridList>
<Checkbox label="Extra Small" isChecked size="xs" />
<Checkbox label="Small" isChecked size="sm" />
<Checkbox label="Medium" isChecked size="md" />
<Checkbox label="Large" isChecked size="lg" />
<Checkbox label="Extra Large" isChecked size="xl" />
</GridList>
);
export const intermediate = () => {
return (
<GridList>

View File

@ -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<SizeOptions, string> = {
md: '20px',
lg: '22px',
xl: '24px',
inherit: '',
inherit: 'inherit',
};
export function getCheckboxSize(size: SizeOptions) {

View File

@ -79,7 +79,7 @@ export function GraphCard({
)}
<Text color="gray">{emptyMessage}</Text>
{moreInfoModalContent && (
<LinkText color="violet" onClick={() => setShowInfoModal(true)}>
<LinkText color="primary" onClick={() => setShowInfoModal(true)}>
More info
</LinkText>
)}

View File

@ -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 = ({
<IconComponent
sx={{
fontSize: getFontSize(size),
color: getColor(color, colorLevel),
color: getColor(color, colorLevel, theme),
}}
style={{ color: getColor(color, colorLevel) }}
style={{ color: getColor(color, colorLevel, theme) }}
weight={source === 'phosphor' ? weight : undefined} // Phosphor icons use 'weight' prop
/>
</Tooltip>

View File

@ -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 && <Icon icon="ErrorOutline" color="yellow" size="lg" />}
</Tooltip>
)}
{!!onClear && value && <ClearIcon source="phosphor" icon="X" size="lg" onClick={onClear} />}
{isPassword && <Icon onClick={() => setShowPassword(!showPassword)} icon={passwordIcon} size="lg" />}
</InputContainer>
{invalid && error && !errorOnHover && <ErrorMessage>{error}</ErrorMessage>}

View File

@ -22,4 +22,5 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
styles?: React.CSSProperties;
inputStyles?: React.CSSProperties;
inputTestId?: string;
onClear?: () => void;
}

View File

@ -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
<>
<Button onClick={() => setIsOpen(true)}>Open Modal</Button>
{isOpen && (
<Modal {...props} buttons={[...buttons, ...exampleButtons]} onCancel={() => setIsOpen(false)}>
<Modal {...props} buttons={[...(buttons || []), ...exampleButtons]} onCancel={() => setIsOpen(false)}>
{content && <Text color="gray">{content}</Text>}
</Modal>
)}

View File

@ -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({
</HeaderContainer>
}
footer={
!!buttons.length && (
!!buttons?.length && (
<ButtonsContainer>
{buttons.map(({ text, variant, onClick, key, buttonDataTestId, ...buttonProps }, index) => (
<Button

View File

@ -50,6 +50,7 @@ export function Pill({
customIconRenderer,
showLabel,
className,
iconSource,
}: PillProps) {
if (!SUPPORTED_CONFIGURATIONS[variant].includes(color)) {
console.debug(`Unsupported configuration for Pill: variant=${variant}, color=${color}`);
@ -72,11 +73,11 @@ export function Pill({
>
{customIconRenderer
? customIconRenderer()
: leftIcon && <Icon icon={leftIcon} size={size} onClick={onClickLeftIcon} />}
: leftIcon && <Icon icon={leftIcon} size={size} onClick={onClickLeftIcon} source={iconSource} />}
<PillText style={customStyle}>{label}</PillText>
{rightIcon && (
<Button style={{ padding: 0 }} variant="text" onClick={onClickRightIcon}>
<Icon icon={rightIcon} size={size} />
<Icon icon={rightIcon} size={size} source={iconSource} />
</Button>
)}
</PillContainer>

View File

@ -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<PillPropsDefaults>, Omit<HTMLAttribut
color?: ColorOptions;
rightIcon?: string;
leftIcon?: string;
iconSource?: IconSource;
customStyle?: React.CSSProperties;
showLabel?: boolean;
customIconRenderer?: () => void;

View File

@ -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),

View File

@ -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',

View File

@ -23,22 +23,30 @@ export const SearchBar = forwardRef<InputRef, SearchBarProps & Omit<InputProps,
height = searchBarDefaults.height,
allowClear = searchBarDefaults.allowClear,
clearIcon,
forceUncontrolled = false,
onCompositionStart,
onCompositionEnd,
onChange,
...props
},
ref,
) => {
// Override value handling when forceUncontrolled is true
const inputValue = forceUncontrolled ? undefined : value;
return (
<StyledSearchBar
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value, e)}
value={value}
value={inputValue}
prefix={<Icon icon="MagnifyingGlass" source="phosphor" />}
allowClear={clearIcon ? allowClear && { clearIcon } : allowClear}
$width={width}
$height={height}
data-testid="search-bar-input"
ref={ref}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
{...props}
/>
);

View File

@ -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]};
}
`;

View File

@ -10,4 +10,7 @@ export interface SearchBarProps {
clearIcon?: React.ReactNode;
disabled?: boolean;
suffix?: React.ReactNode;
forceUncontrolled?: boolean;
onCompositionStart?: React.CompositionEventHandler<HTMLInputElement>;
onCompositionEnd?: React.CompositionEventHandler<HTMLInputElement>;
}

View File

@ -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);
});
});

View File

@ -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]);
});
});

View File

@ -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

View File

@ -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 (
<SearchInputContainer>
<Input
@ -29,6 +30,7 @@ export default function DropdownSearchBar({ placeholder, value, size, onChange }
value={value}
onChange={(e) => onChange?.(e.target.value)}
style={{ fontSize: size || 'md' }}
onClear={onClear}
/>
</SearchInputContainer>
);

View File

@ -49,8 +49,7 @@ export const useEntityOperations = ({
useGetAutoCompleteResultsLazyQuery();
// Handles search input for entity autocomplete
const handleSearchEntities = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const handleSearchEntities = (value: string) => {
setSearchText(value);
};

View File

@ -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',

View File

@ -14,30 +14,49 @@ export const useRowSelection = <T>(
[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;

View File

@ -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<typeof Tabs>;

View File

@ -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 }) {
<Tooltip title={tab.tooltip}>
<TabViewWrapper id={tab.id} $disabled={tab.disabled} data-testid={tab.dataTestId}>
{tab.name}
{!!tab.count && <Pill label={`${tab.count}`} size="xs" color="violet" />}
{!!tab.count && <Pill label={`${tab.count}`} size="xs" color="primary" />}
</TabViewWrapper>
</Tooltip>
);
@ -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 = (
<StyledTabs
activeKey={selectedTab}
@ -217,6 +317,9 @@ export function Tabs({
onUrlChange(urlMap[key]);
}
}}
$navMarginBottom={styleOptions?.navMarginBottom}
$navMarginTop={styleOptions?.navMarginTop}
$containerHeight={styleOptions?.containerHeight}
$addPaddingLeft={addPaddingLeft}
$hideTabsHeader={!!hideTabsHeader}
$scrollable={scrollToTopOnChange}
@ -225,7 +328,9 @@ export function Tabs({
{tabs.map((tab) => {
return (
<TabPane tab={<TabView tab={tab} />} key={tab.key} disabled={tab.disabled}>
{tab.component}
<ErrorBoundary resetKeys={[tab.key]} variant="tab">
{tab.component}
</ErrorBoundary>
</TabPane>
);
})}

View File

@ -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 (
<CardContainer>
<Header>
<TitleContainer>
<Title>{title}</Title>
<SubTitleContainer>
<SubTitle>{subTitle}</SubTitle>
</SubTitleContainer>
</TitleContainer>
<Switch
disabled={disabled}
isChecked={value}
onChange={(e) => onToggle(e.target.checked)}
colorScheme="primary"
label=""
labelStyle={{ display: 'none' }}
data-testid={toggleDataTestId}
/>
</Header>
{children}
</CardContainer>
);
};

View File

@ -0,0 +1,7 @@
import styled from 'styled-components';
export const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;

View File

@ -0,0 +1 @@
export { ToggleCard } from './ToggleCard';

View File

@ -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;
}

View File

@ -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<HTMLDivElement>(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<Margin>(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 (
<svg width={width} height={height}>
<GridColumns
scale={xScale}
x={minX}
y1={maxY}
y2={minY}
width={chartWidth}
height={chartHeight}
stroke={colors.gray[100]}
numTicks={5}
/>
<div ref={wrapperRef}>
<svg width={width} height={height}>
<Group className="content-group">
<GridColumns
scale={xScale}
x={minX}
y1={maxY}
y2={minY}
width={chartWidth}
height={chartHeight}
stroke={colors.gray[100]}
numTicks={5}
/>
{dataWithOffsets.map(({ datum, offset }) => (
<BoxPlot
key={datum.key}
horizontal
boxWidth={finalBoxSize}
min={datum.min}
firstQuartile={datum.firstQuartile}
median={datum.median}
thirdQuartile={datum.thirdQuartile}
max={datum.max}
valueScale={xScale}
top={offset}
>
{renderWhisker ? (props) => renderWhisker({ datum, tooltip, ...props }) : undefined}
</BoxPlot>
))}
{dataWithOffsets.map(({ datum, offset }) => (
<BoxPlot
key={datum.key}
horizontal
boxWidth={finalBoxSize}
min={datum.min}
firstQuartile={datum.firstQuartile}
median={datum.median}
thirdQuartile={datum.thirdQuartile}
max={datum.max}
valueScale={xScale}
top={offset}
>
{renderWhisker ? (props) => renderWhisker({ datum, tooltip, ...props }) : undefined}
</BoxPlot>
))}
<line x1={0} x2={width} y1={maxY} y2={maxY} strokeWidth={1} stroke={colors.gray[100]} />
<Axis
scale={xScale}
top={maxY}
hideTicks
hideAxisLine
orientation="bottom"
numTicks={7}
tickLabelProps={{
fontSize: '10px',
fontFamily: 'Mulish',
fill: colors.gray[1700],
}}
label={axisLabel}
labelProps={AXIS_LABEL_PROPS}
/>
<line x1={0} x2={width} y1={maxY} y2={maxY} strokeWidth={1} stroke={colors.gray[100]} />
</Group>
<Axis
scale={xScale}
top={maxY}
hideTicks
hideAxisLine
orientation="bottom"
numTicks={NUMBER_OF_TICKS}
tickFormat={abbreviateNumber}
tickLabelProps={{
fontSize: '10px',
fontFamily: 'Mulish',
fill: colors.gray[1700],
}}
label={axisLabel}
labelProps={AXIS_LABEL_PROPS}
tickClassName="bottom-axis-tick"
/>
{tooltip.tooltipOpen &&
renderTooltip?.({
x: tooltip.tooltipLeft,
y: tooltip.tooltipTop,
minY,
maxY,
datum: tooltip.tooltipData,
})}
</svg>
<DynamicMarginSetter
setMargin={setDynamicMargin}
wrapperRef={wrapperRef}
currentMargin={dynamicMargin}
minimalMargin={defaultMargin}
/>
{tooltip.tooltipOpen &&
renderTooltip?.({
x: tooltip.tooltipLeft,
y: tooltip.tooltipTop,
minY,
maxY,
datum: tooltip.tooltipData,
})}
</svg>
</div>
);
}

View File

@ -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';

View File

@ -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<HTMLInputElement>) => void;
debouncedSetFilterText: (value: string) => void;
matchResultCount: number;
numRows: number;
options?: {
@ -25,12 +26,20 @@ export const InlineListSearch: React.FC<InlineListSearchProps> = ({
entityTypeName,
options,
}) => {
const [debouncedSearchText, setDebouncedSearchText] = useState(searchText);
useDebounce(
() => {
debouncedSetFilterText(debouncedSearchText);
},
500,
[debouncedSearchText],
);
return (
<SearchContainer>
<StyledInput
value={searchText}
placeholder={options?.placeholder || 'Search...'}
onChange={debouncedSetFilterText}
onChange={(e) => setDebouncedSearchText(e.target.value)}
icon={options?.hidePrefix ? undefined : { icon: 'MagnifyingGlass', source: 'phosphor' }}
label=""
/>

View File

@ -63,8 +63,7 @@ export const AcrylAssertionListFilters: React.FC<AcrylAssertionListFiltersProps>
}) => {
const [appliedRecommendedFilters, setAppliedRecommendedFilters] = useState([]);
const handleSearchTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const searchText = event.target.value;
const handleSearchTextChange = (searchText: string) => {
handleFilterChange({
...selectedFilters,
filterCriteria: { ...selectedFilters.filterCriteria, searchText },

View File

@ -34,8 +34,7 @@ export const IncidentFilterContainer: React.FC<IncidentAssigneeAvatarStack> = ({
handleFilterChange,
selectedFilters,
}) => {
const handleSearchTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const searchText = event.target.value;
const handleSearchTextChange = (searchText: string) => {
handleFilterChange({
...selectedFilters,
filterCriteria: { ...selectedFilters.filterCriteria, searchText },

View File

@ -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<FallbackProps>;
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 ||
(() => (
<ErrorFallback
variant={variant}
// Custom message for on-prem customers
actionMessage="Please report the error messages from your browser to your Datahub Administrator"
/>
));
return (
<ReactErrorBoundary
FallbackComponent={FallbackComponent}
onError={(e, i) => logError(e, i)}
resetKeys={resetKeys}
>
{children}
</ReactErrorBoundary>
);
};

View File

@ -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<ErrorFallbackProps> = ({ variant, actionMessage = DEFAULT_MESSAGE }) => {
const history = useHistory();
return (
<Container variant={variant}>
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" rx="100" fill="#F9FAFC" />
<path
d="M110 145C110 146.978 109.414 148.911 108.315 150.556C107.216 152.2 105.654 153.482 103.827 154.239C102 154.996 99.9889 155.194 98.0491 154.808C96.1093 154.422 94.3275 153.47 92.9289 152.071C91.5304 150.673 90.578 148.891 90.1922 146.951C89.8063 145.011 90.0043 143 90.7612 141.173C91.5181 139.346 92.7998 137.784 94.4443 136.685C96.0888 135.587 98.0222 135 100 135C102.652 135 105.196 136.054 107.071 137.929C108.946 139.804 110 142.348 110 145ZM100 120C101.326 120 102.598 119.473 103.536 118.536C104.473 117.598 105 116.326 105 115V50C105 48.6739 104.473 47.4021 103.536 46.4645C102.598 45.5268 101.326 45 100 45C98.6739 45 97.4022 45.5268 96.4645 46.4645C95.5268 47.4021 95 48.6739 95 50V115C95 116.326 95.5268 117.598 96.4645 118.536C97.4022 119.473 98.6739 120 100 120Z"
fill="#67739E"
/>
</svg>
<TextContainer>
<Text color="gray" weight="bold" size="xl">
Whoops!
</Text>
<TextContainer>
<Text color="gray" size="lg">
Something didn&apos;t go as planned.
</Text>
<Text color="gray" size="lg">
{actionMessage}
</Text>
</TextContainer>
</TextContainer>
{variant !== 'sidebar' && (
<ButtonContainer>
<Button size="sm" variant="outline" onClick={() => history.go(0)}>
Refresh
</Button>
<Button size="sm" variant="filled" onClick={() => history.push('/')}>
Home
</Button>
</ButtonContainer>
)}
</Container>
);
};
export default ErrorFallback;