mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:
parent
8b65a14fcc
commit
12590c9a8a
31 changed files with 1187 additions and 373 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
61
src/platform/packages/shared/kbn-restorable-state/README.md
Normal file
61
src/platform/packages/shared/kbn-restorable-state/README.md
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
14
src/platform/packages/shared/kbn-restorable-state/index.ts
Normal file
14
src/platform/packages/shared/kbn-restorable-state/index.ts
Normal 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';
|
|
@ -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'],
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/restorable-state",
|
||||
"owner": "@elastic/kibana-data-discovery",
|
||||
"group": "platform",
|
||||
"visibility": "shared"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>();
|
|
@ -46,5 +46,6 @@
|
|||
"@kbn/data-grid-in-table-search",
|
||||
"@kbn/data-view-utils",
|
||||
"@kbn/react-hooks",
|
||||
"@kbn/restorable-state",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>();
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue