diff --git a/.changeset/modern-masks-fetch.md b/.changeset/modern-masks-fetch.md new file mode 100644 index 0000000000..7b52ed8d01 --- /dev/null +++ b/.changeset/modern-masks-fetch.md @@ -0,0 +1,5 @@ +--- +'react-json-tree': minor +--- + +Add expand/collapse all feature diff --git a/packages/react-json-tree/README.md b/packages/react-json-tree/README.md index 7a55b3e110..7577d62f8b 100644 --- a/packages/react-json-tree/README.md +++ b/packages/react-json-tree/README.md @@ -137,6 +137,21 @@ Their full signatures are: - `labelRenderer: function(keyPath, nodeType, expanded, expandable)` - `valueRenderer: function(valueAsString, value, ...keyPath)` +#### Customize "Expand All/Collapse All" Buttons + +Passing the `expandCollapseAll` props will activate in the top right corner of the JSONTree component the `expand all/collapse all` buttons. You can pass a JSON to customize the expand all/collapse all icons. The default icons are from [FontAwesome](https://fontawesome.com/). + +```jsx + +``` + #### More Options - `shouldExpandNodeInitially: function(keyPath, data, level)` - determines if node should be expanded when it first renders (root is expanded by default) diff --git a/packages/react-json-tree/examples/package.json b/packages/react-json-tree/examples/package.json index af9b7394d1..883e2616b7 100644 --- a/packages/react-json-tree/examples/package.json +++ b/packages/react-json-tree/examples/package.json @@ -23,7 +23,7 @@ "react": "^18.2.0", "react-base16-styling": "^0.9.1", "react-dom": "^18.2.0", - "react-json-tree": "^0.18.0" + "react-json-tree": "link:.." }, "devDependencies": { "@babel/core": "^7.21.4", diff --git a/packages/react-json-tree/examples/src/App.tsx b/packages/react-json-tree/examples/src/App.tsx index a972ce5888..8d8f6e9f40 100644 --- a/packages/react-json-tree/examples/src/App.tsx +++ b/packages/react-json-tree/examples/src/App.tsx @@ -190,7 +190,12 @@ const App = () => ( Sort object keys with sortObjectKeys prop.

- +

Collapsed root node

