From c6594df7919014495b4ea9817d390387ea4bc1db Mon Sep 17 00:00:00 2001 From: Liam Tait <Liam-Tait@users.noreply.github.com> Date: Wed, 16 Mar 2022 01:02:54 +1300 Subject: [PATCH] refactor(devtools): add types to Explorer (#2949) * refactor(devtools): add types to Explorer Add types to Explorer component with as minimal functional changes as possible while still getting type safety 2742 * remove unused set param from explorer toggle * Wrap Explorer toggle with useCallback * Rename Explorer toggle to toggleExpanded * Remove unused path * Move subEntryPages definition next to usage * Set type to be a string instead of string union * Remove unused depth prop * Move chunkArrays to own tested function * set handleEntry as required * Add LabelButton for accesibility * fix test * Remove shadowing * Set subEntries as empty array by default * Add type for property * Convert handleEntry function to react component with entry props * Use unknown for value * Set RenderProps to required where possible * Add required attributes to Explorer tests --- src/devtools/Explorer.tsx | 153 +++++++++++++++++---------- src/devtools/tests/Explorer.test.tsx | 57 ++++++++++ 2 files changed, 156 insertions(+), 54 deletions(-) create mode 100644 src/devtools/tests/Explorer.test.tsx diff --git a/src/devtools/Explorer.tsx b/src/devtools/Explorer.tsx index b409bc2ce0..7c97ae6f94 100644 --- a/src/devtools/Explorer.tsx +++ b/src/devtools/Explorer.tsx @@ -1,5 +1,3 @@ -// @ts-nocheck - import React from 'react' import { styled } from './utils' @@ -13,11 +11,15 @@ export const Entry = styled('div', { }) export const Label = styled('span', { + color: 'white', +}) + +export const LabelButton = styled('button', { cursor: 'pointer', color: 'white', }) -export const Value = styled('span', (props, theme) => ({ +export const Value = styled('span', (_props, theme) => ({ color: theme.danger, })) @@ -32,7 +34,12 @@ export const Info = styled('span', { fontSize: '.7em', }) -export const Expander = ({ expanded, style = {}, ...rest }) => ( +type ExpanderProps = { + expanded: boolean + style?: React.CSSProperties +} + +export const Expander = ({ expanded, style = {} }: ExpanderProps) => ( <span style={{ display: 'inline-block', @@ -45,43 +52,81 @@ export const Expander = ({ expanded, style = {}, ...rest }) => ( </span> ) -const DefaultRenderer = ({ - handleEntry, +type Entry = { + label: string +} + +type RendererProps = { + HandleEntry: HandleEntryComponent + label?: string + value: unknown + subEntries: Entry[] + subEntryPages: Entry[][] + type: string + expanded: boolean + toggleExpanded: () => void + pageSize: number +} + +/** + * Chunk elements in the array by size + * + * when the array cannot be chunked evenly by size, the last chunk will be + * filled with the remaining elements + * + * @example + * chunkArray(['a','b', 'c', 'd', 'e'], 2) // returns [['a','b'], ['c', 'd'], ['e']] + */ +export function chunkArray<T>(array: T[], size: number): T[][] { + if (size < 1) return [] + let i = 0 + const result: T[][] = [] + while (i < array.length) { + result.push(array.slice(i, i + size)) + i = i + size + } + return result +} + +type Renderer = (props: RendererProps) => JSX.Element + +export const DefaultRenderer: Renderer = ({ + HandleEntry, label, value, - // path, - subEntries, - subEntryPages, + subEntries = [], + subEntryPages = [], type, - // depth, - expanded, - toggle, + expanded = false, + toggleExpanded, pageSize, }) => { - const [expandedPages, setExpandedPages] = React.useState([]) + const [expandedPages, setExpandedPages] = React.useState<number[]>([]) return ( <Entry key={label}> {subEntryPages?.length ? ( <> - <Label onClick={() => toggle()}> + <button onClick={() => toggleExpanded()}> <Expander expanded={expanded} /> {label}{' '} <Info> {String(type).toLowerCase() === 'iterable' ? '(Iterable) ' : ''} {subEntries.length} {subEntries.length > 1 ? `items` : `item`} </Info> - </Label> + </button> {expanded ? ( subEntryPages.length === 1 ? ( <SubEntries> - {subEntries.map(entry => handleEntry(entry))} + {subEntries.map(entry => ( + <HandleEntry entry={entry} /> + ))} </SubEntries> ) : ( <SubEntries> {subEntryPages.map((entries, index) => ( <div key={index}> <Entry> - <Label + <LabelButton onClick={() => setExpandedPages(old => old.includes(index) @@ -92,10 +137,12 @@ const DefaultRenderer = ({ > <Expander expanded={expanded} /> [{index * pageSize} ...{' '} {index * pageSize + pageSize - 1}] - </Label> + </LabelButton> {expandedPages.includes(index) ? ( <SubEntries> - {entries.map(entry => handleEntry(entry))} + {entries.map(entry => ( + <HandleEntry entry={entry} /> + ))} </SubEntries> ) : null} </Entry> @@ -117,36 +164,43 @@ const DefaultRenderer = ({ ) } +type HandleEntryComponent = (props: { entry: Entry }) => JSX.Element + +type ExplorerProps = Partial<RendererProps> & { + renderer?: Renderer + defaultExpanded?: true | Record<string, boolean> +} + +type Property = { + defaultExpanded?: boolean | Record<string, boolean> + label: string + value: unknown +} + +function isIterable(x: any): x is Iterable<unknown> { + return Symbol.iterator in x +} + export default function Explorer({ value, defaultExpanded, renderer = DefaultRenderer, pageSize = 100, - depth = 0, ...rest -}) { - const [expanded, setExpanded] = React.useState(defaultExpanded) +}: ExplorerProps) { + const [expanded, setExpanded] = React.useState(Boolean(defaultExpanded)) + const toggleExpanded = React.useCallback(() => setExpanded(old => !old), []) - const toggle = set => { - setExpanded(old => (typeof set !== 'undefined' ? set : !old)) - } - - const path = [] + let type: string = typeof value + let subEntries: Property[] = [] - let type = typeof value - let subEntries - const subEntryPages = [] - - const makeProperty = sub => { - const newPath = path.concat(sub.label) + const makeProperty = (sub: { label: string; value: unknown }): Property => { const subDefaultExpanded = defaultExpanded === true ? { [sub.label]: true } : defaultExpanded?.[sub.label] return { ...sub, - path: newPath, - depth: depth + 1, defaultExpanded: subDefaultExpanded, } } @@ -155,54 +209,45 @@ export default function Explorer({ type = 'array' subEntries = value.map((d, i) => makeProperty({ - label: i, + label: i.toString(), value: d, }) ) } else if ( value !== null && typeof value === 'object' && + isIterable(value) && typeof value[Symbol.iterator] === 'function' ) { type = 'Iterable' subEntries = Array.from(value, (val, i) => makeProperty({ - label: i, + label: i.toString(), value: val, }) ) } else if (typeof value === 'object' && value !== null) { type = 'object' - // eslint-disable-next-line no-shadow - subEntries = Object.entries(value).map(([label, value]) => + subEntries = Object.entries(value).map(([key, val]) => makeProperty({ - label, - value, + label: key, + value: val, }) ) } - if (subEntries) { - let i = 0 - - while (i < subEntries.length) { - subEntryPages.push(subEntries.slice(i, i + pageSize)) - i = i + pageSize - } - } + const subEntryPages = chunkArray(subEntries, pageSize) return renderer({ - handleEntry: entry => ( - <Explorer key={entry.label} renderer={renderer} {...rest} {...entry} /> + HandleEntry: ({ entry }) => ( + <Explorer value={value} renderer={renderer} {...rest} {...entry} /> ), type, subEntries, subEntryPages, - depth, value, - path, expanded, - toggle, + toggleExpanded, pageSize, ...rest, }) diff --git a/src/devtools/tests/Explorer.test.tsx b/src/devtools/tests/Explorer.test.tsx new file mode 100644 index 0000000000..7a14ad404c --- /dev/null +++ b/src/devtools/tests/Explorer.test.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' + +import { chunkArray, DefaultRenderer } from '../Explorer' + +describe('Explorer', () => { + describe('chunkArray', () => { + it('when the size is less than one return an empty array', () => { + expect(chunkArray([1, 2, 3], 0)).toStrictEqual([]) + }) + + it('when the array is empty return an empty array', () => { + expect(chunkArray([], 2)).toStrictEqual([]) + }) + + it('when the array is evenly chunked return full chunks ', () => { + expect(chunkArray([1, 2, 3, 4], 2)).toStrictEqual([ + [1, 2], + [3, 4], + ]) + }) + + it('when the array is not evenly chunkable by size the last item is the remaining elements ', () => { + const chunks = chunkArray([1, 2, 3, 4, 5], 2) + const lastChunk = chunks[chunks.length - 1] + expect(lastChunk).toStrictEqual([5]) + }) + }) + + describe('DefaultRenderer', () => { + it('when the entry label is clicked, toggle expanded', async () => { + const toggleExpanded = jest.fn() + + render( + <DefaultRenderer + label="the top level label" + toggleExpanded={toggleExpanded} + pageSize={10} + expanded={false} + subEntryPages={[[{ label: 'A lovely label' }]]} + HandleEntry={() => <></>} + value={undefined} + subEntries={[]} + type="string" + /> + ) + + const expandButton = screen.getByRole('button', { + name: /▶ the top level label 0 item/i, + }) + + fireEvent.click(expandButton) + + expect(toggleExpanded).toHaveBeenCalledTimes(1) + }) + }) +})