diff --git a/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts index a6635c9851..a22fdf0581 100644 --- a/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts @@ -330,4 +330,58 @@ test.describe('Environment Editor', () => { await privateRow.waitFor({ state: 'visible' }); await expect.soft(privateRow.locator('.fa-lock')).toBeVisible(); }); + + test('clearing the JSON editor reports a JSON error instead of keeping stale table values', async ({ page, app }) => { + const text = await loadFixture('environments.yaml'); + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text); + await page.getByLabel('Import').click(); + await page.locator('[data-test-id="import-from-clipboard"]').click(); + await page.getByRole('button', { name: 'Scan' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); + + // wait for import dialog to close before proceeding + await page.getByRole('dialog').waitFor({ state: 'hidden' }); + + // open the JSON environment in the environment editor + await page.getByLabel('Select an API Collection Environment').click(); + await page.getByRole('button', { name: 'Manage API collection environments' }).click(); + const environmentDialog = page.getByRole('dialog', { name: 'Manage Environments' }); + await environmentDialog.getByLabel('Environments', { exact: true }).getByText('ExampleA').click(); + + // delete all JSON content + const jsonEditor = environmentDialog.getByTestId('CodeEditor').getByRole('textbox'); + await jsonEditor.focus(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.press('Delete'); + + /* Empty content must be reported like any other parse error, not silently dropped. */ + await expect.soft(environmentDialog.getByText('Unexpected end of JSON input')).toBeVisible(); + + /* The existing JSON error dialog blocks the switch, so the table can never show values that no + longer match the editor content. */ + await page.getByRole('button', { name: 'Table Edit' }).click(); + await expect + .soft(page.getByText('Please modify and fix the JSON string error before switch to Table view')) + .toBeVisible(); + await expect.soft(page.getByRole('listbox', { name: 'Environment Key Value Pair' })).toBeHidden(); + await page.getByRole('button', { name: 'Ok', exact: true }).click(); + + /* Reopening remounts the editor and restores the unsaved empty content from its cache: the error + must survive, otherwise the document is empty but still switchable. */ + await page.getByRole('button', { name: 'Close', exact: true }).click(); + await page.getByRole('heading', { name: 'Manage Environments' }).waitFor({ state: 'hidden' }); + // the picker popover can still be open; this click dismisses it, then reopen the editor + await page.locator('body').click(); + await page.getByRole('button', { name: 'Select an API Collection Environment' }).click(); + await page.getByRole('button', { name: 'Manage API collection environments' }).click(); + await page.getByRole('dialog', { name: 'Manage Environments' }).waitFor({ state: 'visible' }); + await environmentDialog.getByLabel('Environments', { exact: true }).getByText('ExampleA').click(); + + await expect.soft(environmentDialog.getByText('Unexpected end of JSON input')).toBeVisible(); + await page.getByRole('button', { name: 'Table Edit' }).click(); + await expect + .soft(page.getByText('Please modify and fix the JSON string error before switch to Table view')) + .toBeVisible(); + await expect.soft(page.getByRole('listbox', { name: 'Environment Key Value Pair' })).toBeHidden(); + }); }); diff --git a/packages/insomnia/src/ui/components/editors/environment-editor.tsx b/packages/insomnia/src/ui/components/editors/environment-editor.tsx index 5d84ab690a..815698f531 100644 --- a/packages/insomnia/src/ui/components/editors/environment-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/environment-editor.tsx @@ -1,6 +1,6 @@ import { isWindows } from 'insomnia-data/common'; import orderedJSON from 'json-order'; -import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { checkNestedKeys } from '~/common/utils/environment-utils'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; @@ -22,20 +22,22 @@ interface Props { export interface EnvironmentEditorHandle { isValid: () => boolean; + /** Throws on invalid content (empty included) - guard with isValid(). */ getValue: () => EnvironmentInfo | null; } export const EnvironmentEditor = forwardRef( ({ environmentInfo, onBlur, onChange, historyKey }, ref) => { const editorRef = useRef(null); - const editorErrorRef = useRef(''); const [error, setError] = useState(''); const getValue = useCallback(() => { - // @ts-expect-error -- current can be null - let value = editorRef.current.getValue(); - if (!editorRef.current || !value) { + const editor = editorRef.current; + if (!editor) { return null; } + /* Empty content is invalid JSON, not "no value": let orderedJSON.parse throw so it follows + the same error path as any other malformed input. */ + let value = editor.getValue(); // On Windows, backslashes are used as directory separators. // The file tag inserted by Nunjucks in JSON uses double backslashes in its path parameter, but in the logic below, orderedJSON.parse unescapes those double backslashes into a single backslash. This causes the file tag to fail when the corresponding environment variable is referenced in a request. @@ -52,20 +54,33 @@ export const EnvironmentEditor = forwardRef( }; return environmentInfo; }, []); + + /** Parses the current document: the value to commit, or the error to show. */ + const validate = useCallback(() => { + try { + const value = getValue(); + if (!value?.object) { + return { value: null, error: '' }; + } + // Check root and nested properties + const err = checkNestedKeys(value.object); + return err ? { value: null, error: err } : { value, error: '' }; + } catch (err) { + return { value: null, error: err.message }; + } + }, [getValue]); + useImperativeHandle( ref, () => ({ - isValid: () => !editorErrorRef.current, + /* Derived from the document, not from the last edit event: onChange is debounced and the + cached content is restored on mount without firing it. */ + isValid: () => !validate().error, getValue, }), - [getValue], + [getValue, validate], ); - const updateEditorError = (message: string) => { - editorErrorRef.current = message; - setError(message); - }; - let defaultValue = orderedJSON.stringify( environmentInfo.object, environmentInfo.propertyOrder || null, @@ -77,6 +92,12 @@ export const EnvironmentEditor = forwardRef( defaultValue = unescapeFileTag(defaultValue); } + /* The editor can hold content that never passed through onChange (the cached document is + restored on mount), so re-validate to keep the notice and isValid() in sync with it. */ + useEffect(() => { + setError(validate().error); + }, [validate, defaultValue, historyKey]); + return (
( autoPrettify enableNunjucks onChange={() => { - updateEditorError(''); - try { - const value = getValue(); - // Check for invalid key names - if (value?.object) { - // Check root and nested properties - const err = checkNestedKeys(value.object); - if (err) { - updateEditorError(err); - } else { - onChange?.(value); - } - } - } catch (err) { - updateEditorError(err.message); + const { value, error: validationError } = validate(); + setError(validationError); + if (value) { + onChange?.(value); } }} defaultValue={defaultValue}