Skip to content

RI-6959: Warn when overriding existing JSON key #291

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 11 commits into from
Jun 24, 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
6 changes: 5 additions & 1 deletion l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,12 @@
"Download": "Download",
"Key should have correct syntax.": "Key should have correct syntax.",
"Value should have JSON format.": "Value should have JSON format.",
"Enter JSON value": "Enter JSON value",
"Duplicate JSON key detected": "Duplicate JSON key detected",
"You already have the same JSON key.": "You already have the same JSON key.",
"If you proceed, a value of the existing JSON key will be overwritten.": "If you proceed, a value of the existing JSON key will be overwritten.",
"Overwrite": "Overwrite",
"Enter JSON key": "Enter JSON key",
"Enter JSON value": "Enter JSON value",
"Add Elements": "Add Elements",
"Remove Elements": "Remove Elements",
"Remove from tail": "Remove from tail",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@
},
"dependencies": {
"@stablelib/snappy": "^1.0.3",
"@types/deep-diff": "^1.0.5",
"@vscode/l10n": "^0.0.18",
"@vscode/webview-ui-toolkit": "^1.4.0",
"axios": "^1.8.3",
Expand All @@ -264,6 +265,7 @@
"connection-string": "^4.4.0",
"cors": "^2.8.5",
"date-fns": "^2.30.0",
"deep-diff": "^1.0.2",
"detect-port": "^1.6.1",
"dotenv": "^16.4.5",
"file-saver": "^2.0.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,84 @@ describe('AddItem', () => {
fireEvent.change(screen.getByTestId('json-key'), { target: { value: '"' } })
fireEvent.click(screen.getByTestId('apply-btn'))

expect(screen.getByTestId('edit-json-error')).toHaveTextContent(JSONErrors.keyCorrectSyntax)
expect(screen.getByTestId('edit-json-error')).toHaveTextContent(
JSONErrors.keyCorrectSyntax,
)
})

it('should show error with invalid value', () => {
render(<AddItem {...mockedProps} onCancel={vi.fn} />)

expect(screen.queryByTestId('json-key')).not.toBeInTheDocument()

fireEvent.change(screen.getByTestId('json-value'), { target: { value: '"' } })
fireEvent.change(screen.getByTestId('json-value'), {
target: { value: '"' },
})
fireEvent.click(screen.getByTestId('apply-btn'))

expect(screen.getByTestId('edit-json-error')).toHaveTextContent(JSONErrors.valueJSONFormat)
expect(screen.getByTestId('edit-json-error')).toHaveTextContent(
JSONErrors.valueJSONFormat,
)
})

it('should submit with proper key and value', () => {
const onSubmit = vi.fn()
render(<AddItem {...mockedProps} isPair onCancel={vi.fn} onSubmit={onSubmit} />)
render(
<AddItem {...mockedProps} isPair onCancel={vi.fn} onSubmit={onSubmit} />,
)

fireEvent.change(screen.getByTestId('json-key'), { target: { value: '"key"' } })
fireEvent.change(screen.getByTestId('json-value'), { target: { value: '1' } })
fireEvent.change(screen.getByTestId('json-key'), {
target: { value: '"key"' },
})
fireEvent.change(screen.getByTestId('json-value'), {
target: { value: '1' },
})
fireEvent.click(screen.getByTestId('apply-btn'))

expect(onSubmit).toBeCalledWith({ key: '"key"', value: '1' })
})

it('should call onCancel when clicking outside if shouldCloseOnOutsideClick is true', () => {
const onCancel = vi.fn()

render(
<div>
<AddItem
{...mockedProps}
isPair
onCancel={onCancel}
shouldCloseOnOutsideClick
/>
<div data-testid="outside" />
</div>,
)

fireEvent.mouseDown(screen.getByTestId('outside'))
fireEvent.mouseUp(screen.getByTestId('outside'))
fireEvent.click(screen.getByTestId('outside'))

expect(onCancel).toHaveBeenCalledTimes(1)
})