diff --git a/packages/react-json-tree/package.json b/packages/react-json-tree/package.json index 5190e75517..8bee4142fc 100644 --- a/packages/react-json-tree/package.json +++ b/packages/react-json-tree/package.json @@ -46,6 +46,9 @@ }, "dependencies": { "@babel/runtime": "^7.21.0", + "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", + "@fortawesome/react-fontawesome": "^0.2.0", "@types/lodash": "^4.14.194", "react-base16-styling": "^0.9.1" }, diff --git a/packages/react-json-tree/src/JSONNestedNode.tsx b/packages/react-json-tree/src/JSONNestedNode.tsx index 0e5b5be52c..30f6c464d0 100644 --- a/packages/react-json-tree/src/JSONNestedNode.tsx +++ b/packages/react-json-tree/src/JSONNestedNode.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; +import ItemRange from './ItemRange'; import JSONArrow from './JSONArrow'; -import getCollectionEntries from './getCollectionEntries'; import JSONNode from './JSONNode'; -import ItemRange from './ItemRange'; +import { useExpandCollapseAllContext } from './expandCollapseContext'; +import getCollectionEntries from './getCollectionEntries'; import type { CircularCache, CommonInternalProps } from './types'; /** @@ -112,23 +113,61 @@ export default function JSONNestedNode(props: Props) { shouldExpandNodeInitially, styling, } = props; + const { expandAllState, setExpandAllState, setEnableDefaultButton } = + useExpandCollapseAllContext(); - const [expanded, setExpanded] = useState( + const [defaultExpanded] = useState( // calculate individual node expansion if necessary - isCircular ? false : shouldExpandNodeInitially(keyPath, data, level) + isCircular + ? false + : (function getDefault() { + switch (expandAllState) { + case 'expand': + return true; + case 'collapse': + return false; + default: + return shouldExpandNodeInitially(keyPath, data, level); + } + })() ); + const [, setTriggerReRender] = useState(defaultExpanded); + + /** + * Used the useRef to handle expanded because calling a setState in a recursive implementation + * could lead to a "Maximum update depth exceeded" error */ + const expandedRef = useRef(defaultExpanded); + + switch (expandAllState) { + case 'expand': + expandedRef.current = isCircular ? false : true; + break; + case 'collapse': + expandedRef.current = false; + break; + case 'default': + expandedRef.current = shouldExpandNodeInitially(keyPath, data, level); + break; + default: //Do nothing; + } + const handleClick = useCallback(() => { - if (expandable) setExpanded(!expanded); - }, [expandable, expanded]); + if (expandable) { + expandedRef.current = !expandedRef.current; + setTriggerReRender((e) => !e); + setEnableDefaultButton(true); + setExpandAllState(undefined); + } + }, [expandable, setEnableDefaultButton, setExpandAllState]); const renderedChildren = - expanded || (hideRoot && level === 0) + expandedRef.current || (hideRoot && level === 0) ? renderChildNodes({ ...props, circularCache, level: level + 1 }) : null; const itemType = ( - + {nodeTypeIndicator} ); @@ -137,9 +176,15 @@ export default function JSONNestedNode(props: Props) { data, itemType, createItemString(data, collectionLimit), - keyPath + keyPath, + expandedRef.current ); - const stylingArgs = [keyPath, nodeType, expanded, expandable] as const; + const stylingArgs = [ + keyPath, + nodeType, + expandedRef.current, + expandable, + ] as const; return hideRoot ? (
  • @@ -153,7 +198,7 @@ export default function JSONNestedNode(props: Props) { )} diff --git a/packages/react-json-tree/src/createStylingFromTheme.ts b/packages/react-json-tree/src/createStylingFromTheme.ts index 5c776c7e85..2be9fe2f59 100644 --- a/packages/react-json-tree/src/createStylingFromTheme.ts +++ b/packages/react-json-tree/src/createStylingFromTheme.ts @@ -46,6 +46,7 @@ const getDefaultThemeStyling = (theme: Base16Theme): StylingConfig => { return { tree: { + position: 'relative', border: 0, padding: 0, marginTop: '0.5em', @@ -58,6 +59,19 @@ const getDefaultThemeStyling = (theme: Base16Theme): StylingConfig => { backgroundColor: colors.BACKGROUND_COLOR, }, + expandCollapseAll: { + color: colors.TEXT_COLOR, + backgroundColor: colors.BACKGROUND_COLOR, + position: 'absolute', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '1rem', + top: '1rem', + right: '1rem', + cursor: 'pointer', + }, + value: ({ style }, nodeType, keyPath) => ({ style: { ...style, diff --git a/packages/react-json-tree/src/expandCollapseButtons.tsx b/packages/react-json-tree/src/expandCollapseButtons.tsx new file mode 100644 index 0000000000..e60b9c271f --- /dev/null +++ b/packages/react-json-tree/src/expandCollapseButtons.tsx @@ -0,0 +1,175 @@ +import { + faArrowDown, + faArrowRight, + faUndo, + faCopy, + faCheck, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { ReactNode, useEffect, useState } from 'react'; +import { ExpandCollapseAll } from '.'; +import { useExpandCollapseAllContext } from './expandCollapseContext'; +import { StylingFunction } from 'react-base16-styling'; + +interface Props { + expandCollapseAll: ExpandCollapseAll; + styling: StylingFunction; + value: unknown; +} + +interface ExpandButtonProps { + expandableDefaultValue?: 'expand' | 'collapse'; + expandIcon?: ReactNode; +} + +interface CollapseButtonProps { + expandableDefaultValue?: 'expand' | 'collapse'; + collapseIcon?: ReactNode; +} + +interface CopyToClipboardButtonProps { + copyToClipboardIcon?: ReactNode; + copiedToClipboardIcon?: ReactNode; + value: unknown; +} + +interface DefaultButtonProps { + defaultIcon?: ReactNode; +} + +function ExpandCollapseButtons({ expandCollapseAll, styling, value }: Props) { + const { enableDefaultButton } = useExpandCollapseAllContext(); + + const expandableDefaultValue = expandCollapseAll?.defaultValue || 'expand'; + + return ( +
    + {enableDefaultButton && ( + + )} + + + + + + +
    + ); +} + +function ExpandButton({ + expandableDefaultValue, + expandIcon, +}: ExpandButtonProps) { + const { expandAllState, setExpandAllState, setEnableDefaultButton } = + useExpandCollapseAllContext(); + + const onExpand = () => { + setExpandAllState('expand'); + setEnableDefaultButton(true); + }; + + const isDefault = !expandAllState || expandAllState === 'default'; + + if ( + expandAllState === 'collapse' || + (isDefault && expandableDefaultValue === 'expand') + ) { + return ( +
    + {expandIcon || } +
    + ); + } + + return <>; +} + +function CollapseButton({ + expandableDefaultValue, + collapseIcon, +}: CollapseButtonProps) { + const { expandAllState, setExpandAllState, setEnableDefaultButton } = + useExpandCollapseAllContext(); + + const onCollapse = () => { + setExpandAllState('collapse'); + setEnableDefaultButton(true); + }; + + const isDefault = !expandAllState || expandAllState === 'default'; + + if ( + expandAllState === 'expand' || + (isDefault && expandableDefaultValue === 'collapse') + ) { + return ( +
    + {collapseIcon || } +
    + ); + } + + return <>; +} + +function CopyToClipboardButton({ + copyToClipboardIcon, + copiedToClipboardIcon, + value, +}: CopyToClipboardButtonProps) { + const [isCopied, setIsCopied] = useState(false); + + const handleOnCopyToClipboard = async () => { + await navigator.clipboard.writeText(JSON.stringify(value, null, 2)); + setIsCopied(true); + }; + + useEffect(() => { + if (isCopied) { + setTimeout(() => setIsCopied(false), 6000); + } + }, [isCopied]); + + if (isCopied) { + return ( +
    + {copiedToClipboardIcon || } +
    + ); + } + + return ( +
    + {copyToClipboardIcon || } +
    + ); +} + +function DefaultButton({ defaultIcon }: DefaultButtonProps) { + const { setExpandAllState, setEnableDefaultButton } = + useExpandCollapseAllContext(); + + const onDefaultCollapse = () => { + setExpandAllState('default'); + setEnableDefaultButton(false); + }; + + return ( +
    + {defaultIcon || } +
    + ); +} + +export default ExpandCollapseButtons; diff --git a/packages/react-json-tree/src/expandCollapseContext.tsx b/packages/react-json-tree/src/expandCollapseContext.tsx new file mode 100644 index 0000000000..0fbc8bf99f --- /dev/null +++ b/packages/react-json-tree/src/expandCollapseContext.tsx @@ -0,0 +1,64 @@ +import React, { + ReactNode, + createContext, + useContext, + useMemo, + useState, +} from 'react'; +import { ExpandCollapseAll } from '.'; +import ExpandCollapseButtons from './expandCollapseButtons'; +import { StylingFunction } from 'react-base16-styling'; + +interface Context { + enableDefaultButton: boolean; + setEnableDefaultButton: any; + expandAllState?: 'expand' | 'collapse' | 'default'; + setExpandAllState: any; +} + +interface Props { + children: ReactNode; + expandCollapseAll?: ExpandCollapseAll; + styling: StylingFunction; + value: unknown; +} + +const ExpandCollapseAllContext = createContext({} as Context); + +function ExpandCollapseAllContextProvider({ + expandCollapseAll, + children, + styling, + value, +}: Props) { + const [enableDefaultButton, setEnableDefaultButton] = useState(false); + const [expandAllState, setExpandAllState] = useState(); + + const contextValue = useMemo( + () => ({ + enableDefaultButton, + setEnableDefaultButton, + expandAllState, + setExpandAllState, + }), + [enableDefaultButton, expandAllState] + ); + + return ( + + {children} + {expandCollapseAll && ( + + )} + + ); +} + +export const useExpandCollapseAllContext = () => + useContext(ExpandCollapseAllContext); + +export default ExpandCollapseAllContextProvider; diff --git a/packages/react-json-tree/src/index.tsx b/packages/react-json-tree/src/index.tsx index 26fab0fc4b..eb0df18d70 100644 --- a/packages/react-json-tree/src/index.tsx +++ b/packages/react-json-tree/src/index.tsx @@ -3,11 +3,13 @@ // Dave Vedder http://www.eskimospy.com/ // port by Daniele Zannotti http://www.github.com/dzannotti -import React, { useMemo } from 'react'; +import React, { ReactNode, useMemo } from 'react'; import JSONNode from './JSONNode'; import createStylingFromTheme from './createStylingFromTheme'; import { invertTheme } from 'react-base16-styling'; import type { StylingValue, Theme } from 'react-base16-styling'; +import ExpandCollapseAllButtonsContext from './expandCollapseContext'; + import type { CommonExternalProps, GetItemString, @@ -20,6 +22,16 @@ interface Props extends Partial { data: unknown; theme?: Theme; invertTheme?: boolean; + expandCollapseAll?: ExpandCollapseAll; +} + +interface ExpandCollapseAll { + defaultValue?: 'expand' | 'collapse'; + expandIcon?: ReactNode; + collapseIcon?: ReactNode; + copyToClipboardIcon?: ReactNode; + copiedToClipboardIcon?: ReactNode; + defaultIcon?: ReactNode; } const identity = (value: any) => value; @@ -41,6 +53,7 @@ export function JSONTree({ labelRenderer = defaultLabelRenderer, valueRenderer = identity, shouldExpandNodeInitially = expandRootNode, + expandCollapseAll, hideRoot = false, getItemString = defaultItemString, postprocessValue = identity, @@ -56,20 +69,26 @@ export function JSONTree({ return (
      - + value={value} + > + +
    ); } @@ -87,4 +106,4 @@ export type { Styling, CommonExternalProps, } from './types'; -export type { StylingValue }; +export type { ExpandCollapseAll, StylingValue }; diff --git a/packages/react-json-tree/src/types.ts b/packages/react-json-tree/src/types.ts index 6a67f376c9..26894388e2 100644 --- a/packages/react-json-tree/src/types.ts +++ b/packages/react-json-tree/src/types.ts @@ -10,7 +10,8 @@ export type GetItemString = ( data: unknown, itemType: React.ReactNode, itemString: string, - keyPath: KeyPath + keyPath: KeyPath, + isExpanded: boolean ) => React.ReactNode; export type LabelRenderer = ( diff --git a/packages/react-json-tree/test/index.spec.tsx b/packages/react-json-tree/test/index.spec.tsx index 996612913e..136b64a974 100644 --- a/packages/react-json-tree/test/index.spec.tsx +++ b/packages/react-json-tree/test/index.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { createRenderer } from 'react-test-renderer/shallow'; import { JSONTree } from '../src/index'; -import JSONNode from '../src/JSONNode'; +import ExpandCollapseAllContext from '../src/expandCollapseContext'; const BASIC_DATA = { a: 1, b: 'c' }; @@ -17,6 +17,6 @@ describe('JSONTree', () => { const result = render(); expect(result.type).toBe('ul'); - expect(result.props.children.type.name).toBe(JSONNode.name); + expect(result.props.children.type.name).toBe(ExpandCollapseAllContext.name); }); });