diff --git a/.eslintignore b/.eslintignore index 5f84c191d62c..7873843b0d0a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,6 +9,7 @@ built-storybooks lib/cli/test lib/core-server/prebuilt lib/codemod/src/transforms/__testfixtures__ +lib/components/src/controls/react-editable-json-tree scripts/storage *.bundle.js *.js.map diff --git a/addons/docs/src/frameworks/react/react-argtypes.stories.tsx b/addons/docs/src/frameworks/react/react-argtypes.stories.tsx index 4148cbd1c955..a352e441d7c1 100644 --- a/addons/docs/src/frameworks/react/react-argtypes.stories.tsx +++ b/addons/docs/src/frameworks/react/react-argtypes.stories.tsx @@ -4,6 +4,7 @@ import { storiesOf, StoryContext } from '@storybook/react'; import { ArgsTable } from '@storybook/components'; import { Args } from '@storybook/api'; import { inferControls } from '@storybook/client-api'; +import { useTheme, Theme } from '@storybook/theming'; import { extractArgTypes } from './extractArgTypes'; import { Component } from '../../blocks'; @@ -16,15 +17,20 @@ const argsTableProps = (component: Component) => { }; function FormatArg({ arg }) { + const theme = useTheme(); + const badgeStyle = { + background: theme.background.hoverable, + border: `1px solid ${theme.background.hoverable}`, + borderRadius: 2, + }; if (typeof arg !== 'undefined') { try { return {JSON.stringify(arg, null, 2)}; } catch (err) { - return {arg.toString()}; + return {arg.toString()}; } } - - return undefined; + return undefined; } const ArgsStory = ({ component }: any) => { diff --git a/examples/official-storybook/stories/addon-controls.stories.tsx b/examples/official-storybook/stories/addon-controls.stories.tsx index 9ef1b6aa88cf..0d32c3e8c45c 100644 --- a/examples/official-storybook/stories/addon-controls.stories.tsx +++ b/examples/official-storybook/stories/addon-controls.stories.tsx @@ -7,18 +7,25 @@ export default { argTypes: { children: { control: 'text', name: 'Children' }, type: { control: 'text', name: 'Type' }, - somethingElse: { control: 'object', name: 'Something Else' }, + json: { control: 'object', name: 'JSON' }, imageUrls: { control: { type: 'file', accept: '.png' }, name: 'Image Urls' }, }, parameters: { chromatic: { disable: true } }, }; -const Template = (args) => + {args.json &&
{JSON.stringify(args.json, null, 2)}
} + +); export const Basic = Template.bind({}); Basic.args = { children: 'basic', - somethingElse: { a: 2 }, + json: DEFAULT_NESTED_OBJECT, }; Basic.parameters = { chromatic: { disable: false } }; @@ -26,7 +33,7 @@ export const Action = Template.bind({}); Action.args = { children: 'hmmm', type: 'action', - somethingElse: { a: 4 }, + json: null, }; export const ImageFileControl = (args) => Your Example Story; @@ -35,6 +42,12 @@ ImageFileControl.args = { }; export const CustomControls = Template.bind({}); +CustomControls.args = { + children: 'hmmm', + type: 'action', + json: DEFAULT_NESTED_OBJECT, +}; + CustomControls.argTypes = { children: { table: { disable: true } }, type: { control: { disable: true } }, diff --git a/lib/components/package.json b/lib/components/package.json index 551b5dbeb39c..c2e90d944253 100644 --- a/lib/components/package.json +++ b/lib/components/package.json @@ -51,6 +51,7 @@ "memoizerific": "^1.11.3", "overlayscrollbars": "^1.13.1", "polished": "^4.0.5", + "prop-types": "^15.7.2", "react-color": "^2.19.3", "react-popper-tooltip": "^3.1.1", "react-syntax-highlighter": "^13.5.3", diff --git a/lib/components/src/blocks/ArgsTable/ArgControl.tsx b/lib/components/src/blocks/ArgsTable/ArgControl.tsx index 5cbfe7e33a98..9140f96eaaf6 100644 --- a/lib/components/src/blocks/ArgsTable/ArgControl.tsx +++ b/lib/components/src/blocks/ArgsTable/ArgControl.tsx @@ -1,7 +1,6 @@ import React, { FC, useCallback, useState, useEffect } from 'react'; import { Args, ArgType } from './types'; import { - ArrayControl, BooleanControl, ColorControl, DateControl, @@ -51,7 +50,8 @@ export const ArgControl: FC = ({ row, arg, updateArgs }) => { const props = { name: key, argType: row, value: boxedValue.value, onChange, onBlur, onFocus }; switch (control.type) { case 'array': - return ; + case 'object': + return ; case 'boolean': return ; case 'color': @@ -60,8 +60,6 @@ export const ArgControl: FC = ({ row, arg, updateArgs }) => { return ; case 'number': return ; - case 'object': - return ; case 'check': case 'inline-check': case 'radio': diff --git a/lib/components/src/controls/Number.tsx b/lib/components/src/controls/Number.tsx index fcfe43efe386..f27b5efb4b30 100644 --- a/lib/components/src/controls/Number.tsx +++ b/lib/components/src/controls/Number.tsx @@ -38,7 +38,8 @@ export const NumberControl: FC = ({ onChange={handleChange} size="flex" placeholder="Adjust number dynamically" - {...{ name, value, min, max, step, onFocus, onBlur }} + value={value === null ? undefined : value} + {...{ name, min, max, step, onFocus, onBlur }} /> ); diff --git a/lib/components/src/controls/Object.tsx b/lib/components/src/controls/Object.tsx index 84287569f714..b168218635a9 100644 --- a/lib/components/src/controls/Object.tsx +++ b/lib/components/src/controls/Object.tsx @@ -1,73 +1,294 @@ -import React, { FC, ChangeEvent, useState, useCallback, useEffect } from 'react'; -import { styled } from '@storybook/theming'; +import { window } from 'global'; +import cloneDeep from 'lodash/cloneDeep'; +import React, { ComponentProps, SyntheticEvent, useCallback, useMemo, useState } from 'react'; +import { styled, useTheme, Theme } from '@storybook/theming'; -import deepEqual from 'fast-deep-equal'; +// @ts-ignore +import { JsonTree } from './react-editable-json-tree'; +import type { ControlProps, ObjectValue, ObjectConfig } from './types'; import { Form } from '../form'; -import { ControlProps, ObjectValue, ObjectConfig } from './types'; -import { ArgType } from '../blocks'; +import { Icons, IconsProps } from '../icon/icon'; +import { IconButton } from '../bar/button'; -const format = (value: any) => (value ? JSON.stringify(value) : ''); +type JsonTreeProps = ComponentProps; -const parse = (value: string) => { - const trimmed = value && value.trim(); - return trimmed ? JSON.parse(trimmed) : {}; -}; +const Wrapper = styled.div(({ theme }) => ({ + position: 'relative', + display: 'flex', + + '.rejt-tree': { + marginLeft: '1rem', + fontSize: '13px', + }, + '.rejt-value-node, .rejt-object-node > .rejt-collapsed, .rejt-array-node > .rejt-collapsed, .rejt-object-node > .rejt-not-collapsed, .rejt-array-node > .rejt-not-collapsed': { + '& > svg': { + opacity: 0, + transition: 'opacity 0.2s', + }, + }, + '.rejt-value-node:hover, .rejt-object-node:hover > .rejt-collapsed, .rejt-array-node:hover > .rejt-collapsed, .rejt-object-node:hover > .rejt-not-collapsed, .rejt-array-node:hover > .rejt-not-collapsed': { + '& > svg': { + opacity: 1, + }, + }, + '.rejt-edit-form button': { + display: 'none', + }, + '.rejt-add-form': { + marginLeft: 10, + }, + '.rejt-add-value-node': { + display: 'inline-flex', + alignItems: 'center', + }, + '.rejt-name': { + lineHeight: '22px', + }, + '.rejt-not-collapsed-delimiter': { + lineHeight: '22px', + }, + '.rejt-plus-menu': { + marginLeft: 5, + }, + '.rejt-object-node > span > *': { + position: 'relative', + zIndex: 2, + }, + '.rejt-object-node, .rejt-array-node': { + position: 'relative', + }, + '.rejt-object-node > span:first-of-type::after, .rejt-array-node > span:first-of-type::after, .rejt-collapsed::before, .rejt-not-collapsed::before': { + content: '""', + position: 'absolute', + top: 0, + display: 'block', + width: '100%', + marginLeft: '-1rem', + padding: '0 4px 0 1rem', + height: 22, + }, + '.rejt-collapsed::before, .rejt-not-collapsed::before': { + zIndex: 1, + background: 'transparent', + borderRadius: 4, + transition: 'background 0.2s', + pointerEvents: 'none', + opacity: 0.1, + }, + '.rejt-object-node:hover, .rejt-array-node:hover': { + '& > .rejt-collapsed::before, & > .rejt-not-collapsed::before': { + background: theme.color.secondary, + }, + }, + '.rejt-collapsed::after, .rejt-not-collapsed::after': { + content: '""', + position: 'absolute', + display: 'inline-block', + pointerEvents: 'none', + width: 0, + height: 0, + }, + '.rejt-collapsed::after': { + left: -8, + top: 8, + borderTop: '3px solid transparent', + borderBottom: '3px solid transparent', + borderLeft: '3px solid rgba(153,153,153,0.6)', + }, + '.rejt-not-collapsed::after': { + left: -10, + top: 10, + borderTop: '3px solid rgba(153,153,153,0.6)', + borderLeft: '3px solid transparent', + borderRight: '3px solid transparent', + }, + '.rejt-value': { + display: 'inline-block', + border: '1px solid transparent', + borderRadius: 4, + margin: '1px 0', + padding: '0 4px', + cursor: 'text', + color: theme.color.defaultText, + }, + '.rejt-value-node:hover > .rejt-value': { + background: theme.background.app, + borderColor: theme.color.border, + }, +})); + +const Button = styled.button<{ primary?: boolean }>(({ theme, primary }) => ({ + border: 0, + height: 20, + margin: 1, + borderRadius: 4, + background: primary ? theme.color.secondary : 'transparent', + color: primary ? theme.color.lightest : theme.color.dark, + fontWeight: primary ? 'bold' : 'normal', + cursor: 'pointer', + order: primary ? 'initial' : 9, +})); + +type ActionIconProps = IconsProps & { disabled?: boolean }; + +const ActionIcon = styled(Icons)(({ theme, icon, disabled }: ActionIconProps) => ({ + display: 'inline-block', + verticalAlign: 'middle', + width: 15, + height: 15, + padding: 3, + marginLeft: 5, + cursor: disabled ? 'not-allowed' : 'pointer', + color: theme.color.mediumdark, + '&:hover': disabled + ? {} + : { + color: icon === 'subtract' ? theme.color.negative : theme.color.ancillary, + }, + 'svg + &': { + marginLeft: 0, + }, +})); -const validate = (value: any, argType: ArgType) => { - if (argType && argType.type.name === 'array') { - return Array.isArray(value); - } - return true; +const Input = styled.input(({ theme, placeholder }) => ({ + outline: 0, + margin: placeholder ? 1 : '1px 0', + padding: '3px 4px', + color: theme.color.defaultText, + background: theme.background.app, + border: `1px solid ${theme.color.border}`, + borderRadius: 4, + lineHeight: '14px', + width: placeholder === 'Key' ? 80 : 120, + '&:focus': { + border: `1px solid ${theme.color.secondary}`, + }, +})); + +const RawButton = styled(IconButton)(({ theme }) => ({ + position: 'absolute', + zIndex: 2, + top: 2, + right: 2, + height: 21, + padding: '0 3px', + background: theme.background.bar, + border: `1px solid ${theme.color.border}`, + borderRadius: 3, + color: theme.color.mediumdark, + fontSize: '9px', + fontWeight: 'bold', + span: { + marginLeft: 3, + marginTop: 1, + }, +})); + +const RawInput = styled(Form.Textarea)(({ theme }) => ({ + flex: 1, + padding: '7px 6px', + fontFamily: theme.typography.fonts.mono, + fontSize: '12px', + lineHeight: '18px', + '&::placeholder': { + fontFamily: theme.typography.fonts.base, + fontSize: '13px', + }, + '&:placeholder-shown': { + padding: '7px 10px', + }, +})); + +const ENTER_EVENT = { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', keyCode: 13 }; +const dispatchEnterKey = (event: SyntheticEvent) => { + event.currentTarget.dispatchEvent(new window.KeyboardEvent('keydown', ENTER_EVENT)); +}; +const selectValue = (event: SyntheticEvent) => { + event.currentTarget.select(); }; -const Wrapper = styled.label({ - display: 'flex', +export type ObjectProps = ControlProps & + ObjectConfig & { + theme: any; // TODO: is there a type for this? + }; + +const getCustomStyleFunction: (theme: Theme) => JsonTreeProps['getStyle'] = (theme) => () => ({ + name: { + color: theme.color.secondary, + }, + collapsed: { + color: theme.color.dark, + }, + ul: { + listStyle: 'none', + margin: '0 0 0 1rem', + padding: 0, + }, + li: { + outline: 0, + }, }); -export type ObjectProps = ControlProps & ObjectConfig; -export const ObjectControl: FC = ({ - name, - argType, - value, - onChange, - onBlur, - onFocus, -}) => { - const [valid, setValid] = useState(true); - const [text, setText] = useState(format(value)); - - useEffect(() => { - const newText = format(value); - if (text !== newText) setText(newText); - }, [value]); - - const handleChange = useCallback( - (e: ChangeEvent) => { +export const ObjectControl: React.FC = ({ name, value, onChange }) => { + const theme = useTheme(); + const data = useMemo(() => value && cloneDeep(value), [value]); + const hasData = data !== null && data !== undefined; + + const [showRaw, setShowRaw] = useState(!hasData); + const [parseError, setParseError] = useState(); + const updateRaw = useCallback( + (raw) => { try { - const newVal = parse(e.target.value); - const newValid = validate(newVal, argType); - if (newValid && !deepEqual(value, newVal)) { - onChange(newVal); - } - setValid(newValid); - } catch (err) { - setValid(false); + if (raw) onChange(JSON.parse(raw)); + setParseError(undefined); + } catch (e) { + setParseError(e); } - setText(e.target.value); }, - [onChange, setValid] + [onChange] + ); + const rawJSONForm = ( + updateRaw(event.target.value)} + placeholder="Enter JSON string" + valid={parseError ? 'error' : null} + /> ); return ( - + {hasData && ( + setShowRaw((v) => !v)}> + + RAW + + )} + {hasData && !showRaw ? ( + Cancel} + editButtonElement={} + addButtonElement={ + + } + plusMenuElement={} + minusMenuElement={} + inputElement={(_: any, __: any, ___: any, key: string) => + key ? : + } + fallback={rawJSONForm} + /> + ) : ( + rawJSONForm + )} ); }; diff --git a/lib/components/src/controls/react-editable-json-tree/LICENSE.md b/lib/components/src/controls/react-editable-json-tree/LICENSE.md new file mode 100644 index 000000000000..387660f7f03b --- /dev/null +++ b/lib/components/src/controls/react-editable-json-tree/LICENSE.md @@ -0,0 +1,14 @@ +Copyright (c) 2016 Oxyno-zeta (Havrileck Alexandre) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/lib/components/src/controls/react-editable-json-tree/components/JsonAddValue.js b/lib/components/src/controls/react-editable-json-tree/components/JsonAddValue.js new file mode 100644 index 000000000000..55971aafb60f --- /dev/null +++ b/lib/components/src/controls/react-editable-json-tree/components/JsonAddValue.js @@ -0,0 +1,137 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import inputUsageTypes from '../types/inputUsageTypes'; + +class JsonAddValue extends Component { + constructor(props) { + super(props); + this.state = { + inputRefKey: null, + inputRefValue: null, + }; + // Bind + this.refInputValue = this.refInputValue.bind(this); + this.refInputKey = this.refInputKey.bind(this); + this.onKeydown = this.onKeydown.bind(this); + this.onSubmit = this.onSubmit.bind(this); + } + + componentDidMount() { + const { inputRefKey, inputRefValue } = this.state; + const { onlyValue } = this.props; + + if (inputRefKey && typeof inputRefKey.focus === 'function') { + inputRefKey.focus(); + } + + if (onlyValue && inputRefValue && typeof inputRefValue.focus === 'function') { + inputRefValue.focus(); + } + + document.addEventListener('keydown', this.onKeydown); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onKeydown); + } + + onKeydown(event) { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.repeat) return; + if (event.code === 'Enter' || event.key === 'Enter') { + event.preventDefault(); + this.onSubmit(); + } + if (event.code === 'Escape' || event.key === 'Escape') { + event.preventDefault(); + this.props.handleCancel(); + } + } + + onSubmit() { + const { handleAdd, onlyValue, onSubmitValueParser, keyPath, deep } = this.props; + const { inputRefKey, inputRefValue } = this.state; + const result = {}; + // Check if we have the key + if (!onlyValue) { + // Check that there is a key + if (!inputRefKey.value) { + // Empty key => Not authorized + return; + } + + result.key = inputRefKey.value; + } + result.newValue = onSubmitValueParser(false, keyPath, deep, result.key, inputRefValue.value); + handleAdd(result); + } + + refInputKey(node) { + this.state.inputRefKey = node; + } + + refInputValue(node) { + this.state.inputRefValue = node; + } + + render() { + const { + handleCancel, + onlyValue, + addButtonElement, + cancelButtonElement, + inputElementGenerator, + keyPath, + deep, + } = this.props; + const addButtonElementLayout = React.cloneElement(addButtonElement, { + onClick: this.onSubmit, + }); + const cancelButtonElementLayout = React.cloneElement(cancelButtonElement, { + onClick: handleCancel, + }); + const inputElementValue = inputElementGenerator(inputUsageTypes.VALUE, keyPath, deep); + const inputElementValueLayout = React.cloneElement(inputElementValue, { + placeholder: 'Value', + ref: this.refInputValue, + }); + let inputElementKeyLayout = null; + + if (!onlyValue) { + const inputElementKey = inputElementGenerator(inputUsageTypes.KEY, keyPath, deep); + inputElementKeyLayout = React.cloneElement(inputElementKey, { + placeholder: 'Key', + ref: this.refInputKey, + }); + } + + return ( + + {inputElementKeyLayout} + {inputElementValueLayout} + {cancelButtonElementLayout} + {addButtonElementLayout} + + ); + } +} + +JsonAddValue.propTypes = { + handleAdd: PropTypes.func.isRequired, + handleCancel: PropTypes.func.isRequired, + onlyValue: PropTypes.bool, + addButtonElement: PropTypes.element, + cancelButtonElement: PropTypes.element, + inputElementGenerator: PropTypes.func.isRequired, + keyPath: PropTypes.array, + deep: PropTypes.number, + onSubmitValueParser: PropTypes.func.isRequired, +}; + +JsonAddValue.defaultProps = { + onlyValue: false, + addButtonElement: , + cancelButtonElement: , +}; + +export default JsonAddValue; diff --git a/lib/components/src/controls/react-editable-json-tree/components/JsonArray.js b/lib/components/src/controls/react-editable-json-tree/components/JsonArray.js new file mode 100644 index 000000000000..61bf8240aa2f --- /dev/null +++ b/lib/components/src/controls/react-editable-json-tree/components/JsonArray.js @@ -0,0 +1,342 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import JsonNode from './JsonNode'; +import JsonAddValue from './JsonAddValue'; +import { ADD_DELTA_TYPE, REMOVE_DELTA_TYPE, UPDATE_DELTA_TYPE } from '../types/deltaTypes'; + +class JsonArray extends Component { + constructor(props) { + super(props); + const keyPath = [...props.keyPath, props.name]; + this.state = { + data: props.data, + name: props.name, + keyPath, + deep: props.deep, + nextDeep: props.deep + 1, + collapsed: props.isCollapsed(keyPath, props.deep, props.data), + addFormVisible: false, + }; + + // Bind + this.handleCollapseMode = this.handleCollapseMode.bind(this); + this.handleRemoveItem = this.handleRemoveItem.bind(this); + this.handleAddMode = this.handleAddMode.bind(this); + this.handleAddValueAdd = this.handleAddValueAdd.bind(this); + this.handleAddValueCancel = this.handleAddValueCancel.bind(this); + this.handleEditValue = this.handleEditValue.bind(this); + this.onChildUpdate = this.onChildUpdate.bind(this); + this.renderCollapsed = this.renderCollapsed.bind(this); + this.renderNotCollapsed = this.renderNotCollapsed.bind(this); + } + + static getDerivedStateFromProps(props, state) { + return props.data !== state.data ? { data: props.data } : null; + } + + onChildUpdate(childKey, childData) { + const { data, keyPath } = this.state; + // Update data + data[childKey] = childData; + // Put new data + this.setState({ + data, + }); + // Spread + const { onUpdate } = this.props; + const size = keyPath.length; + onUpdate(keyPath[size - 1], data); + } + + handleAddMode() { + this.setState({ + addFormVisible: true, + }); + } + + handleCollapseMode() { + this.setState((state) => ({ + collapsed: !state.collapsed, + })); + } + + handleRemoveItem(index) { + return () => { + const { beforeRemoveAction, logger } = this.props; + const { data, keyPath, nextDeep: deep } = this.state; + const oldValue = data[index]; + + // Before Remove Action + beforeRemoveAction(index, keyPath, deep, oldValue) + .then(() => { + const deltaUpdateResult = { + keyPath, + deep, + key: index, + oldValue, + type: REMOVE_DELTA_TYPE, + }; + + data.splice(index, 1); + this.setState({ data }); + + // Spread new update + const { onUpdate, onDeltaUpdate } = this.props; + onUpdate(keyPath[keyPath.length - 1], data); + // Spread delta update + onDeltaUpdate(deltaUpdateResult); + }) + .catch(logger.error); + }; + } + + handleAddValueAdd({ newValue }) { + const { data, keyPath, nextDeep: deep } = this.state; + const { beforeAddAction, logger } = this.props; + + beforeAddAction(data.length, keyPath, deep, newValue) + .then(() => { + // Update data + const newData = [...data, newValue]; + this.setState({ + data: newData, + }); + // Cancel add to close + this.handleAddValueCancel(); + // Spread new update + const { onUpdate, onDeltaUpdate } = this.props; + onUpdate(keyPath[keyPath.length - 1], newData); + // Spread delta update + onDeltaUpdate({ + type: ADD_DELTA_TYPE, + keyPath, + deep, + key: newData.length - 1, + newValue, + }); + }) + .catch(logger.error); + } + + handleAddValueCancel() { + this.setState({ + addFormVisible: false, + }); + } + + handleEditValue({ key, value }) { + return new Promise((resolve, reject) => { + const { beforeUpdateAction } = this.props; + const { data, keyPath, nextDeep: deep } = this.state; + + // Old value + const oldValue = data[key]; + + // Before update action + beforeUpdateAction(key, keyPath, deep, oldValue, value) + .then(() => { + // Update value + data[key] = value; + // Set state + this.setState({ + data, + }); + // Spread new update + const { onUpdate, onDeltaUpdate } = this.props; + onUpdate(keyPath[keyPath.length - 1], data); + // Spread delta update + onDeltaUpdate({ + type: UPDATE_DELTA_TYPE, + keyPath, + deep, + key, + newValue: value, + oldValue, + }); + // Resolve + resolve(); + }) + .catch(reject); + }); + } + + renderCollapsed() { + const { name, data, keyPath, deep } = this.state; + const { handleRemove, readOnly, getStyle, dataType, minusMenuElement } = this.props; + const { minus, collapsed } = getStyle(name, data, keyPath, deep, dataType); + + const isReadOnly = readOnly(name, data, keyPath, deep, dataType); + + const removeItemButton = React.cloneElement(minusMenuElement, { + onClick: handleRemove, + className: 'rejt-minus-menu', + style: minus, + }); + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + + + [...] {data.length} {data.length === 1 ? 'item' : 'items'} + + {!isReadOnly && removeItemButton} + + ); + /* eslint-enable */ + } + + renderNotCollapsed() { + const { name, data, keyPath, deep, addFormVisible, nextDeep } = this.state; + const { + isCollapsed, + handleRemove, + onDeltaUpdate, + readOnly, + getStyle, + dataType, + addButtonElement, + cancelButtonElement, + editButtonElement, + inputElementGenerator, + textareaElementGenerator, + minusMenuElement, + plusMenuElement, + beforeRemoveAction, + beforeAddAction, + beforeUpdateAction, + logger, + onSubmitValueParser, + } = this.props; + const { minus, plus, delimiter, ul, addForm } = getStyle(name, data, keyPath, deep, dataType); + + const isReadOnly = readOnly(name, data, keyPath, deep, dataType); + + const addItemButton = React.cloneElement(plusMenuElement, { + onClick: this.handleAddMode, + className: 'rejt-plus-menu', + style: plus, + }); + const removeItemButton = React.cloneElement(minusMenuElement, { + onClick: handleRemove, + className: 'rejt-minus-menu', + style: minus, + }); + + const onlyValue = true; + const startObject = '['; + const endObject = ']'; + return ( + + + {startObject} + + {!addFormVisible && addItemButton} +
    + {data.map((item, index) => ( + + ))} +
+ {!isReadOnly && addFormVisible && ( +
+ +
+ )} + + {endObject} + + {!isReadOnly && removeItemButton} +
+ ); + } + + render() { + const { name, collapsed, data, keyPath, deep } = this.state; + const { dataType, getStyle } = this.props; + const value = collapsed ? this.renderCollapsed() : this.renderNotCollapsed(); + const style = getStyle(name, data, keyPath, deep, dataType); + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
+ + + {name} :{' '} + + + {value} +
+ ); + /* eslint-enable */ + } +} + +JsonArray.propTypes = { + data: PropTypes.array.isRequired, + name: PropTypes.string.isRequired, + isCollapsed: PropTypes.func.isRequired, + keyPath: PropTypes.array, + deep: PropTypes.number, + handleRemove: PropTypes.func, + onUpdate: PropTypes.func.isRequired, + onDeltaUpdate: PropTypes.func.isRequired, + readOnly: PropTypes.func.isRequired, + dataType: PropTypes.string, + getStyle: PropTypes.func.isRequired, + addButtonElement: PropTypes.element, + cancelButtonElement: PropTypes.element, + editButtonElement: PropTypes.element, + inputElementGenerator: PropTypes.func.isRequired, + textareaElementGenerator: PropTypes.func.isRequired, + minusMenuElement: PropTypes.element, + plusMenuElement: PropTypes.element, + beforeRemoveAction: PropTypes.func, + beforeAddAction: PropTypes.func, + beforeUpdateAction: PropTypes.func, + logger: PropTypes.object.isRequired, + onSubmitValueParser: PropTypes.func.isRequired, +}; + +JsonArray.defaultProps = { + keyPath: [], + deep: 0, + minusMenuElement: - , + plusMenuElement: + , +}; + +export default JsonArray; diff --git a/lib/components/src/controls/react-editable-json-tree/components/JsonFunctionValue.js b/lib/components/src/controls/react-editable-json-tree/components/JsonFunctionValue.js new file mode 100644 index 000000000000..ae205834ee73 --- /dev/null +++ b/lib/components/src/controls/react-editable-json-tree/components/JsonFunctionValue.js @@ -0,0 +1,209 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { isComponentWillChange } from '../utils/objectTypes'; +import inputUsageTypes from '../types/inputUsageTypes'; + +class JsonFunctionValue extends Component { + constructor(props) { + super(props); + const keyPath = [...props.keyPath, props.name]; + this.state = { + value: props.value, + name: props.name, + keyPath, + deep: props.deep, + editEnabled: false, + inputRef: null, + }; + + // Bind + this.handleEditMode = this.handleEditMode.bind(this); + this.refInput = this.refInput.bind(this); + this.handleCancelEdit = this.handleCancelEdit.bind(this); + this.handleEdit = this.handleEdit.bind(this); + this.onKeydown = this.onKeydown.bind(this); + } + + static getDerivedStateFromProps(props, state) { + return props.value !== state.value ? { value: props.value } : null; + } + + componentDidUpdate() { + const { editEnabled, inputRef, name, value, keyPath, deep } = this.state; + const { readOnly, dataType } = this.props; + const readOnlyResult = readOnly(name, value, keyPath, deep, dataType); + + if (editEnabled && !readOnlyResult && typeof inputRef.focus === 'function') { + inputRef.focus(); + } + } + + componentDidMount() { + document.addEventListener('keydown', this.onKeydown); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onKeydown); + } + + onKeydown(event) { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.repeat) return; + if (event.code === 'Enter' || event.key === 'Enter') { + event.preventDefault(); + this.handleEdit(); + } + if (event.code === 'Escape' || event.key === 'Escape') { + event.preventDefault(); + this.handleCancelEdit(); + } + } + + handleEdit() { + const { handleUpdateValue, originalValue, logger, onSubmitValueParser, keyPath } = this.props; + const { inputRef, name, deep } = this.state; + if (!inputRef) return; + + const newValue = onSubmitValueParser(true, keyPath, deep, name, inputRef.value); + + const result = { + value: newValue, + key: name, + }; + + // Run update + handleUpdateValue(result) + .then(() => { + // Cancel edit mode if necessary + if (!isComponentWillChange(originalValue, newValue)) { + this.handleCancelEdit(); + } + }) + .catch(logger.error); + } + + handleEditMode() { + this.setState({ + editEnabled: true, + }); + } + + refInput(node) { + this.state.inputRef = node; + } + + handleCancelEdit() { + this.setState({ + editEnabled: false, + }); + } + + render() { + const { name, value, editEnabled, keyPath, deep } = this.state; + const { + handleRemove, + originalValue, + readOnly, + dataType, + getStyle, + editButtonElement, + cancelButtonElement, + textareaElementGenerator, + minusMenuElement, + keyPath: comeFromKeyPath, + } = this.props; + + const style = getStyle(name, originalValue, keyPath, deep, dataType); + let result = null; + let minusElement = null; + const resultOnlyResult = readOnly(name, originalValue, keyPath, deep, dataType); + + if (editEnabled && !resultOnlyResult) { + const textareaElement = textareaElementGenerator( + inputUsageTypes.VALUE, + comeFromKeyPath, + deep, + name, + originalValue, + dataType + ); + + const editButtonElementLayout = React.cloneElement(editButtonElement, { + onClick: this.handleEdit, + }); + const cancelButtonElementLayout = React.cloneElement(cancelButtonElement, { + onClick: this.handleCancelEdit, + }); + const textareaElementLayout = React.cloneElement(textareaElement, { + ref: this.refInput, + defaultValue: originalValue, + }); + + result = ( + + {textareaElementLayout} {cancelButtonElementLayout} + {editButtonElementLayout} + + ); + minusElement = null; + } else { + /* eslint-disable jsx-a11y/no-static-element-interactions */ + result = ( + + {value} + + ); + /* eslint-enable */ + const minusMenuLayout = React.cloneElement(minusMenuElement, { + onClick: handleRemove, + className: 'rejt-minus-menu', + style: style.minus, + }); + minusElement = resultOnlyResult ? null : minusMenuLayout; + } + + return ( +
  • + + {name} :{' '} + + {result} + {minusElement} +
  • + ); + } +} + +JsonFunctionValue.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.any.isRequired, + originalValue: PropTypes.any, + keyPath: PropTypes.array, + deep: PropTypes.number, + handleRemove: PropTypes.func, + handleUpdateValue: PropTypes.func, + readOnly: PropTypes.func.isRequired, + dataType: PropTypes.string, + getStyle: PropTypes.func.isRequired, + editButtonElement: PropTypes.element, + cancelButtonElement: PropTypes.element, + textareaElementGenerator: PropTypes.func.isRequired, + minusMenuElement: PropTypes.element, + logger: PropTypes.object.isRequired, + onSubmitValueParser: PropTypes.func.isRequired, +}; + +JsonFunctionValue.defaultProps = { + keyPath: [], + deep: 0, + handleUpdateValue: () => {}, + editButtonElement: , + cancelButtonElement: , + minusMenuElement: - , +}; + +export default JsonFunctionValue; diff --git a/lib/components/src/controls/react-editable-json-tree/components/JsonNode.js b/lib/components/src/controls/react-editable-json-tree/components/JsonNode.js new file mode 100644 index 000000000000..ca46745edc6c --- /dev/null +++ b/lib/components/src/controls/react-editable-json-tree/components/JsonNode.js @@ -0,0 +1,342 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import JsonValue from './JsonValue'; +import JsonObject from './JsonObject'; +import JsonArray from './JsonArray'; +import JsonFunctionValue from './JsonFunctionValue'; +import { getObjectType } from '../utils/objectTypes'; +import dataTypes from '../types/dataTypes'; + +class JsonNode extends Component { + constructor(props) { + super(props); + this.state = { + data: props.data, + name: props.name, + keyPath: props.keyPath, + deep: props.deep, + }; + } + + static getDerivedStateFromProps(props, state) { + return props.data !== state.data ? { data: props.data } : null; + } + + render() { + const { data, name, keyPath, deep } = this.state; + const { + isCollapsed, + handleRemove, + handleUpdateValue, + onUpdate, + onDeltaUpdate, + readOnly, + getStyle, + addButtonElement, + cancelButtonElement, + editButtonElement, + inputElementGenerator, + textareaElementGenerator, + minusMenuElement, + plusMenuElement, + beforeRemoveAction, + beforeAddAction, + beforeUpdateAction, + logger, + onSubmitValueParser, + } = this.props; + const readOnlyTrue = () => true; + + const dataType = getObjectType(data); + switch (dataType) { + case dataTypes.ERROR: + return ( + + ); + case dataTypes.OBJECT: + return ( + + ); + case dataTypes.ARRAY: + return ( + + ); + case dataTypes.STRING: + return ( + + ); + case dataTypes.NUMBER: + return ( + + ); + case dataTypes.BOOLEAN: + return ( + + ); + case dataTypes.DATE: + return ( + + ); + case dataTypes.NULL: + return ( + + ); + case dataTypes.UNDEFINED: + return ( + + ); + case dataTypes.FUNCTION: + return ( + + ); + case dataTypes.SYMBOL: + return ( + + ); + default: + return null; + } + } +} + +JsonNode.propTypes = { + name: PropTypes.string.isRequired, + data: PropTypes.any, + isCollapsed: PropTypes.func.isRequired, + keyPath: PropTypes.array, + deep: PropTypes.number, + handleRemove: PropTypes.func, + handleUpdateValue: PropTypes.func, + onUpdate: PropTypes.func.isRequired, + onDeltaUpdate: PropTypes.func.isRequired, + readOnly: PropTypes.func.isRequired, + getStyle: PropTypes.func.isRequired, + addButtonElement: PropTypes.element, + cancelButtonElement: PropTypes.element, + editButtonElement: PropTypes.element, + inputElementGenerator: PropTypes.func.isRequired, + textareaElementGenerator: PropTypes.func.isRequired, + minusMenuElement: PropTypes.element, + plusMenuElement: PropTypes.element, + beforeRemoveAction: PropTypes.func, + beforeAddAction: PropTypes.func, + beforeUpdateAction: PropTypes.func, + logger: PropTypes.object.isRequired, + onSubmitValueParser: PropTypes.func.isRequired, +}; + +JsonNode.defaultProps = { + keyPath: [], + deep: 0, +}; + +export default JsonNode; diff --git a/lib/components/src/controls/react-editable-json-tree/components/JsonObject.js b/lib/components/src/controls/react-editable-json-tree/components/JsonObject.js new file mode 100644 index 000000000000..7e3c298e97d8 --- /dev/null +++ b/lib/components/src/controls/react-editable-json-tree/components/JsonObject.js @@ -0,0 +1,346 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import JsonNode from './JsonNode'; +import JsonAddValue from './JsonAddValue'; +import { ADD_DELTA_TYPE, REMOVE_DELTA_TYPE, UPDATE_DELTA_TYPE } from '../types/deltaTypes'; + +class JsonObject extends Component { + constructor(props) { + super(props); + const keyPath = props.deep === -1 ? [] : [...props.keyPath, props.name]; + this.state = { + name: props.name, + data: props.data, + keyPath, + deep: props.deep, + nextDeep: props.deep + 1, + collapsed: props.isCollapsed(keyPath, props.deep, props.data), + addFormVisible: false, + }; + + // Bind + this.handleCollapseMode = this.handleCollapseMode.bind(this); + this.handleRemoveValue = this.handleRemoveValue.bind(this); + this.handleAddMode = this.handleAddMode.bind(this); + this.handleAddValueAdd = this.handleAddValueAdd.bind(this); + this.handleAddValueCancel = this.handleAddValueCancel.bind(this); + this.handleEditValue = this.handleEditValue.bind(this); + this.onChildUpdate = this.onChildUpdate.bind(this); + this.renderCollapsed = this.renderCollapsed.bind(this); + this.renderNotCollapsed = this.renderNotCollapsed.bind(this); + } + + static getDerivedStateFromProps(props, state) { + return props.data !== state.data ? { data: props.data } : null; + } + + onChildUpdate(childKey, childData) { + const { data, keyPath } = this.state; + // Update data + data[childKey] = childData; + // Put new data + this.setState({ + data, + }); + // Spread + const { onUpdate } = this.props; + const size = keyPath.length; + onUpdate(keyPath[size - 1], data); + } + + handleAddMode() { + this.setState({ + addFormVisible: true, + }); + } + + handleAddValueCancel() { + this.setState({ + addFormVisible: false, + }); + } + + handleAddValueAdd({ key, newValue }) { + const { data, keyPath, nextDeep: deep } = this.state; + const { beforeAddAction, logger } = this.props; + + beforeAddAction(key, keyPath, deep, newValue) + .then(() => { + // Update data + data[key] = newValue; + this.setState({ + data, + }); + // Cancel add to close + this.handleAddValueCancel(); + // Spread new update + const { onUpdate, onDeltaUpdate } = this.props; + onUpdate(keyPath[keyPath.length - 1], data); + // Spread delta update + onDeltaUpdate({ + type: ADD_DELTA_TYPE, + keyPath, + deep, + key, + newValue, + }); + }) + .catch(logger.error); + } + + handleRemoveValue(key) { + return () => { + const { beforeRemoveAction, logger } = this.props; + const { data, keyPath, nextDeep: deep } = this.state; + const oldValue = data[key]; + // Before Remove Action + beforeRemoveAction(key, keyPath, deep, oldValue) + .then(() => { + const deltaUpdateResult = { + keyPath, + deep, + key, + oldValue, + type: REMOVE_DELTA_TYPE, + }; + + delete data[key]; + this.setState({ data }); + + // Spread new update + const { onUpdate, onDeltaUpdate } = this.props; + onUpdate(keyPath[keyPath.length - 1], data); + // Spread delta update + onDeltaUpdate(deltaUpdateResult); + }) + .catch(logger.error); + }; + } + + handleCollapseMode() { + this.setState((state) => ({ + collapsed: !state.collapsed, + })); + } + + handleEditValue({ key, value }) { + return new Promise((resolve, reject) => { + const { beforeUpdateAction } = this.props; + const { data, keyPath, nextDeep: deep } = this.state; + + // Old value + const oldValue = data[key]; + + // Before update action + beforeUpdateAction(key, keyPath, deep, oldValue, value) + .then(() => { + // Update value + data[key] = value; + // Set state + this.setState({ + data, + }); + // Spread new update + const { onUpdate, onDeltaUpdate } = this.props; + onUpdate(keyPath[keyPath.length - 1], data); + // Spread delta update + onDeltaUpdate({ + type: UPDATE_DELTA_TYPE, + keyPath, + deep, + key, + newValue: value, + oldValue, + }); + // Resolve + resolve(); + }) + .catch(reject); + }); + } + + renderCollapsed() { + const { name, keyPath, deep, data } = this.state; + const { handleRemove, readOnly, dataType, getStyle, minusMenuElement } = this.props; + + const { minus, collapsed } = getStyle(name, data, keyPath, deep, dataType); + const keyList = Object.getOwnPropertyNames(data); + + const isReadOnly = readOnly(name, data, keyPath, deep, dataType); + + const removeItemButton = React.cloneElement(minusMenuElement, { + onClick: handleRemove, + className: 'rejt-minus-menu', + style: minus, + }); + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + + + {'{...}'} {keyList.length} {keyList.length === 1 ? 'key' : 'keys'} + + {!isReadOnly && removeItemButton} + + ); + /* eslint-enable */ + } + + renderNotCollapsed() { + const { name, data, keyPath, deep, nextDeep, addFormVisible } = this.state; + const { + isCollapsed, + handleRemove, + onDeltaUpdate, + readOnly, + getStyle, + dataType, + addButtonElement, + cancelButtonElement, + editButtonElement, + inputElementGenerator, + textareaElementGenerator, + minusMenuElement, + plusMenuElement, + beforeRemoveAction, + beforeAddAction, + beforeUpdateAction, + logger, + onSubmitValueParser, + } = this.props; + + const { minus, plus, addForm, ul, delimiter } = getStyle(name, data, keyPath, deep, dataType); + const keyList = Object.getOwnPropertyNames(data); + + const isReadOnly = readOnly(name, data, keyPath, deep, dataType); + + const addItemButton = React.cloneElement(plusMenuElement, { + onClick: this.handleAddMode, + className: 'rejt-plus-menu', + style: plus, + }); + const removeItemButton = React.cloneElement(minusMenuElement, { + onClick: handleRemove, + className: 'rejt-minus-menu', + style: minus, + }); + + const list = keyList.map((key) => ( + + )); + + const startObject = '{'; + const endObject = '}'; + + return ( + + + {startObject} + + {!isReadOnly && addItemButton} +
      + {list} +
    + {!isReadOnly && addFormVisible && ( +
    + +
    + )} + + {endObject} + + {!isReadOnly && removeItemButton} +
    + ); + } + + render() { + const { name, collapsed, data, keyPath, deep } = this.state; + const { getStyle, dataType } = this.props; + const value = collapsed ? this.renderCollapsed() : this.renderNotCollapsed(); + const style = getStyle(name, data, keyPath, deep, dataType); + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
    + + + {name} :{' '} + + + {value} +
    + ); + /* eslint-enable */ + } +} + +JsonObject.propTypes = { + data: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + isCollapsed: PropTypes.func.isRequired, + keyPath: PropTypes.array, + deep: PropTypes.number, + handleRemove: PropTypes.func, + onUpdate: PropTypes.func.isRequired, + onDeltaUpdate: PropTypes.func.isRequired, + readOnly: PropTypes.func.isRequired, + dataType: PropTypes.string, + getStyle: PropTypes.func.isRequired, + addButtonElement: PropTypes.element, + cancelButtonElement: PropTypes.element, + editButtonElement: PropTypes.element, + inputElementGenerator: PropTypes.func.isRequired, + textareaElementGenerator: PropTypes.func.isRequired, + minusMenuElement: PropTypes.element, + plusMenuElement: PropTypes.element, + beforeRemoveAction: PropTypes.func, + beforeAddAction: PropTypes.func, + beforeUpdateAction: PropTypes.func, + logger: PropTypes.object.isRequired, + onSubmitValueParser: PropTypes.func.isRequired, +}; + +JsonObject.defaultProps = { + keyPath: [], + deep: 0, + minusMenuElement: - , + plusMenuElement: + , +}; + +export default JsonObject; diff --git a/lib/components/src/controls/react-editable-json-tree/components/JsonValue.js b/lib/components/src/controls/react-editable-json-tree/components/JsonValue.js new file mode 100644 index 000000000000..f2bf4dcd056f --- /dev/null +++ b/lib/components/src/controls/react-editable-json-tree/components/JsonValue.js @@ -0,0 +1,198 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { isComponentWillChange } from '../utils/objectTypes'; +import inputUsageTypes from '../types/inputUsageTypes'; + +class JsonValue extends Component { + constructor(props) { + super(props); + const keyPath = [...props.keyPath, props.name]; + this.state = { + value: props.value, + name: props.name, + keyPath, + deep: props.deep, + editEnabled: false, + inputRef: null, + }; + + // Bind + this.handleEditMode = this.handleEditMode.bind(this); + this.refInput = this.refInput.bind(this); + this.handleCancelEdit = this.handleCancelEdit.bind(this); + this.handleEdit = this.handleEdit.bind(this); + this.onKeydown = this.onKeydown.bind(this); + } + + static getDerivedStateFromProps(props, state) { + return props.value !== state.value ? { value: props.value } : null; + } + + componentDidUpdate() { + const { editEnabled, inputRef, name, value, keyPath, deep } = this.state; + const { readOnly, dataType } = this.props; + const isReadOnly = readOnly(name, value, keyPath, deep, dataType); + + if (editEnabled && !isReadOnly && typeof inputRef.focus === 'function') { + inputRef.focus(); + } + } + + componentDidMount() { + document.addEventListener('keydown', this.onKeydown); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onKeydown); + } + + onKeydown(event) { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.repeat) return; + if (event.code === 'Enter' || event.key === 'Enter') { + event.preventDefault(); + this.handleEdit(); + } + if (event.code === 'Escape' || event.key === 'Escape') { + event.preventDefault(); + this.handleCancelEdit(); + } + } + + handleEdit() { + const { handleUpdateValue, originalValue, logger, onSubmitValueParser, keyPath } = this.props; + const { inputRef, name, deep } = this.state; + if (!inputRef) return; + + const newValue = onSubmitValueParser(true, keyPath, deep, name, inputRef.value); + + const result = { + value: newValue, + key: name, + }; + + // Run update + handleUpdateValue(result) + .then(() => { + // Cancel edit mode if necessary + if (!isComponentWillChange(originalValue, newValue)) { + this.handleCancelEdit(); + } + }) + .catch(logger.error); + } + + handleEditMode() { + this.setState({ + editEnabled: true, + }); + } + + refInput(node) { + this.state.inputRef = node; + } + + handleCancelEdit() { + this.setState({ + editEnabled: false, + }); + } + + render() { + const { name, value, editEnabled, keyPath, deep } = this.state; + const { + handleRemove, + originalValue, + readOnly, + dataType, + getStyle, + editButtonElement, + cancelButtonElement, + inputElementGenerator, + minusMenuElement, + keyPath: comeFromKeyPath, + } = this.props; + + const style = getStyle(name, originalValue, keyPath, deep, dataType); + const isReadOnly = readOnly(name, originalValue, keyPath, deep, dataType); + const isEditing = editEnabled && !isReadOnly; + const inputElement = inputElementGenerator( + inputUsageTypes.VALUE, + comeFromKeyPath, + deep, + name, + originalValue, + dataType + ); + + const editButtonElementLayout = React.cloneElement(editButtonElement, { + onClick: this.handleEdit, + }); + const cancelButtonElementLayout = React.cloneElement(cancelButtonElement, { + onClick: this.handleCancelEdit, + }); + const inputElementLayout = React.cloneElement(inputElement, { + ref: this.refInput, + defaultValue: JSON.stringify(originalValue), + }); + const minusMenuLayout = React.cloneElement(minusMenuElement, { + onClick: handleRemove, + className: 'rejt-minus-menu', + style: style.minus, + }); + + return ( +
  • + + {name} + {' : '} + + {isEditing ? ( + + {inputElementLayout} {cancelButtonElementLayout} + {editButtonElementLayout} + + ) : ( + + {String(value)} + + )} + {!isReadOnly && !isEditing && minusMenuLayout} +
  • + ); + } +} + +JsonValue.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.any.isRequired, + originalValue: PropTypes.any, + keyPath: PropTypes.array, + deep: PropTypes.number, + handleRemove: PropTypes.func, + handleUpdateValue: PropTypes.func, + readOnly: PropTypes.func.isRequired, + dataType: PropTypes.string, + getStyle: PropTypes.func.isRequired, + editButtonElement: PropTypes.element, + cancelButtonElement: PropTypes.element, + inputElementGenerator: PropTypes.func.isRequired, + minusMenuElement: PropTypes.element, + logger: PropTypes.object.isRequired, + onSubmitValueParser: PropTypes.func.isRequired, +}; + +JsonValue.defaultProps = { + keyPath: [], + deep: 0, + handleUpdateValue: () => Promise.resolve(), + editButtonElement: , + cancelButtonElement: , + minusMenuElement: - , +}; + +export default JsonValue; diff --git a/lib/components/src/controls/react-editable-json-tree/index.js b/lib/components/src/controls/react-editable-json-tree/index.js new file mode 100644 index 000000000000..7e77c0b2f002 --- /dev/null +++ b/lib/components/src/controls/react-editable-json-tree/index.js @@ -0,0 +1,173 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import JsonNode from './components/JsonNode'; +import { value, object, array } from './utils/styles'; +import { ADD_DELTA_TYPE, REMOVE_DELTA_TYPE, UPDATE_DELTA_TYPE } from './types/deltaTypes'; +import { getObjectType } from './utils/objectTypes'; +import DATA_TYPES from './types/dataTypes'; +import INPUT_USAGE_TYPES from './types/inputUsageTypes'; +import parse from './utils/parse'; + +class JsonTree extends Component { + constructor(props) { + super(props); + this.state = { + data: props.data, + rootName: props.rootName, + }; + // Bind + this.onUpdate = this.onUpdate.bind(this); + this.removeRoot = this.removeRoot.bind(this); + } + + static getDerivedStateFromProps(props, state) { + if (props.data !== state.data || props.rootName !== state.rootName) { + return { + data: props.data, + rootName: props.rootName, + }; + } + return null; + } + + onUpdate(key, data) { + this.setState({ data }); + this.props.onFullyUpdate(data); + } + + removeRoot() { + this.onUpdate(null, null); + } + + render() { + const { data, rootName } = this.state; + const { + isCollapsed, + onDeltaUpdate, + readOnly, + getStyle, + addButtonElement, + cancelButtonElement, + editButtonElement, + inputElement, + textareaElement, + minusMenuElement, + plusMenuElement, + beforeRemoveAction, + beforeAddAction, + beforeUpdateAction, + logger, + onSubmitValueParser, + fallback, + } = this.props; + + // Node type + const dataType = getObjectType(data); + + let readOnlyFunction = readOnly; + if (getObjectType(readOnly) === 'Boolean') { + readOnlyFunction = () => readOnly; + } + let inputElementFunction = inputElement; + if (inputElement && getObjectType(inputElement) !== 'Function') { + inputElementFunction = () => inputElement; + } + let textareaElementFunction = textareaElement; + if (textareaElement && getObjectType(textareaElement) !== 'Function') { + textareaElementFunction = () => textareaElement; + } + + if (dataType === 'Object' || dataType === 'Array') { + return ( +
    + +
    + ); + } + + return fallback; + } +} + +JsonTree.propTypes = { + data: PropTypes.any.isRequired, + rootName: PropTypes.string, + isCollapsed: PropTypes.func, + onFullyUpdate: PropTypes.func, + onDeltaUpdate: PropTypes.func, + readOnly: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + getStyle: PropTypes.func, + addButtonElement: PropTypes.element, + cancelButtonElement: PropTypes.element, + editButtonElement: PropTypes.element, + inputElement: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + textareaElement: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + minusMenuElement: PropTypes.element, + plusMenuElement: PropTypes.element, + beforeRemoveAction: PropTypes.func, + beforeAddAction: PropTypes.func, + beforeUpdateAction: PropTypes.func, + logger: PropTypes.object, + onSubmitValueParser: PropTypes.func, +}; + +JsonTree.defaultProps = { + rootName: 'root', + isCollapsed: (keyPath, deep) => deep !== -1, + getStyle: (keyName, data, keyPath, deep, dataType) => { + switch (dataType) { + case 'Object': + case 'Error': + return object; + case 'Array': + return array; + default: + return value; + } + }, + /* eslint-disable no-unused-vars */ + readOnly: (keyName, data, keyPath, deep, dataType) => false, + onFullyUpdate: (data) => {}, + onDeltaUpdate: ({ type, keyPath, deep, key, newValue, oldValue }) => {}, + beforeRemoveAction: (key, keyPath, deep, oldValue) => new Promise((resolve) => resolve()), + beforeAddAction: (key, keyPath, deep, newValue) => new Promise((resolve) => resolve()), + beforeUpdateAction: (key, keyPath, deep, oldValue, newValue) => + new Promise((resolve) => resolve()), + logger: { error: () => {} }, + onSubmitValueParser: (isEditMode, keyPath, deep, name, rawValue) => parse(rawValue), + inputElement: (usage, keyPath, deep, keyName, data, dataType) => , + textareaElement: (usage, keyPath, deep, keyName, data, dataType) =>