This commit is contained in:
Luca Tagliabue 2023-06-02 08:42:00 -07:00 committed by GitHub
commit 2449c05220
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 321 additions and 32 deletions

View File

@ -0,0 +1,5 @@
---
'react-json-tree': minor
---
Add expand/collapse all feature

View File

@ -137,6 +137,21 @@ Their full signatures are:
- `labelRenderer: function(keyPath, nodeType, expanded, expandable)` - `labelRenderer: function(keyPath, nodeType, expanded, expandable)`
- `valueRenderer: function(valueAsString, value, ...keyPath)` - `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
<JSONTree
expandCollapseAll={{
defaultValue: 'expand',
expandIcon: /* your custom expand icon */,
collapseIcon: /* your custom collapse icon */,
defaultIcon: /* your custom restore to default icon */,
}}
/>
```
#### More Options #### More Options
- `shouldExpandNodeInitially: function(keyPath, data, level)` - determines if node should be expanded when it first renders (root is expanded by default) - `shouldExpandNodeInitially: function(keyPath, data, level)` - determines if node should be expanded when it first renders (root is expanded by default)

View File

@ -23,7 +23,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-base16-styling": "^0.9.1", "react-base16-styling": "^0.9.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-json-tree": "^0.18.0" "react-json-tree": "link:.."
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.21.4", "@babel/core": "^7.21.4",

View File

@ -46,6 +46,9 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0", "@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", "@types/lodash": "^4.14.194",
"react-base16-styling": "^0.9.1" "react-base16-styling": "^0.9.1"
}, },

View File