it('should NOT call onCancel when clicking outside if shouldCloseOnOutsideClick is false', () => {
const onCancel = vi.fn()

render(
<div>
<AddItem
{...mockedProps}
isPair
onCancel={onCancel}
shouldCloseOnOutsideClick={false}
/>
<div data-testid="outside" />
</div>,
)

fireEvent.mouseDown(screen.getByTestId('outside'))
fireEvent.mouseUp(screen.getByTestId('outside'))
fireEvent.click(screen.getByTestId('outside'))

expect(onCancel).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import styles from '../../styles.module.scss'
export interface Props {
isPair: boolean
onCancel: () => void
onSubmit: (pair: { key?: string, value: string }) => void
onSubmit: (pair: { key?: string; value: string }) => void
leftPadding?: number
shouldCloseOnOutsideClick?: boolean
}

export const AddItem = (props: Props) => {
Expand All @@ -29,13 +30,14 @@ export const AddItem = (props: Props) => {
leftPadding = 0,
onCancel,
onSubmit,
shouldCloseOnOutsideClick = true,
} = props

const [key, setKey] = useState<string>('')
const [value, setValue] = useState<string>('')
const [error, setError] = useState<Nullable<string>>(null)

const handleClickOutside = () => { onCancel?.() }
const handleClickOutside = () => shouldCloseOnOutsideClick && onCancel?.()

const inputRef = useRef<HTMLInputElement>(null)

Expand Down Expand Up @@ -91,7 +93,9 @@ export const AddItem = (props: Props) => {
value={key}
invalid={!!error}
placeholder={l10n.t('Enter JSON key')}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setKey(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setKey(e.target.value)
}
data-testid="json-key"
/>
</span>
Expand All @@ -103,7 +107,9 @@ export const AddItem = (props: Props) => {
value={value}
placeholder={l10n.t('Enter JSON value')}
invalid={!!error}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue(e.target.value)
}
data-testid="json-value"
/>
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,41 @@ describe('EditEntireItemAction', () => {
)
expect(handleUpdateValueFormSubmit).not.toHaveBeenCalled()
})

it('should call onCancel when clicking outside and shouldCloseOnOutsideClick is true', () => {
const handleCancel = vi.fn()

render(
<div data-testid="outside">
<EditEntireItemAction
{...instance(mockedProps)}
initialValue={valueOfEntireItem}
onCancel={handleCancel}
shouldCloseOnOutsideClick
/>
</div>,
)

// Simulate outside click
fireEvent.mouseDown(screen.getByTestId('outside'))
expect(handleCancel).toHaveBeenCalled()
})

it('should NOT call onCancel when clicking outside and shouldCloseOnOutsideClick is false', () => {
const handleCancel = vi.fn()

render(
<div data-testid="outside">
<EditEntireItemAction
{...instance(mockedProps)}
initialValue={valueOfEntireItem}
onCancel={handleCancel}
shouldCloseOnOutsideClick={false}
/>
</div>,
)

fireEvent.mouseDown(screen.getByTestId('outside'))
expect(handleCancel).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,22 @@ import styles from '../../styles.module.scss'

export interface Props {
initialValue: string
shouldCloseOnOutsideClick?: boolean
onCancel?: () => void
onSubmit: (value: string) => void
}

export const EditEntireItemAction = (props: Props) => {
const { initialValue, onCancel, onSubmit } = props
const {
initialValue,
onCancel,
onSubmit,
shouldCloseOnOutsideClick = true,
} = props
const [value, setValue] = useState<string>(initialValue)
const [error, setError] = useState<Nullable<string>>(null)

const handleClickOutside = () => {
onCancel?.()
}
const handleClickOutside = () => shouldCloseOnOutsideClick && onCancel?.()

const textareaRef = useRef<HTMLTextAreaElement>(null)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,4 +338,66 @@ describe('RejsonDetails', () => {
expect(useRejson.setReJSONDataAction).not.toBeCalled()
expect(useRejson.appendReJSONArrayItemAction).toBeCalled()
})

it('should show confirmation dialog when adding a key that already exists', () => {
render(
<RejsonDetails
{...instance(mockedProps)}
data={{ existingKey: '123' }}
dataType="object"
selectedKey={mockedSelectedKey}
isDownloaded
/>,
)

fireEvent.click(screen.getByTestId('add-object-btn'))

fireEvent.change(screen.getByTestId('json-key'), {
target: { value: '"existingKey"' },
})

fireEvent.change(screen.getByTestId('json-value'), {
target: { value: '"newValue"' },
})

fireEvent.click(screen.getByTestId('apply-btn'))

expect(screen.getByText('Duplicate JSON key detected')).toBeInTheDocument()
expect(useRejson.setReJSONDataAction).not.toBeCalled()
})

it('should call setReJSONDataAction when user confirms overwrite', () => {
render(
<RejsonDetails
{...instance(mockedProps)}
data={{ existingKey: '123' }}
dataType="object"
selectedKey={mockedSelectedKey}
isDownloaded
/>,
)

fireEvent.click(screen.getByTestId('add-object-btn'))

fireEvent.change(screen.getByTestId('json-key'), {
target: { value: '"existingKey"' },
})

fireEvent.change(screen.getByTestId('json-value'), {
target: { value: '"newValue"' },
})

fireEvent.click(screen.getByTestId('apply-btn'))

const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)

expect(useRejson.setReJSONDataAction).toBeCalledWith(
mockedSelectedKey,
'["existingKey"]',
'"newValue"',
undefined, // length is not required
expect.any(Function), // callback is not required
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
setReJSONDataAction,
} from '../hooks/useRejsonStore'

import { checkExistingPath } from '../utils/path'
import ReJSONConfirmDialog from '../rejson-object/RejsonConfirmDialog'
import styles from '../styles.module.scss'

export const RejsonDetails = (props: BaseProps) => {
Expand All @@ -31,6 +33,8 @@ export const RejsonDetails = (props: BaseProps) => {
} = props

const [addRootKVPair, setAddRootKVPair] = useState<boolean>(false)
const [isConfirmVisible, setIsConfirmVisible] = useState<boolean>(false)
const [confirmDialogAction, setConfirmDialogAction] = useState<any>(() => {})

const databaseId = useKeysInContext((state) => state.databaseId)

Expand Down Expand Up @@ -61,14 +65,38 @@ export const RejsonDetails = (props: BaseProps) => {
})
}

const handleFormSubmit = ({ key, value }: { key?: string, value: string }) => {
const handleFormSubmit = ({
key,
value,
}: {
key?: string
value: string
}) => {
const updatedPath = wrapPath(key as string)
if (key) {
const isExisting = checkExistingPath(updatedPath as string, data)

if (isExisting) {
setConfirmDialogAction(() => () => {
setIsConfirmVisible(false)

if (updatedPath) {
handleSetRejsonDataAction(selectedKey, updatedPath, value)
}
setAddRootKVPair(false)
})

setIsConfirmVisible(true)
return
}
}

setAddRootKVPair(false)
if (isRealArray(data, dataType)) {
handleAppendRejsonArrayItemAction(selectedKey, '.', value)
return
}

const updatedPath = wrapPath(key as string)
if (updatedPath) {
handleSetRejsonDataAction(selectedKey, updatedPath, value)
}
Expand Down Expand Up @@ -101,6 +129,11 @@ export const RejsonDetails = (props: BaseProps) => {
<span>{getBrackets(isObject ? ObjectTypes.Object : ObjectTypes.Array, 'start')}</span>
</div>
)}
<ReJSONConfirmDialog
open={isConfirmVisible}
onClose={() => setIsConfirmVisible(false)}
onConfirm={confirmDialogAction}
/>
<RejsonDynamicTypes
data={data}
parentPath={parentPath}
Expand Down
Loading