Skip to content

Commit

Permalink
itemWrapperUtils tests (deephaven#1890)
Browse files Browse the repository at this point in the history
  • Loading branch information
bmingles committed Apr 29, 2024
1 parent 7b8d221 commit 26d1c7a
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 34 deletions.
2 changes: 1 addition & 1 deletion packages/components/src/spectrum/listView/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { SpectrumListViewProps } from '@adobe/react-spectrum';
import { EMPTY_FUNCTION } from '@deephaven/utils';
import {
ItemElementOrPrimitive,
ItemKey,
ItemSelection,
NormalizedItem,
Expand All @@ -11,6 +10,7 @@ import {
wrapItemChildren,
} from '../utils';
import { ListViewWrapper } from './ListViewWrapper';
import { ItemElementOrPrimitive } from '../shared';

export type ListViewProps = {
children: ItemElementOrPrimitive | ItemElementOrPrimitive[];
Expand Down
40 changes: 38 additions & 2 deletions packages/components/src/spectrum/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,41 @@
* See https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/collections/src/Item.ts#L17
* https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/collections/src/Section.ts#L18
*/
export { Item, Section } from '@adobe/react-spectrum';
export type { ItemProps, SectionProps } from '@react-types/shared';
import { Section as SpectrumSection } from '@adobe/react-spectrum';
import type {
ItemElement,
ItemRenderer,
SectionProps as SpectrumSectionProps,
} from '@react-types/shared';

export { Item } from '@adobe/react-spectrum';
export type { ItemProps } from '@react-types/shared';

/*
* We support primitive values as shorthand for `Item` elements in certain
* components. This type represents this augmentation of the Spectrum types.
*/
export type ItemElementOrPrimitive<T = unknown> =
| number
| string
| boolean
| ItemElement<T>;

/**
* Spectrum SectionProps augmented with support for primitive item children.
*/
export type SectionProps<T> = Omit<SpectrumSectionProps<T>, 'children'> & {
children:
| ItemElement<T>
| ItemElement<T>[]
| ItemRenderer<T>
| ItemElementOrPrimitive<T>
| ItemElementOrPrimitive<T>[];
};

/**
* Re-export Spectrum Section component with augmented props type.
*/
export const Section = SpectrumSection as <T>(
props: SectionProps<T>
) => JSX.Element;
30 changes: 20 additions & 10 deletions packages/components/src/spectrum/utils/itemUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { SpectrumPickerProps } from '@adobe/react-spectrum';
import type { ItemRenderer } from '@react-types/shared';
import { isElementOfType } from '@deephaven/react-hooks';
import { KeyedItem, SelectionT } from '@deephaven/utils';
import { Item, ItemProps, Section, SectionProps } from '../shared';
import {
Item,
ItemElementOrPrimitive,
ItemProps,
Section,
SectionProps,
} from '../shared';
import { PopperOptions } from '../../popper';
import { Text } from '../Text';
import ItemContent from '../ItemContent';
Expand All @@ -20,14 +26,20 @@ export const ITEM_EMPTY_STRING_TEXT_VALUE = 'Empty';
* an incoming prop.
*/
type SectionPropsNoItemRenderer<T> = Omit<SectionProps<T>, 'children'> & {
children: Exclude<SectionProps<T>['children'], ItemRenderer<T>>;
children:
| Exclude<SectionProps<T>['children'], ItemRenderer<T>>
| ItemElementOrPrimitive<T>
| ItemElementOrPrimitive<T>[];
};

export type ItemElement = ReactElement<ItemProps<unknown>>;
export type SectionElement = ReactElement<SectionPropsNoItemRenderer<unknown>>;
export type ItemElement<T = unknown> = ReactElement<ItemProps<T>>;
export type SectionElement<T = unknown> = ReactElement<
SectionPropsNoItemRenderer<T>
>;

export type ItemElementOrPrimitive = number | string | boolean | ItemElement;
export type ItemOrSection = ItemElementOrPrimitive | SectionElement;
export type ItemOrSection<T = unknown> =
| ItemElementOrPrimitive<T>
| SectionElement<T>;

// Picker uses `icon` slot. ListView can use `image` or `illustration` slots.
// https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/picker/src/Picker.tsx#L194
Expand Down Expand Up @@ -152,7 +164,7 @@ export async function getPositionOfSelectedItemElement<
*/
export function isSectionElement<T>(
node: ReactNode
): node is ReactElement<SectionProps<T>> {
): node is SectionElement<T> {
return isElementOfType(node, Section);
}

Expand All @@ -161,9 +173,7 @@ export function isSectionElement<T>(
* @param node The node to check
* @returns True if the node is an Item element
*/
export function isItemElement<T>(
node: ReactNode
): node is ReactElement<ItemProps<T>> {
export function isItemElement<T>(node: ReactNode): node is ItemElement<T> {
return isElementOfType(node, Item);
}

Expand Down
119 changes: 116 additions & 3 deletions packages/components/src/spectrum/utils/itemWrapperUtils.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,45 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { dh as dhIcons } from '@deephaven/icons';
import { NON_BREAKING_SPACE } from '@deephaven/utils';
import { Icon } from '../icons';
import { ItemContent } from '../ItemContent';
import { Item } from '../shared';
import { Item, Section } from '../shared';
import { Text } from '../Text';
import { ITEM_EMPTY_STRING_TEXT_VALUE } from './itemUtils';
import { wrapItemChildren } from './itemWrapperUtils';
import {
wrapIcon,
wrapItemChildren,
wrapPrimitiveWithText,
} from './itemWrapperUtils';

describe('wrapIcon', () => {
it.each([
['vsAccount', dhIcons.vsAccount],
['nonExisting', dhIcons.vsBlank],
[null, dhIcons.vsBlank],
[undefined, dhIcons.vsBlank],
])('should wrap icon key with Icon: %s', (iconKey, expectedIcon) => {
const slot = 'illustration';

const actual = wrapIcon(iconKey, slot);

expect(actual).toEqual(
<Icon slot={slot}>
<FontAwesomeIcon icon={expectedIcon} />
</Icon>
);
});

it('should return given content if not a string', () => {
const content = <div>Not a string</div>;
const slot = 'illustration';

const actual = wrapIcon(content, slot);

expect(actual).toBe(content);
});
});

describe.each([null, { placement: 'top' }] as const)(
'wrapItemChildren: %s',
Expand All @@ -14,13 +51,25 @@ describe.each([null, { placement: 'top' }] as const)(
'Item 3',
'',
// eslint-disable-next-line react/jsx-key
<Item textValue="">Empty textValue</Item>,
// eslint-disable-next-line react/jsx-key
<Item textValue="Item 4">Item 4</Item>,
<Item key="Item 5" textValue="Item 5">
Item 5
</Item>,
<Item key="Item 6" textValue="Item 6">
<ItemContent tooltipOptions={tooltipOptions}>Item 6</ItemContent>
</Item>,
/* eslint-disable react/jsx-curly-brace-presence */
<Section key="Section 1">
{'Section 1 - Item 1'}
{'Section 1 - Item 2'}
</Section>,
/* eslint-enable react/jsx-curly-brace-presence */
// eslint-disable-next-line react/jsx-key
<Section title="Section 2">Section 2 - Item 1</Section>,
// eslint-disable-next-line react/jsx-key
<Section title={<span>Section 3</span>}>Section 3 - Item 1</Section>,
];

const expected = [
Expand All @@ -39,6 +88,11 @@ describe.each([null, { placement: 'top' }] as const)(
{''}
</ItemContent>
</Item>,
<Item key="" textValue={ITEM_EMPTY_STRING_TEXT_VALUE}>
<ItemContent tooltipOptions={tooltipOptions}>
Empty textValue
</ItemContent>
</Item>,
<Item key="Item 4" textValue="Item 4">
<ItemContent tooltipOptions={tooltipOptions}>Item 4</ItemContent>
</Item>,
Expand All @@ -48,11 +102,70 @@ describe.each([null, { placement: 'top' }] as const)(
<Item key="Item 6" textValue="Item 6">
<ItemContent tooltipOptions={tooltipOptions}>Item 6</ItemContent>
</Item>,
<Section key="Section 1">
<Item key="Section 1 - Item 1" textValue="Section 1 - Item 1">
<ItemContent tooltipOptions={tooltipOptions}>
Section 1 - Item 1
</ItemContent>
</Item>
<Item key="Section 1 - Item 2" textValue="Section 1 - Item 2">
<ItemContent tooltipOptions={tooltipOptions}>
Section 1 - Item 2
</ItemContent>
</Item>
</Section>,
<Section key="Section 2" title="Section 2">
<Item key="Section 2 - Item 1" textValue="Section 2 - Item 1">
<ItemContent tooltipOptions={tooltipOptions}>
Section 2 - Item 1
</ItemContent>
</Item>
</Section>,
// eslint-disable-next-line react/jsx-key
<Section title={<span>Section 3</span>}>
<Item key="Section 3 - Item 1" textValue="Section 3 - Item 1">
<ItemContent tooltipOptions={tooltipOptions}>
Section 3 - Item 1
</ItemContent>
</Item>
</Section>,
];

const actual = wrapItemChildren(given, tooltipOptions);

expect(actual).toEqual(expected);

const actualSingle = wrapItemChildren(given[0], tooltipOptions);
expect(actualSingle).toEqual(expected[0]);
});
}
);

describe('wrapPrimitiveWithText', () => {
it('should wrap primitive with Text element', () => {
const content = 'Text content';
const slot = 'slot';

const actual = wrapPrimitiveWithText(content, slot);

expect(actual).toEqual(<Text slot={slot}>{content}</Text>);
});

it('should return content if it is not a primitive type', () => {
const content = <div>Not a primitive</div>;
const slot = 'slot';

const actual = wrapPrimitiveWithText(content, slot);

expect(actual).toEqual(content);
});
it.each([null, undefined, ''])(
'should wrap &nbsp; if given empty content: %s',
content => {
const slot = 'slot';

const actual = wrapPrimitiveWithText(content, slot);

expect(actual).toEqual(<Text slot={slot}>{NON_BREAKING_SPACE}</Text>);
}
);
});
28 changes: 11 additions & 17 deletions packages/components/src/spectrum/utils/itemWrapperUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ export function wrapIcon(
export function wrapItemChildren(
itemsOrSections: ItemOrSection | ItemOrSection[],
tooltipOptions: TooltipOptions | null
): (ItemElement | SectionElement)[] {
): ItemElement | SectionElement | (ItemElement | SectionElement)[] {
const itemsOrSectionsArray = Array.isArray(itemsOrSections)
? itemsOrSections
: [itemsOrSections];

return itemsOrSectionsArray.map(item => {
const result = itemsOrSectionsArray.map(item => {
if (isItemElement(item)) {
// Item content is already wrapped
if (isElementOfType(item.props.children, ItemContent)) {
Expand Down Expand Up @@ -101,23 +101,17 @@ export function wrapItemChildren(
});
}

if (
typeof item === 'string' ||
typeof item === 'number' ||
typeof item === 'boolean'
) {
const text = String(item);
const textValue = text === '' ? ITEM_EMPTY_STRING_TEXT_VALUE : text;
const text = String(item);
const textValue = text === '' ? ITEM_EMPTY_STRING_TEXT_VALUE : text;

return (
<Item key={text} textValue={textValue}>
<ItemContent tooltipOptions={tooltipOptions}>{text}</ItemContent>
</Item>
);
}

return item;
return (
<Item key={text} textValue={textValue}>
<ItemContent tooltipOptions={tooltipOptions}>{text}</ItemContent>
</Item>
);
});

return Array.isArray(itemsOrSections) ? result : result[0];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe.each([
'wrapPrimitiveWithText(mock.description, description)',
],
] as [NormalizedItem, string, string, string, string][])(
'should return a render function that can be used to render a normalized item in collection components.',
'should return a render function that can be used to render a normalized item in collection components: %s, %s, %s, %s, %s',
(normalizedItem, textValue, icon, content, description) => {
const { result } = renderHook(() =>
useRenderNormalizedItem({
Expand Down

0 comments on commit 26d1c7a

Please sign in to comment.