[Discover][Lens] Removes the dataview dependency from the text based mode (#158531)

## Summary

Closes https://github.com/elastic/kibana/issues/154334

This PR:
- make the text based languages to work with adhoc dataviews and not
permanent dataviews. The text based languages will not be related with
dataviews
- enables the timepicker always for text based languages
- the timepicker is disabled if the index pattern doesn't have an
@timestamp field
- the timepicker is enabled if the index pattern has an @timestamp field
- the timepicker is enabled if the index pattern doesn't have an
@timestamp field but there is a dirty state (user is writing the query
and hasn't hit the update button. We do that to give the user the
ability to change the timepicker with the query
- An info text has been added to the editor footer to inform the users
about the @timestamp existence

The timepicker in the disabled state needs to have a disabled status
text (All time) but this is not possible atm. I have created an issue to
eui https://github.com/elastic/eui/issues/6814 to add this property.
This is going to be tackled before the 8.9 FF but we don't want to block
this PR

<img width="1839" alt="image"
src="8fc0a492-1f00-41b6-a4a6-b0527725931f">

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [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

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Matthias Wilhelm 2023-05-31 10:55:16 +02:00 committed by GitHub
parent 58e0fdf096
commit 3ef8b46433
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 339 additions and 127 deletions

View file

@ -32,6 +32,7 @@ interface EditorFooterProps {
lines: number;
containerCSS: Interpolation<Theme>;
errors?: MonacoError[];
detectTimestamp: boolean;
onErrorClick: (error: MonacoError) => void;
refreshErrors: () => void;
}
@ -40,6 +41,7 @@ export const EditorFooter = memo(function EditorFooter({
lines,
containerCSS,
errors,
detectTimestamp,
onErrorClick,
refreshErrors,
}: EditorFooterProps) {
@ -54,7 +56,7 @@ export const EditorFooter = memo(function EditorFooter({
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiFlexItem grow={false} style={{ marginRight: '16px' }}>
<EuiFlexItem grow={false} style={{ marginRight: '8px' }}>
<EuiText size="xs" color="subdued" data-test-subj="TextBasedLangEditor-footer-lines">
<p>
{i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.lineCount', {
@ -64,6 +66,32 @@ export const EditorFooter = memo(function EditorFooter({
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ marginRight: '16px' }}>
<EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="calendar" color="subdued" size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued" data-test-subj="TextBasedLangEditor-date-info">
<p>
{detectTimestamp
? i18n.translate(
'textBasedEditor.query.textBasedLanguagesEditor.timestampDetected',
{
defaultMessage: '@timestamp detected',
}
)
: i18n.translate(
'textBasedEditor.query.textBasedLanguagesEditor.timestampNotDetected',
{
defaultMessage: '@timestamp not detected',
}
)}
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{errors && errors.length > 0 && (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center">

View file

@ -63,6 +63,33 @@ describe('TextBasedLanguagesEditor', () => {
});
});
it('should render the date info with no @timestamp detected', async () => {
const newProps = {
...props,
isCodeEditorExpanded: true,
};
await act(async () => {
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
expect(
component.find('[data-test-subj="TextBasedLangEditor-date-info"]').at(0).text()
).toStrictEqual('@timestamp not detected');
});
});
it('should render the date info with @timestamp detected if detectTimestamp is true', async () => {
const newProps = {
...props,
isCodeEditorExpanded: true,
detectTimestamp: true,
};
await act(async () => {
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
expect(
component.find('[data-test-subj="TextBasedLangEditor-date-info"]').at(0).text()
).toStrictEqual('@timestamp detected');
});
});
it('should render the errors badge for the inline mode by default if errors are provides', async () => {
const newProps = {
...props,

View file

@ -55,6 +55,7 @@ export interface TextBasedLanguagesEditorProps {
onTextLangQuerySubmit: () => void;
expandCodeEditor: (status: boolean) => void;
isCodeEditorExpanded: boolean;
detectTimestamp?: boolean;
errors?: Error[];
isDisabled?: boolean;
isDarkMode?: boolean;
@ -87,6 +88,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
onTextLangQuerySubmit,
expandCodeEditor,
isCodeEditorExpanded,
detectTimestamp = false,
errors,
isDisabled,
isDarkMode,
@ -537,6 +539,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
errors={editorErrors}
onErrorClick={onErrorClick}
refreshErrors={onTextLangQuerySubmit}
detectTimestamp={detectTimestamp}
/>
)}
</div>
@ -608,6 +611,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
errors={editorErrors}
onErrorClick={onErrorClick}
refreshErrors={onTextLangQuerySubmit}
detectTimestamp={detectTimestamp}
/>
)}
{isCodeEditorExpanded && (

View file

@ -48,10 +48,13 @@ export const DiscoverTopNav = ({
const dataView = useInternalStateSelector((state) => state.dataView!);
const savedDataViews = useInternalStateSelector((state) => state.savedDataViews);
const savedSearch = useSavedSearchInitial();
const showDatePicker = useMemo(
() => dataView.isTimeBased() && dataView.type !== DataViewType.ROLLUP,
[dataView]
);
const showDatePicker = useMemo(() => {
// always show the timepicker for text based languages
return (
isPlainRecord ||
(!isPlainRecord && dataView.isTimeBased() && dataView.type !== DataViewType.ROLLUP)
);
}, [dataView, isPlainRecord]);
const services = useDiscoverServices();
const { dataViewEditor, navigation, dataViewFieldEditor, data, uiSettings, dataViews } = services;

View file

@ -23,6 +23,7 @@ import { DiscoverMainProvider } from '../services/discover_state_provider';
import { DiscoverAppState } from '../services/discover_app_state_container';
import { DiscoverStateContainer } from '../services/discover_state';
import { VIEW_MODE } from '@kbn/saved-search-plugin/public';
import { dataViewAdHoc } from '../../../__mocks__/data_view_complex';
function getHookProps(
query: AggregateQuery | Query | undefined,
@ -84,6 +85,7 @@ const renderHookWithContext = (
appState?: DiscoverAppState
) => {
const props = getHookProps(query, useDataViewsService ? getDataViewsService() : undefined);
props.stateContainer.actions.setDataView(dataViewMock);
if (appState) {
props.stateContainer.appState.getState = jest.fn(() => {
return appState;
@ -98,7 +100,7 @@ const renderHookWithContext = (
describe('useTextBasedQueryLanguage', () => {
test('a text based query should change state when loading and finished', async () => {
const { replaceUrlState, stateContainer } = renderHookWithContext(false);
const { replaceUrlState, stateContainer } = renderHookWithContext(true);
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(1));
expect(replaceUrlState).toHaveBeenCalledWith({ index: 'the-data-view-id' });
@ -191,11 +193,7 @@ describe('useTextBasedQueryLanguage', () => {
query: { sql: 'SELECT field1 from the-data-view-title WHERE field1=1' },
});
await waitFor(() => {
expect(replaceUrlState).toHaveBeenCalledWith({
index: 'the-data-view-id',
});
});
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(0));
});
test('if its not a text based query coming along, it should be ignored', async () => {
const { replaceUrlState, stateContainer } = renderHookWithContext(false);
@ -270,7 +268,7 @@ describe('useTextBasedQueryLanguage', () => {
],
query: { sql: 'SELECT field1 from the-data-view-title' },
});
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(1));
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(2));
expect(replaceUrlState).toHaveBeenCalledWith({
columns: ['field1'],
});
@ -288,7 +286,7 @@ describe('useTextBasedQueryLanguage', () => {
fetchStatus: FetchStatus.LOADING,
query: { sql: 'SELECT * from the-data-view-title WHERE field1=2' },
});
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(0));
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(1));
documents$.next({
recordRawType: RecordRawType.PLAIN,
fetchStatus: FetchStatus.COMPLETE,
@ -301,7 +299,7 @@ describe('useTextBasedQueryLanguage', () => {
],
query: { sql: 'SELECT * from the-data-view-title WHERE field1=2' },
});
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(1));
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(2));
stateContainer.appState.getState = jest.fn(() => {
return { columns: ['field1', 'field2'], index: 'the-data-view-id' };
});
@ -344,17 +342,10 @@ describe('useTextBasedQueryLanguage', () => {
});
test('changing a text based query with an index pattern that not corresponds to a dataview should return results', async () => {
const dataViewsCreateMock = discoverServiceMock.dataViews.create as jest.Mock;
dataViewsCreateMock.mockImplementation(() => ({
...dataViewMock,
}));
const dataViewsService = {
...discoverServiceMock.dataViews,
create: dataViewsCreateMock,
};
const props = getHookProps(query, dataViewsService);
const props = getHookProps(query, discoverServiceMock.dataViews);
const { stateContainer, replaceUrlState } = props;
const documents$ = stateContainer.dataState.data$.documents$;
props.stateContainer.actions.setDataView(dataViewMock);
renderHook(() => useTextBasedQueryLanguage(props), { wrapper: getHookContext(stateContainer) });
@ -374,6 +365,7 @@ describe('useTextBasedQueryLanguage', () => {
],
query: { sql: 'SELECT field1 from the-data-view-*' },
});
props.stateContainer.actions.setDataView(dataViewAdHoc);
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(1));
await waitFor(() => {

View file

@ -37,6 +37,7 @@ export function useTextBasedQueryLanguage({
columns: [],
query: undefined,
});
const indexTitle = useRef<string>('');
const savedSearch = useSavedSearchInitial();
const cleanup = useCallback(() => {
@ -80,36 +81,24 @@ export function useTextBasedQueryLanguage({
}
}
const indexPatternFromQuery = getIndexPatternFromSQLQuery(query.sql);
const internalState = stateContainer.internalState.getState();
const dataViewList = [...internalState.savedDataViews, ...internalState.adHocDataViews];
let dataViewObj = dataViewList.find(({ title }) => title === indexPatternFromQuery);
// no dataview found but the index pattern is valid
// create an adhoc instance instead
if (!dataViewObj) {
dataViewObj = await dataViews.create({
title: indexPatternFromQuery,
});
stateContainer.internalState.transitions.setAdHocDataViews([dataViewObj]);
if (dataViewObj.fields.getByName('@timestamp')?.type === 'date') {
dataViewObj.timeFieldName = '@timestamp';
} else if (dataViewObj.fields.getByType('date')?.length) {
const dateFields = dataViewObj.fields.getByType('date');
dataViewObj.timeFieldName = dateFields[0].name;
}
}
const dataViewObj = stateContainer.internalState.getState().dataView!;
// don't set the columns on initial fetch, to prevent overwriting existing state
const addColumnsToState = Boolean(
nextColumns.length && (!initialFetch || !stateColumns?.length)
);
// no need to reset index to state if it hasn't changed
const addDataViewToState = Boolean(dataViewObj.id !== index);
if (!addColumnsToState && !addDataViewToState) {
const addDataViewToState = Boolean(dataViewObj?.id !== index) || initialFetch;
const queryChanged = indexPatternFromQuery !== indexTitle.current;
if (!addColumnsToState && !queryChanged) {
return;
}
if (queryChanged) {
indexTitle.current = indexPatternFromQuery;
}
const nextState = {
...(addDataViewToState && { index: dataViewObj.id }),
...(addColumnsToState && { columns: nextColumns }),

View file

@ -17,6 +17,7 @@ import {
isEqualState,
} from '../../services/discover_app_state_container';
import { addLog } from '../../../../utils/add_log';
import { isTextBasedQuery } from '../../utils/is_text_based_query';
import { FetchStatus } from '../../../types';
import { loadAndResolveDataView } from '../../utils/resolve_data_view';
@ -61,7 +62,7 @@ export const buildStateSubscribe =
// NOTE: this is also called when navigating from discover app to context app
if (nextState.index && dataViewChanged) {
const { dataView: nextDataView, fallback } = await loadAndResolveDataView(
{ id: nextState.index, savedSearch },
{ id: nextState.index, savedSearch, isTextBasedQuery: isTextBasedQuery(nextState?.query) },
{ internalStateContainer: internalState, services }
);

View file

@ -12,6 +12,9 @@ import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { AggregateQuery, Query } from '@kbn/es-query';
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import { DataView } from '@kbn/data-views-plugin/common';
import { getDataViewByTextBasedQueryLang } from '../utils/get_data_view_by_text_based_query_lang';
import { isTextBasedQuery } from '../utils/is_text_based_query';
import { getRawRecordType } from '../utils/get_raw_record_type';
import { DiscoverAppState } from './discover_app_state_container';
import { DiscoverServices } from '../../../build_services';
@ -129,11 +132,13 @@ export function getDataStateContainer({
searchSessionManager,
getAppState,
getSavedSearch,
setDataView,
}: {
services: DiscoverServices;
searchSessionManager: DiscoverSearchSessionManager;
getAppState: () => DiscoverAppState;
getSavedSearch: () => SavedSearch;
setDataView: (dataView: DataView) => void;
}): DiscoverDataStateContainer {
const { data, uiSettings, toastNotifications } = services;
const { timefilter } = data.query.timefilter;
@ -226,7 +231,17 @@ export function getDataStateContainer({
};
}
const fetchQuery = (resetQuery?: boolean) => {
const fetchQuery = async (resetQuery?: boolean) => {
const query = getAppState().query;
const currentDataView = getSavedSearch().searchSource.getField('index');
if (isTextBasedQuery(query)) {
const nextDataView = await getDataViewByTextBasedQueryLang(query, currentDataView, services);
if (nextDataView !== currentDataView) {
setDataView(nextDataView);
}
}
if (resetQuery) {
refetch$.next('reset');
} else {

View file

@ -37,6 +37,14 @@ const startSync = (appState: DiscoverAppStateContainer) => {
async function getState(url: string = '/', savedSearch?: SavedSearch) {
const nextHistory = createBrowserHistory();
nextHistory.push(url);
discoverServiceMock.dataViews.create = jest.fn().mockReturnValue({
...dataViewMock,
isPersisted: () => false,
id: 'ad-hoc-id',
title: 'test',
});
const nextState = getDiscoverStateContainer({
services: discoverServiceMock,
history: nextHistory,
@ -635,12 +643,6 @@ describe('Test discover state actions', () => {
});
test('onCreateDefaultAdHocDataView', async () => {
discoverServiceMock.dataViews.create = jest.fn().mockReturnValue({
...dataViewMock,
isPersisted: () => false,
id: 'ad-hoc-id',
title: 'test',
});
const { state } = await getState('/', savedSearchMock);
await state.actions.loadSavedSearch({ savedSearchId: savedSearchMock.id });
const unsubscribe = state.actions.initializeAndSync();

View file

@ -228,13 +228,6 @@ export function getDiscoverStateContainer({
*/
const internalStateContainer = getInternalStateContainer();
const dataStateContainer = getDataStateContainer({
services,
searchSessionManager,
getAppState: appStateContainer.getState,
getSavedSearch: savedSearchContainer.getState,
});
const pauseAutoRefreshInterval = async (dataView: DataView) => {
if (dataView && (!dataView.isTimeBased() || dataView.type === DataViewType.ROLLUP)) {
const state = stateStorage.get<QueryState>(GLOBAL_STATE_URL_KEY);
@ -247,13 +240,20 @@ export function getDiscoverStateContainer({
}
}
};
const setDataView = (dataView: DataView) => {
internalStateContainer.transitions.setDataView(dataView);
pauseAutoRefreshInterval(dataView);
savedSearchContainer.getState().searchSource.setField('index', dataView);
};
const dataStateContainer = getDataStateContainer({
services,
searchSessionManager,
getAppState: appStateContainer.getState,
getSavedSearch: savedSearchContainer.getState,
setDataView,
});
const loadDataViewList = async () => {
const dataViewList = await services.dataViews.getIdsWithTitle(true);
internalStateContainer.transitions.setSavedDataViews(dataViewList);

View file

@ -7,6 +7,7 @@
*/
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { cloneDeep, isEqual } from 'lodash';
import { getDataViewByTextBasedQueryLang } from '../utils/get_data_view_by_text_based_query_lang';
import { isTextBasedQuery } from '../utils/is_text_based_query';
import { loadAndResolveDataView } from '../utils/resolve_data_view';
import { DiscoverInternalStateContainer } from './discover_internal_state_container';
@ -151,6 +152,11 @@ const getStateDataView = async (
if (dataView) {
return dataView;
}
const query = appState?.query;
if (isTextBasedQuery(query)) {
return await getDataViewByTextBasedQueryLang(query, dataView, services);
}
const result = await loadAndResolveDataView(
{

View file

@ -0,0 +1,48 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { getDataViewByTextBasedQueryLang } from './get_data_view_by_text_based_query_lang';
import { dataViewAdHoc } from '../../../__mocks__/data_view_complex';
import { dataViewMock } from '../../../__mocks__/data_view';
import { discoverServiceMock } from '../../../__mocks__/services';
describe('getDataViewByTextBasedQueryLang', () => {
discoverServiceMock.dataViews.create = jest.fn().mockReturnValue({
...dataViewMock,
isPersisted: () => false,
id: 'ad-hoc-id',
title: 'test',
});
const services = discoverServiceMock;
it('returns the current dataview if is adhoc and query has not changed', async () => {
const query = { sql: 'Select * from data-view-ad-hoc-title' };
const dataView = await getDataViewByTextBasedQueryLang(query, dataViewAdHoc, services);
expect(dataView).toStrictEqual(dataViewAdHoc);
});
it('creates an adhoc dataview if the current dataview is persistent and query has not changed', async () => {
const query = { sql: 'Select * from the-data-view-title' };
const dataView = await getDataViewByTextBasedQueryLang(query, dataViewMock, services);
expect(dataView.isPersisted()).toEqual(false);
expect(dataView.timeFieldName).toBe('@timestamp');
});
it('creates an adhoc dataview if the current dataview is ad hoc and query has changed', async () => {
discoverServiceMock.dataViews.create = jest.fn().mockReturnValue({
...dataViewAdHoc,
isPersisted: () => false,
id: 'ad-hoc-id-1',
title: 'test-1',
timeFieldName: undefined,
});
const query = { sql: 'Select * from the-data-view-title' };
const dataView = await getDataViewByTextBasedQueryLang(query, dataViewAdHoc, services);
expect(dataView.isPersisted()).toEqual(false);
expect(dataView.timeFieldName).toBeUndefined();
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { AggregateQuery, getIndexPatternFromSQLQuery } from '@kbn/es-query';
import { DataView } from '@kbn/data-views-plugin/common';
import { DiscoverServices } from '../../../build_services';
export async function getDataViewByTextBasedQueryLang(
query: AggregateQuery,
currentDataView: DataView | undefined,
services: DiscoverServices
) {
const text = 'sql' in query ? query.sql : undefined;
const indexPatternFromQuery = getIndexPatternFromSQLQuery(text);
if (
currentDataView?.isPersisted() ||
indexPatternFromQuery !== currentDataView?.getIndexPattern()
) {
const dataViewObj = await services.dataViews.create({
title: indexPatternFromQuery,
});
if (dataViewObj.fields.getByName('@timestamp')?.type === 'date') {
dataViewObj.timeFieldName = '@timestamp';
}
return dataViewObj;
}
return currentDataView;
}

View file

@ -123,7 +123,8 @@ export function resolveDataView(
return ownDataView;
}
if (stateVal && !stateValFound) {
// no warnings for text based mode
if (stateVal && !stateValFound && !Boolean(isTextBasedQuery)) {
const warningTitle = i18n.translate('discover.valueIsNotConfiguredDataViewIDWarningTitle', {
defaultMessage: '{stateVal} is not a configured data view ID',
values: {
@ -146,20 +147,18 @@ export function resolveDataView(
});
return ownDataView;
}
if (!Boolean(isTextBasedQuery)) {
toastNotifications.addWarning({
title: warningTitle,
text: i18n.translate('discover.showingDefaultDataViewWarningDescription', {
defaultMessage:
'Showing the default data view: "{loadedDataViewTitle}" ({loadedDataViewId})',
values: {
loadedDataViewTitle: loadedDataView.getIndexPattern(),
loadedDataViewId: loadedDataView.id,
},
}),
'data-test-subj': 'dscDataViewNotFoundShowDefaultWarning',
});
}
toastNotifications.addWarning({
title: warningTitle,
text: i18n.translate('discover.showingDefaultDataViewWarningDescription', {
defaultMessage:
'Showing the default data view: "{loadedDataViewTitle}" ({loadedDataViewId})',
values: {
loadedDataViewTitle: loadedDataView.getIndexPattern(),
loadedDataViewId: loadedDataView.id,
},
}),
'data-test-subj': 'dscDataViewNotFoundShowDefaultWarning',
});
}
return loadedDataView;

View file

@ -40,6 +40,7 @@ async function mountComponent({
dataView = dataViewWithTimefieldMock,
currentSuggestion,
allSuggestions,
isPlainRecord,
}: {
noChart?: boolean;
noHits?: boolean;
@ -49,6 +50,7 @@ async function mountComponent({
dataView?: DataView;
currentSuggestion?: Suggestion;
allSuggestions?: Suggestion[];
isPlainRecord?: boolean;
} = {}) {
(searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation(
jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: noHits ? 0 : 2 } } }))
@ -84,6 +86,7 @@ async function mountComponent({
breakdown: noBreakdown ? undefined : { field: undefined },
currentSuggestion,
allSuggestions,
isPlainRecord,
appendHistogram,
onResetChartHeight: jest.fn(),
onChartHiddenChange: jest.fn(),
@ -149,6 +152,14 @@ describe('Chart', () => {
expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeTruthy();
});
test('render when is text based and not timebased', async () => {
const component = await mountComponent({ isPlainRecord: true, dataView: dataViewMock });
expect(
component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists()
).toBeTruthy();
expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeTruthy();
});
test('triggers onEditVisualization on click', async () => {
expect(mockUseEditVisualization).not.toHaveBeenCalled();
const component = await mountComponent();

View file

@ -136,7 +136,7 @@ export function Chart({
!chart.hidden &&
dataView.id &&
dataView.type !== DataViewType.ROLLUP &&
dataView.isTimeBased()
(isPlainRecord || (!isPlainRecord && dataView.isTimeBased()))
);
const input$ = useMemo(
@ -219,6 +219,7 @@ export function Chart({
dataView,
relativeTimeRange: originalRelativeTimeRange ?? relativeTimeRange,
lensAttributes: lensAttributesContext.attributes,
isPlainRecord,
});
return (

View file

@ -21,16 +21,21 @@ export const useEditVisualization = ({
dataView,
relativeTimeRange,
lensAttributes,
isPlainRecord,
}: {
services: UnifiedHistogramServices;
dataView: DataView;
relativeTimeRange?: TimeRange;
lensAttributes: TypedLensByValueInput['attributes'];
isPlainRecord?: boolean;
}) => {
const [canVisualize, setCanVisualize] = useState(false);
const checkCanVisualize = useCallback(async () => {
if (!dataView.id || !dataView.isTimeBased() || !dataView.getTimeField().visualizable) {
if (!dataView.id) {
return false;
}
if (!isPlainRecord && (!dataView.isTimeBased() || !dataView.getTimeField().visualizable)) {
return false;
}
@ -43,7 +48,7 @@ export const useEditVisualization = ({
);
return Boolean(compatibleActions.length);
}, [dataView, services.uiActions]);
}, [dataView, isPlainRecord, services.uiActions]);
const onEditVisualization = useMemo(() => {
if (!canVisualize) {

View file

@ -115,6 +115,7 @@ describe('QueryBarTopRowTopRow', () => {
const TIMEPICKER_SELECTOR = 'Memo(EuiSuperDatePicker)';
const REFRESH_BUTTON_SELECTOR = 'EuiSuperUpdateButton';
const TIMEPICKER_DURATION = '[data-shared-timefilter-duration]';
const TEXT_BASED_EDITOR = '[data-test-subj="unifiedTextLangEditor"]';
beforeEach(() => {
jest.clearAllMocks();
@ -293,7 +294,7 @@ describe('QueryBarTopRowTopRow', () => {
});
it('Should NOT render query input bar if on text based languages mode', () => {
const component = shallow(
const component = mount(
wrapQueryBarTopRowInContext({
query: sqlQuery,
isDirty: false,
@ -307,6 +308,8 @@ describe('QueryBarTopRowTopRow', () => {
);
expect(component.find(QUERY_INPUT_SELECTOR).length).toBe(0);
expect(component.find(TEXT_BASED_EDITOR).length).toBe(1);
expect(component.find(TEXT_BASED_EDITOR).prop('detectTimestamp')).toBe(true);
});
});

View file

@ -27,6 +27,7 @@ import {
OnRefreshProps,
useIsWithinBreakpoints,
EuiSuperUpdateButton,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TimeHistoryContract, getQueryLog } from '@kbn/data-plugin/public';
@ -64,6 +65,30 @@ export const strings = {
}),
};
const getWrapperWithTooltip = (
children: JSX.Element,
enableTooltip: boolean,
query?: Query | AggregateQuery
) => {
if (enableTooltip && query && isOfAggregateQueryType(query)) {
const textBasedLanguage = getAggregateQueryMode(query);
return (
<EuiToolTip
position="top"
content={i18n.translate('unifiedSearch.query.queryBar.textBasedNonTimestampWarning', {
defaultMessage:
'Date range selection for {language} queries requires the presence of an @timestamp field in the dataset.',
values: { language: textBasedLanguage },
})}
>
{children}
</EuiToolTip>
);
} else {
return children;
}
};
const SuperDatePicker = React.memo(
EuiSuperDatePicker as any
) as unknown as typeof EuiSuperDatePicker;
@ -394,33 +419,45 @@ export const QueryBarTopRow = React.memo(
if (!shouldRenderDatePicker()) {
return null;
}
let isDisabled = props.isDisabled;
let enableTooltip = false;
// On text based mode the datepicker is always on when the user has unsaved changes.
// When the user doesn't have any changes it should be disabled if dataview doesn't have @timestamp field
if (Boolean(isQueryLangSelected) && !props.isDirty) {
const adHocDataview = props.indexPatterns?.[0];
if (adHocDataview && typeof adHocDataview !== 'string') {
isDisabled = !Boolean(adHocDataview.timeFieldName);
enableTooltip = !Boolean(adHocDataview.timeFieldName);
}
}
const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper');
return (
<EuiFlexItem className={wrapperClasses}>
<SuperDatePicker
isDisabled={props.isDisabled}
start={props.dateRangeFrom}
end={props.dateRangeTo}
isPaused={props.isRefreshPaused}
refreshInterval={props.refreshInterval}
onTimeChange={onTimeChange}
onRefresh={onRefresh}
onRefreshChange={props.onRefreshChange}
showUpdateButton={false}
recentlyUsedRanges={recentlyUsedRanges}
locale={i18n.getLocale()}
commonlyUsedRanges={commonlyUsedRanges}
dateFormat={uiSettings.get('dateFormat')}
isAutoRefreshOnly={showAutoRefreshOnly}
className="kbnQueryBar__datePicker"
isQuickSelectOnly={isMobile ? false : isQueryInputFocused}
width={isMobile ? 'full' : 'auto'}
compressed={shouldShowDatePickerAsBadge()}
/>
</EuiFlexItem>
const datePicker = (
<SuperDatePicker
isDisabled={isDisabled}
start={props.dateRangeFrom}
end={props.dateRangeTo}
isPaused={props.isRefreshPaused}
refreshInterval={props.refreshInterval}
onTimeChange={onTimeChange}
onRefresh={onRefresh}
onRefreshChange={props.onRefreshChange}
showUpdateButton={false}
recentlyUsedRanges={recentlyUsedRanges}
locale={i18n.getLocale()}
commonlyUsedRanges={commonlyUsedRanges}
dateFormat={uiSettings.get('dateFormat')}
isAutoRefreshOnly={showAutoRefreshOnly}
className="kbnQueryBar__datePicker"
isQuickSelectOnly={isMobile ? false : isQueryInputFocused}
width={isMobile ? 'full' : 'auto'}
compressed={shouldShowDatePickerAsBadge()}
/>
);
const component = getWrapperWithTooltip(datePicker, enableTooltip, props.query);
return <EuiFlexItem className={wrapperClasses}>{component}</EuiFlexItem>;
}
function renderUpdateButton() {
@ -577,6 +614,11 @@ export const QueryBarTopRow = React.memo(
}
function renderTextLangEditor() {
const adHocDataview = props.indexPatterns?.[0];
let detectTimestamp = false;
if (adHocDataview && typeof adHocDataview !== 'string') {
detectTimestamp = Boolean(adHocDataview?.timeFieldName);
}
return (
isQueryLangSelected &&
props.query &&
@ -587,6 +629,7 @@ export const QueryBarTopRow = React.memo(
expandCodeEditor={(status: boolean) => setCodeEditorIsExpanded(status)}
isCodeEditorExpanded={codeEditorIsExpanded}
errors={props.textBasedLanguageModeErrors}
detectTimestamp={detectTimestamp}
onTextLangQuerySubmit={() =>
onSubmit({
query: queryRef.current,

View file

@ -404,7 +404,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.discover.waitUntilSidebarHasLoaded();
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
'1 popular field. 53 available fields. 0 empty fields. 3 meta fields.'
'53 available fields. 0 empty fields. 3 meta fields.'
);
});

View file

@ -1080,6 +1080,8 @@ export const LensTopNavMenu = ({
dataViewPickerComponentProps={dataViewPickerProps}
showDatePicker={
indexPatterns.some((ip) => ip.isTimeBased()) ||
// always show the timepicker for text based languages
isOnTextBasedMode ||
Boolean(
allLoaded &&
activeDatasourceId &&

View file

@ -191,8 +191,9 @@ describe('Text based languages utils', () => {
),
create: jest.fn().mockReturnValue(
Promise.resolve({
id: '1',
title: 'my-fake-index-pattern',
id: '4',
title: 'my-adhoc-index-pattern',
name: 'my-adhoc-index-pattern',
timeFieldName: 'timeField',
isPersisted: () => false,
})
@ -252,6 +253,11 @@ describe('Text based languages utils', () => {
timeField: 'timeField',
title: 'my-fake-restricted-pattern',
},
{
id: '4',
timeField: 'timeField',
title: 'my-adhoc-index-pattern',
},
],
layers: {
first: {
@ -280,7 +286,7 @@ describe('Text based languages utils', () => {
],
columns: [],
errors: [],
index: '1',
index: '4',
query: {
sql: 'SELECT * FROM my-fake-index-pattern',
},

View file

@ -83,29 +83,22 @@ export async function getStateFromAggregateQuery(
let allColumns: TextBasedLayerColumn[] = [];
let timeFieldName;
try {
const dataView = dataViewId
? await dataViews.get(dataViewId)
: await dataViews.create({
title: indexPattern,
});
if (!dataViewId && !dataView.isPersisted()) {
if (dataView && dataView.id) {
if (dataView.fields.getByName('@timestamp')?.type === 'date') {
dataView.timeFieldName = '@timestamp';
} else if (dataView.fields.getByType('date')?.length) {
const dateFields = dataView.fields.getByType('date');
dataView.timeFieldName = dateFields[0].name;
}
dataViewId = dataView?.id;
indexPatternRefs = [
...indexPatternRefs,
{
id: dataView.id,
title: dataView.name,
timeField: dataView.timeFieldName,
},
];
const dataView = await dataViews.create({
title: indexPattern,
});
if (dataView && dataView.id) {
if (dataView?.fields?.getByName('@timestamp')?.type === 'date') {
dataView.timeFieldName = '@timestamp';
}
dataViewId = dataView?.id;
indexPatternRefs = [
...indexPatternRefs,
{
id: dataView.id,
title: dataView.name,
timeField: dataView.timeFieldName,
},
];
}
timeFieldName = dataView.timeFieldName;
const table = await fetchDataFromAggregateQuery(query, dataView, data, expressions);