Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
*.css
.eslintrc.js
*.test.*
jest.config.js
95 changes: 48 additions & 47 deletions README.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,13 +367,29 @@ function App() {
: false
}
restrictEdit={restrictEdit}
// restrictEdit={(nodeData) => !(typeof nodeData.value === 'string')}
restrictDelete={restrictDelete}
restrictAdd={restrictAdd}
restrictTypeSelection={dataDefinition?.restrictTypeSelection}
restrictDrag={false}
searchFilter={dataDefinition?.searchFilter}
searchText={searchText}
keySort={sortKeys}
// keySort={
// sortKeys
// ? (a, b) => {
// const nameRev1 = String(a[0]).length
// const nameRev2 = String(b[0]).length
// if (nameRev1 < nameRev2) {
// return -1
// }
// if (nameRev1 > nameRev2) {
// return 1
// }
// return 0
// }
// : false
// }
defaultValue={dataDefinition?.defaultValue ?? defaultNewValue}
showArrayIndices={showIndices}
showStringQuotes={showStringQuotes}
Expand Down
3 changes: 2 additions & 1 deletion demo/src/demoData/dataDefinitions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
UpdateFunction,
UpdateFunctionProps,
} from '../json-edit-react/src/types'
import { Input } from 'object-property-assigner/build'
import { type Input } from 'object-property-assigner'
import jsonSchema from './jsonSchema.json'
import customNodesSchema from './customNodesSchema.json'
import Ajv from 'ajv'
Expand Down Expand Up @@ -91,6 +91,7 @@ export const demoDataDefinitions: Record<string, DemoData> = {
collapse: 2,
data: data.intro,
customNodeDefinitions: [dateNodeDefinition],
// restrictEdit: ({ key }) => key === 'number',
},
starWars: {
name: '🚀 Star Wars',
Expand Down
9 changes: 9 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
roots: ['<rootDir>/test'],
testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
verbose: true,
testTimeout: 10000,
}
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"homepage": "https://carlosnz.github.io/json-edit-react",
"scripts": {
"setup": "yarn install && cd demo && yarn install",
"test": "jest",
"demo": "cd demo && node ./scripts/getVersion.js && yarn && yarn start",
"build": "rimraf ./build && rollup -c && rimraf ./build/dts",
"lint": "npx eslint \"src/**\"",
Expand All @@ -48,6 +49,7 @@
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"@types/jest": "^29.5.14",
"@types/node": "^20.11.17",
"@types/react": ">=16.0.0",
"@typescript-eslint/eslint-plugin": "^6.4.0",
Expand All @@ -60,6 +62,7 @@
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"fs-extra": "^11.2.0",
"jest": "^29.7.0",
"react-dom": ">=16.0.0",
"rimraf": "^5.0.5",
"rollup": "^4.10.0",
Expand All @@ -68,6 +71,7 @@
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-sizes": "^1.0.6",
"rollup-plugin-styles": "^4.0.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
Expand Down
86 changes: 65 additions & 21 deletions src/CollectionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,20 @@ import React, { useEffect, useState, useMemo, useRef } from 'react'
import { ValueNodeWrapper } from './ValueNodeWrapper'
import { EditButtons, InputButtons } from './ButtonPanels'
import { getCustomNode } from './CustomNode'
import { type CollectionNodeProps, type NodeData, type CollectionData } from './types'
import {
type CollectionNodeProps,
type NodeData,
type CollectionData,
type ValueData,
} from './types'
import { Icon } from './Icons'
import { filterNode, getModifier, isCollection } from './helpers'
import {
filterNode,
getModifier,
getNextOrPrevious,
insertCharInTextArea,
isCollection,
} from './helpers'
import { AutogrowTextArea } from './AutogrowTextArea'
import { useTheme, useTreeState } from './contexts'
import { useCollapseTransition, useCommon, useDragNDrop } from './hooks'
Expand Down Expand Up @@ -36,7 +47,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
searchFilter,
searchText,
indent,
keySort,
sort,
showArrayIndices,
defaultValue,
translate,
Expand Down Expand Up @@ -101,6 +112,9 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
}
}, [collapseState])

// For JSON-editing TextArea
const textAreaRef = useRef<HTMLTextAreaElement>(null)

