[Discover] Restore tab content internal state when switching tabs (field list and data table comparison) (#224299)

- Closes https://github.com/elastic/kibana/issues/218511

Previous possible solutions:
- https://github.com/elastic/kibana/pull/220780 (via portals)
- https://github.com/elastic/kibana/pull/224077 (via additional props on
UnifiedFieldList)
- https://github.com/elastic/kibana/pull/224242 (via tabs single context
and generic utils)

## Summary

This PR keeps track of the UnifiedFieldList internal state changes and
restores it when switching tabs.
Based on @davismcphee POC https://github.com/elastic/kibana/pull/224169

UnifiedFieldList:
- [x] field search
- [x] field type filters
- [x] scroll position
- [x] collapsed/expanded accordion sections 

UnifiedDataTable:
- [x] comparing mode (from POC) 

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Davis McPhee <davis.mcphee@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Rechkunova 2025-06-24 16:10:04 +02:00 committed by GitHub
parent 8b65a14fcc
commit 12590c9a8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1187 additions and 373 deletions

1
.github/CODEOWNERS vendored
View file

@ -512,6 +512,7 @@ src/platform/packages/shared/kbn-react-hooks @elastic/obs-ux-logs-team
src/platform/packages/shared/kbn-recently-accessed @elastic/appex-sharedux
src/platform/packages/shared/kbn-repo-info @elastic/kibana-operations
src/platform/packages/shared/kbn-resizable-layout @elastic/kibana-data-discovery
src/platform/packages/shared/kbn-restorable-state @elastic/kibana-data-discovery
src/platform/packages/shared/kbn-rison @elastic/kibana-operations
src/platform/packages/shared/kbn-router-to-openapispec @elastic/kibana-core
src/platform/packages/shared/kbn-router-utils @elastic/obs-ux-logs-team

View file

@ -801,6 +801,7 @@
"@kbn/response-ops-rule-params": "link:src/platform/packages/shared/response-ops/rule_params",
"@kbn/response-ops-rules-apis": "link:src/platform/packages/shared/response-ops/rules-apis",
"@kbn/response-stream-plugin": "link:examples/response_stream",
"@kbn/restorable-state": "link:src/platform/packages/shared/kbn-restorable-state",
"@kbn/rison": "link:src/platform/packages/shared/kbn-rison",
"@kbn/rollup": "link:x-pack/platform/packages/private/rollup",
"@kbn/rollup-plugin": "link:x-pack/platform/plugins/private/rollup",

View file

@ -0,0 +1,61 @@
# @kbn/restorable-state
This package provides a set of utilities for managing and restoring the subcomponents state in a centralized way.
In order to use this package:
1. Define a state interface which would include all the tracked subcomponent states and create utils tailored to your state interface
```typescript
import { createRestorableStateProvider } from '@kbn/restorable-state';
export interface MyRestorableState {
count: number;
}
export const { withRestorableState, useRestorableState } =
createRestorableStateProvider<MyRestorableState>();
```
2. Wrap your component with the `withRestorableState` HOC and use the `useRestorableState` instead of the usual `useState`.
```typescript
import { withRestorableState, useRestorableState } from '../path/to/your/restorable-state-utils';
interface InternalMyComponentProps {
// your component props here
}
const InternalMyComponent: React.FC<InternalMyComponentProps> = () => {
const [count, setCount] = useRestorableState('count', 0);
return (
<div>
<button onClick={() => setCount(value => value + 1)}>Increment</button>
<p>Count: {count}</p>
</div>
);
}
export const MyComponent = withRestorableState(InternalMyComponent);
// props are now extended with initialState and onInitialStateChange
export type MyComponentProps = ComponentProps<typeof MyComponent>;
```
3. Use the `MyComponent` in your application. The state will be automatically restored when the component is mounted again.
```typescript
import { MyComponent } from '../path/to/your/my_component';
const App = () => {
return (
<MyComponent
// This component will now accept 2 more props
initialState={/* initialState */} // Initial state for the subcomponents
onInitialStateChange={(newState) => { // A callback to keep track of subcomponents state changes
console.log('Initial state changed:', newState);
}}
/>
);
}
```

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export {
type RestorableStateProviderProps,
type RestorableStateProviderApi,
createRestorableStateProvider,
} from './src/restorable_state_provider';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/src/platform/packages/shared/kbn-restorable-state'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-browser",
"id": "@kbn/restorable-state",
"owner": "@elastic/kibana-data-discovery",
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/restorable-state",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { ComponentProps, useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRestorableStateProvider } from './restorable_state_provider';
interface RestorableState {
count?: number;
message?: string;
anotherMessage?: string;
}
describe('createRestorableStateProvider', () => {
it('withRestorableState should work correctly', async () => {
const { withRestorableState, useRestorableState } =
createRestorableStateProvider<RestorableState>();
const MockChildComponent = () => {
const [message, setMessage] = useRestorableState('message', 'Hello');
const [count, setCount] = useRestorableState('count', 0);
return (
<button
data-test-subj="message-button"
onClick={() => {
setMessage((value) => `${value} World`);
setCount((value) => (value || 0) + 1);
}}
>
{message} - {count || 0}
</button>
);
};
const MockParentComponent = () => {
const [anotherMessage, setAnotherMessage] = useRestorableState('anotherMessage', '+');
return (
<div>
<MockChildComponent />
<button
data-test-subj="another-message-button"
onClick={() => {
setAnotherMessage((value) => `${value}+`);
}}
>
{anotherMessage}
</button>
</div>
);
};
const WrappedComponent = withRestorableState(MockParentComponent);
let mockStoredState: RestorableState | undefined;
const props: ComponentProps<typeof WrappedComponent> = {
initialState: undefined,
onInitialStateChange: jest.fn((state) => {
mockStoredState = state;
}),
};
render(<WrappedComponent {...props} />);
const button = screen.getByTestId('message-button');
expect(button).toHaveTextContent('Hello');
expect(props.onInitialStateChange).not.toHaveBeenCalled();
await userEvent.click(button);
await userEvent.click(button);
expect(button).toHaveTextContent('Hello World World - 2');
expect(props.onInitialStateChange).toHaveBeenCalledTimes(4);
expect(mockStoredState).toEqual({ message: 'Hello World World', count: 2 });
const anotherButton = screen.getByTestId('another-message-button');
expect(anotherButton).toHaveTextContent('+');
await userEvent.click(anotherButton);
expect(anotherButton).toHaveTextContent('++');
expect(props.onInitialStateChange).toHaveBeenCalledTimes(5);
expect(mockStoredState).toEqual({
message: 'Hello World World',
count: 2,
anotherMessage: '++',
});
const propsWithSavedState: ComponentProps<typeof WrappedComponent> = {
initialState: { message: 'Hi', anotherMessage: '---' },
onInitialStateChange: jest.fn((state) => {
mockStoredState = state;
}),
};
render(<WrappedComponent {...propsWithSavedState} />);
expect(screen.getAllByTestId('message-button')[1]).toHaveTextContent('Hi - 0');
expect(screen.getAllByTestId('another-message-button')[1]).toHaveTextContent('---');
});
it('useRestorableRef should work correctly', async () => {
const { withRestorableState, useRestorableRef } =
createRestorableStateProvider<RestorableState>();
const MockChildComponent = () => {
const countRef = useRestorableRef('count', 0);
return (
<button
data-test-subj="count-button"
onClick={() => {
countRef.current = (countRef.current || 0) + 1;
}}
>
{countRef.current || 0}
</button>
);
};
const MockParentComponent = () => {
const [isVisible, setIsVisible] = useState(true);
return (
<div>
{isVisible && <MockChildComponent />}
<button onClick={() => setIsVisible(false)}>Hide</button>
</div>
);
};
const WrappedComponent = withRestorableState(MockParentComponent);
let mockStoredState: RestorableState | undefined;
const props: ComponentProps<typeof WrappedComponent> = {
initialState: undefined,
onInitialStateChange: jest.fn((state) => {
mockStoredState = state;
}),
};
render(<WrappedComponent {...props} />);
const button = screen.getByTestId('count-button');
expect(button).toHaveTextContent('0');
expect(props.onInitialStateChange).not.toHaveBeenCalled();
await userEvent.click(button);
await userEvent.click(button);
await userEvent.click(button);
expect(props.onInitialStateChange).not.toHaveBeenCalled();
await userEvent.click(screen.getByText('Hide'));
expect(props.onInitialStateChange).toHaveBeenCalled();
expect(mockStoredState).toEqual({ count: 3 });
render(<WrappedComponent {...props} initialState={{ count: 5 }} />);
expect(screen.getByTestId('count-button')).toHaveTextContent('5');
});
});

View file

@ -0,0 +1,223 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, {
useContext,
useState,
useRef,
createContext,
PropsWithChildren,
forwardRef,
useImperativeHandle,
SetStateAction,
Dispatch,
useMemo,
useEffect,
} from 'react';
import useLatest from 'react-use/lib/useLatest';
import useUnmount from 'react-use/lib/useUnmount';
import { BehaviorSubject, Subject, map } from 'rxjs';
export interface RestorableStateProviderProps<TState extends object> {
initialState?: Partial<TState>;
onInitialStateChange?: (initialState: Partial<TState>) => void;
}
export interface RestorableStateProviderApi {
refreshInitialState: () => void;
}
type RestorableStateContext<TState extends object> = Pick<
RestorableStateProviderProps<TState>,
'onInitialStateChange'
> & {
initialState$: BehaviorSubject<Partial<TState> | undefined>;
initialStateRefresh$: Subject<Partial<TState> | undefined>;
};
type InitialValue<TState extends object, TKey extends keyof TState> =
| TState[TKey]
| (() => TState[TKey]);
type ShouldIgnoredRestoredValue<TState extends object, TKey extends keyof TState> = (
restoredValue: TState[TKey]
) => boolean;
export const createRestorableStateProvider = <TState extends object>() => {
const context = createContext<RestorableStateContext<TState>>({
initialState$: new BehaviorSubject<Partial<TState> | undefined>(undefined),
initialStateRefresh$: new Subject<Partial<TState> | undefined>(),
onInitialStateChange: undefined,
});
const RestorableStateProvider = forwardRef<
RestorableStateProviderApi,
PropsWithChildren<RestorableStateProviderProps<TState>>
>(function RestorableStateProvider(
{ initialState, onInitialStateChange: currentOnInitialStateChange, children },
ref
) {
const latestInitialState = useLatest(initialState);
const [initialState$] = useState(() => new BehaviorSubject(latestInitialState.current));
const [initialStateRefresh$] = useState(() => new Subject<Partial<TState> | undefined>());
const onInitialStateChange = useStableFunction((newInitialState: Partial<TState>) => {
initialState$.next(newInitialState);
currentOnInitialStateChange?.(newInitialState);
});
useImperativeHandle(
ref,
() => ({
refreshInitialState: () => {
initialState$.next(latestInitialState.current);
initialStateRefresh$.next(initialState$.getValue());
},
}),
[initialState$, initialStateRefresh$, latestInitialState]
);
const value = useMemo<RestorableStateContext<TState>>(
() => ({
initialState$,
initialStateRefresh$,
onInitialStateChange,
}),
[initialState$, initialStateRefresh$, onInitialStateChange]
);
return <context.Provider value={value}>{children}</context.Provider>;
});
const withRestorableState = <TProps extends object>(Component: React.ComponentType<TProps>) =>
forwardRef<RestorableStateProviderApi, TProps & RestorableStateProviderProps<TState>>(
function RestorableStateProviderHOC({ initialState, onInitialStateChange, ...props }, ref) {
return (
<RestorableStateProvider
ref={ref}
initialState={initialState}
onInitialStateChange={onInitialStateChange}
>
<Component {...(props as TProps)} />
</RestorableStateProvider>
);
}
);
const getInitialValue = <TKey extends keyof TState>(
initialState: Partial<TState> | undefined,
key: TKey,
initialValue: InitialValue<TState, TKey>,
shouldIgnoredRestoredValue?: ShouldIgnoredRestoredValue<TState, TKey>
): TState[TKey] => {
if (
initialState &&
key in initialState &&
!shouldIgnoredRestoredValue?.(initialState[key] as TState[TKey])
) {
return initialState[key] as TState[TKey];
}
if (typeof initialValue === 'function') {
return (initialValue as () => TState[TKey])();
}
return initialValue;
};
const useInitialStateRefresh = <TKey extends keyof TState>(
key: TKey,
initialValue: InitialValue<TState, TKey>,
refreshValue: Dispatch<TState[TKey]>,
shouldIgnoredRestoredValue?: ShouldIgnoredRestoredValue<TState, TKey>
) => {
const { initialStateRefresh$ } = useContext(context);
const latestInitialValue = useLatest(initialValue);
const latestShouldIgnoredRestoredValue = useLatest(shouldIgnoredRestoredValue);
const stableRefreshValue = useStableFunction(refreshValue);
useEffect(() => {
const subscription = initialStateRefresh$
.pipe(
map((initialState) =>
getInitialValue(
initialState,
key,
latestInitialValue.current,
latestShouldIgnoredRestoredValue.current
)
)
)
.subscribe(stableRefreshValue);
return () => {
subscription.unsubscribe();
};
}, [
initialStateRefresh$,
key,
latestShouldIgnoredRestoredValue,
latestInitialValue,
stableRefreshValue,
]);
};
const useRestorableState = <TKey extends keyof TState>(
key: TKey,
initialValue: InitialValue<TState, TKey>,
shouldIgnoredRestoredValue?: ShouldIgnoredRestoredValue<TState, TKey>
) => {
const { initialState$, onInitialStateChange } = useContext(context);
const [value, _setValue] = useState(() =>
getInitialValue(initialState$.getValue(), key, initialValue, shouldIgnoredRestoredValue)
);
const setValue = useStableFunction<Dispatch<SetStateAction<TState[TKey]>>>((newValue) => {
_setValue((prevValue) => {
const nextValue =
typeof newValue === 'function'
? (newValue as (prevValue: TState[TKey]) => TState[TKey])(prevValue)
: newValue;
// TODO: another approach to consider is to call `onInitialStateChange` only on unmount and not on every state change
onInitialStateChange?.({ ...initialState$.getValue(), [key]: nextValue });
return nextValue;
});
});
useInitialStateRefresh(key, initialValue, _setValue, shouldIgnoredRestoredValue);
return [value, setValue] as const;
};
const useRestorableRef = <TKey extends keyof TState>(key: TKey, initialValue: TState[TKey]) => {
const { initialState$, onInitialStateChange } = useContext(context);
const initialState = initialState$.getValue();
const valueRef = useRef(getInitialValue(initialState, key, initialValue));
useUnmount(() => {
onInitialStateChange?.({ ...initialState$.getValue(), [key]: valueRef.current });
});
useInitialStateRefresh(key, initialValue, (newValue) => {
valueRef.current = newValue;
});
return valueRef;
};
return { withRestorableState, useRestorableState, useRestorableRef };
};
const useStableFunction = <T extends (...args: Parameters<T>) => ReturnType<T>>(fn: T) => {
const lastestFn = useLatest(fn);
const [stableFn] = useState(() => (...args: Parameters<T>) => {
return lastestFn.current(...args);
});
return stableFn;
};

View file

@ -0,0 +1,9 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": []
}

View file

@ -40,3 +40,5 @@ export {
export { getDataGridDensity } from './src/hooks/use_data_grid_density';
export { getRowHeight } from './src/hooks/use_row_height';
export type { UnifiedDataTableRestorableState } from './src/restorable_state';

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import { FormattedMessage } from '@kbn/i18n-react';
import './data_table.scss';
@ -96,6 +96,7 @@ import {
type ColorIndicatorControlColumnParams,
} from './custom_control_columns';
import { useSorting } from '../hooks/use_sorting';
import { useRestorableState, withRestorableState } from '../restorable_state';
const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS];
const VIRTUALIZATION_OPTIONS: EuiDataGridProps['virtualizationOptions'] = {
@ -115,7 +116,7 @@ export enum DataLoadingState {
/**
* Unified Data Table props
*/
export interface UnifiedDataTableProps {
interface InternalUnifiedDataTableProps {
/**
* Determines which element labels the grid for ARIA
*/
@ -443,7 +444,7 @@ export interface UnifiedDataTableProps {
export const EuiDataGridMemoized = React.memo(EuiDataGrid);
export const UnifiedDataTable = ({
const InternalUnifiedDataTable = ({
ariaLabelledBy,
columns,
columnsMeta,
@ -515,12 +516,12 @@ export const UnifiedDataTable = ({
dataGridDensityState,
onUpdateDataGridDensity,
onUpdatePageIndex,
}: UnifiedDataTableProps) => {
}: InternalUnifiedDataTableProps) => {
const { fieldFormats, toastNotifications, dataViewFieldEditor, uiSettings, storage, data } =
services;
const dataGridRef = useRef<EuiDataGridRefProps>(null);
const [isFilterActive, setIsFilterActive] = useState(false);
const [isCompareActive, setIsCompareActive] = useState(false);
const [isCompareActive, setIsCompareActive] = useRestorableState('isCompareActive', false);
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const displayedColumns = getDisplayedColumns(columns, dataView);
const defaultColumns = displayedColumns.includes('_source');
@ -1028,21 +1029,21 @@ export const UnifiedDataTable = ({
return leftControls;
}, [
selectedDocsCount,
selectedDocsState,
externalAdditionalControls,
selectedDocsCount,
inTableSearchControl,
isPlainRecord,
isFilterActive,
setIsFilterActive,
enableComparisonMode,
rows,
selectedDocsState,
enableComparisonMode,
setIsCompareActive,
fieldFormats,
unifiedDataTableContextValue.pageIndex,
unifiedDataTableContextValue.pageSize,
toastNotifications,
visibleColumns,
renderCustomToolbar,
inTableSearchControl,
]);
const renderCustomToolbarFn: EuiDataGridProps['renderCustomToolbar'] | undefined = useMemo(
@ -1346,3 +1347,7 @@ export const UnifiedDataTable = ({
</UnifiedDataTableContext.Provider>
);
};
export const UnifiedDataTable = withRestorableState(InternalUnifiedDataTable);
export type UnifiedDataTableProps = ComponentProps<typeof UnifiedDataTable>;

View file

@ -7,8 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import type { DataTableRecord } from '@kbn/discover-utils';
import { useRestorableState, UnifiedDataTableRestorableState } from '../restorable_state';
export interface UseSelectedDocsState {
isDocSelected: (docId: string) => boolean;
@ -29,53 +30,68 @@ export interface UseSelectedDocsState {
export const useSelectedDocs = (
docMap: Map<string, { doc: DataTableRecord; docIndex: number }>
): UseSelectedDocsState => {
const [selectedDocsSet, setSelectedDocsSet] = useState<Set<string>>(new Set());
const [selectedDocsMap, setSelectedDocsMap] = useRestorableState('selectedDocsMap', {});
const lastCheckboxToggledDocId = useRef<string | undefined>();
const toggleDocSelection = useCallback((docId: string) => {
setSelectedDocsSet((prevSelectedRowsSet) => {
const newSelectedRowsSet = new Set(prevSelectedRowsSet);
if (newSelectedRowsSet.has(docId)) {
newSelectedRowsSet.delete(docId);
} else {
newSelectedRowsSet.add(docId);
}
return newSelectedRowsSet;
});
lastCheckboxToggledDocId.current = docId;
}, []);
const toggleDocSelection = useCallback(
(docId: string) => {
setSelectedDocsMap((prevSelectedRowsSet) => {
const newSelectedRowsSet = { ...prevSelectedRowsSet };
if (newSelectedRowsSet[docId]) {
delete newSelectedRowsSet[docId];
} else {
newSelectedRowsSet[docId] = true;
}
return newSelectedRowsSet;
});
lastCheckboxToggledDocId.current = docId;
},
[setSelectedDocsMap]
);
const replaceSelectedDocs = useCallback((docIds: string[]) => {
setSelectedDocsSet(new Set(docIds));
}, []);
const replaceSelectedDocs = useCallback(
(docIds: string[]) => {
setSelectedDocsMap(createSelectedDocsMapFromIds(docIds));
},
[setSelectedDocsMap]
);
const selectAllDocs = useCallback(() => {
setSelectedDocsSet(new Set(docMap.keys()));
}, [docMap]);
setSelectedDocsMap(createSelectedDocsMapFromIds([...docMap.keys()]));
}, [docMap, setSelectedDocsMap]);
const selectMoreDocs = useCallback((docIds: string[]) => {
setSelectedDocsSet((prevSelectedRowsSet) => new Set([...prevSelectedRowsSet, ...docIds]));
}, []);
const selectMoreDocs = useCallback(
(docIds: string[]) => {
setSelectedDocsMap((prevSelectedRowsSet) =>
createSelectedDocsMapFromIds([...getIdsFromSelectedDocsMap(prevSelectedRowsSet), ...docIds])
);
},
[setSelectedDocsMap]
);
const deselectSomeDocs = useCallback((docIds: string[]) => {
setSelectedDocsSet(
(prevSelectedRowsSet) =>
new Set([...prevSelectedRowsSet].filter((docId) => !docIds.includes(docId)))
);
}, []);
const deselectSomeDocs = useCallback(
(docIds: string[]) => {
setSelectedDocsMap((prevSelectedRowsSet) =>
createSelectedDocsMapFromIds(
getIdsFromSelectedDocsMap(prevSelectedRowsSet).filter((docId) => !docIds.includes(docId))
)
);
},
[setSelectedDocsMap]
);
const clearAllSelectedDocs = useCallback(() => {
setSelectedDocsSet(new Set());
}, []);
setSelectedDocsMap({});
}, [setSelectedDocsMap]);
const selectedDocIds = useMemo(
() => Array.from(selectedDocsSet).filter((docId) => docMap.has(docId)),
[selectedDocsSet, docMap]
() => getIdsFromSelectedDocsMap(selectedDocsMap).filter((docId) => docMap.has(docId)),
[selectedDocsMap, docMap]
);
const isDocSelected = useCallback(
(docId: string) => selectedDocsSet.has(docId) && docMap.has(docId),
[selectedDocsSet, docMap]
(docId: string) => Boolean(selectedDocsMap[docId]) && docMap.has(docId),
[selectedDocsMap, docMap]
);
const toggleMultipleDocsSelection = useCallback(
@ -166,3 +182,18 @@ export const useSelectedDocs = (
]
);
};
function createSelectedDocsMapFromIds(
docIds: string[]
): UnifiedDataTableRestorableState['selectedDocsMap'] {
return docIds.reduce((acc, docId) => {
acc[docId] = true;
return acc;
}, {} as UnifiedDataTableRestorableState['selectedDocsMap']);
}
function getIdsFromSelectedDocsMap(
selectedDocsMap: UnifiedDataTableRestorableState['selectedDocsMap']
): string[] {
return Object.keys(selectedDocsMap);
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { createRestorableStateProvider } from '@kbn/restorable-state';
type SelectedDocId = string;
export interface UnifiedDataTableRestorableState {
selectedDocsMap: Record<SelectedDocId, boolean>;
isCompareActive: boolean;
}
export const { withRestorableState, useRestorableState } =
createRestorableStateProvider<UnifiedDataTableRestorableState>();

View file

@ -46,5 +46,6 @@
"@kbn/data-grid-in-table-search",
"@kbn/data-view-utils",
"@kbn/react-hooks",
"@kbn/restorable-state",
]
}

View file

@ -60,6 +60,7 @@ export type {
AdditionalFieldGroups,
} from './src/types';
export { ExistenceFetchStatus, FieldsGroupNames } from './src/types';
export type { UnifiedFieldListRestorableState } from './src/restorable_state';
export {
useExistingFieldsFetcher,

View file

@ -66,4 +66,26 @@ describe('UnifiedFieldList <FieldNameSearch />', () => {
await user.click(button);
expect(screen.getByRole('searchbox')).toHaveValue('that');
});
it('should be able to clear the initial value', async () => {
const FieldNameSearchWithWrapper = ({ defaultNameFilter = '' }) => {
const [nameFilter, setNameFilter] = useState(defaultNameFilter);
const props: FieldNameSearchProps = {
nameFilter,
onChange: setNameFilter,
screenReaderDescriptionId: 'htmlId',
'data-test-subj': 'searchInput',
};
return (
<div>
<FieldNameSearch {...props} />
</div>
);
};
render(<FieldNameSearchWithWrapper defaultNameFilter="initial" />);
expect(screen.getByRole('searchbox')).toHaveValue('initial');
const button = screen.getByTestId('clearSearchButton');
await user.click(button);
expect(screen.getByRole('searchbox')).toHaveValue('');
});
});

View file

@ -51,10 +51,13 @@ export const FieldNameSearch: React.FC<FieldNameSearchProps> = ({
description: 'Search the list of fields in the data view for the provided text',
});
const { inputValue, handleInputChange } = useDebouncedValue({
onChange,
value: nameFilter,
});
const { inputValue, handleInputChange } = useDebouncedValue(
{
onChange,
value: nameFilter,
},
{ allowFalsyValue: true }
);
return (
<EuiFieldSearch
@ -63,7 +66,7 @@ export const FieldNameSearch: React.FC<FieldNameSearchProps> = ({
data-test-subj={`${dataTestSubject}FieldSearch`}
fullWidth
onChange={(e) => {
handleInputChange(e.target.value);
handleInputChange(e.currentTarget.value);
}}
onFocus={onFocus}
onBlur={onBlur}

View file

@ -13,6 +13,7 @@ import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/
import { EuiText, EuiLoadingSpinner, EuiThemeProvider } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { I18nProvider } from '@kbn/i18n-react';
import { ReactWrapper } from 'enzyme';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { coreMock } from '@kbn/core/public/mocks';
@ -26,6 +27,8 @@ import { screen, within } from '@testing-library/react';
import { render } from '@elastic/eui/lib/test/rtl';
import userEvent from '@testing-library/user-event';
const DESCRIPTION_ID = 'fieldListGrouped__ariaDescription';
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
@ -79,7 +82,7 @@ describe('UnifiedFieldList FieldListGrouped + useGroupedFields()', () => {
hookParams: Omit<GroupedFieldsParams<DataViewField>, 'services'>;
}
function mountWithRTL({ listProps, hookParams }: WrapperProps) {
async function mountWithRTL({ listProps, hookParams }: WrapperProps) {
const Wrapper: React.FC<WrapperProps> = (props) => {
const {
fieldListFiltersProps,
@ -97,7 +100,13 @@ describe('UnifiedFieldList FieldListGrouped + useGroupedFields()', () => {
);
};
render(<Wrapper hookParams={hookParams} listProps={listProps} />);
await act(async () => {
render(
<I18nProvider>
<Wrapper hookParams={hookParams} listProps={listProps} />
</I18nProvider>
);
});
}
const mountComponent = async (component: React.ReactElement) =>
@ -350,7 +359,8 @@ describe('UnifiedFieldList FieldListGrouped + useGroupedFields()', () => {
dataViewId: dataView.id!,
allFields: manyFields,
};
const wrapper = await mountGroupedList({
await mountWithRTL({
listProps: {
...defaultProps,
fieldsExistenceStatus: ExistenceFetchStatus.succeeded,
@ -358,52 +368,27 @@ describe('UnifiedFieldList FieldListGrouped + useGroupedFields()', () => {
hookParams,
});
expect(wrapper.find(`#${defaultProps.screenReaderDescriptionId}`).first().text()).toBe(
expect(screen.getByTestId(DESCRIPTION_ID)).toHaveTextContent(
'25 available fields. 112 unmapped fields. 3 meta fields.'
);
await act(async () => {
await wrapper
.find('[data-test-subj="fieldListFiltersFieldSearch"]')
.last()
.simulate('change', {
target: { value: '@' },
});
await wrapper.update();
});
await userEvent.type(screen.getByTestId('fieldListFiltersFieldSearch'), '@');
expect(wrapper.find(`#${defaultProps.screenReaderDescriptionId}`).first().text()).toBe(
expect(screen.getByTestId(DESCRIPTION_ID)).toHaveTextContent(
'2 available fields. 8 unmapped fields. 0 meta fields.'
);
await act(async () => {
await wrapper
.find('[data-test-subj="fieldListFiltersFieldSearch"]')
.last()
.simulate('change', {
target: { value: '_' },
});
await wrapper.update();
});
await userEvent.clear(screen.getByTestId('fieldListFiltersFieldSearch'));
await userEvent.type(screen.getByTestId('fieldListFiltersFieldSearch'), '_');
expect(wrapper.find(`#${defaultProps.screenReaderDescriptionId}`).first().text()).toBe(
expect(screen.getByTestId(DESCRIPTION_ID)).toHaveTextContent(
'3 available fields. 24 unmapped fields. 3 meta fields.'
);
await act(async () => {
await wrapper
.find('[data-test-subj="fieldListFiltersFieldTypeFilterToggle"]')
.last()
.simulate('click');
await wrapper.update();
});
await userEvent.click(screen.getByTestId('fieldListFiltersFieldTypeFilterToggle'));
await userEvent.click(screen.getByTestId('typeFilter-date'));
await act(async () => {
await wrapper.find('button[data-test-subj="typeFilter-date"]').first().simulate('click');
await wrapper.update();
});
expect(wrapper.find(`#${defaultProps.screenReaderDescriptionId}`).first().text()).toBe(
expect(screen.getByTestId(DESCRIPTION_ID)).toHaveTextContent(
'1 available field. 4 unmapped fields. 0 meta fields.'
);
}, 10000);
@ -495,7 +480,7 @@ describe('UnifiedFieldList FieldListGrouped + useGroupedFields()', () => {
describe('Skip Link Functionality', () => {
it('renders the skip link when there is a next section', async () => {
mountWithRTL({
await mountWithRTL({
listProps: {
...defaultProps,
fieldsExistenceStatus: ExistenceFetchStatus.succeeded,
@ -517,7 +502,7 @@ describe('UnifiedFieldList FieldListGrouped + useGroupedFields()', () => {
});
it('does not render a skip link in the last section', async () => {
mountWithRTL({
await mountWithRTL({
listProps: {
...defaultProps,
fieldsExistenceStatus: ExistenceFetchStatus.succeeded,
@ -542,7 +527,7 @@ describe('UnifiedFieldList FieldListGrouped + useGroupedFields()', () => {
it('sets focus on the next section when skip link is clicked', async () => {
const user = userEvent.setup();
mountWithRTL({
await mountWithRTL({
listProps: {
...defaultProps,
fieldsExistenceStatus: ExistenceFetchStatus.succeeded,

View file

@ -8,7 +8,7 @@
*/
import { partition, throttle } from 'lodash';
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { i18n } from '@kbn/i18n';
import { EuiScreenReaderOnly, EuiSpacer, EuiSkipLink, useGeneratedHtmlId } from '@elastic/eui';
@ -18,11 +18,16 @@ import { FieldsAccordion, type FieldsAccordionProps, getFieldKey } from './field
import type { FieldListGroups, FieldListItem } from '../../types';
import { ExistenceFetchStatus, FieldsGroup, FieldsGroupNames } from '../../types';
import './field_list_grouped.scss';
import {
useRestorableState,
useRestorableRef,
type UnifiedFieldListRestorableState,
} from '../../restorable_state';
const PAGINATION_SIZE = 50;
export const LOCAL_STORAGE_KEY_SECTIONS = 'unifiedFieldList.initiallyOpenSections';
type InitiallyOpenSections = Record<string, boolean>;
type InitiallyOpenSections = UnifiedFieldListRestorableState['accordionState'];
function getDisplayedFieldsLength<T extends FieldListItem>(
fieldGroups: FieldListGroups<T>,
@ -65,40 +70,64 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
Object.entries(fieldGroups),
([, { showInAccordion }]) => showInAccordion
);
const [pageSize, setPageSize] = useState(PAGINATION_SIZE);
const isInitializedRef = useRef<boolean>(false);
const [pageSize, setPageSize] = useRestorableState('pageSize', PAGINATION_SIZE);
const scrollPositionRef = useRestorableRef('scrollPosition', 0);
const [scrollContainer, setScrollContainer] = useState<Element | undefined>(undefined);
const [storedInitiallyOpenSections, storeInitiallyOpenSections] =
useLocalStorage<InitiallyOpenSections>(
`${localStorageKeyPrefix ? localStorageKeyPrefix + '.' : ''}${LOCAL_STORAGE_KEY_SECTIONS}`,
{}
);
const [accordionState, setAccordionState] = useState<InitiallyOpenSections>(() =>
Object.fromEntries(
fieldGroupsToShow.map(([key, { isInitiallyOpen }]) => {
const storedInitiallyOpen = localStorageKeyPrefix
? storedInitiallyOpenSections?.[key]
: null; // from localStorage
return [
key,
typeof storedInitiallyOpen === 'boolean' ? storedInitiallyOpen : isInitiallyOpen,
];
})
)
const [accordionState, setAccordionState] = useRestorableState(
'accordionState',
() =>
Object.fromEntries(
fieldGroupsToShow.map(([key, { isInitiallyOpen }]) => {
const storedInitiallyOpen = localStorageKeyPrefix
? storedInitiallyOpenSections?.[key]
: null; // from localStorage
return [
key,
typeof storedInitiallyOpen === 'boolean' ? storedInitiallyOpen : isInitiallyOpen,
];
})
),
(restoredAccordionState) => {
return (
fieldGroupsToShow.length !== Object.keys(restoredAccordionState).length ||
fieldGroupsToShow.some(([key]) => !(key in restoredAccordionState))
);
}
);
useEffect(() => {
// Reset the scroll if we have made material changes to the field list
if (scrollContainer && scrollToTopResetCounter) {
if (!isInitializedRef.current) {
isInitializedRef.current = true;
// If this is the first time after the mount, no need to reset the scroll position
return;
}
scrollContainer.scrollTop = 0;
scrollPositionRef.current = 0;
setPageSize(PAGINATION_SIZE);
}
}, [scrollToTopResetCounter, scrollContainer]);
}, [scrollToTopResetCounter, scrollContainer, setPageSize, scrollPositionRef]);
const lazyScroll = useCallback(() => {
if (scrollContainer) {
if (scrollContainer.scrollTop === scrollPositionRef.current) {
// scroll top was restored to the last position
return;
}
const nearBottom =
scrollContainer.scrollTop + scrollContainer.clientHeight >
scrollContainer.scrollHeight * 0.9;
scrollPositionRef.current = scrollContainer.scrollTop;
if (nearBottom) {
setPageSize(
Math.max(
@ -111,7 +140,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
);
}
}
}, [scrollContainer, pageSize, setPageSize, fieldGroups, accordionState]);
}, [scrollContainer, scrollPositionRef, setPageSize, pageSize, fieldGroups, accordionState]);
const paginatedFields = useMemo(() => {
let remainingItems = pageSize;
@ -134,6 +163,16 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
className="unifiedFieldList__fieldListGrouped"
data-test-subj={`${dataTestSubject}FieldGroups`}
ref={(el) => {
if (el && !el.scrollTop && scrollPositionRef.current) {
// restore scroll position after restoring the initial ref value
setTimeout(() => {
el.scrollTo?.({
top: scrollPositionRef.current,
behavior: 'instant',
});
}, 0);
}
if (el && !el.dataset.dynamicScroll) {
el.dataset.dynamicScroll = 'true';
setScrollContainer(el);

View file

@ -16,6 +16,7 @@ import React, {
useRef,
useMemo,
useEffect,
type ComponentProps,
} from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
@ -50,6 +51,52 @@ import type {
UnifiedFieldListSidebarContainerStateService,
SearchMode,
} from '../../types';
import { withRestorableState } from '../../restorable_state';
const RESPONSIVE_BREAKPOINTS = ['xs', 's'];
interface InternalUnifiedFieldListSidebarContainerProps {
stateService: UnifiedFieldListSidebarContainerStateService;
isFieldListFlyoutVisible: boolean;
setIsFieldListFlyoutVisible: (isVisible: boolean) => void;
commonSidebarProps: UnifiedFieldListSidebarProps;
prependInFlyout?: () => UnifiedFieldListSidebarProps['prepend'];
variant: 'responsive' | 'button-and-flyout-always' | 'list-always';
workspaceSelectedFieldNames?: UnifiedFieldListSidebarCustomizableProps['workspaceSelectedFieldNames'];
}
const InternalUnifiedFieldListSidebarContainer: React.FC<
InternalUnifiedFieldListSidebarContainerProps
> = (props) => {
const { variant, commonSidebarProps } = props;
if (variant === 'button-and-flyout-always') {
return <ButtonVariant {...props} />;
}
if (variant === 'list-always') {
return <ListVariant commonSidebarProps={commonSidebarProps} />;
}
return (
<>
<EuiHideFor sizes={RESPONSIVE_BREAKPOINTS}>
<ListVariant commonSidebarProps={commonSidebarProps} />
</EuiHideFor>
<EuiShowFor sizes={RESPONSIVE_BREAKPOINTS}>
<ButtonVariant {...props} />
</EuiShowFor>
</>
);
};
const UnifiedFieldListSidebarContainerWithRestorableState = withRestorableState(
memo(InternalUnifiedFieldListSidebarContainer)
);
type UnifiedFieldListSidebarContainerPropsWithRestorableState = ComponentProps<
typeof UnifiedFieldListSidebarContainerWithRestorableState
>;
export interface UnifiedFieldListSidebarContainerApi {
sidebarVisibility: SidebarVisibility;
@ -80,12 +127,12 @@ export type UnifiedFieldListSidebarContainerProps = Omit<
/**
* Custom content to render at the top of field list in the flyout (for example a data view picker)
*/
prependInFlyout?: () => UnifiedFieldListSidebarProps['prepend'];
prependInFlyout?: InternalUnifiedFieldListSidebarContainerProps['prependInFlyout'];
/**
* Customization for responsive behaviour. Default: `responsive`.
*/
variant?: 'responsive' | 'button-and-flyout-always' | 'list-always';
variant?: InternalUnifiedFieldListSidebarContainerProps['variant'];
/**
* Custom logic for determining which field is selected. Otherwise, use `workspaceSelectedFieldNames` prop.
@ -99,6 +146,9 @@ export type UnifiedFieldListSidebarContainerProps = Omit<
removedFieldName?: string;
editedFieldName?: string;
}) => Promise<void>;
initialState?: UnifiedFieldListSidebarContainerPropsWithRestorableState['initialState'];
onInitialStateChange?: UnifiedFieldListSidebarContainerPropsWithRestorableState['onInitialStateChange'];
};
/**
@ -106,274 +156,293 @@ export type UnifiedFieldListSidebarContainerProps = Omit<
* Desktop: Sidebar view, all elements are visible
* Mobile: A button to trigger a flyout with all elements
*/
const UnifiedFieldListSidebarContainer = memo(
forwardRef<UnifiedFieldListSidebarContainerApi, UnifiedFieldListSidebarContainerProps>(
function UnifiedFieldListSidebarContainer(props, componentRef) {
const {
getCreationOptions,
services,
dataView,
workspaceSelectedFieldNames,
prependInFlyout,
variant = 'responsive',
onFieldEdited,
additionalFilters,
} = props;
const [stateService] = useState<UnifiedFieldListSidebarContainerStateService>(
createStateService({ options: getCreationOptions() })
);
const { data, dataViewFieldEditor } = services;
const [isFieldListFlyoutVisible, setIsFieldListFlyoutVisible] = useState<boolean>(false);
const [sidebarVisibility] = useState(() =>
getSidebarVisibility({
localStorageKey: stateService.creationOptions.localStorageKeyPrefix
? `${stateService.creationOptions.localStorageKeyPrefix}:sidebarClosed`
: undefined,
})
);
const isSidebarCollapsed = useObservable(sidebarVisibility.isCollapsed$, false);
const UnifiedFieldListSidebarContainer = forwardRef<
UnifiedFieldListSidebarContainerApi,
UnifiedFieldListSidebarContainerProps
>(function UnifiedFieldListSidebarContainer(
{ initialState, onInitialStateChange, ...props },
componentRef
) {
const {
getCreationOptions,
services,
dataView,
workspaceSelectedFieldNames,
prependInFlyout,
variant = 'responsive',
onFieldEdited,
additionalFilters,
} = props;
const [stateService] = useState<UnifiedFieldListSidebarContainerStateService>(
createStateService({ options: getCreationOptions() })
);
const { data, dataViewFieldEditor } = services;
const [isFieldListFlyoutVisible, setIsFieldListFlyoutVisible] = useState<boolean>(false);
const [sidebarVisibility] = useState(() =>
getSidebarVisibility({
localStorageKey: stateService.creationOptions.localStorageKeyPrefix
? `${stateService.creationOptions.localStorageKeyPrefix}:sidebarClosed`
: undefined,
})
);
const isSidebarCollapsed = useObservable(sidebarVisibility.isCollapsed$, false);
const canEditDataView =
Boolean(dataViewFieldEditor?.userPermissions.editIndexPattern()) ||
Boolean(dataView && !dataView.isPersisted());
const closeFieldEditor = useRef<() => void | undefined>();
const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
closeFieldEditor.current = ref;
}, []);
const canEditDataView =
Boolean(dataViewFieldEditor?.userPermissions.editIndexPattern()) ||
Boolean(dataView && !dataView.isPersisted());
const closeFieldEditor = useRef<() => void | undefined>();
const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
closeFieldEditor.current = ref;
}, []);
const closeFieldListFlyout = useCallback(() => {
setIsFieldListFlyoutVisible(false);
}, []);
const closeFieldListFlyout = useCallback(() => {
setIsFieldListFlyoutVisible(false);
}, []);
const querySubscriberResult = useQuerySubscriber({
data,
timeRangeUpdatesType: stateService.creationOptions.timeRangeUpdatesType,
});
const searchMode: SearchMode | undefined = querySubscriberResult.searchMode;
const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
const querySubscriberResult = useQuerySubscriber({
data,
timeRangeUpdatesType: stateService.creationOptions.timeRangeUpdatesType,
});
const searchMode: SearchMode | undefined = querySubscriberResult.searchMode;
const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
const filters = useMemo(
() => [...(querySubscriberResult.filters ?? []), ...(additionalFilters ?? [])],
[querySubscriberResult.filters, additionalFilters]
);
const filters = useMemo(
() => [...(querySubscriberResult.filters ?? []), ...(additionalFilters ?? [])],
[querySubscriberResult.filters, additionalFilters]
);
const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
disableAutoFetching: stateService.creationOptions.disableFieldsExistenceAutoFetching,
dataViews: searchMode === 'documents' && dataView ? [dataView] : [],
query: querySubscriberResult.query,
filters,
fromDate: querySubscriberResult.fromDate,
toDate: querySubscriberResult.toDate,
services,
});
const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
disableAutoFetching: stateService.creationOptions.disableFieldsExistenceAutoFetching,
dataViews: searchMode === 'documents' && dataView ? [dataView] : [],
query: querySubscriberResult.query,
filters,
fromDate: querySubscriberResult.fromDate,
toDate: querySubscriberResult.toDate,
services,
});
const editField = useMemo(
() =>
dataView && dataViewFieldEditor && searchMode === 'documents' && canEditDataView
? async (fieldName?: string) => {
const ref = await dataViewFieldEditor.openEditor({
ctx: {
dataView,
},
fieldName,
onSave: async () => {
if (onFieldEdited) {
await onFieldEdited({ editedFieldName: fieldName });
}
},
});
setFieldEditorRef(ref);
closeFieldListFlyout();
}
: undefined,
[
searchMode,
canEditDataView,
dataViewFieldEditor,
dataView,
setFieldEditorRef,
closeFieldListFlyout,
onFieldEdited,
]
);
const deleteField = useMemo(
() =>
dataView && dataViewFieldEditor && editField
? async (fieldName: string) => {
const ref = await dataViewFieldEditor.openDeleteModal({
ctx: {
dataView,
},
fieldName,
onDelete: async () => {
if (onFieldEdited) {
await onFieldEdited({ removedFieldName: fieldName });
}
},
});
setFieldEditorRef(ref);
closeFieldListFlyout();
}
: undefined,
[
dataView,
setFieldEditorRef,
editField,
closeFieldListFlyout,
dataViewFieldEditor,
onFieldEdited,
]
);
useEffect(() => {
const cleanup = () => {
if (closeFieldEditor?.current) {
closeFieldEditor?.current();
const editField = useMemo(
() =>
dataView && dataViewFieldEditor && searchMode === 'documents' && canEditDataView
? async (fieldName?: string) => {
const ref = await dataViewFieldEditor.openEditor({
ctx: {
dataView,
},
fieldName,
onSave: async () => {
if (onFieldEdited) {
await onFieldEdited({ editedFieldName: fieldName });
}
},
});
setFieldEditorRef(ref);
closeFieldListFlyout();
}
};
return () => {
// Make sure to close the editor when unmounting
cleanup();
};
}, []);
: undefined,
[
searchMode,
canEditDataView,
dataViewFieldEditor,
dataView,
setFieldEditorRef,
closeFieldListFlyout,
onFieldEdited,
]
);
useImperativeHandle(
componentRef,
() => ({
sidebarVisibility,
refetchFieldsExistenceInfo,
closeFieldListFlyout,
createField: editField,
editField,
deleteField,
}),
[
sidebarVisibility,
refetchFieldsExistenceInfo,
closeFieldListFlyout,
editField,
deleteField,
]
);
const deleteField = useMemo(
() =>
dataView && dataViewFieldEditor && editField
? async (fieldName: string) => {
const ref = await dataViewFieldEditor.openDeleteModal({
ctx: {
dataView,
},
fieldName,
onDelete: async () => {
if (onFieldEdited) {
await onFieldEdited({ removedFieldName: fieldName });
}
},
});
setFieldEditorRef(ref);
closeFieldListFlyout();
}
: undefined,
[
dataView,
setFieldEditorRef,
editField,
closeFieldListFlyout,
dataViewFieldEditor,
onFieldEdited,
]
);
if (!dataView) {
return null;
useEffect(() => {
const cleanup = () => {
if (closeFieldEditor?.current) {
closeFieldEditor?.current();
}
};
return () => {
// Make sure to close the editor when unmounting
cleanup();
};
}, []);
const commonSidebarProps: UnifiedFieldListSidebarProps = {
...props,
searchMode,
stateService,
isProcessing,
isAffectedByGlobalFilter,
onEditField: editField,
onDeleteField: deleteField,
compressed: stateService.creationOptions.compressed ?? false,
buttonAddFieldVariant: stateService.creationOptions.buttonAddFieldVariant ?? 'primary',
};
useImperativeHandle(
componentRef,
() => ({
sidebarVisibility,
refetchFieldsExistenceInfo,
closeFieldListFlyout,
createField: editField,
editField,
deleteField,
}),
[sidebarVisibility, refetchFieldsExistenceInfo, closeFieldListFlyout, editField, deleteField]
);
if (stateService.creationOptions.showSidebarToggleButton) {
commonSidebarProps.isSidebarCollapsed = isSidebarCollapsed;
commonSidebarProps.onToggleSidebar = sidebarVisibility.toggle;
}
const commonSidebarProps: UnifiedFieldListSidebarProps = useMemo(() => {
const commonProps: UnifiedFieldListSidebarProps = {
...props,
searchMode,
stateService,
isProcessing,
isAffectedByGlobalFilter,
onEditField: editField,
onDeleteField: deleteField,
compressed: stateService.creationOptions.compressed ?? false,
buttonAddFieldVariant: stateService.creationOptions.buttonAddFieldVariant ?? 'primary',
};
const buttonPropsToTriggerFlyout = stateService.creationOptions.buttonPropsToTriggerFlyout;
const renderListVariant = () => {
return <UnifiedFieldListSidebar {...commonSidebarProps} />;
};
const renderButtonVariant = () => {
return (
<>
<div className="unifiedFieldListSidebar__mobile">
<EuiButton
{...buttonPropsToTriggerFlyout}
contentProps={{
...buttonPropsToTriggerFlyout?.contentProps,
className: 'unifiedFieldListSidebar__mobileButton',
}}
fullWidth
onClick={() => setIsFieldListFlyoutVisible(true)}
>
<FormattedMessage
id="unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel"
defaultMessage="Fields"
/>
<EuiBadge
className="unifiedFieldListSidebar__mobileBadge"
color={workspaceSelectedFieldNames?.[0] === '_source' ? 'default' : 'accent'}
>
{!workspaceSelectedFieldNames?.length ||
workspaceSelectedFieldNames[0] === '_source'
? 0
: workspaceSelectedFieldNames.length}
</EuiBadge>
</EuiButton>
</div>
{isFieldListFlyoutVisible && (
<EuiPortal>
<EuiFlyout
size="s"
onClose={() => setIsFieldListFlyoutVisible(false)}
aria-labelledby="flyoutTitle"
ownFocus
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">
<EuiLink color="text" onClick={() => setIsFieldListFlyoutVisible(false)}>
<EuiIcon
className="eui-alignBaseline"
aria-label={i18n.translate(
'unifiedFieldList.fieldListSidebar.flyoutBackIcon',
{
defaultMessage: 'Back',
}
)}
type="arrowLeft"
/>{' '}
<strong>
{i18n.translate('unifiedFieldList.fieldListSidebar.flyoutHeading', {
defaultMessage: 'Field list',
})}
</strong>
</EuiLink>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<UnifiedFieldListSidebar
{...commonSidebarProps}
alwaysShowActionButton={true}
buttonAddFieldVariant="primary" // always for the flyout
isSidebarCollapsed={undefined}
prepend={prependInFlyout?.()}
/>
</EuiFlyout>
</EuiPortal>
)}
</>
);
};
if (variant === 'button-and-flyout-always') {
return renderButtonVariant();
}
if (variant === 'list-always') {
return renderListVariant();
}
return (
<>
<EuiHideFor sizes={['xs', 's']}>{renderListVariant()}</EuiHideFor>
<EuiShowFor sizes={['xs', 's']}>{renderButtonVariant()}</EuiShowFor>
</>
);
if (stateService.creationOptions.showSidebarToggleButton) {
commonProps.isSidebarCollapsed = isSidebarCollapsed;
commonProps.onToggleSidebar = sidebarVisibility.toggle;
}
)
);
return commonProps;
}, [
deleteField,
editField,
isAffectedByGlobalFilter,
isProcessing,
isSidebarCollapsed,
props,
searchMode,
sidebarVisibility.toggle,
stateService,
]);
if (!dataView) {
return null;
}
return (
<UnifiedFieldListSidebarContainerWithRestorableState
stateService={stateService}
isFieldListFlyoutVisible={isFieldListFlyoutVisible}
setIsFieldListFlyoutVisible={setIsFieldListFlyoutVisible}
commonSidebarProps={commonSidebarProps}
prependInFlyout={prependInFlyout}
variant={variant}
workspaceSelectedFieldNames={workspaceSelectedFieldNames}
initialState={initialState}
onInitialStateChange={onInitialStateChange}
/>
);
});
function ListVariant({
commonSidebarProps,
}: {
commonSidebarProps: InternalUnifiedFieldListSidebarContainerProps['commonSidebarProps'];
}) {
return <UnifiedFieldListSidebar {...commonSidebarProps} />;
}
function ButtonVariant({
stateService,
isFieldListFlyoutVisible,
setIsFieldListFlyoutVisible,
commonSidebarProps,
prependInFlyout,
workspaceSelectedFieldNames,
}: InternalUnifiedFieldListSidebarContainerProps) {
const buttonPropsToTriggerFlyout = stateService.creationOptions.buttonPropsToTriggerFlyout;
return (
<>
<div className="unifiedFieldListSidebar__mobile">
<EuiButton
{...buttonPropsToTriggerFlyout}
contentProps={{
...buttonPropsToTriggerFlyout?.contentProps,
className: 'unifiedFieldListSidebar__mobileButton',
}}
fullWidth
onClick={() => setIsFieldListFlyoutVisible(true)}
>
<FormattedMessage
id="unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel"
defaultMessage="Fields"
/>
<EuiBadge
className="unifiedFieldListSidebar__mobileBadge"
color={workspaceSelectedFieldNames?.[0] === '_source' ? 'default' : 'accent'}
>
{!workspaceSelectedFieldNames?.length || workspaceSelectedFieldNames[0] === '_source'
? 0
: workspaceSelectedFieldNames.length}
</EuiBadge>
</EuiButton>
</div>
{isFieldListFlyoutVisible && (
<EuiPortal>
<EuiFlyout
size="s"
onClose={() => setIsFieldListFlyoutVisible(false)}
aria-labelledby="flyoutTitle"
ownFocus
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">
<EuiLink color="text" onClick={() => setIsFieldListFlyoutVisible(false)}>
<EuiIcon
className="eui-alignBaseline"
aria-label={i18n.translate(
'unifiedFieldList.fieldListSidebar.flyoutBackIcon',
{
defaultMessage: 'Back',
}
)}
type="arrowLeft"
/>{' '}
<strong>
{i18n.translate('unifiedFieldList.fieldListSidebar.flyoutHeading', {
defaultMessage: 'Field list',
})}
</strong>
</EuiLink>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<UnifiedFieldListSidebar
{...commonSidebarProps}
alwaysShowActionButton={true}
buttonAddFieldVariant="primary" // always for the flyout
isSidebarCollapsed={undefined}
prepend={prependInFlyout?.()}
/>
</EuiFlyout>
</EuiPortal>
)}
</>
);
}
// Necessary for React.lazy
// eslint-disable-next-line import/no-default-export

View file

@ -7,13 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { htmlIdGenerator } from '@elastic/eui';
import { type DataViewField } from '@kbn/data-views-plugin/common';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import { type FieldTypeKnown, getFieldIconType, fieldNameWildcardMatcher } from '@kbn/field-utils';
import { getFieldIconType, fieldNameWildcardMatcher } from '@kbn/field-utils';
import { type FieldListFiltersProps } from '../components/field_list_filters';
import { type FieldListItem, GetCustomFieldType } from '../types';
import { useRestorableState } from '../restorable_state';
const htmlId = htmlIdGenerator('fieldList');
@ -52,8 +53,8 @@ export function useFieldFilters<T extends FieldListItem = DataViewField>({
onSupportedFieldFilter,
services,
}: FieldFiltersParams<T>): FieldFiltersResult<T> {
const [selectedFieldTypes, setSelectedFieldTypes] = useState<FieldTypeKnown[]>([]);
const [nameFilter, setNameFilter] = useState<string>('');
const [selectedFieldTypes, setSelectedFieldTypes] = useRestorableState('selectedFieldTypes', []);
const [nameFilter, setNameFilter] = useRestorableState('nameFilter', '');
const screenReaderDescriptionId = useMemo(() => htmlId(), []);
const docLinks = services.core.docLinks;

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { createRestorableStateProvider } from '@kbn/restorable-state';
import type { FieldTypeKnown } from '@kbn/field-utils';
export interface UnifiedFieldListRestorableState {
/**
* Field search
*/
nameFilter: string;
/**
* Field type filters
*/
selectedFieldTypes: FieldTypeKnown[];
/**
* The number of actually rendered (visible) fields
*/
pageSize: number;
/**
* Scroll position of the field list
*/
scrollPosition: number;
/**
* Which sections of the field list are expanded
*/
accordionState: Record<string, boolean>;
}
export const { withRestorableState, useRestorableState, useRestorableRef } =
createRestorableStateProvider<UnifiedFieldListRestorableState>();

View file

@ -34,7 +34,8 @@
"@kbn/visualization-utils",
"@kbn/search-types",
"@kbn/fields-metadata-plugin",
"@kbn/esql-utils"
"@kbn/esql-utils",
"@kbn/restorable-state"
],
"exclude": ["target/**/*"]
}

View file

@ -26,6 +26,7 @@ import { SearchResponseWarningsCallout } from '@kbn/search-response-warnings';
import type {
DataGridDensity,
UnifiedDataTableProps,
UnifiedDataTableRestorableState,
UseColumnsProps,
} from '@kbn/unified-data-table';
import {
@ -77,6 +78,7 @@ import {
} from '../../../../context_awareness';
import {
internalStateActions,
useCurrentTabAction,
useCurrentTabSelector,
useInternalStateDispatch,
useInternalStateSelector,
@ -398,6 +400,14 @@ function DiscoverDocumentsComponent({
[viewModeToggle, callouts, loadingIndicator]
);
const dataGridUiState = useCurrentTabSelector((state) => state.uiState.dataGrid);
const setDataGridUiState = useCurrentTabAction(internalStateActions.setDataGridUiState);
const onInitialStateChange = useCallback(
(newDataGridUiState: Partial<UnifiedDataTableRestorableState>) =>
dispatch(setDataGridUiState({ dataGridUiState: newDataGridUiState })),
[dispatch, setDataGridUiState]
);
if (isDataViewLoading || (isEmptyDataResult && isDataLoading)) {
return (
// class is used in tests
@ -478,6 +488,8 @@ function DiscoverDocumentsComponent({
cellActionsTriggerId={DISCOVER_CELL_ACTIONS_TRIGGER.id}
cellActionsMetadata={cellActionsMetadata}
cellActionsHandling="append"
initialState={dataGridUiState}
onInitialStateChange={onInitialStateChange}
/>
</CellActionsProvider>
</div>

View file

@ -35,6 +35,8 @@ import type { DiscoverCustomizationId } from '../../../../customizations/customi
import type { FieldListCustomization, SearchBarCustomization } from '../../../../customizations';
import { DiscoverTestProvider } from '../../../../__mocks__/test_provider';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { UnifiedFieldListRestorableState } from '@kbn/unified-field-list';
import { internalStateActions } from '../../state_management/redux';
type TestWrapperProps = DiscoverSidebarResponsiveProps & { selectedDataView: DataView };
@ -180,12 +182,25 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): TestWrapperProps
};
}
function getStateContainer({ query }: { query?: Query | AggregateQuery }) {
function getStateContainer({
query,
fieldListUiState,
}: {
query?: Query | AggregateQuery;
fieldListUiState?: UnifiedFieldListRestorableState;
}) {
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.appState.set({
query: query ?? { query: '', language: 'lucene' },
filters: [],
});
if (fieldListUiState) {
stateContainer.internalState.dispatch(
stateContainer.injectCurrentTab(internalStateActions.setFieldListUiState)({
fieldListUiState,
})
);
}
return stateContainer;
}
@ -194,7 +209,10 @@ type MountReturn<WithRTL extends boolean> = WithRTL extends true ? undefined : E
async function mountComponent<WithReactTestingLibrary extends boolean = false>(
props: TestWrapperProps,
appStateParams: { query?: Query | AggregateQuery } = {},
appStateParams: {
query?: Query | AggregateQuery;
fieldListUiState?: UnifiedFieldListRestorableState;
} = {},
services?: DiscoverServices,
withReactTestingLibrary?: WithReactTestingLibrary
): Promise<MountReturn<WithReactTestingLibrary>> {
@ -505,9 +523,9 @@ describe('discover responsive sidebar', function () {
);
await act(async () => {
findTestSubject(comp, 'fieldListFiltersFieldSearch').simulate('change', {
target: { value: 'bytes' },
});
const input = findTestSubject(comp, 'fieldListFiltersFieldSearch').find('input');
input.getDOMNode().setAttribute('value', 'byte');
input.simulate('change');
});
expect(findTestSubject(comp, 'fieldListGroupedAvailableFields-count').text()).toBe('1');
@ -545,6 +563,24 @@ describe('discover responsive sidebar', function () {
expect(mockCalcFieldCounts.mock.calls.length).toBe(1);
}, 10000);
it('should restore sidebar state after switching tabs', async function () {
const comp = await mountComponent(props, {
fieldListUiState: {
nameFilter: 'byte',
selectedFieldTypes: ['number'],
pageSize: 10,
scrollPosition: 0,
accordionState: {},
},
});
expect(findTestSubject(comp, 'fieldListGroupedAvailableFields-count').text()).toBe('1');
expect(findTestSubject(comp, 'fieldListGrouped__ariaDescription').text()).toBe(
'1 popular field. 1 available field. 0 meta fields.'
);
expect(findTestSubject(comp, 'fieldListFiltersFieldSearch').prop('value')).toBe('byte');
});
it('should show "Add a field" button to create a runtime field', async () => {
const services = createMockServices();
const comp = await mountComponent(props, {}, services);

View file

@ -21,6 +21,7 @@ import {
UnifiedFieldListSidebarContainer,
type UnifiedFieldListSidebarContainerProps,
type UnifiedFieldListSidebarContainerApi,
type UnifiedFieldListRestorableState,
FieldsGroupNames,
} from '@kbn/unified-field-list';
import { calcFieldCounts } from '@kbn/discover-utils/src/utils/calc_field_counts';
@ -39,7 +40,13 @@ import {
import { useDiscoverCustomization } from '../../../../customizations';
import { useAdditionalFieldGroups } from '../../hooks/sidebar/use_additional_field_groups';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { useDataViewsForPicker } from '../../state_management/redux';
import {
internalStateActions,
useCurrentTabAction,
useCurrentTabSelector,
useDataViewsForPicker,
useInternalStateDispatch,
} from '../../state_management/redux';
const EMPTY_FIELD_COUNTS = {};
@ -382,6 +389,20 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
});
}, [isSidebarCollapsed, unifiedFieldListSidebarContainerApi, sidebarToggleState$]);
const dispatch = useInternalStateDispatch();
const fieldListUiState = useCurrentTabSelector((state) => state.uiState.fieldList);
const setFieldListUiState = useCurrentTabAction(internalStateActions.setFieldListUiState);
const onInitialStateChange = useCallback(
(newFieldListUiState: Partial<UnifiedFieldListRestorableState>) => {
dispatch(
setFieldListUiState({
fieldListUiState: newFieldListUiState,
})
);
},
[dispatch, setFieldListUiState]
);
return (
<EuiFlexGroup
gutterSize="none"
@ -412,6 +433,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
trackUiMetric={trackUiMetric}
variant={fieldListVariant}
workspaceSelectedFieldNames={columns}
initialState={fieldListUiState}
onInitialStateChange={onInitialStateChange}
/>
) : null}
</EuiFlexItem>

View file

@ -68,6 +68,7 @@ export const defaultTabState: Omit<TabState, keyof TabItem> = {
loadingStatus: LoadingStatus.Uninitialized,
result: {},
},
uiState: {},
};
const initialState: DiscoverInternalState = {
@ -219,6 +220,22 @@ export const internalStateSlice = createSlice({
tab.overriddenVisContextAfterInvalidation = undefined;
state.expandedDoc = undefined;
}),
setDataGridUiState: (
state,
action: TabAction<{ dataGridUiState: Partial<TabState['uiState']['dataGrid']> }>
) =>
withTab(state, action, (tab) => {
tab.uiState.dataGrid = action.payload.dataGridUiState;
}),
setFieldListUiState: (
state,
action: TabAction<{ fieldListUiState: Partial<TabState['uiState']['fieldList']> }>
) =>
withTab(state, action, (tab) => {
tab.uiState.fieldList = action.payload.fieldListUiState;
}),
},
extraReducers: (builder) => {
builder.addCase(loadDataViewList.fulfilled, (state, action) => {
@ -281,6 +298,9 @@ export const createInternalStateStore = (options: InternalStateDependencies) =>
thunk: { extraArgument: options },
serializableCheck: !IS_JEST_ENVIRONMENT,
}).prepend(createMiddleware(options).middleware),
devTools: {
name: 'DiscoverInternalState',
},
});
};

View file

@ -11,6 +11,8 @@ import type { RefreshInterval } from '@kbn/data-plugin/common';
import type { DataViewListItem } from '@kbn/data-views-plugin/public';
import type { DataTableRecord } from '@kbn/discover-utils';
import type { Filter, TimeRange } from '@kbn/es-query';
import type { UnifiedDataTableRestorableState } from '@kbn/unified-data-table';
import type { UnifiedFieldListRestorableState } from '@kbn/unified-field-list';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram';
import type { TabItem } from '@kbn/unified-tabs';
import type { DiscoverAppState } from '../discover_app_state_container';
@ -72,6 +74,10 @@ export interface TabState extends TabItem {
documentsRequest: DocumentsRequest;
totalHitsRequest: TotalHitsRequest;
chartRequest: ChartRequest;
uiState: {
dataGrid?: Partial<UnifiedDataTableRestorableState>;
fieldList?: Partial<UnifiedFieldListRestorableState>;
};
}
export interface RecentlyClosedTabState extends TabState {

View file

@ -1568,6 +1568,8 @@
"@kbn/response-ops-rules-apis/*": ["src/platform/packages/shared/response-ops/rules-apis/*"],
"@kbn/response-stream-plugin": ["examples/response_stream"],
"@kbn/response-stream-plugin/*": ["examples/response_stream/*"],
"@kbn/restorable-state": ["src/platform/packages/shared/kbn-restorable-state"],
"@kbn/restorable-state/*": ["src/platform/packages/shared/kbn-restorable-state/*"],
"@kbn/rison": ["src/platform/packages/shared/kbn-rison"],
"@kbn/rison/*": ["src/platform/packages/shared/kbn-rison/*"],
"@kbn/rollup": ["x-pack/platform/packages/private/rollup"],

View file

@ -6907,6 +6907,10 @@
version "0.0.0"
uid ""
"@kbn/restorable-state@link:src/platform/packages/shared/kbn-restorable-state":
version "0.0.0"
uid ""
"@kbn/rison@link:src/platform/packages/shared/kbn-rison":
version "0.0.0"
uid ""