From e11aad60c9dae7e3945617486e35ae4d925a08e3 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 18 Feb 2025 11:31:16 +1300 Subject: [PATCH 1/6] Add viewOnly prop --- README.md | 3 ++- demo/src/App.tsx | 1 + src/JsonEditor.tsx | 27 ++++++++++++++++++++++----- src/types.ts | 1 + 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e39cc716..d731d49b 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ The only *required* property is `data` (although you will need to provide a `set | `restrictAdd` | `boolean\|FilterFunction` | `false` | As with `restrictEdit` but for adding new properties | | `restrictTypeSelection` | `boolean\|DataType[]\|TypeFilterFunction` | `false` | For restricting the data types the user can select. Can be a list of data types (e.g. `[ 'string', 'number', 'boolean', 'array', 'object', 'null' ]`), a boolean. or a function — see [TypeFilterFunction](#typefilterfunction) | | `restrictDrag` | `boolean\|FilterFunction` | `true` | Set to `false` to enable drag and drop functionality — see [Drag-n-drop](#drag-n-drop) | +| `viewOnly` | `boolean` | | A shorthand if you just want the component to be a viewer, with no editing. Overrides any values of the above edit restrictions. | ### Look and Feel / UI @@ -329,7 +330,7 @@ For restricting data types, the (Type) filter function is slightly more sophisti - `"object"` - `"array"` -There is no specific restriction function for editing object key names, but they must return `true` for *both* `restrictEdit` and `restrictDelete` (and `restrictAdd` for collections), since changing a key name is equivalent to deleting a property and adding a new one. +There is no specific restriction function for editing object key names, but they must return `false` for *both* `restrictEdit` and `restrictDelete` (and `restrictAdd` for collections), since changing a key name is equivalent to deleting a property and adding a new one. You can also set a dynamic default value by passing a filter function to the `defaultValue` prop -- the input is the same as the above, but also takes the new `key` value as its second parameter, so the new value can depend on the new key added. diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 6fd60a17..18356b40 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -382,6 +382,7 @@ function App() { } : false } + // viewOnly restrictEdit={restrictEdit} // restrictEdit={(nodeData) => !(typeof nodeData.value === 'string')} restrictDelete={restrictDelete} diff --git a/src/JsonEditor.tsx b/src/JsonEditor.tsx index 0a7f44c0..26ed5d19 100644 --- a/src/JsonEditor.tsx +++ b/src/JsonEditor.tsx @@ -51,6 +51,7 @@ const Editor: React.FC = ({ restrictAdd = false, restrictTypeSelection = false, restrictDrag = true, + viewOnly, searchFilter: searchFilterInput, searchText, searchDebounceTime = 350, @@ -249,10 +250,22 @@ const Editor: React.FC = ({ }) } - const restrictEditFilter = useMemo(() => getFilterFunction(restrictEdit), [restrictEdit]) - const restrictDeleteFilter = useMemo(() => getFilterFunction(restrictDelete), [restrictDelete]) - const restrictAddFilter = useMemo(() => getFilterFunction(restrictAdd), [restrictAdd]) - const restrictDragFilter = useMemo(() => getFilterFunction(restrictDrag), [restrictDrag]) + const restrictEditFilter = useMemo( + () => getFilterFunction(restrictEdit, viewOnly), + [restrictEdit, viewOnly] + ) + const restrictDeleteFilter = useMemo( + () => getFilterFunction(restrictDelete, viewOnly), + [restrictDelete, viewOnly] + ) + const restrictAddFilter = useMemo( + () => getFilterFunction(restrictAdd, viewOnly), + [restrictAdd, viewOnly] + ) + const restrictDragFilter = useMemo( + () => getFilterFunction(restrictDrag, viewOnly), + [restrictDrag, viewOnly] + ) const searchFilter = useMemo(() => getSearchFilter(searchFilterInput), [searchFilterInput]) const fullKeyboardControls = useMemo( @@ -426,7 +439,11 @@ const updateDataObject = ( } } -const getFilterFunction = (propValue: boolean | number | FilterFunction): FilterFunction => { +const getFilterFunction = ( + propValue: boolean | number | FilterFunction, + viewOnly?: boolean +): FilterFunction => { + if (viewOnly) return () => true if (typeof propValue === 'boolean') return () => propValue if (typeof propValue === 'number') return ({ level }) => level >= propValue return propValue diff --git a/src/types.ts b/src/types.ts index 5c905811..9529bf0f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,7 @@ export interface JsonEditorProps { restrictAdd?: boolean | FilterFunction restrictTypeSelection?: boolean | DataType[] | TypeFilterFunction restrictDrag?: boolean | FilterFunction + viewOnly?: boolean searchText?: string searchFilter?: 'key' | 'value' | 'all' | SearchFilterFunction searchDebounceTime?: number From 43847145691b40b1a8e0f5417df3fc6e563a656c Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 19 Feb 2025 21:58:45 +1300 Subject: [PATCH 2/6] Handle truncation toggle --- README.md | 2 ++ src/ValueNodeWrapper.tsx | 1 + src/ValueNodes.tsx | 41 ++++++++++++++++++++++++++++++++++------ src/localisation.ts | 1 + src/style.css | 9 +++++++++ src/types.ts | 1 + 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d731d49b..52418bc0 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ It's pretty self explanatory (click the "edit" icon to edit, etc.), but there ar - For Boolean inputs, space bar will toggle the value - Easily move to the next/previous node (for editing) using the `Tab`/`Shift-Tab` key - Drag and drop items to change the structure or modify display order +- When editing is not permitted, double-clicking a string value will expand the text to the full value if it is truncated due to length - JSON text input can accept "looser" input, if an additional JSON parsing method is provided (e.g. [JSON5](https://json5.org/)). See `jsonParse` prop. [Have a play with the Demo app](https://carlosnz.github.io/json-edit-react/) to get a feel for it! @@ -639,6 +640,7 @@ Localise your implementation by passing in a `translations` object to replace th ERROR_ADD: 'Adding node unsuccessful', DEFAULT_STRING: 'New data!', DEFAULT_NEW_KEY: 'key', + SHOW_LESS: '(Show less)', } ``` diff --git a/src/ValueNodeWrapper.tsx b/src/ValueNodeWrapper.tsx index 05587226..82835715 100644 --- a/src/ValueNodeWrapper.tsx +++ b/src/ValueNodeWrapper.tsx @@ -223,6 +223,7 @@ export const ValueNodeWrapper: React.FC = (props) => { parentData, setValue: updateValue, isEditing, + canEdit, setIsEditing: canEdit ? () => setCurrentlyEditingElement(path) : () => {}, handleEdit, handleCancel, diff --git a/src/ValueNodes.tsx b/src/ValueNodes.tsx index a02c0495..57b9a09a 100644 --- a/src/ValueNodes.tsx +++ b/src/ValueNodes.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useRef } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { AutogrowTextArea } from './AutogrowTextArea' -import { insertCharInTextArea, toPathString, truncate } from './helpers' +import { insertCharInTextArea, toPathString } from './helpers' import { useTheme } from './contexts' import { type InputProps } from './types' @@ -11,6 +11,7 @@ export const StringValue: React.FC = ({ setValue, isEditing, path, + canEdit, setIsEditing, handleEdit, stringTruncate, @@ -18,8 +19,10 @@ export const StringValue: React.FC = ({ nodeData, handleKeyboard, keyboardCommon, + translate, }) => { const { getStyles } = useTheme() + const [isExpanded, setIsExpanded] = useState(false) const textAreaRef = useRef(null) @@ -27,6 +30,12 @@ export const StringValue: React.FC = ({ const quoteChar = showStringQuotes ? '"' : '' + const requiresTruncation = value.length > stringTruncate + + const handleMaybeEdit = () => { + canEdit ? setIsEditing(true) : setIsExpanded(!isExpanded) + } + return isEditing ? ( = ({ ) : (
setIsEditing(true)} + onDoubleClick={handleMaybeEdit} onClick={(e) => { - if (e.getModifierState('Control') || e.getModifierState('Meta')) setIsEditing(true) + if (e.getModifierState('Control') || e.getModifierState('Meta')) handleMaybeEdit() }} className="jer-value-string" style={getStyles('string', nodeData)} > {quoteChar} - {truncate(value, stringTruncate)} - {quoteChar} + {!requiresTruncation ? ( + `${value}${quoteChar}` + ) : isExpanded ? ( + <> + + {value} + {quoteChar} + + setIsExpanded(false)}> + {' '} + {translate('SHOW_LESS', nodeData)} + + + ) : ( + <> + {value.slice(0, stringTruncate - 2).trimEnd()} + setIsExpanded(true)}> + ... + + {quoteChar} + + )}
) } diff --git a/src/localisation.ts b/src/localisation.ts index 091b9965..210db099 100644 --- a/src/localisation.ts +++ b/src/localisation.ts @@ -11,6 +11,7 @@ const localisedStrings = { ERROR_ADD: 'Adding node unsuccessful', DEFAULT_STRING: 'New data!', DEFAULT_NEW_KEY: 'key', + SHOW_LESS: '(Show less)', } export type LocalisedStrings = typeof localisedStrings diff --git a/src/style.css b/src/style.css index 532d5de5..dcaa196b 100644 --- a/src/style.css +++ b/src/style.css @@ -225,6 +225,15 @@ select:focus + .focus { overflow-wrap: anywhere; } +.jer-string-expansion { + cursor: pointer; + opacity: 0.6; +} + +.jer-show-less { + font-size: 80%; +} + .jer-hyperlink { text-decoration: underline; } diff --git a/src/types.ts b/src/types.ts index 9529bf0f..1b06902e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -314,6 +314,7 @@ export interface CustomButtonDefinition { export interface InputProps { value: unknown setValue: (value: ValueData) => void + canEdit: boolean isEditing: boolean setIsEditing: React.Dispatch> handleEdit: () => void From dacf19354a69c0cf3e2ec5c74197e1199230d410 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 19 Feb 2025 23:50:46 +1300 Subject: [PATCH 3/6] Refactor ValueNodes to isolate StringDisplay --- src/ValueNodes.tsx | 119 ++++++++++++++-------- src/customComponents/ActiveHyperlinks.tsx | 29 +++--- src/helpers.ts | 10 -- src/index.ts | 3 +- src/types.ts | 1 + 5 files changed, 94 insertions(+), 68 deletions(-) diff --git a/src/ValueNodes.tsx b/src/ValueNodes.tsx index 57b9a09a..12f4cbec 100644 --- a/src/ValueNodes.tsx +++ b/src/ValueNodes.tsx @@ -2,32 +2,34 @@ import React, { useEffect, useRef, useState } from 'react' import { AutogrowTextArea } from './AutogrowTextArea' import { insertCharInTextArea, toPathString } from './helpers' import { useTheme } from './contexts' -import { type InputProps } from './types' +import { type NodeData, type InputProps } from './types' +import { type TranslateFunction } from './localisation' export const INVALID_FUNCTION_STRING = '**INVALID_FUNCTION**' -export const StringValue: React.FC = ({ - value, - setValue, - isEditing, - path, +interface StringDisplayProps { + nodeData: NodeData + styles: React.CSSProperties + pathString: string + showStringQuotes?: boolean + stringTruncate?: number + canEdit: boolean + setIsEditing: (value: React.SetStateAction) => void + translate: TranslateFunction +} +export const StringDisplay: React.FC = ({ + nodeData, + showStringQuotes = true, + stringTruncate = 200, + pathString, canEdit, setIsEditing, - handleEdit, - stringTruncate, - showStringQuotes, - nodeData, - handleKeyboard, - keyboardCommon, + styles, translate, }) => { - const { getStyles } = useTheme() + const value = nodeData.value as string const [isExpanded, setIsExpanded] = useState(false) - const textAreaRef = useRef(null) - - const pathString = toPathString(path) - const quoteChar = showStringQuotes ? '"' : '' const requiresTruncation = value.length > stringTruncate @@ -36,33 +38,7 @@ export const StringValue: React.FC = ({ canEdit ? setIsEditing(true) : setIsExpanded(!isExpanded) } - return isEditing ? ( - >} - isEditing={isEditing} - handleKeyPress={(e) => { - handleKeyboard(e, { - stringConfirm: handleEdit, - stringLineBreak: () => { - // Simulates standard text-area line break behaviour. Only - // required when control key is not "standard" text-area - // behaviour ("Shift-Enter" or "Enter") - const newValue = insertCharInTextArea( - textAreaRef as React.MutableRefObject, - '\n' - ) - setValue(newValue) - }, - ...keyboardCommon, - }) - }} - styles={getStyles('input', nodeData)} - /> - ) : ( + return (
= ({ if (e.getModifierState('Control') || e.getModifierState('Meta')) handleMaybeEdit() }} className="jer-value-string" - style={getStyles('string', nodeData)} + style={styles} > {quoteChar} {!requiresTruncation ? ( @@ -99,6 +75,59 @@ export const StringValue: React.FC = ({ ) } +export const StringValue: React.FC = ({ + value, + setValue, + isEditing, + path, + handleEdit, + nodeData, + handleKeyboard, + keyboardCommon, + ...props +}) => { + const { getStyles } = useTheme() + + const textAreaRef = useRef(null) + + const pathString = toPathString(path) + + return isEditing ? ( + >} + isEditing={isEditing} + handleKeyPress={(e) => { + handleKeyboard(e, { + stringConfirm: handleEdit, + stringLineBreak: () => { + // Simulates standard text-area line break behaviour. Only + // required when control key is not "standard" text-area + // behaviour ("Shift-Enter" or "Enter") + const newValue = insertCharInTextArea( + textAreaRef as React.MutableRefObject, + '\n' + ) + setValue(newValue) + }, + ...keyboardCommon, + }) + }} + styles={getStyles('input', nodeData)} + /> + ) : ( + + ) +} + export const NumberValue: React.FC = ({ value, setValue, diff --git a/src/customComponents/ActiveHyperlinks.tsx b/src/customComponents/ActiveHyperlinks.tsx index 6a207af1..d0a0a8b9 100644 --- a/src/customComponents/ActiveHyperlinks.tsx +++ b/src/customComponents/ActiveHyperlinks.tsx @@ -7,18 +7,17 @@ */ import React from 'react' -import { truncate } from '../../src/helpers' -import { type CustomNodeProps, type CustomNodeDefinition } from '../types' +import { StringDisplay } from '../../src/ValueNodes' +import { toPathString } from '../helpers' +import { type CustomNodeProps, type CustomNodeDefinition, type ValueNodeProps } from '../types' +import { useCommon } from '../hooks' -export const LinkCustomComponent: React.FC> = ({ - value, - setIsEditing, - getStyles, - customNodeProps, - nodeData, -}) => { - const stringTruncateLength = customNodeProps?.stringTruncate ?? 100 +export const LinkCustomComponent: React.FC< + CustomNodeProps<{ stringTruncate?: number }> & ValueNodeProps +> = (props) => { + const { value, setIsEditing, getStyles, nodeData } = props const styles = getStyles('string', nodeData) + const { canEdit } = useCommon({ props }) return (
setIsEditing(true)} @@ -34,7 +33,13 @@ export const LinkCustomComponent: React.FC - "{truncate(value as string, stringTruncateLength)}" +
) @@ -44,7 +49,7 @@ export const LinkCustomComponent: React.FC typeof value === 'string' && /^https?:\/\/.+\..+$/.test(value), - element: LinkCustomComponent, // the component defined above + element: LinkCustomComponent as React.FC, // the component defined above showOnView: true, showOnEdit: false, } diff --git a/src/helpers.ts b/src/helpers.ts index f83fa59e..4985c3a1 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -110,16 +110,6 @@ export const matchNodeKey: SearchFilterFunction = ({ key, path }, searchText = ' return false } -/** - * Truncates a string to a specified length, appends `...` if truncated - */ -export const truncate = (string: string, length = 200) => - typeof string === 'string' - ? string.length < length - ? string - : `${string.slice(0, length - 2).trim()}...` - : string - /** * Converts a part expressed as an array of properties to a single string */ diff --git a/src/index.ts b/src/index.ts index 3b718028..aa786db3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ export { JsonEditor } from './JsonEditor' export { defaultTheme } from './contexts/ThemeProvider' export { IconAdd, IconEdit, IconDelete, IconCopy, IconOk, IconCancel, IconChevron } from './Icons' +export { StringDisplay } from './ValueNodes' export { LinkCustomComponent, LinkCustomNodeDefinition } from './customComponents' -export { matchNode, matchNodeKey, isCollection, truncate } from './helpers' +export { matchNode, matchNodeKey, isCollection } from './helpers' export { default as assign } from 'object-property-assigner' export { default as extract } from 'object-property-extractor' export { diff --git a/src/types.ts b/src/types.ts index 1b06902e..76bd08fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -298,6 +298,7 @@ export interface CustomNodeDefinition, U = Record> From baabf88b75b11e482100e92246cfdf75f7038fd8 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 19 Feb 2025 23:53:05 +1300 Subject: [PATCH 4/6] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 52418bc0..e988a8f6 100644 --- a/README.md +++ b/README.md @@ -811,9 +811,9 @@ A few helper functions, components and types that might be useful in your own im - `themes`: an object containing all the built-in theme definitions - `LinkCustomComponent`: the component used to render [hyperlinks](#active-hyperlinks) - `LinkCustomNodeDefinition`: custom node definition for [hyperlinks](#active-hyperlinks) +- `StringDisplay`: main component used to display a string value, re-used in the above "Link" Custom Component - `IconAdd`, `IconEdit`, `IconDelete`, `IconCopy`, `IconOk`, `IconCancel`, `IconChevron`: all the built-in [icon](#icons) components - `matchNode`, `matchNodeKey`: helpers for defining custom [Search](#searchfiltering) functions -- `truncate`: function to truncate a string to a specified length. See [here](https://github.com/CarlosNZ/json-edit-react/blob/d5fdbdfed6da7152f5802c67fbb3577810d13adc/src/ValueNodes.tsx#L9-L13) - `extract`: function to extract a deeply nested object value from a string path. See [here](https://github.com/CarlosNZ/object-property-extractor) - `assign`: function to set a deep object value from a string path. See [here](https://github.com/CarlosNZ/object-property-assigner) From fbbf4cea07d50e6ef1b711a1b879a978314fa3ea Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Thu, 20 Feb 2025 00:08:30 +1300 Subject: [PATCH 5/6] Styling improvement --- src/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/style.css b/src/style.css index dcaa196b..5909cde6 100644 --- a/src/style.css +++ b/src/style.css @@ -228,6 +228,7 @@ select:focus + .focus { .jer-string-expansion { cursor: pointer; opacity: 0.6; + filter: saturate(50%); } .jer-show-less { From ad528a1d4ccde16ecdfb8b1db6c18c5ec4a941d0 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Thu, 20 Feb 2025 00:09:40 +1300 Subject: [PATCH 6/6] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e988a8f6..511a1fa1 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ It's pretty self explanatory (click the "edit" icon to edit, etc.), but there ar - For Boolean inputs, space bar will toggle the value - Easily move to the next/previous node (for editing) using the `Tab`/`Shift-Tab` key - Drag and drop items to change the structure or modify display order -- When editing is not permitted, double-clicking a string value will expand the text to the full value if it is truncated due to length +- When editing is not permitted, double-clicking a string value will expand the text to the full value if it is truncated due to length (there is also a clickable "..." for long strings) - JSON text input can accept "looser" input, if an additional JSON parsing method is provided (e.g. [JSON5](https://json5.org/)). See `jsonParse` prop. [Have a play with the Demo app](https://carlosnz.github.io/json-edit-react/) to get a feel for it!