const getDefaultNewValue = useMemo(
() => (nodeData: NodeData, newKey: string) => {
if (typeof defaultValue !== 'function') return defaultValue
Expand All @@ -121,19 +135,38 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
showCollectionWrapper = true,
} = useMemo(() => getCustomNode(customNodeDefinitions, nodeData), [])

const childrenEditing = areChildrenBeingEdited(pathString)

// For when children are accessed via Tab
if (childrenEditing && collapsed) animateCollapse(false)

// Early return if this node is filtered out
if (!filterNode('collection', nodeData, searchFilter, searchText) && nodeData.level > 0)
return null
const isVisible =
filterNode('collection', nodeData, searchFilter, searchText) || nodeData.level === 0
if (!isVisible && !childrenEditing) return null

const collectionType = Array.isArray(data) ? 'array' : 'object'
const brackets =
collectionType === 'array' ? { open: '[', close: ']' } : { open: '{', close: '}' }

const handleKeyPressEdit = (e: React.KeyboardEvent) =>
const handleKeyPressEdit = (e: React.KeyboardEvent) => {
// Normal "Tab" key functionality in TextArea
// Defined here explicitly rather than in handleKeyboard as we *don't* want
// to override the normal Tab key with the custom "Tab" key value
if (e.key === 'Tab' && !e.getModifierState('Shift')) {
e.preventDefault()
const newValue = insertCharInTextArea(
textAreaRef as React.MutableRefObject<HTMLTextAreaElement>,
'\t'
)
setStringifiedValue(newValue)
return
}
handleKeyboard(e, {
objectConfirm: handleEdit,
cancel: handleCancel,
})
}

const handleCollapse = (e: React.MouseEvent) => {
const modifier = getModifier(e)
Expand Down Expand Up @@ -212,18 +245,13 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
const showKey = showLabel && !hideKey && name !== undefined
const showCustomNodeContents =
CustomNode && ((isEditing && showOnEdit) || (!isEditing && showOnView))
const sortKeys = keySort && collectionType === 'object'

const keyValueArray = Object.entries(data).map(([key, value]) => [
collectionType === 'array' ? Number(key) : key,
value,
])
const keyValueArray = Object.entries(data).map(
([key, value]) =>
[collectionType === 'array' ? Number(key) : key, value] as [string | number, ValueData]
)

if (sortKeys) {
keyValueArray.sort(
typeof keySort === 'function' ? (a: string[], b) => keySort(a[0], b[0] as string) : undefined
)
}
if (collectionType === 'object') sort<[string | number, ValueData]>(keyValueArray, (_) => _)

const CollectionChildren = !hasBeenOpened.current ? null : !isEditing ? (
keyValueArray.map(([key, value], index) => {
Expand Down Expand Up @@ -271,6 +299,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
<div className="jer-collection-text-edit">
<div>
<AutogrowTextArea
textAreaRef={textAreaRef}
className="jer-collection-text-area"
name={pathString}
value={stringifiedValue}
Expand All @@ -290,7 +319,9 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
// no way to open a collapsed custom node, so this ensures it will stay open.
// It can still be displayed collapsed by handling it internally if this is
// desired.
const isCollapsed = !showCollectionWrapper ? false : collapsed
// Also, if the node is editing via "Tab" key, it's parent must be opened,
// hence `childrenEditing` check
const isCollapsed = !showCollectionWrapper ? false : collapsed && !childrenEditing
if (!isCollapsed) hasBeenOpened.current = true

const customNodeAllProps = {
Expand All @@ -304,7 +335,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
handleCancel,
handleKeyPress: handleKeyPressEdit,
isEditing,
setIsEditing: () => setCurrentlyEditingElement(pathString),
setIsEditing: () => setCurrentlyEditingElement(path),
getStyles,
canDragOnto: canEdit,
}
Expand All @@ -329,6 +360,19 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
handleKeyboard(e, {
stringConfirm: () => handleEditKey((e.target as HTMLInputElement).value),
cancel: handleCancel,
tabForward: () => {
handleEditKey((e.target as HTMLInputElement).value)
const firstChildKey = keyValueArray?.[0][0]
setCurrentlyEditingElement(
firstChildKey
? [...path, firstChildKey]
: getNextOrPrevious(nodeData.fullData, path, 'next', sort)
)
},
tabBack: () => {
handleEditKey((e.target as HTMLInputElement).value)
setCurrentlyEditingElement(getNextOrPrevious(nodeData.fullData, path, 'prev', sort))
},
})
}
style={{ width: `${String(name).length / 1.5 + 0.5}em` }}
Expand All @@ -339,7 +383,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
className="jer-key-text"
style={getStyles('property', nodeData)}
onClick={(e) => e.stopPropagation()}
onDoubleClick={() => canEditKey && setCurrentlyEditingElement(`key_${pathString}`)}
onDoubleClick={() => canEditKey && setCurrentlyEditingElement(path, 'key')}
>
{name === '' ? (
<span className={path.length > 0 ? 'jer-empty-string' : undefined}>
Expand All @@ -358,7 +402,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
canEdit
? () => {
hasBeenOpened.current = true
setCurrentlyEditingElement(pathString)
setCurrentlyEditingElement(path)
}
: undefined
}
Expand Down Expand Up @@ -451,7 +495,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
style={{
overflowY: isCollapsed || isAnimating ? 'clip' : 'visible',
// Prevent collapse if this node or any children are being edited
maxHeight: areChildrenBeingEdited(pathString) ? undefined : maxHeight,
maxHeight: childrenEditing ? undefined : maxHeight,
...getStyles('collectionInner', nodeData),
}}
ref={contentRef}
Expand Down
33 changes: 32 additions & 1 deletion src/JsonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
type JsonData,
type KeyboardControls,
} from './types'
import { useTheme, ThemeProvider, TreeStateProvider, defaultTheme } from './contexts'
import { useTheme, ThemeProvider, TreeStateProvider, defaultTheme, useTreeState } from './contexts'
import { useData } from './hooks/useData'
import { getTranslateFunction } from './localisation'
import { ValueNodeWrapper } from './ValueNodeWrapper'
Expand Down Expand Up @@ -75,6 +75,7 @@ const Editor: React.FC<JsonEditorProps> = ({
insertAtTop = false,
}) => {
const { getStyles } = useTheme()
const { setCurrentlyEditingElement } = useTreeState()
const collapseFilter = useCallback(getFilterFunction(collapse), [collapse])
const translate = useCallback(getTranslateFunction(translations, customText), [
translations,
Expand All @@ -87,6 +88,7 @@ const Editor: React.FC<JsonEditorProps> = ({
const mainContainerRef = useRef<HTMLDivElement>(null)

useEffect(() => {
setCurrentlyEditingElement(null)
const debounce = setTimeout(() => setDebouncedSearchText(searchText), searchDebounceTime)
return () => clearTimeout(debounce)
}, [searchText, searchDebounceTime])
Expand Down Expand Up @@ -263,6 +265,34 @@ const Editor: React.FC<JsonEditorProps> = ({
[keyboardControls]
)

// Common "sort" method for ordering nodes, based on the `keySort` prop
// - If it's false (the default), we do nothing
// - If true, use default array sort on the node's key
// - Otherwise sort via the defined comparison function
// The "nodeMap" is due to the fact that this sort is performed on different
// shaped arrays in different places, so in each implementation we pass a
// function to convert each element into a [key, value] tuple, the shape
// expected by the comparison function
const sort = useCallback(
<T,>(arr: T[], nodeMap: (input: T) => [string | number, unknown]) => {
if (keySort === false) return

if (typeof keySort === 'function') {
arr.sort((a, b) => keySort(nodeMap(a), nodeMap(b)))
return
}

arr.sort((a, b) => {
const A = nodeMap(a)[0]
const B = nodeMap(b)[0]
if (A < B) return -1
if (A > B) return 1
return 0
})
},
[keySort]
)

const otherProps = {
mainContainerRef: mainContainerRef as React.MutableRefObject<Element>,
name: rootName,
Expand All @@ -287,6 +317,7 @@ const Editor: React.FC<JsonEditorProps> = ({
searchText: debouncedSearchText,
enableClipboard,
keySort,
sort,
showArrayIndices,
showStringQuotes,
indent,
Expand Down
Loading