[Console] Fix editor value resetting at layout change (#193516)

## Summary

This PR fixes the bug where the editor input resets when we resize the
page and the layout changes. The following fixes were applied:

- The `isVerticalLayout` prop was moved one level down. This removes
unnecessary rerendering of the editor when the layout is changed.
- Allowed the localstorage value to be undefined. Undefined means that
nothing is stored so we should display the default input. If an empty
string is stored, we should display an empty editor.
- Updates local storage with debounce every time when the editor input
is changes. This ensures the editor input is not reset when the editor
is rerendered.
- Updates the local storage value to `undefined` if it is an empty
string when data is being initialized at Main. This ensures that the
default input is displayed when we switch between tabs/pages and the
input is empty.

Before:



https://github.com/user-attachments/assets/a0535780-d75a-4df8-9e04-9d34b6f5f4f1




Now:




https://github.com/user-attachments/assets/7db46c2c-c35e-461f-99e8-b86c66fb6ae5

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Elena Stoeva 2024-09-24 09:56:47 +01:00 committed by GitHub
parent 194d6307dc
commit 3b8e56fd34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 246 additions and 242 deletions

View file

@ -34,5 +34,5 @@ export interface TextObject {
*
* Used to re-populate a text editor buffer.
*/
text: string;
text: string | undefined;
}

View file

@ -8,16 +8,20 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
useIsWithinBreakpoints,
} from '@elastic/eui';
import { Settings } from './settings';
import { Variables } from './variables';
export interface Props {
isVerticalLayout: boolean;
}
export function Config() {
const isVerticalLayout = useIsWithinBreakpoints(['xs', 's', 'm']);
export function Config({ isVerticalLayout }: Props) {
return (
<EuiPanel
color="subdued"

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useRef, useCallback, memo, useEffect, useState } from 'react';
import React, { useCallback, memo, useEffect, useState } from 'react';
import { debounce } from 'lodash';
import {
EuiProgress,
@ -16,6 +16,7 @@ import {
EuiFlexItem,
EuiButtonEmpty,
EuiResizableContainer,
useIsWithinBreakpoints,
} from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
@ -29,11 +30,11 @@ import {
} from '../../components';
import { getAutocompleteInfo, StorageKeys } from '../../../services';
import {
useEditorReadContext,
useServicesContext,
useRequestReadContext,
useRequestActionContext,
useEditorActionContext,
useEditorReadContext,
} from '../../contexts';
import { MonacoEditor } from './monaco_editor';
import { MonacoEditorOutput } from './monaco_editor_output';
@ -45,245 +46,232 @@ const DEBOUNCE_DELAY = 500;
interface Props {
loading: boolean;
isVerticalLayout: boolean;
inputEditorValue: string;
setInputEditorValue: (value: string) => void;
}
export const Editor = memo(
({ loading, isVerticalLayout, inputEditorValue, setInputEditorValue }: Props) => {
const {
services: { storage, objectStorageClient },
} = useServicesContext();
export const Editor = memo(({ loading, inputEditorValue, setInputEditorValue }: Props) => {
const {
services: { storage, objectStorageClient },
} = useServicesContext();
const editorValueRef = useRef<TextObject | null>(null);
const { currentTextObject } = useEditorReadContext();
const {
requestInFlight,
lastResult: { data: requestData, error: requestError },
} = useRequestReadContext();
const { currentTextObject } = useEditorReadContext();
const dispatch = useRequestActionContext();
const editorDispatch = useEditorActionContext();
const {
requestInFlight,
lastResult: { data: requestData, error: requestError },
} = useRequestReadContext();
const [fetchingAutocompleteEntities, setFetchingAutocompleteEntities] = useState(false);
const dispatch = useRequestActionContext();
const editorDispatch = useEditorActionContext();
useEffect(() => {
const debouncedSetFechingAutocompleteEntities = debounce(
setFetchingAutocompleteEntities,
DEBOUNCE_DELAY
);
const subscription = getAutocompleteInfo().isLoading$.subscribe(
debouncedSetFechingAutocompleteEntities
);
const [fetchingAutocompleteEntities, setFetchingAutocompleteEntities] = useState(false);
return () => {
subscription.unsubscribe();
debouncedSetFechingAutocompleteEntities.cancel();
};
}, []);
const [firstPanelSize, secondPanelSize] = storage.get(StorageKeys.SIZE, [
INITIAL_PANEL_SIZE,
INITIAL_PANEL_SIZE,
]);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const onPanelSizeChange = useCallback(
debounce((sizes) => {
storage.set(StorageKeys.SIZE, Object.values(sizes));
}, 300),
[]
useEffect(() => {
const debouncedSetFechingAutocompleteEntities = debounce(
setFetchingAutocompleteEntities,
DEBOUNCE_DELAY
);
const subscription = getAutocompleteInfo().isLoading$.subscribe(
debouncedSetFechingAutocompleteEntities
);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const debouncedUpdateLocalStorageValue = useCallback(
debounce((textObject: TextObject) => {
editorValueRef.current = textObject;
objectStorageClient.text.update(textObject);
}, DEBOUNCE_DELAY),
[]
);
return () => {
subscription.unsubscribe();
debouncedSetFechingAutocompleteEntities.cancel();
};
}, []);
useEffect(() => {
return () => {
editorDispatch({
type: 'setCurrentTextObject',
payload: editorValueRef.current!,
});
};
}, [editorDispatch]);
const [firstPanelSize, secondPanelSize] = storage.get(StorageKeys.SIZE, [
INITIAL_PANEL_SIZE,
INITIAL_PANEL_SIZE,
]);
// Always keep the localstorage in sync with the value in the editor
// to avoid losing the text object when the user navigates away from the shell
useEffect(() => {
// Only update when its not empty, this is to avoid setting the localstorage value
// to an empty string that will then be replaced by the example request.
if (inputEditorValue !== '') {
const textObject = {
...currentTextObject,
text: inputEditorValue,
updatedAt: Date.now(),
} as TextObject;
const isVerticalLayout = useIsWithinBreakpoints(['xs', 's', 'm']);
debouncedUpdateLocalStorageValue(textObject);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [inputEditorValue, debouncedUpdateLocalStorageValue]);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const onPanelSizeChange = useCallback(
debounce((sizes) => {
storage.set(StorageKeys.SIZE, Object.values(sizes));
}, 300),
[]
);
const data = getResponseWithMostSevereStatusCode(requestData) ?? requestError;
const isLoading = loading || requestInFlight;
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const debouncedUpdateLocalStorageValue = useCallback(
debounce((newValue: string | undefined) => {
const textObject = {
...currentTextObject,
text: newValue,
updatedAt: Date.now(),
} as TextObject;
if (!currentTextObject) return null;
objectStorageClient.text.update(textObject);
return (
<>
{fetchingAutocompleteEntities ? (
<div className="conApp__requestProgressBarContainer">
<EuiProgress size="xs" color="accent" position="absolute" />
</div>
) : null}
<EuiResizableContainer
style={{ height: '100%' }}
direction={isVerticalLayout ? 'vertical' : 'horizontal'}
onPanelWidthChange={(sizes) => onPanelSizeChange(sizes)}
data-test-subj="consoleEditorContainer"
>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
initialSize={firstPanelSize}
minSize={PANEL_MIN_SIZE}
tabIndex={0}
paddingSize="none"
editorDispatch({
type: 'setCurrentTextObject',
payload: textObject,
});
}, DEBOUNCE_DELAY),
[]
);
// Always keep the localstorage value in sync with the value in the editor
// to avoid losing the text object when the user navigates away from the shell
useEffect(() => {
debouncedUpdateLocalStorageValue(inputEditorValue);
}, [debouncedUpdateLocalStorageValue, inputEditorValue]);
if (!currentTextObject) return null;
const data = getResponseWithMostSevereStatusCode(requestData) ?? requestError;
const isLoading = loading || requestInFlight;
return (
<>
{fetchingAutocompleteEntities ? (
<div className="conApp__requestProgressBarContainer">
<EuiProgress size="xs" color="accent" position="absolute" />
</div>
) : null}
<EuiResizableContainer
style={{ height: '100%' }}
direction={isVerticalLayout ? 'vertical' : 'horizontal'}
onPanelWidthChange={(sizes) => onPanelSizeChange(sizes)}
data-test-subj="consoleEditorContainer"
>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
initialSize={firstPanelSize}
minSize={PANEL_MIN_SIZE}
tabIndex={0}
paddingSize="none"
>
<EuiSplitPanel.Outer
grow={true}
borderRadius="none"
hasShadow={false}
style={{ height: '100%' }}
>
<EuiSplitPanel.Outer
<EuiSplitPanel.Inner
paddingSize="none"
grow={true}
borderRadius="none"
hasShadow={false}
style={{ height: '100%' }}
className="consoleEditorPanel"
style={{ top: 0, height: 'calc(100% - 40px)' }}
>
<EuiSplitPanel.Inner
paddingSize="none"
grow={true}
className="consoleEditorPanel"
style={{ top: 0, height: 'calc(100% - 40px)' }}
>
{loading ? (
<EditorContentSpinner />
) : (
<MonacoEditor
localStorageValue={currentTextObject.text}
value={inputEditorValue}
setValue={setInputEditorValue}
/>
)}
</EuiSplitPanel.Inner>
{!loading && (
<EuiSplitPanel.Inner
grow={false}
paddingSize="s"
css={{
backgroundColor: euiThemeVars.euiFormBackgroundColor,
}}
className="consoleEditorPanel"
>
<EuiButtonEmpty
size="xs"
color="primary"
data-test-subj="clearConsoleInput"
onClick={() => setInputEditorValue('')}
>
{i18n.translate('console.editor.clearConsoleInputButton', {
defaultMessage: 'Clear this input',
})}
</EuiButtonEmpty>
</EuiSplitPanel.Inner>
{loading ? (
<EditorContentSpinner />
) : (
<MonacoEditor
localStorageValue={currentTextObject.text}
value={inputEditorValue}
setValue={setInputEditorValue}
/>
)}
</EuiSplitPanel.Outer>
</EuiResizablePanel>
</EuiSplitPanel.Inner>
<EuiResizableButton
className="conApp__resizerButton"
aria-label={i18n.translate('console.editor.adjustPanelSizeAriaLabel', {
defaultMessage: "Press left/right to adjust panels' sizes",
})}
/>
<EuiResizablePanel
initialSize={secondPanelSize}
minSize={PANEL_MIN_SIZE}
tabIndex={0}
paddingSize="none"
>
<EuiSplitPanel.Outer
borderRadius="none"
hasShadow={false}
style={{ height: '100%' }}
>
{!loading && (
<EuiSplitPanel.Inner
paddingSize="none"
css={{ alignContent: 'center', top: 0 }}
grow={false}
paddingSize="s"
css={{
backgroundColor: euiThemeVars.euiFormBackgroundColor,
}}
className="consoleEditorPanel"
>
{data ? (
<MonacoEditorOutput />
) : isLoading ? (
<EditorContentSpinner />
) : (
<OutputPanelEmptyState />
)}
</EuiSplitPanel.Inner>
{(data || isLoading) && (
<EuiSplitPanel.Inner
grow={false}
paddingSize="s"
css={{
backgroundColor: euiThemeVars.euiFormBackgroundColor,
<EuiButtonEmpty
size="xs"
color="primary"
data-test-subj="clearConsoleInput"
onClick={() => {
setInputEditorValue('');
}}
className="consoleEditorPanel"
>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
color="primary"
data-test-subj="clearConsoleOutput"
onClick={() => dispatch({ type: 'cleanRequest', payload: undefined })}
>
{i18n.translate('console.editor.clearConsoleOutputButton', {
defaultMessage: 'Clear this output',
})}
</EuiButtonEmpty>
</EuiFlexItem>
{i18n.translate('console.editor.clearConsoleInputButton', {
defaultMessage: 'Clear this input',
})}
</EuiButtonEmpty>
</EuiSplitPanel.Inner>
)}
</EuiSplitPanel.Outer>
</EuiResizablePanel>
<EuiFlexItem>
<NetworkRequestStatusBar
requestInProgress={requestInFlight}
requestResult={
data
? {
method: data.request.method.toUpperCase(),
endpoint: data.request.path,
statusCode: data.response.statusCode,
statusText: data.response.statusText,
timeElapsedMs: data.response.timeMs,
}
: undefined
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiResizableButton
className="conApp__resizerButton"
aria-label={i18n.translate('console.editor.adjustPanelSizeAriaLabel', {
defaultMessage: "Press left/right to adjust panels' sizes",
})}
/>
<EuiResizablePanel
initialSize={secondPanelSize}
minSize={PANEL_MIN_SIZE}
tabIndex={0}
paddingSize="none"
>
<EuiSplitPanel.Outer borderRadius="none" hasShadow={false} style={{ height: '100%' }}>
<EuiSplitPanel.Inner
paddingSize="none"
css={{ alignContent: 'center', top: 0 }}
className="consoleEditorPanel"
>
{data ? (
<MonacoEditorOutput />
) : isLoading ? (
<EditorContentSpinner />
) : (
<OutputPanelEmptyState />
)}
</EuiSplitPanel.Outer>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
</>
);
}
);
</EuiSplitPanel.Inner>
{(data || isLoading) && (
<EuiSplitPanel.Inner
grow={false}
paddingSize="s"
css={{
backgroundColor: euiThemeVars.euiFormBackgroundColor,
}}
className="consoleEditorPanel"
>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
color="primary"
data-test-subj="clearConsoleOutput"
onClick={() => dispatch({ type: 'cleanRequest', payload: undefined })}
>
{i18n.translate('console.editor.clearConsoleOutputButton', {
defaultMessage: 'Clear this output',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<NetworkRequestStatusBar
requestInProgress={requestInFlight}
requestResult={
data
? {
method: data.request.method.toUpperCase(),
endpoint: data.request.path,
statusCode: data.response.statusCode,
statusText: data.response.statusText,
timeElapsedMs: data.response.timeMs,
}
: undefined
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
)}
</EuiSplitPanel.Outer>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
</>
);
});

View file

@ -61,7 +61,7 @@ export const useSetInitialValue = (params: SetInitialValueParams) => {
if (parsedURL.origin === 'https://www.elastic.co') {
const resp = await fetch(parsedURL);
const data = await resp.text();
setValue(`${localStorageValue}\n\n${data}`);
setValue(`${localStorageValue ?? ''}\n\n${data}`);
} else {
toasts.addWarning(
i18n.translate('console.monaco.loadFromDataUnrecognizedUrlErrorMessage', {
@ -107,7 +107,8 @@ export const useSetInitialValue = (params: SetInitialValueParams) => {
if (loadFromParam) {
loadBufferFromRemote(loadFromParam);
} else {
setValue(localStorageValue || DEFAULT_INPUT_VALUE);
// Only set to default input value if the localstorage value is undefined
setValue(localStorageValue ?? DEFAULT_INPUT_VALUE);
}
return () => {

View file

@ -26,6 +26,7 @@ import {
EuiFormFieldset,
EuiCheckableCard,
EuiResizableContainer,
useIsWithinBreakpoints,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
@ -77,11 +78,7 @@ const CheckeableCardLabel = ({ historyItem }: { historyItem: HistoryProps }) =>
);
};
interface Props {
isVerticalLayout: boolean;
}
export function History({ isVerticalLayout }: Props) {
export function History() {
const { euiTheme } = useEuiTheme();
const {
services: { history, routeHistory },
@ -99,6 +96,8 @@ export function History({ isVerticalLayout }: Props) {
const [viewingReq, setViewingReq] = useState<any>(null);
const isVerticalLayout = useIsWithinBreakpoints(['xs', 's', 'm']);
const initialize = useCallback(() => {
const nextSelectedIndex = 0;
setViewingReq(requests[nextSelectedIndex]);

View file

@ -18,7 +18,6 @@ import {
EuiButtonEmpty,
EuiHorizontalRule,
EuiScreenReaderOnly,
useIsWithinBreakpoints,
useEuiOverflowScroll,
useEuiTheme,
} from '@elastic/eui';
@ -84,8 +83,6 @@ export function Main({ currentTabProp, isEmbeddable = false }: MainProps) {
services: { notifications, routeHistory },
} = useServicesContext();
const isVerticalLayout = useIsWithinBreakpoints(['xs', 's', 'm']);
const storageTourState = localStorage.getItem(TOUR_STORAGE_KEY);
const initialTourState = storageTourState ? JSON.parse(storageTourState) : INITIAL_TOUR_CONFIG;
const [tourStepProps, actions, tourState] = useEuiTour(getTourSteps(docLinks), initialTourState);
@ -186,6 +183,8 @@ export function Main({ currentTabProp, isEmbeddable = false }: MainProps) {
);
}
if (!currentTextObject) return null;
const shortcutsButton = (
<NavIconButton
iconType="keyboard"
@ -314,13 +313,12 @@ export function Main({ currentTabProp, isEmbeddable = false }: MainProps) {
{currentTab === SHELL_TAB_ID && (
<Editor
loading={!done}
isVerticalLayout={isVerticalLayout}
inputEditorValue={inputEditorValue}
setInputEditorValue={setInputEditorValue}
/>
)}
{currentTab === HISTORY_TAB_ID && <History isVerticalLayout={isVerticalLayout} />}
{currentTab === CONFIG_TAB_ID && <Config isVerticalLayout={isVerticalLayout} />}
{currentTab === HISTORY_TAB_ID && <History />}
{currentTab === CONFIG_TAB_ID && <Config />}
</EuiSplitPanel.Inner>
<EuiHorizontalRule margin="none" />
<EuiSplitPanel.Inner

View file

@ -8,6 +8,7 @@
*/
import { useCallback, useEffect, useState } from 'react';
import { TextObject } from '../../../../common/text_object';
import { migrateToTextObjects } from './data_migration';
import { useEditorActionContext, useServicesContext } from '../../contexts';
@ -37,16 +38,29 @@ export const useDataInit = () => {
const newObject = await objectStorageClient.text.create({
createdAt: Date.now(),
updatedAt: Date.now(),
text: '',
text: undefined,
});
dispatch({ type: 'setCurrentTextObject', payload: newObject });
} else {
dispatch({
type: 'setCurrentTextObject',
// For backwards compatibility, we sort here according to date created to
// always take the first item created.
payload: results.sort((a, b) => a.createdAt - b.createdAt)[0],
});
// For backwards compatibility, we sort here according to date created to
// always take the first item created.
const lastObject = results.sort((a, b) => a.createdAt - b.createdAt)[0];
if (lastObject.text === '') {
// If the last stored text is empty, add a new object with undefined text so that the default input is displayed at initial render
const textObject = {
...lastObject,
text: undefined,
updatedAt: Date.now(),
} as TextObject;
objectStorageClient.text.update(textObject);
dispatch({ type: 'setCurrentTextObject', payload: textObject });
} else {
dispatch({
type: 'setCurrentTextObject',
payload: lastObject,
});
}
}
} catch (e) {
setError(e);

View file

@ -33,7 +33,7 @@
"@kbn/react-kibana-mount",
"@kbn/ui-theme",
"@kbn/core-doc-links-browser",
"@kbn/shared-ux-router"
"@kbn/shared-ux-router",
],
"exclude": [
"target/**/*",