diff --git a/README.md b/README.md index 3952484..b486b19 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) @@ -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 @@ -1002,6 +1004,15 @@ In most cases it will be preferable (and simpler) to create custom nodes to matc custom node levels - 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 diff --git a/custom-component-library/components/DateObject/definition.ts b/custom-component-library/components/DateObject/definition.ts index 2606f3c..31ed434 100644 --- a/custom-component-library/components/DateObject/definition.ts +++ b/custom-component-library/components/DateObject/definition.ts @@ -9,6 +9,7 @@ export const DateObjectDefinition: CustomNodeDefinition = { 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 diff --git a/custom-component-library/components/EnhancedLink/component.tsx b/custom-component-library/components/EnhancedLink/component.tsx new file mode 100644 index 0000000..7d7f742 --- /dev/null +++ b/custom-component-library/components/EnhancedLink/component.tsx @@ -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> = ( + 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 ( +
{ + if (e.getModifierState('Control') || e.getModifierState('Meta')) setIsEditing(true) + }} + style={styles} + > + {isEditing ? ( +
+
+ {textLabel}: + setText(val)} + /> +
+
+ {urlLabel}: + setUrl(val)} + handleEdit={() => { + handleEdit({ [textField]: text, [urlField]: url }) + }} + /> +
+
+ ) : ( + { + return ( + + {children} + + ) + }} + /> + )} +
+ ) +} diff --git a/custom-component-library/components/EnhancedLink/definition.ts b/custom-component-library/components/EnhancedLink/definition.ts new file mode 100644 index 0000000..72e5b20 --- /dev/null +++ b/custom-component-library/components/EnhancedLink/definition.ts @@ -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 = { + 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, +} diff --git a/custom-component-library/components/EnhancedLink/index.ts b/custom-component-library/components/EnhancedLink/index.ts new file mode 100644 index 0000000..75c618f --- /dev/null +++ b/custom-component-library/components/EnhancedLink/index.ts @@ -0,0 +1 @@ +export * from './definition' diff --git a/custom-component-library/components/data.ts b/custom-component-library/components/data.ts index 1e49153..dda0e89 100644 --- a/custom-component-library/components/data.ts +++ b/custom-component-library/components/data.ts @@ -4,7 +4,7 @@ */ export const testData = { - intro: `# json-edit-react + Intro: `# json-edit-react ## Custom Component library @@ -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': { diff --git a/custom-component-library/components/index.ts b/custom-component-library/components/index.ts index 4afae55..e1933a4 100644 --- a/custom-component-library/components/index.ts +++ b/custom-component-library/components/index.ts @@ -1,4 +1,5 @@ export * from './Hyperlink' +export * from './EnhancedLink' export * from './DateObject' export * from './Undefined' export * from './DatePicker' diff --git a/custom-component-library/src/App.tsx b/custom-component-library/src/App.tsx index 4be3246..497fead 100644 --- a/custom-component-library/src/App.tsx +++ b/custom-component-library/src/App.tsx @@ -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 @@ -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, @@ -49,7 +59,7 @@ function App() { }, { ...MarkdownNodeDefinition, - condition: ({ key }) => key === 'intro', + condition: ({ key }) => key === 'Intro', hideKey: true, }, ]} diff --git a/src/CollectionNode.tsx b/src/CollectionNode.tsx index 0712af4..878ea78 100644 --- a/src/CollectionNode.tsx +++ b/src/CollectionNode.tsx @@ -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' @@ -56,6 +56,7 @@ export const CollectionNode: React.FC = (props) => { newKeyOptions, translate, customNodeDefinitions, + customNodeData, jsonParse, jsonStringify, TextEditor, @@ -163,10 +164,7 @@ export const CollectionNode: React.FC = (props) => { showOnEdit, showOnView, showCollectionWrapper = true, - } = useMemo( - () => getCustomNode(customNodeDefinitions, nodeData), - [nodeData, customNodeDefinitions] - ) + } = customNodeData const childrenEditing = areChildrenBeingEdited(pathString) @@ -304,13 +302,16 @@ export const CollectionNode: React.FC = (props) => { parentData: data, fullData: nodeData.fullData, } + + const childCustomNodeData = getCustomNode(customNodeDefinitions, childNodeData) + return (
- {isCollection(value) ? ( + {isCollection(value) && !childCustomNodeData?.renderCollectionAsValue ? ( = (props) => { nodeData={childNodeData} showCollectionCount={showCollectionCount} canDragOnto={canEdit} + customNodeData={childCustomNodeData} /> ) : ( = (props) => { nodeData={childNodeData} canDragOnto={canEdit} showLabel={collectionType === 'object' ? true : showArrayIndices} + customNodeData={childCustomNodeData} /> )}
diff --git a/src/CustomNode.ts b/src/CustomNode.ts index b266b63..56479ef 100644 --- a/src/CustomNode.ts +++ b/src/CustomNode.ts @@ -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 diff --git a/src/JsonEditor.tsx b/src/JsonEditor.tsx index 540ab76..84a10cd 100644 --- a/src/JsonEditor.tsx +++ b/src/JsonEditor.tsx @@ -33,6 +33,7 @@ import { getTranslateFunction } from './localisation' import { ValueNodeWrapper } from './ValueNodeWrapper' import './style.css' +import { getCustomNode } from './CustomNode' const Editor: React.FC = ({ data: srcData, @@ -336,6 +337,8 @@ const Editor: React.FC = ({ [keySort] ) + const customNodeData = getCustomNode(customNodeDefinitions, nodeData) + const otherProps = { mainContainerRef: mainContainerRef as React.MutableRefObject, name: rootName, @@ -369,6 +372,7 @@ const Editor: React.FC = ({ stringTruncate, translate, customNodeDefinitions, + customNodeData, customButtons, parentData: null, jsonParse: jsonParseReplacement, @@ -401,7 +405,7 @@ const Editor: React.FC = ({ className={`jer-editor-container ${className ?? ''}`} style={mainContainerStyles} > - {isCollection(data) ? ( + {isCollection(data) && !customNodeData.renderCollectionAsValue ? ( ) : ( diff --git a/src/KeyDisplay.tsx b/src/KeyDisplay.tsx index 7b3f1f2..389d48d 100644 --- a/src/KeyDisplay.tsx +++ b/src/KeyDisplay.tsx @@ -40,6 +40,9 @@ export const KeyDisplay: React.FC = ({ }) => { const { setCurrentlyEditingElement } = useTreeState() + const displayEmptyStringText = name === '' && path.length > 0 + const displayColon = name !== '' || path.length > 0 + if (!isEditingKey) return ( = ({ onDoubleClick={() => canEditKey && setCurrentlyEditingElement(path, 'key')} onClick={handleClick} > - {name === '' ? ( + {displayEmptyStringText ? ( 0 ? 'jer-empty-string' : undefined}> {/* display "" using pseudo class CSS */} ) : ( `${name}` )} - : + {displayColon ? : : null} ) diff --git a/src/ValueNodeWrapper.tsx b/src/ValueNodeWrapper.tsx index b484f46..a2fb04d 100644 --- a/src/ValueNodeWrapper.tsx +++ b/src/ValueNodeWrapper.tsx @@ -19,7 +19,7 @@ import { type EnumDefinition, } from './types' import { useTheme, useTreeState } from './contexts' -import { getCustomNode, type CustomNodeData } from './CustomNode' +import { type CustomNodeData } from './CustomNode' import { filterNode, getNextOrPrevious, isJsEvent, matchEnumType } from './helpers' import { useCommon, useDragNDrop } from './hooks' import { KeyDisplay } from './KeyDisplay' @@ -43,6 +43,7 @@ export const ValueNodeWrapper: React.FC = (props) => { indent, translate, customNodeDefinitions, + customNodeData, handleKeyboard, keyboardControls, sort, @@ -83,7 +84,6 @@ export const ValueNodeWrapper: React.FC = (props) => { { canDrag, canDragOnto, path, nodeData, onMove, onError, translate } ) - const customNodeData = getCustomNode(customNodeDefinitions, nodeData) const [dataType, setDataType] = useState(getDataType(data, customNodeData)) const updateValue = useCallback( diff --git a/src/helpers.ts b/src/helpers.ts index 3061e91..3ef0ec7 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -17,7 +17,7 @@ import { } from './types' export const isCollection = (value: unknown): value is Record | unknown[] => - value !== null && typeof value === 'object' && !(value instanceof Date) + value !== null && typeof value === 'object' export const isJsEvent = (value: unknown) => { return ( diff --git a/src/types.ts b/src/types.ts index db4612f..27c423a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import { type JSX } from 'react' import { type Options as AssignOptions } from 'object-property-assigner' import { type LocalisedStrings, type TranslateFunction } from './localisation' import { type ExternalTriggers } from './hooks' +import { CustomNodeData } from './CustomNode' export type JsonData = Record | Array | unknown @@ -271,6 +272,7 @@ interface BaseNodeProps { sort: SortFunction translate: TranslateFunction customNodeDefinitions: CustomNodeDefinition[] + customNodeData: CustomNodeData customButtons: CustomButtonDefinition[] errorMessageTimeout: number keyboardControls: KeyboardControlsFull @@ -353,6 +355,7 @@ export interface CustomNodeDefinition, U = Record> wrapperProps?: Record + renderCollectionAsValue?: boolean // For JSON stringify/parse stringifyReplacer?: (value: unknown) => unknown