@ -1,8 +1,9 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import JSONArrow from './JSONArrow';
import getCollectionEntries from './getCollectionEntries';
import JSONNode from './JSONNode';
import ItemRange from './ItemRange'; import ItemRange from './ItemRange';
import JSONArrow from './JSONArrow';
import JSONNode from './JSONNode';
import { useExpandCollapseAllContext } from './expandCollapseContext';
import getCollectionEntries from './getCollectionEntries';
import type { CircularCache, CommonInternalProps } from './types'; import type { CircularCache, CommonInternalProps } from './types';
/** /**
@ -112,23 +113,61 @@ export default function JSONNestedNode(props: Props) {
shouldExpandNodeInitially, shouldExpandNodeInitially,
styling, styling,
} = props; } = props;
const { expandAllState, setExpandAllState, setEnableDefaultButton } =
useExpandCollapseAllContext();
const [expanded, setExpanded] = useState<boolean>( const [defaultExpanded] = useState<boolean>(
// calculate individual node expansion if necessary // 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<boolean>(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<boolean>(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(() => { const handleClick = useCallback(() => {
if (expandable) setExpanded(!expanded); if (expandable) {
}, [expandable, expanded]); expandedRef.current = !expandedRef.current;
setTriggerReRender((e) => !e);
setEnableDefaultButton(true);
setExpandAllState(undefined);
}
}, [expandable, setEnableDefaultButton, setExpandAllState]);
const renderedChildren = const renderedChildren =
expanded || (hideRoot && level === 0) expandedRef.current || (hideRoot && level === 0)
? renderChildNodes({ ...props, circularCache, level: level + 1 }) ? renderChildNodes({ ...props, circularCache, level: level + 1 })
: null; : null;
const itemType = ( const itemType = (
<span {...styling('nestedNodeItemType', expanded)}> <span {...styling('nestedNodeItemType', expandedRef.current)}>
{nodeTypeIndicator} {nodeTypeIndicator}
</span> </span>
); );
@ -137,9 +176,15 @@ export default function JSONNestedNode(props: Props) {
data, data,
itemType, itemType,
createItemString(data, collectionLimit), 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 ? ( return hideRoot ? (
<li {...styling('rootNode', ...stylingArgs)}> <li {...styling('rootNode', ...stylingArgs)}>
@ -153,7 +198,7 @@ export default function JSONNestedNode(props: Props) {
<JSONArrow <JSONArrow
styling={styling} styling={styling}
nodeType={nodeType} nodeType={nodeType}
expanded={expanded} expanded={expandedRef.current}
onClick={handleClick} onClick={handleClick}
/> />
)} )}

View File

@ -46,6 +46,7 @@ const getDefaultThemeStyling = (theme: Base16Theme): StylingConfig => {
return { return {
tree: { tree: {
position: 'relative',
border: 0, border: 0,
padding: 0, padding: 0,
marginTop: '0.5em', marginTop: '0.5em',
@ -58,6 +59,19 @@ const getDefaultThemeStyling = (theme: Base16Theme): StylingConfig => {
backgroundColor: colors.BACKGROUND_COLOR, 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) => ({ value: ({ style }, nodeType, keyPath) => ({
style: { style: {
...style, ...style,

View File

@ -0,0 +1,129 @@
import {
faArrowDown,
faArrowRight,
faUndo,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { ReactNode } from 'react';
import { ExpandCollapseAll } from '.';
import { useExpandCollapseAllContext } from './expandCollapseContext';
import { StylingFunction } from 'react-base16-styling';
interface Props {
expandCollapseAll: ExpandCollapseAll;
styling: StylingFunction;
}
interface ExpandButtonProps {
expandableDefaultValue?: 'expand' | 'collapse';
expandIcon?: ReactNode;
}
interface CollapseButtonProps {
expandableDefaultValue?: 'expand' | 'collapse';
collapseIcon?: ReactNode;
}
interface DefaultButtonProps {
defaultIcon?: ReactNode;
}
function ExpandCollapseButtons({ expandCollapseAll, styling }: Props) {
const { enableDefaultButton } = useExpandCollapseAllContext();
const expandableDefaultValue = expandCollapseAll?.defaultValue || 'expand';
return (
<div {...styling('expandCollapseAll')}>
{enableDefaultButton && (
<DefaultButton defaultIcon={expandCollapseAll?.defaultIcon} />
)}
<ExpandButton
expandableDefaultValue={expandableDefaultValue}
expandIcon={expandCollapseAll?.expandIcon}
/>
<CollapseButton
expandableDefaultValue={expandCollapseAll?.defaultValue}
collapseIcon={expandCollapseAll?.collapseIcon}
/>
</div>
);
}
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 (
<div role="presentation" onClick={onExpand}>
{expandIcon || <FontAwesomeIcon icon={faArrowRight} />}
</div>
);
}
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 (
<div role="presentation" onClick={onCollapse}>
{collapseIcon || <FontAwesomeIcon icon={faArrowDown} />}
</div>
);
}
return <></>;
}
function DefaultButton({ defaultIcon }: DefaultButtonProps) {
const { setExpandAllState, setEnableDefaultButton } =
useExpandCollapseAllContext();
const onDefaultCollapse = () => {
setExpandAllState('default');
setEnableDefaultButton(false);
};
return (
<div role="presentation" onClick={onDefaultCollapse}>
{defaultIcon || <FontAwesomeIcon icon={faUndo} />}
</div>
);
return <></>;
}
export default ExpandCollapseButtons;

View File

@ -0,0 +1,61 @@
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;
}
const ExpandCollapseAllContext = createContext<Context>({} as Context);
function ExpandCollapseAllContextProvider({
expandCollapseAll,
children,
styling,
}: Props) {
const [enableDefaultButton, setEnableDefaultButton] = useState(false);
const [expandAllState, setExpandAllState] = useState();
const value = useMemo(
() => ({
enableDefaultButton,
setEnableDefaultButton,
expandAllState,
setExpandAllState,
}),
[enableDefaultButton, expandAllState]
);
return (
<ExpandCollapseAllContext.Provider value={value}>
{children}
{expandCollapseAll && (
<ExpandCollapseButtons
expandCollapseAll={expandCollapseAll}
styling={styling}
/>
)}
</ExpandCollapseAllContext.Provider>
);
}
export const useExpandCollapseAllContext = () =>
useContext(ExpandCollapseAllContext);
export default ExpandCollapseAllContextProvider;

View File

@ -3,11 +3,13 @@
// Dave Vedder <veddermatic@gmail.com> http://www.eskimospy.com/ // Dave Vedder <veddermatic@gmail.com> http://www.eskimospy.com/
// port by Daniele Zannotti http://www.github.com/dzannotti <dzannotti@me.com> // port by Daniele Zannotti http://www.github.com/dzannotti <dzannotti@me.com>
import React, { useMemo } from 'react'; import React, { ReactNode, useMemo } from 'react';
import JSONNode from './JSONNode'; import JSONNode from './JSONNode';
import createStylingFromTheme from './createStylingFromTheme'; import createStylingFromTheme from './createStylingFromTheme';
import { invertTheme } from 'react-base16-styling'; import { invertTheme } from 'react-base16-styling';
import type { StylingValue, Theme } from 'react-base16-styling'; import type { StylingValue, Theme } from 'react-base16-styling';
import ExpandCollapseAllButtonsContext from './expandCollapseContext';
import type { import type {
CommonExternalProps, CommonExternalProps,
GetItemString, GetItemString,
@ -20,6 +22,14 @@ interface Props extends Partial<CommonExternalProps> {
data: unknown; data: unknown;
theme?: Theme; theme?: Theme;
invertTheme?: boolean; invertTheme?: boolean;
expandCollapseAll?: ExpandCollapseAll;
}
interface ExpandCollapseAll {
defaultValue?: 'expand' | 'collapse';
expandIcon?: ReactNode;
collapseIcon?: ReactNode;
defaultIcon?: ReactNode;
} }
const identity = (value: any) => value; const identity = (value: any) => value;
@ -41,6 +51,7 @@ export function JSONTree({
labelRenderer = defaultLabelRenderer, labelRenderer = defaultLabelRenderer,
valueRenderer = identity, valueRenderer = identity,
shouldExpandNodeInitially = expandRootNode, shouldExpandNodeInitially = expandRootNode,
expandCollapseAll,
hideRoot = false, hideRoot = false,
getItemString = defaultItemString, getItemString = defaultItemString,
postprocessValue = identity, postprocessValue = identity,
@ -56,20 +67,25 @@ export function JSONTree({
return ( return (
<ul {...styling('tree')}> <ul {...styling('tree')}>
<JSONNode <ExpandCollapseAllButtonsContext
keyPath={hideRoot ? [] : keyPath} expandCollapseAll={expandCollapseAll}
value={postprocessValue(value)}
isCustomNode={isCustomNode}
styling={styling} styling={styling}
labelRenderer={labelRenderer} >
valueRenderer={valueRenderer} <JSONNode
shouldExpandNodeInitially={shouldExpandNodeInitially} keyPath={hideRoot ? [] : keyPath}
hideRoot={hideRoot} value={postprocessValue(value)}
getItemString={getItemString} isCustomNode={isCustomNode}
postprocessValue={postprocessValue} styling={styling}
collectionLimit={collectionLimit} labelRenderer={labelRenderer}
sortObjectKeys={sortObjectKeys} valueRenderer={valueRenderer}
/> shouldExpandNodeInitially={shouldExpandNodeInitially}
hideRoot={hideRoot}
getItemString={getItemString}
postprocessValue={postprocessValue}
collectionLimit={collectionLimit}
sortObjectKeys={sortObjectKeys}
/>
</ExpandCollapseAllButtonsContext>
</ul> </ul>
); );
} }
@ -87,4 +103,4 @@ export type {
Styling, Styling,
CommonExternalProps, CommonExternalProps,
} from './types'; } from './types';
export type { StylingValue }; export type { ExpandCollapseAll, StylingValue };

View File

@ -10,7 +10,8 @@ export type GetItemString = (
data: unknown, data: unknown,
itemType: React.ReactNode, itemType: React.ReactNode,
itemString: string, itemString: string,
keyPath: KeyPath keyPath: KeyPath,
isExpanded: boolean,
) => React.ReactNode; ) => React.ReactNode;
export type LabelRenderer = ( export type LabelRenderer = (

View File

@ -2,7 +2,7 @@ import React from 'react';
import { createRenderer } from 'react-test-renderer/shallow'; import { createRenderer } from 'react-test-renderer/shallow';
import { JSONTree } from '../src/index'; import { JSONTree } from '../src/index';
import JSONNode from '../src/JSONNode'; import ExpandCollapseAllContext from '../src/expandCollapseContext';
const BASIC_DATA = { a: 1, b: 'c' }; const BASIC_DATA = { a: 1, b: 'c' };
@ -17,6 +17,6 @@ describe('JSONTree', () => {
const result = render(<JSONTree data={BASIC_DATA} />); const result = render(<JSONTree data={BASIC_DATA} />);
expect(result.type).toBe('ul'); expect(result.type).toBe('ul');
expect(result.props.children.type.name).toBe(JSONNode.name); expect(result.props.children.type.name).toBe(ExpandCollapseAllContext.name);
}); });
}); });