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)
+    })
+  })
+})