Skip to content

#203 Option to render custom object as "Value" #204

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

Merged
merged 6 commits into from
Jun 7, 2025
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ A highly-configurable [React](https://github.com/facebook/react) component for e
- [Active hyperlinks](#active-hyperlinks)
- [Handling JSON](#handling-json)
- [Custom Collection nodes](#custom-collection-nodes)
- [Displaying Collections as Values](#displaying-collections-as-values)
- [Custom Text](#custom-text)
- [Custom Buttons](#custom-buttons)
- [Keyboard customisation](#keyboard-customisation)
Expand Down Expand Up @@ -946,6 +947,7 @@ Custom nodes are provided in the `customNodeDefinitions` prop, as an array of ob
showCollectionWrapper // boolean (optional), default true
wrapperElement // React component (optional) to wrap *outside* the normal collection wrapper
wrapperProps // object (optional) -- props for the above wrapper component
renderCollectionAsValue // For special "object" data that should be treated like a "Value" node

// For JSON conversion -- only needed if editing as JSON text
stringifyReplacer // function for stringifying to JSON (if non-JSON data type)
Expand All @@ -963,7 +965,7 @@ By default, your component will be presented to the right of the property key it

Also, by default, your component will be treated as a "display" element, i.e. it will appear in the JSON viewer, but when editing, it will revert to the standard editing interface. This can be changed, however, with the `showOnEdit`, `showOnView` and `showEditTools` props. For example, a Date picker might only be required when *editing* and left as-is for display. The `showEditTools` prop refers to the editing icons (copy, add, edit, delete) that appear to the right of each value on hover. If you choose to disable these but you still want to your component to have an "edit" mode, you'll have to provide your own UI mechanism to toggle editing.

You can allow users to create new instances of your special nodes by selecting them as a "Type" in the types selector when editing/adding values. Set `showInTypesSelector: true` to enable this. However, if this is enabled you need to also provide a `name` (which is what the user will see in the selector) and a `defaultValue` which is the data that is inserted when the user selects this "type". (The `defaultValue` must return `true` if passed through the `condition` function in order for it to be immediately displayed using your custom component.)
You can allow users to create new instances of your special nodes by selecting them as a "Type" in the types selector when editing/adding values. Set `showInTypesSelector: true` to enable this. However, if this is enabled you need to *also* provide a `name` (which is what the user will see in the selector) and a `defaultValue` which is the data that is inserted when the user selects this "type". (The `defaultValue` must return `true` if passed through the `condition` function in order for it to be immediately displayed using your custom component.)

### Active hyperlinks

Expand Down Expand Up @@ -1002,6 +1004,15 @@ In most cases it will be preferable (and simpler) to create custom nodes to matc
<img width="450" alt="custom node levels" src="image/custom_component_levels.png">
- There is one additional prop, `showCollectionWrapper` (default `true`), which, when set to `false`, hides the surrounding collection elements (namely the hide/show chevron and the brackets). In this case, you would have to provide your own hide/show mechanism in your component should you want it.

### Displaying Collections as Values

If you have a specialised `object` that you would like to display as though it were a regular "value" -- for example, a JavaScript [`Date` object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) -- you can set the `renderCollectionAsValue` to `true`. This passes the entire object as a value rather than being rendered as a "collection" of key-value pairs, but you'll have to make sure your custom component handles it appropriately.

There are two examples in the [Custom Component Library](https://github.com/CarlosNZ/json-edit-react/blob/main/custom-component-library/README.md):

- [Date Object](#add-link)
- ["Enhanced" link](#add-link) (object with "url" and "text" fields, displayed as clickable string)


## Custom Text

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const DateObjectDefinition: CustomNodeDefinition<DateObjectProps> = {
name: 'Date Object', // shown in the Type selector menu
showInTypesSelector: true,
defaultValue: new Date(),
renderCollectionAsValue: true,
// IMPORTANT: This component can't be used in conjunction with a ISO string
// matcher (such as the DatePicker in this repo) -- because JSON.stringify
// automatically serializes Date objects to ISO Strings, there's no way to
Expand Down
94 changes: 94 additions & 0 deletions custom-component-library/components/EnhancedLink/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* A custom "URL" renderer -- an object that has "text" and "url" properties,
* but is displayed as a clickable string
*/

import React, { useState } from 'react'
import { toPathString, StringDisplay, StringEdit, type CustomNodeProps } from '@json-edit-react'

export interface EnhancedLinkProps {
linkStyles?: React.CSSProperties
propertyStyles?: React.CSSProperties
labels?: { text: string; url: string }
fieldNames?: { text: string; url: string }
stringTruncate?: number
[key: string]: unknown
}

type EnhancedLink = {
[key: string]: string
}

export const EnhancedLinkCustomComponent: React.FC<CustomNodeProps<EnhancedLinkProps>> = (
props
) => {
const { setIsEditing, getStyles, nodeData, customNodeProps = {}, isEditing, handleEdit } = props
const {
linkStyles = { fontWeight: 'bold', textDecoration: 'underline' },
propertyStyles = {},
labels: { text: textLabel, url: urlLabel } = { text: 'Text', url: 'Link' },
fieldNames: { text: textField, url: urlField } = { text: 'text', url: 'url' },
stringTruncate = 120,
} = customNodeProps
const [text, setText] = useState((nodeData.value as EnhancedLink)[textField])
const [url, setUrl] = useState((nodeData.value as EnhancedLink)[urlField])

const styles = getStyles('string', nodeData)

return (
<div
onClick={(e) => {
if (e.getModifierState('Control') || e.getModifierState('Meta')) setIsEditing(true)
}}
style={styles}
>
{isEditing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3em', marginTop: '0.4em' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5em' }}>
<span style={propertyStyles}>{textLabel}:</span>
<StringEdit
styles={getStyles('input', nodeData)}
pathString={toPathString(nodeData.path)}
{...props}
value={text}
setValue={(val) => setText(val)}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5em' }}>
<span style={propertyStyles}>{urlLabel}:</span>
<StringEdit
styles={getStyles('input', nodeData)}
pathString={toPathString(nodeData.path)}
{...props}
value={url}
setValue={(val) => setUrl(val)}
handleEdit={() => {
handleEdit({ [textField]: text, [urlField]: url })
}}
/>
</div>
</div>
) : (
<StringDisplay
{...props}
pathString={toPathString(nodeData.path)}
styles={{ ...styles }}
value={text}
stringTruncate={stringTruncate}
TextWrapper={({ children }) => {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{ ...styles, ...linkStyles, cursor: 'pointer' }}
>
{children}
</a>
)
}}
/>
)}
</div>
)
}
19 changes: 19 additions & 0 deletions custom-component-library/components/EnhancedLink/definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { isCollection, type CustomNodeDefinition } from '@json-edit-react'
import { EnhancedLinkCustomComponent, EnhancedLinkProps } from './component'

const TEXT_FIELD = 'text'
const URL_FIELD = 'url'

export const EnhancedLinkCustomNodeDefinition: CustomNodeDefinition<EnhancedLinkProps> = {
condition: ({ value }) => isCollection(value) && TEXT_FIELD in value && URL_FIELD in value,
element: EnhancedLinkCustomComponent,
name: 'Enhanced Link', // shown in the Type selector menu
showInTypesSelector: true,
defaultValue: {
[TEXT_FIELD]: 'This is the text that is displayed',
[URL_FIELD]: 'https://link.goes.here',
},
customNodeProps: { fieldNames: { text: TEXT_FIELD, url: URL_FIELD } },
showOnEdit: true,
renderCollectionAsValue: true,
}
1 change: 1 addition & 0 deletions custom-component-library/components/EnhancedLink/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './definition'
12 changes: 8 additions & 4 deletions custom-component-library/components/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

export const testData = {
intro: `# json-edit-react
Intro: `# json-edit-react

## Custom Component library

Expand All @@ -22,14 +22,18 @@ export const testData = {
Click [here](https://github.com/CarlosNZ/json-edit-react/blob/main/custom-component-library/README.md) for more info
`,
'Active Links': {
url: 'https://carlosnz.github.io/json-edit-react/',
longUrl:
Url: 'https://carlosnz.github.io/json-edit-react/',
'Long URL':
'https://www.google.com/maps/place/Sky+Tower/@-36.8465603,174.7609398,818m/data=!3m1!1e3!4m6!3m5!1s0x6d0d47f06d4bdc25:0x2d1b5c380ad9387!8m2!3d-36.848448!4d174.762191!16zL20vMDFuNXM2?entry=ttu&g_ep=EgoyMDI1MDQwOS4wIKXMDSoASAFQAw%3D%3D',
'Enhanced Link': {
text: 'This link displays custom text',
url: 'https://github.com/CarlosNZ/json-edit-react/tree/main/custom-component-library#custom-component-library',
},
},
'Date & Time': {
Date: new Date().toISOString(),
'Show Time in Date?': true,
info: 'Date is stored as ISO string. To use JS Date objects, set STORE_DATE_AS_DATE_OBJECT to true in App.tsx.',
info: 'Replaced in App.tsx',
},

'Non-JSON types': {
Expand Down
1 change: 1 addition & 0 deletions custom-component-library/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './Hyperlink'
export * from './EnhancedLink'
export * from './DateObject'
export * from './Undefined'
export * from './DatePicker'
Expand Down
18 changes: 14 additions & 4 deletions custom-component-library/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ import {
SymbolDefinition,
BigIntDefinition,
MarkdownNodeDefinition,
EnhancedLinkCustomNodeDefinition,
} from '../components'
import { testData } from '../components/data'
import { JsonData, JsonEditor } from '@json-edit-react'

// @ts-expect-error redefine after initialisation
testData['Date & Time'].Date = STORE_DATE_AS_DATE_OBJECT ? new Date() : new Date().toISOString()
if (testData?.['Date & Time']) {
// @ts-expect-error redefine after initialisation
testData['Date & Time'].Date = STORE_DATE_AS_DATE_OBJECT ? new Date() : new Date().toISOString()

testData['Date & Time'].info = STORE_DATE_AS_DATE_OBJECT
? 'Date is stored a JS Date object. To use ISO string, set STORE_DATE_AS_DATE_OBJECT to false in App.tsx.'
: 'Date is stored as ISO string. To use JS Date objects, set STORE_DATE_AS_DATE_OBJECT to true in App.tsx.'
}

type TestData = typeof testData

Expand All @@ -36,8 +43,11 @@ function App() {
LinkCustomNodeDefinition,
{
...(STORE_DATE_AS_DATE_OBJECT ? DateObjectDefinition : DatePickerDefinition),
customNodeProps: { showTime: (data as TestData)['Date & Time']['Show Time in Date?'] },
customNodeProps: {
showTime: (data as TestData)?.['Date & Time']?.['Show Time in Date?'] ?? false,
},
},
EnhancedLinkCustomNodeDefinition,
UndefinedDefinition,
BooleanToggleDefinition,
NanDefinition,
Expand All @@ -49,7 +59,7 @@ function App() {
},
{
...MarkdownNodeDefinition,
condition: ({ key }) => key === 'intro',
condition: ({ key }) => key === 'Intro',
hideKey: true,
},
]}
Expand Down
15 changes: 9 additions & 6 deletions src/CollectionNode.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import React, { useEffect, useState, useRef, useCallback } from 'react'
import { ValueNodeWrapper } from './ValueNodeWrapper'
import { EditButtons, InputButtons } from './ButtonPanels'
import { getCustomNode } from './CustomNode'
Expand Down Expand Up @@ -56,6 +56,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
newKeyOptions,
translate,
customNodeDefinitions,
customNodeData,
jsonParse,
jsonStringify,
TextEditor,
Expand Down Expand Up @@ -163,10 +164,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
showOnEdit,
showOnView,
showCollectionWrapper = true,
} = useMemo(
() => getCustomNode(customNodeDefinitions, nodeData),
[nodeData, customNodeDefinitions]
)
} = customNodeData

const childrenEditing = areChildrenBeingEdited(pathString)

Expand Down Expand Up @@ -304,13 +302,16 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
parentData: data,
fullData: nodeData.fullData,
}

const childCustomNodeData = getCustomNode(customNodeDefinitions, childNodeData)

return (
<div
className="jer-collection-element"
key={key}
style={getStyles('collectionElement', childNodeData)}
>
{isCollection(value) ? (
{isCollection(value) && !childCustomNodeData?.renderCollectionAsValue ? (
<CollectionNode
key={key}
{...props}
Expand All @@ -319,6 +320,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
nodeData={childNodeData}
showCollectionCount={showCollectionCount}
canDragOnto={canEdit}
customNodeData={childCustomNodeData}
/>
) : (
<ValueNodeWrapper
Expand All @@ -329,6 +331,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
nodeData={childNodeData}
canDragOnto={canEdit}
showLabel={collectionType === 'object' ? true : showArrayIndices}
customNodeData={childCustomNodeData}
/>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/CustomNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface CustomNodeData {
showEditTools?: boolean
showCollectionWrapper?: boolean
passOriginalNode?: boolean
renderCollectionAsValue?: boolean
}

// Fetches matching custom nodes (based on condition filter) from custom node
Expand Down
6 changes: 5 additions & 1 deletion src/JsonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { getTranslateFunction } from './localisation'
import { ValueNodeWrapper } from './ValueNodeWrapper'

import './style.css'
import { getCustomNode } from './CustomNode'

const Editor: React.FC<JsonEditorProps> = ({
data: srcData,
Expand Down Expand Up @@ -336,6 +337,8 @@ const Editor: React.FC<JsonEditorProps> = ({
[keySort]
)

const customNodeData = getCustomNode(customNodeDefinitions, nodeData)

const otherProps = {
mainContainerRef: mainContainerRef as React.MutableRefObject<Element>,
name: rootName,
Expand Down Expand Up @@ -369,6 +372,7 @@ const Editor: React.FC<JsonEditorProps> = ({
stringTruncate,
translate,
customNodeDefinitions,
customNodeData,
customButtons,
parentData: null,
jsonParse: jsonParseReplacement,
Expand Down Expand Up @@ -401,7 +405,7 @@ const Editor: React.FC<JsonEditorProps> = ({
className={`jer-editor-container ${className ?? ''}`}
style={mainContainerStyles}
>
{isCollection(data) ? (
{isCollection(data) && !customNodeData.renderCollectionAsValue ? (
<CollectionNode data={data} {...otherProps} />
) : (
<ValueNodeWrapper data={data as ValueData} showLabel {...otherProps} />
Expand Down
7 changes: 5 additions & 2 deletions src/KeyDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const KeyDisplay: React.FC<KeyDisplayProps> = ({
}) => {
const { setCurrentlyEditingElement } = useTreeState()

const displayEmptyStringText = name === '' && path.length > 0
const displayColon = name !== '' || path.length > 0

if (!isEditingKey)
return (
<span
Expand All @@ -52,14 +55,14 @@ export const KeyDisplay: React.FC<KeyDisplayProps> = ({
onDoubleClick={() => canEditKey && setCurrentlyEditingElement(path, 'key')}
onClick={handleClick}
>
{name === '' ? (
{displayEmptyStringText ? (
<span className={path.length > 0 ? 'jer-empty-string' : undefined}>
{/* display "<empty string>" using pseudo class CSS */}
</span>
) : (
`${name}`
)}
<span className="jer-key-colon">:</span>
{displayColon ? <span className="jer-key-colon">:</span> : null}
</span>
)

Expand Down
Loading