Skip to content

Commit 93af801

Browse files
committed
wip
1 parent de373ea commit 93af801

File tree

5 files changed

+224
-16
lines changed

5 files changed

+224
-16
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@
255255
},
256256
"dependencies": {
257257
"@stablelib/snappy": "^1.0.3",
258+
"@types/deep-diff": "^1.0.5",
258259
"@vscode/l10n": "^0.0.18",
259260
"@vscode/webview-ui-toolkit": "^1.4.0",
260261
"axios": "^1.8.3",
@@ -264,6 +265,7 @@
264265
"connection-string": "^4.4.0",
265266
"cors": "^2.8.5",
266267
"date-fns": "^2.30.0",
268+
"deep-diff": "^1.0.2",
267269
"detect-port": "^1.6.1",
268270
"dotenv": "^16.4.5",
269271
"file-saver": "^2.0.5",

src/webviews/src/modules/key-details/components/rejson-details/rejson-object/RejsonObject.tsx

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import React, { useEffect, useState } from 'react'
22
import cx from 'classnames'
3+
import { diff } from 'deep-diff'
34

5+
import { useShallow } from 'zustand/react/shallow'
46
import { Spinner } from 'uiSrc/ui'
57

8+
import { checkExistingPath } from './tbd'
9+
import ReJSONConfirmDialog from './RejsonConfirmDialog'
610
import { RejsonDynamicTypes } from '../rejson-dynamic-types'
711
import { JSONObjectProps, ObjectTypes } from '../interfaces'
812
import { generatePath, getBrackets, wrapPath } from '../utils'
@@ -13,10 +17,18 @@ import {
1317
EditItemFieldAction,
1418
} from '../components'
1519

20+
import { useRejsonStore } from '../hooks/useRejsonStore'
1621
import styles from '../styles.module.scss'
1722

1823
const defaultValue: [] = []
1924

25+
const JSONParse = (value: string) => JSON.parse(value)
26+
27+
const hasModifications = (oldObj: any, newObj: any) => {
28+
const differences = diff(oldObj, newObj)
29+
return differences?.some((d: any) => d.kind === 'E')
30+
}
31+
2032
export const RejsonObject = React.memo((props: JSONObjectProps) => {
2133
const {
2234
type,
@@ -38,14 +50,24 @@ export const RejsonObject = React.memo((props: JSONObjectProps) => {
3850
value: currentValue,
3951
} = props
4052

41-
const [path] = useState<string>(currentFullPath || generatePath(parentPath, keyName))
53+
const [path] = useState<string>(
54+
currentFullPath || generatePath(parentPath, keyName),
55+
)
4256
const [value, setValue] = useState<any>(defaultValue)
4357
const [downloaded, setDownloaded] = useState<boolean>(isDownloaded)
4458
const [editEntireObject, setEditEntireObject] = useState<boolean>(false)
4559
const [valueOfEntireObject, setValueOfEntireObject] = useState<any>('')
4660
const [addNewKeyValuePair, setAddNewKeyValuePair] = useState<boolean>(false)
4761
const [loading, setLoading] = useState<boolean>(false)
4862
const [isExpanded, setIsExpanded] = useState<boolean>(false)
63+
const [isConfirmVisible, setIsConfirmVisible] = useState<boolean>(false)
64+
const [confirmDialogAction, setConfirmDialogAction] = useState<any>(() => {})
65+
66+
const { fullValue } = useRejsonStore(
67+
useShallow((state) => ({
68+
fullValue: state.data.data,
69+
})),
70+
)
4971

5072
useEffect(() => {
5173
if (!expandedRows?.has(path)) {
@@ -62,34 +84,71 @@ export const RejsonObject = React.memo((props: JSONObjectProps) => {
6284
fetchObject()
6385
}, [])
6486

65-
const handleFormSubmit = ({ key, value }: { key?: string, value: string }) => {
66-
setAddNewKeyValuePair(false)
67-
87+
const handleFormSubmit = ({
88+
key,
89+
value: updatedValue,
90+
}: {
91+
key?: string
92+
value: string
93+
}) => {
6894
if (type === ObjectTypes.Array) {
6995
handleAppendRejsonObjectItemAction(selectedKey, path, value)
7096
return
7197
}
7298

7399
const updatedPath = wrapPath(key as string, path)
100+
const isKeyExisting = fullValue
101+
? checkExistingPath(updatedPath || '', JSONParse(fullValue as string))
102+
: false
103+
104+
if (isKeyExisting) {
105+
setConfirmDialogAction(() => () => {
106+
setAddNewKeyValuePair(false)
107+
108+
if (updatedPath) {
109+
handleSetRejsonDataAction(selectedKey, updatedPath, updatedValue)
110+
}
111+
})
112+
113+
setIsConfirmVisible(true)
114+
return
115+
}
116+
117+
setAddNewKeyValuePair(false)
74118
if (updatedPath) {
75-
handleSetRejsonDataAction(selectedKey, updatedPath, value)
119+
handleSetRejsonDataAction(selectedKey, updatedPath, updatedValue)
76120
}
77121
}
78122

79-
const handleUpdateValueFormSubmit = (value: string) => {
123+
const handleUpdateValueFormSubmit = (updatedValue: string) => {
124+
if (hasModifications(value, updatedValue)) {
125+
setConfirmDialogAction(() => () => {
126+
setEditEntireObject(false)
127+
handleSetRejsonDataAction(selectedKey, path, updatedValue as string)
128+
})
129+
130+
setIsConfirmVisible(true)
131+
return
132+
}
133+
80134
setEditEntireObject(false)
81-
handleSetRejsonDataAction(selectedKey, path, value as string)
135+
handleSetRejsonDataAction(selectedKey, path, updatedValue as string)
82136
}
83137

84138
const onClickEditEntireObject = async () => {
85139
const data = await handleFetchVisualisationResults(selectedKey, path, true)
86140

87141
setEditEntireObject(true)
88-
setValueOfEntireObject(typeof data.data === 'object' ? JSON.stringify(data.data, (_key, value) => (
89-
typeof value === 'bigint'
90-
? value.toString()
91-
: value
92-
), 4) : data.data)
142+
setValueOfEntireObject(
143+
typeof data.data === 'object'
144+
? JSON.stringify(
145+
data.data,
146+
(_key, value) =>
147+
(typeof value === 'bigint' ? value.toString() : value),
148+
4,
149+
)
150+
: data.data,
151+
)
93152
}
94153

95154
const onClickExpandCollapse = (path: string) => {
@@ -116,7 +175,10 @@ export const RejsonObject = React.memo((props: JSONObjectProps) => {
116175
const spinnerDelay = setTimeout(() => setLoading(true), 300)
117176

118177
try {
119-
const { data, downloaded } = await handleFetchVisualisationResults(selectedKey, path)
178+
const { data, downloaded } = await handleFetchVisualisationResults(
179+
selectedKey,
180+
path,
181+
)
120182

121183
setValue(data)
122184
onJsonKeyExpandAndCollapse(true, path)
@@ -134,7 +196,15 @@ export const RejsonObject = React.memo((props: JSONObjectProps) => {
134196
<>
135197
<div className={styles.row} key={keyName + parentPath}>
136198
<div className={styles.rowContainer}>
137-
<div className={styles.quotedKeyName} style={{ paddingLeft: `${leftPadding}em` }}>
199+
<ReJSONConfirmDialog
200+
open={isConfirmVisible}
201+
onClose={() => setIsConfirmVisible(false)}
202+
onConfirm={confirmDialogAction}
203+
/>
204+
<div
205+
className={styles.quotedKeyName}
206+
style={{ paddingLeft: `${leftPadding}em` }}
207+
>
138208
<span
139209
className={cx(styles.quoted, styles.keyName)}
140210
onClick={() => onClickExpandCollapse(path)}
@@ -155,7 +225,11 @@ export const RejsonObject = React.memo((props: JSONObjectProps) => {
155225
{getBrackets(type, 'end')}
156226
</div>
157227
)}
158-
{isExpanded && !editEntireObject && <span className={styles.defaultFontOpenIndex}>{getBrackets(type, 'start')}</span>}
228+
{isExpanded && !editEntireObject && (
229+
<span className={styles.defaultFontOpenIndex}>
230+
{getBrackets(type, 'start')}
231+
</span>
232+
)}
159233
</div>
160234
{!editEntireObject && !loading && (
161235
<EditItemFieldAction
@@ -193,7 +267,9 @@ export const RejsonObject = React.memo((props: JSONObjectProps) => {
193267
onJsonKeyExpandAndCollapse={onJsonKeyExpandAndCollapse}
194268
handleSubmitUpdateValue={handleSubmitUpdateValue}
195269
handleFetchVisualisationResults={handleFetchVisualisationResults}
196-
handleAppendRejsonObjectItemAction={handleAppendRejsonObjectItemAction}
270+
handleAppendRejsonObjectItemAction={
271+
handleAppendRejsonObjectItemAction
272+
}
197273
handleSetRejsonDataAction={handleSetRejsonDataAction}
198274
/>
199275
)}
@@ -203,6 +279,7 @@ export const RejsonObject = React.memo((props: JSONObjectProps) => {
203279
onCancel={() => setAddNewKeyValuePair(false)}
204280
onSubmit={handleFormSubmit}
205281
leftPadding={leftPadding}
282+
shouldCloseOnOutsideClick={false}
206283
/>
207284
)}
208285
{isExpanded && !editEntireObject && (
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/* eslint-disable @typescript-eslint/quotes */
2+
import { checkExistingPath } from './tbd'
3+
4+
describe('checkExistingPath', () => {
5+
it('returns true for empty path (a.k.a. the whole object)', () => {
6+
const obj = { foo: 123 }
7+
expect(checkExistingPath(`$`, obj)).toBe(true)
8+
})
9+
10+
it('detects root-level existing key', () => {
11+
const obj = { foo: 123 }
12+
expect(checkExistingPath(`$['foo']`, obj)).toBe(true)
13+
})
14+
15+
it('detects root-level missing key', () => {
16+
const obj = { foo: 123 }
17+
expect(checkExistingPath(`$['bar']`, obj)).toBe(false)
18+
})
19+
20+
it('detects nested existing key', () => {
21+
const obj = { array: { nested: 42 } }
22+
expect(checkExistingPath(`$['array']['nested']`, obj)).toBe(true)
23+
})
24+
25+
it('detects nested missing key', () => {
26+
const obj = { array: { nested: 42 } }
27+
expect(checkExistingPath(`$['array']['newNested']`, obj)).toBe(false)
28+
})
29+
30+
it('returns false if parent is missing', () => {
31+
const obj = {}
32+
expect(checkExistingPath(`$['nonExistent']['child']`, obj)).toBe(false)
33+
})
34+
35+
it('handles numeric index paths', () => {
36+
const obj = { arr: [{ val: 1 }] }
37+
expect(checkExistingPath(`$['arr'][0]['val']`, obj)).toBe(true)
38+
expect(checkExistingPath(`$['arr'][1]['val']`, obj)).toBe(false)
39+
})
40+
41+
it('handles non-object parents gracefully', () => {
42+
const obj = { a: 123 }
43+
expect(checkExistingPath(`$['a']['b']`, obj)).toBe(false) // number can't have a child
44+
})
45+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { get } from 'lodash'
2+
import { IJSONData } from '../interfaces'
3+
4+
// Matches [number] or ['string'] / ["string"] segments (with support for escaped characters inside strings)
5+
const REGEX = /\[(?:(["'])((?:\\.|(?!\1).)*)\1|(\d+))\]/g
6+
7+
/**
8+
* Parses a Redis JSONPath string into lodash.get compatible path chunks.
9+
* Supports both numeric indices and JSON string keys (single or double quoted).
10+
*
11+
* Example: $['foo'][0]["bar"] => ['foo', 0, 'bar']
12+
*/
13+
14+
const parseRedisJsonPath = (path: string): (string | number)[] => {
15+
if (typeof path !== 'string') throw new TypeError('Path must be a string')
16+
17+
const matches = Array.from(path.matchAll(REGEX))
18+
19+
const chunks: (string | number)[] = []
20+
let lastIndex = 0
21+
22+
if (path.startsWith('$') || path.startsWith('.')) {
23+
lastIndex = 1
24+
}
25+
26+
matches.forEach((match) => {
27+
if (match.index !== lastIndex) {
28+
throw new SyntaxError(
29+
`Invalid segment at position ${lastIndex}: "${path.slice(lastIndex)}"`,
30+
)
31+
}
32+
33+
const [, quote, strContent, numContent] = match
34+
35+
// Assumming the path will be created from wrapPath()
36+
// no need to handle the JSON encodings
37+
if (quote) {
38+
const jsonStr = `"${strContent}"`
39+
chunks.push(JSON.parse(jsonStr))
40+
} else if (numContent) {
41+
chunks.push(Number(numContent))
42+
}
43+
44+
lastIndex = match.index! + match[0].length
45+
})
46+
47+
if (lastIndex !== path.length) {
48+
throw new SyntaxError(
49+
`Unexpected trailing content starting at position ${lastIndex}: "${path.slice(lastIndex)}"`,
50+
)
51+
}
52+
53+
return chunks
54+
}
55+
56+
export const checkExistingPath = (path: string, object: IJSONData): boolean => {
57+
const parsedPath = parseRedisJsonPath(path)
58+
59+
if (!parsedPath.length) {
60+
// Path is root "$". We don't want to override the whole object.
61+
return true
62+
}
63+
64+
const isRootKey = parsedPath.length === 1
65+
66+
const parent = isRootKey ? object : get(object, parsedPath.slice(0, -1))
67+
const key = parsedPath[parsedPath.length - 1]
68+
69+
if (typeof parent !== 'object' || parent === null) return false
70+
71+
return Object.prototype.hasOwnProperty.call(parent, key)
72+
}
73+
74+
export default parseRedisJsonPath

yarn.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,6 +1640,11 @@
16401640
dependencies:
16411641
"@types/node" "*"
16421642

1643+
"@types/deep-diff@^1.0.5":
1644+
version "1.0.5"
1645+
resolved "https://registry.yarnpkg.com/@types/deep-diff/-/deep-diff-1.0.5.tgz#95c08a57f097ffadd28bc98a45a8025f53c581e4"
1646+
integrity sha512-PQyNSy1YMZU1hgZA5tTYfHPpUAo9Dorn1PZho2/budQLfqLu3JIP37JAavnwYpR1S2yFZTXa3hxaE4ifGW5jaA==
1647+
16431648
"@types/detect-port@^1.3.5":
16441649
version "1.3.5"
16451650
resolved "https://registry.yarnpkg.com/@types/detect-port/-/detect-port-1.3.5.tgz#deecde143245989dee0e82115f3caba5ee0ea747"
@@ -3352,6 +3357,11 @@ decompress-response@^6.0.0:
33523357
dependencies:
33533358
mimic-response "^3.1.0"
33543359

3360+
deep-diff@^1.0.2:
3361+
version "1.0.2"
3362+
resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26"
3363+
integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==
3364+
33553365
deep-eql@^4.1.3:
33563366
version "4.1.3"
33573367
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d"

0 commit comments

Comments
 (0)