Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new object explorer implementation based on react-arborist #1271

Closed
wants to merge 16 commits into from
1 change: 1 addition & 0 deletions packages/toolpad-app/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function parseBuidEnvVars(env) {
TOOLPAD_TARGET: target,
TOOLPAD_VERSION: pkgJson.version,
TOOLPAD_BUILD: env.GIT_SHA1?.slice(0, 7) || 'dev',
TOOLPAD_EXPERIMENTAL_OBJECT_EXPLORER: process.env.TOOLPAD_EXPERIMENTAL_OBJECT_EXPLORER,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/toolpad-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"prisma": "^4.8.1",
"quickjs-emscripten": "^0.21.1",
"react": "^18.2.0",
"react-arborist": "^2.1.0",
"react-devtools-inline": "^4.27.1",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
Expand Down
13 changes: 12 additions & 1 deletion packages/toolpad-app/src/components/JsonView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { SxProps, styled, IconButton, Tooltip, Snackbar } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import clsx from 'clsx';
import ObjectInspector, { ObjectInspectorProps } from './ObjectInspector';
import MuiObjectInspector from './MuiObjectInspector';

const classes = {
viewport: 'Toolpad_ObjectInspectorViewport',
copyToClipboardButton: 'Toolpad_CopyToClipboardButton',
disabled: 'Toolpad_ObjectInspectorDisabled',
inspector: 'Toolpad_ObjectInspectorInspector',
};

const JsonViewRoot = styled('div')(({ theme }) => ({
Expand All @@ -25,6 +27,9 @@ const JsonViewRoot = styled('div')(({ theme }) => ({
[`& .${classes.viewport}`]: {
overflow: 'auto',
flex: 1,
},

[`& .${classes.inspector}`]: {
padding: theme.spacing(1),
},

Expand Down Expand Up @@ -65,7 +70,13 @@ export default function JsonView({ src, sx, copyToClipboard, disabled, ...props
<JsonViewRoot sx={sx} className={clsx({ [classes.disabled]: disabled })}>
<React.Fragment>
<div className={classes.viewport}>
<ObjectInspector expandLevel={1} expandPaths={expandPaths} data={src} {...props} />
{process.env.TOOLPAD_EXPERIMENTAL_OBJECT_EXPLORER ? (
<MuiObjectInspector data={src} expandPaths={expandPaths} />
) : (
<div className={classes.inspector}>
<ObjectInspector expandLevel={1} expandPaths={expandPaths} data={src} {...props} />
</div>
)}
</div>

{copyToClipboard ? (
Expand Down
277 changes: 277 additions & 0 deletions packages/toolpad-app/src/components/MuiObjectInspector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import * as React from 'react';
import clsx from 'clsx';
import { styled, SvgIcon } from '@mui/material';
import { NodeRendererProps, Tree as ArboristTree } from 'react-arborist';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ArrowRightIcon from '@mui/icons-material/ArrowRight';

function getType(value: unknown) {
if (value === null) {
return 'null';
}

if (Array.isArray(value)) {
return 'array';
}

if (typeof value === 'string' && /^(#|rgb|rgba|hsl|hsla)/.test(value)) {
return 'color';
}

return typeof value;
}

type PropValueType = ReturnType<typeof getType>;

function getLabel(value: unknown, type: PropValueType, open: boolean): string {
switch (type) {
case 'array': {
const length: number = (value as unknown[]).length;
if (open) {
return `Array (${length} ${length === 1 ? 'item' : 'items'})`;
}
return length > 0 ? '[…]' : '[]';
}
case 'null':
return 'null';
case 'undefined':
return 'undefined';
case 'function':
return `f ${(value as Function).name}()`;
case 'object': {
const keyCount = Object.keys(value as object).length;
if (open) {
return `Object (${keyCount} ${keyCount === 1 ? 'key' : 'keys'})`;
}
return keyCount > 0 ? '{…}' : '{}';
}
case 'string':
return `"${value}"`;
case 'symbol':
return `Symbol(${String(value)})`;
case 'bigint':
case 'boolean':
case 'number':
default:
return String(value);
}
}

function getTokenType(type: string): string {
switch (type) {
case 'color':
return 'string';
case 'object':
case 'array':
return 'comment';
default:
return type;
}
}

export interface ObjectTreePropertyNode {
id: string;
label?: string;
value: unknown;
type: PropValueType;
children?: ObjectTreePropertyNode[];
}

function createPropertiesData(data: object, id: string): ObjectTreePropertyNode[] {
const result: ObjectTreePropertyNode[] = [];
for (const [label, value] of Object.entries(data)) {
const itemId = `${id}.${label}`;
const type = getType(value);
result.push({
id: itemId,
label,
value,
type,
children:
value && typeof value === 'object' ? createPropertiesData(value, itemId) : undefined,
});
}
return result;
}

export interface CreateObjectTreeDataParams {
label?: string;
id?: string;
}

export function createObjectTreeData(
data: unknown,
{ id = '$ROOT', label }: CreateObjectTreeDataParams,
): ObjectTreePropertyNode[] {
const children = data && typeof data === 'object' ? createPropertiesData(data, id) : [];
return [
{
label,
id,
type: getType(data),
value: data,
children: children.length > 0 ? children : undefined,
},
];
}

const classes = {
node: 'Toolpad__ObjectExplorerNode',
token: 'Toolpad__ObjectExplorerToken',
};

export const Tree = styled(ArboristTree<ObjectTreePropertyNode>)(({ theme }) => ({
color: theme.palette.mode === 'dark' ? '#d4d4d4' : '#000000',
background: theme.palette.mode === 'dark' ? '#0c2945' : '#ffffff',
fontSize: 12,
fontFamily:
'"SF Mono", Monaco, Menlo, Consolas, "Ubuntu Mono", "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace;',

[`.${classes.node}`]: {
whiteSpace: 'noWrap',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
},

[`& .${classes.token}.string`]: {
color: theme.palette.mode === 'dark' ? '#ce9178' : '#a31515',
},
[`& .${classes.token}.boolean`]: {
color: theme.palette.mode === 'dark' ? '#569cd6' : '#0000ff',
},
[`& .${classes.token}.number`]: {
color: theme.palette.mode === 'dark' ? '#b5cea8' : '#098658',
},
[`& .${classes.token}.comment`]: {
color: theme.palette.mode === 'dark' ? '#608b4e' : '#008000',
},
[`& .${classes.token}.null`]: {
color: theme.palette.mode === 'dark' ? '#569cd6' : '#0000ff',
},
[`& .${classes.token}.undefined`]: {
color: theme.palette.mode === 'dark' ? '#569cd6' : '#0000ff',
},
[`& .${classes.token}.function`]: {
color: theme.palette.mode === 'dark' ? '#569cd6' : '#0000ff',
},
}));

interface PropertyValueProps {
open?: boolean;
type: PropValueType;
value: unknown;
}

function PropertyValue({ open = false, type, value }: PropertyValueProps) {
return (
<span className={clsx(classes.token, getTokenType(type))}>{getLabel(value, type, open)}</span>
);
}

interface TreeItemIconProps {
leaf: boolean;
open: boolean;
}

function TreeItemIcon({ leaf, open }: TreeItemIconProps) {
if (leaf) {
return <SvgIcon />;
}
return open ? <ArrowDropDownIcon /> : <ArrowRightIcon />;
}

export function ObjectPropertyEntry<T extends ObjectTreePropertyNode = ObjectTreePropertyNode>({
node,
style,
dragHandle,
}: NodeRendererProps<T>) {
return (
// TODO: react-arborist sets role treeitem to its parent but suggests onClick on this node
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={classes.node}
style={{ ...style, userSelect: 'unset' }}
ref={dragHandle}
onClick={() => node.toggle()}
>
<TreeItemIcon leaf={node.isLeaf} open={node.isOpen} />
<span>
{node.data.label ? <span>{node.data.label}: </span> : null}
<PropertyValue open={node.isOpen} value={node.data.value} type={node.data.type} />
</span>
</div>
);
}

function useDimensions<E extends HTMLElement>(): [
React.RefCallback<E>,
{ width?: number; height?: number },
] {
const elmRef = React.useRef<E | null>(null);
const [dimensions, setDimensions] = React.useState({});

const observerRef = React.useRef<ResizeObserver | undefined>();
const getObserver = () => {
let observer = observerRef.current;
if (!observer) {
observer = new ResizeObserver(() => {
setDimensions(elmRef.current?.getBoundingClientRect().toJSON());
});
observerRef.current = observer;
}
return observer;
};

const ref = React.useCallback((elm: E | null) => {
elmRef.current = elm;
setDimensions(elm?.getBoundingClientRect().toJSON());
const observer = getObserver();
observer.disconnect();
if (elm) {
observer.observe(elm);
}
}, []);

return [ref, dimensions];
}

export interface MuiObjectInspectorProps {
label?: string;
data?: unknown;
expandPaths?: string[];
}

export default function MuiObjectInspector({
label,
data = {},
expandPaths,
}: MuiObjectInspectorProps) {
const initialOpenState = React.useMemo(
() => (expandPaths ? Object.fromEntries(expandPaths.map((path) => [path, true])) : {}),
[expandPaths],
);

const treeData: ObjectTreePropertyNode[] = React.useMemo(
() => createObjectTreeData(data, { label }),
[data, label],
);

const [rootRef, dimensions] = useDimensions<HTMLDivElement>();

return (
<div ref={rootRef} style={{ width: '100%', height: '100%', overflow: 'hidden' }}>
<Tree
indent={8}
disableDrag
disableDrop
data={treeData}
initialOpenState={initialOpenState}
width={dimensions.width}
height={dimensions.height}
>
{ObjectPropertyEntry}
</Tree>
</div>
);
}
4 changes: 3 additions & 1 deletion packages/toolpad-app/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export type BuildEnvVars = Record<
// The current Toolpad version
| 'TOOLPAD_VERSION'
// The current Toolpad build number
| 'TOOLPAD_BUILD',
| 'TOOLPAD_BUILD'
// Enable an experimental object explorer based on MUI TreeView
| 'TOOLPAD_EXPERIMENTAL_OBJECT_EXPLORER',
string
>;

Expand Down
2 changes: 1 addition & 1 deletion packages/toolpad-app/src/utils/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export function findImports(src: string): string[] {
/**
* Limits the length of a string and adds ellipsis if necessary.
*/
export function truncate(str: string, maxLength: number, dots: string = '...') {
export function truncate(str: string, maxLength: number, dots: string = '') {
if (str.length <= maxLength) {
return str;
}
Expand Down
3 changes: 3 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ services:
fromDatabase:
name: toolpad-db
property: connectionString
- key: TOOLPAD_EXPERIMENTAL_OBJECT_EXPLORER
value: null
previewValue: '1'
- fromGroup: toolpad-settings
- fromGroup: toolpad-basic-auth
buildFilter:
Expand Down
Loading