[Lens] Move app state to redux toolkit (#100338) (#100851)

Co-authored-by: Marta Bondyra <marta.bondyra@gmail.com>
This commit is contained in:
Kibana Machine 2021-05-28 05:55:40 -04:00 committed by GitHub
parent e8039e188b
commit c4ad5d86fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 2912 additions and 2356 deletions

View file

@ -158,6 +158,7 @@
"@mapbox/mapbox-gl-draw": "1.3.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mapbox/vector-tile": "1.3.1",
"@reduxjs/toolkit": "^1.5.1",
"@scant/router": "^0.1.1",
"@slack/webhook": "^5.0.4",
"@turf/along": "6.0.1",
@ -170,6 +171,7 @@
"@turf/distance": "6.0.1",
"@turf/helpers": "6.0.1",
"@turf/length": "^6.0.2",
"@types/redux-logger": "^3.0.8",
"JSONStream": "1.3.5",
"abort-controller": "^3.0.0",
"abortcontroller-polyfill": "^1.4.0",
@ -362,6 +364,7 @@
"redux": "^4.0.5",
"redux-actions": "^2.6.5",
"redux-devtools-extension": "^2.13.8",
"redux-logger": "^3.0.6",
"redux-observable": "^1.2.0",
"redux-saga": "^1.1.3",
"redux-thunk": "^2.3.0",

File diff suppressed because it is too large Load diff

View file

@ -7,49 +7,38 @@
import './app.scss';
import _ from 'lodash';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { isEqual, partition } from 'lodash';
import React, { useState, useEffect, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { Toast } from 'kibana/public';
import { VisualizeFieldContext } from 'src/plugins/ui_actions/public';
import { Datatable } from 'src/plugins/expressions/public';
import { EuiBreadcrumb } from '@elastic/eui';
import { delay, finalize, switchMap, tap } from 'rxjs/operators';
import { downloadMultipleAs } from '../../../../../src/plugins/share/public';
import {
createKbnUrlStateStorage,
withNotifyOnErrors,
} from '../../../../../src/plugins/kibana_utils/public';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import {
OnSaveProps,
checkForDuplicateTitle,
} from '../../../../../src/plugins/saved_objects/public';
import { checkForDuplicateTitle } from '../../../../../src/plugins/saved_objects/public';
import { injectFilterReferences } from '../persistence';
import { trackUiEvent } from '../lens_ui_telemetry';
import {
DataPublicPluginStart,
esFilters,
exporters,
Filter,
IndexPattern as IndexPatternInstance,
IndexPatternsContract,
Query,
SavedQuery,
syncQueryStateWithUrl,
waitUntilNextSessionCompletes$,
} from '../../../../../src/plugins/data/public';
import { LENS_EMBEDDABLE_TYPE, getFullPath, APP_ID } from '../../common';
import { LensAppProps, LensAppServices, LensAppState } from './types';
import { getLensTopNavConfig } from './lens_top_nav';
import { esFilters, syncQueryStateWithUrl } from '../../../../../src/plugins/data/public';
import { getFullPath, APP_ID } from '../../common';
import { LensAppProps, LensAppServices, RunSave } from './types';
import { LensTopNavMenu } from './lens_top_nav';
import { Document } from '../persistence';
import { SaveModal } from './save_modal';
import {
LensByReferenceInput,
LensEmbeddableInput,
} from '../editor_frame_service/embeddable/embeddable';
import { useTimeRange } from './time_range';
import { EditorFrameInstance } from '../types';
import {
setState as setAppState,
useLensSelector,
useLensDispatch,
LensAppState,
DispatchSetState,
} from '../state_management';
export function App({
history,
@ -67,7 +56,6 @@ export function App({
data,
chrome,
overlays,
navigation,
uiSettings,
application,
stateTransfer,
@ -81,29 +69,18 @@ export function App({
dashboardFeatureFlag,
} = useKibana<LensAppServices>().services;
const startSession = useCallback(() => data.search.session.start(), [data.search.session]);
const dispatch = useLensDispatch();
const dispatchSetState: DispatchSetState = useCallback(
(state: Partial<LensAppState>) => dispatch(setAppState(state)),
[dispatch]
);
const [state, setState] = useState<LensAppState>(() => {
return {
query: data.query.queryString.getQuery(),
// Do not use app-specific filters from previous app,
// only if Lens was opened with the intention to visualize a field (e.g. coming from Discover)
filters: !initialContext
? data.query.filterManager.getGlobalFilters()
: data.query.filterManager.getFilters(),
isLoading: Boolean(initialInput),
indexPatternsForTopNav: [],
isLinkedToOriginatingApp: Boolean(incomingState?.originatingApp),
isSaveable: false,
searchSessionId: startSession(),
};
});
const appState = useLensSelector((state) => state.app);
// Used to show a popover that guides the user towards changing the date range when no data is available.
const [indicateNoData, setIndicateNoData] = useState(false);
const [isSaveModalVisible, setIsSaveModalVisible] = useState(false);
const { lastKnownDoc } = state;
const { lastKnownDoc } = appState;
const showNoDataPopover = useCallback(() => {
setIndicateNoData(true);
@ -116,19 +93,10 @@ export function App({
}, [
setIndicateNoData,
indicateNoData,
state.query,
state.filters,
state.indexPatternsForTopNav,
state.searchSessionId,
appState.indexPatternsForTopNav,
appState.searchSessionId,
]);
const { resolvedDateRange, from: fromDate, to: toDate } = useTimeRange(
data,
state.lastKnownDoc,
setState,
state.searchSessionId
);
const onError = useCallback(
(e: { message: string }) =>
notifications.toasts.addDanger({
@ -142,56 +110,13 @@ export function App({
Boolean(
// Temporarily required until the 'by value' paradigm is default.
dashboardFeatureFlag.allowByValueEmbeddables &&
state.isLinkedToOriginatingApp &&
appState.isLinkedToOriginatingApp &&
!(initialInput as LensByReferenceInput)?.savedObjectId
),
[dashboardFeatureFlag.allowByValueEmbeddables, state.isLinkedToOriginatingApp, initialInput]
[dashboardFeatureFlag.allowByValueEmbeddables, appState.isLinkedToOriginatingApp, initialInput]
);
useEffect(() => {
// Clear app-specific filters when navigating to Lens. Necessary because Lens
// can be loaded without a full page refresh. If the user navigates to Lens from Discover
// we keep the filters
if (!initialContext) {
data.query.filterManager.setAppFilters([]);
}
const filterSubscription = data.query.filterManager.getUpdates$().subscribe({
next: () => {
setState((s) => ({
...s,
filters: data.query.filterManager.getFilters(),
searchSessionId: startSession(),
}));
trackUiEvent('app_filters_updated');
},
});
const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({
next: () => {
setState((s) => ({
...s,
searchSessionId: startSession(),
}));
},
});
const autoRefreshSubscription = data.query.timefilter.timefilter
.getAutoRefreshFetch$()
.pipe(
tap(() => {
setState((s) => ({
...s,
searchSessionId: startSession(),
}));
}),
switchMap((done) =>
// best way in lens to estimate that all panels are updated is to rely on search session service state
waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done))
)
)
.subscribe();
const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: uiSettings.get('state:storeInSessionStorage'),
@ -202,41 +127,10 @@ export function App({
kbnUrlStateStorage
);
const sessionSubscription = data.search.session
.getSession$()
// wait for a tick to filter/timerange subscribers the chance to update the session id in the state
.pipe(delay(0))
// then update if it didn't get updated yet
.subscribe((newSessionId) => {
if (newSessionId) {
setState((prevState) => {
if (prevState.searchSessionId !== newSessionId) {
return { ...prevState, searchSessionId: newSessionId };
} else {
return prevState;
}
});
}
});
return () => {
stopSyncingQueryServiceStateWithUrl();
filterSubscription.unsubscribe();
timeSubscription.unsubscribe();
autoRefreshSubscription.unsubscribe();
sessionSubscription.unsubscribe();
};
}, [
data.query.filterManager,
data.query.timefilter.timefilter,
data.search.session,
notifications.toasts,
uiSettings,
data.query,
history,
initialContext,
startSession,
]);
}, [data.search.session, notifications.toasts, uiSettings, data.query, history]);
useEffect(() => {
onAppLeave((actions) => {
@ -244,11 +138,11 @@ export function App({
// or when the user has configured something without saving
if (
application.capabilities.visualize.save &&
!_.isEqual(
state.persistedDoc?.state,
!isEqual(
appState.persistedDoc?.state,
getLastKnownDocWithoutPinnedFilters(lastKnownDoc)?.state
) &&
(state.isSaveable || state.persistedDoc)
(appState.isSaveable || appState.persistedDoc)
) {
return actions.confirm(
i18n.translate('xpack.lens.app.unsavedWorkMessage', {
@ -265,8 +159,8 @@ export function App({
}, [
onAppLeave,
lastKnownDoc,
state.isSaveable,
state.persistedDoc,
appState.isSaveable,
appState.persistedDoc,
application.capabilities.visualize.save,
]);
@ -274,7 +168,7 @@ export function App({
useEffect(() => {
const isByValueMode = getIsByValueMode();
const breadcrumbs: EuiBreadcrumb[] = [];
if (state.isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) {
if (appState.isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) {
breadcrumbs.push({
onClick: () => {
redirectToOrigin();
@ -297,113 +191,31 @@ export function App({
let currentDocTitle = i18n.translate('xpack.lens.breadcrumbsCreate', {
defaultMessage: 'Create',
});
if (state.persistedDoc) {
if (appState.persistedDoc) {
currentDocTitle = isByValueMode
? i18n.translate('xpack.lens.breadcrumbsByValue', { defaultMessage: 'Edit visualization' })
: state.persistedDoc.title;
: appState.persistedDoc.title;
}
breadcrumbs.push({ text: currentDocTitle });
chrome.setBreadcrumbs(breadcrumbs);
}, [
dashboardFeatureFlag.allowByValueEmbeddables,
state.isLinkedToOriginatingApp,
getOriginatingAppName,
state.persistedDoc,
redirectToOrigin,
getIsByValueMode,
initialInput,
application,
chrome,
]);
useEffect(() => {
if (
!initialInput ||
(attributeService.inputIsRefType(initialInput) &&
initialInput.savedObjectId === state.persistedDoc?.savedObjectId)
) {
return;
}
setState((s) => ({ ...s, isLoading: true }));
attributeService
.unwrapAttributes(initialInput)
.then((attributes) => {
if (!initialInput) {
return;
}
const doc = {
...initialInput,
...attributes,
type: LENS_EMBEDDABLE_TYPE,
};
if (attributeService.inputIsRefType(initialInput)) {
chrome.recentlyAccessed.add(
getFullPath(initialInput.savedObjectId),
attributes.title,
initialInput.savedObjectId
);
}
const indexPatternIds = _.uniq(
doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
);
getAllIndexPatterns(indexPatternIds, data.indexPatterns)
.then(({ indexPatterns }) => {
// Don't overwrite any pinned filters
data.query.filterManager.setAppFilters(
injectFilterReferences(doc.state.filters, doc.references)
);
setState((s) => ({
...s,
isLoading: false,
...(!_.isEqual(state.persistedDoc, doc) ? { persistedDoc: doc } : null),
lastKnownDoc: doc,
query: doc.state.query,
indexPatternsForTopNav: indexPatterns,
}));
})
.catch((e) => {
setState((s) => ({ ...s, isLoading: false }));
redirectTo();
});
})
.catch((e) => {
setState((s) => ({ ...s, isLoading: false }));
notifications.toasts.addDanger(
i18n.translate('xpack.lens.app.docLoadingError', {
defaultMessage: 'Error loading saved document',
})
);
redirectTo();
});
}, [
notifications,
data.indexPatterns,
data.query.filterManager,
initialInput,
attributeService,
redirectTo,
chrome.recentlyAccessed,
state.persistedDoc,
appState.isLinkedToOriginatingApp,
appState.persistedDoc,
]);
const tagsIds =
state.persistedDoc && savedObjectsTagging
? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references)
appState.persistedDoc && savedObjectsTagging
? savedObjectsTagging.ui.getTagIdsFromReferences(appState.persistedDoc.references)
: [];
const runSave = async (
saveProps: Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
dashboardId?: string | null;
onTitleDuplicate?: OnSaveProps['onTitleDuplicate'];
newDescription?: string;
newTags?: string[];
},
options: { saveToLibrary: boolean }
) => {
const runSave: RunSave = async (saveProps, options) => {
if (!lastKnownDoc) {
return;
}
@ -502,10 +314,8 @@ export function App({
docToSave.title,
newInput.savedObjectId
);
setState((s) => ({
...s,
isLinkedToOriginatingApp: false,
}));
dispatchSetState({ isLinkedToOriginatingApp: false });
setIsSaveModalVisible(false);
// remove editor state so the connection is still broken after reload
@ -519,12 +329,12 @@ export function App({
...docToSave,
...newInput,
};
setState((s) => ({
...s,
dispatchSetState({
isLinkedToOriginatingApp: false,
persistedDoc: newDoc,
lastKnownDoc: newDoc,
isLinkedToOriginatingApp: false,
}));
});
setIsSaveModalVisible(false);
} catch (e) {
@ -535,187 +345,37 @@ export function App({
}
};
const lastKnownDocRef = useRef(state.lastKnownDoc);
lastKnownDocRef.current = state.lastKnownDoc;
const activeDataRef = useRef(state.activeData);
activeDataRef.current = state.activeData;
const { TopNavMenu } = navigation.ui;
const savingToLibraryPermitted = Boolean(
state.isSaveable && application.capabilities.visualize.save
appState.isSaveable && application.capabilities.visualize.save
);
const savingToDashboardPermitted = Boolean(
state.isSaveable && application.capabilities.dashboard?.showWriteControls
);
const unsavedTitle = i18n.translate('xpack.lens.app.unsavedFilename', {
defaultMessage: 'unsaved',
});
const topNavConfig = getLensTopNavConfig({
showSaveAndReturn: Boolean(
state.isLinkedToOriginatingApp &&
// Temporarily required until the 'by value' paradigm is default.
(dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput))
),
enableExportToCSV: Boolean(
state.isSaveable && state.activeData && Object.keys(state.activeData).length
),
isByValueMode: getIsByValueMode(),
allowByValue: dashboardFeatureFlag.allowByValueEmbeddables,
showCancel: Boolean(state.isLinkedToOriginatingApp),
savingToLibraryPermitted,
savingToDashboardPermitted,
actions: {
exportToCSV: () => {
if (!state.activeData) {
return;
}
const datatables = Object.values(state.activeData);
const content = datatables.reduce<Record<string, { content: string; type: string }>>(
(memo, datatable, i) => {
// skip empty datatables
if (datatable) {
const postFix = datatables.length > 1 ? `-${i + 1}` : '';
memo[`${lastKnownDoc?.title || unsavedTitle}${postFix}.csv`] = {
content: exporters.datatableToCSV(datatable, {
csvSeparator: uiSettings.get('csv:separator', ','),
quoteValues: uiSettings.get('csv:quoteValues', true),
formatFactory: data.fieldFormats.deserialize,
}),
type: exporters.CSV_MIME_TYPE,
};
}
return memo;
},
{}
);
if (content) {
downloadMultipleAs(content);
}
},
saveAndReturn: () => {
if (savingToDashboardPermitted && lastKnownDoc) {
// disabling the validation on app leave because the document has been saved.
onAppLeave((actions) => {
return actions.default();
});
runSave(
{
newTitle: lastKnownDoc.title,
newCopyOnSave: false,
isTitleDuplicateConfirmed: false,
returnToOrigin: true,
},
{
saveToLibrary:
(initialInput && attributeService.inputIsRefType(initialInput)) ?? false,
}
);
}
},
showSaveModal: () => {
if (savingToDashboardPermitted || savingToLibraryPermitted) {
setIsSaveModalVisible(true);
}
},
cancel: () => {
if (redirectToOrigin) {
redirectToOrigin();
}
},
},
});
return (
<>
<div className="lnsApp">
<TopNavMenu
setMenuMountPoint={setHeaderActionMenu}
config={topNavConfig}
showSearchBar={true}
showDatePicker={true}
showQueryBar={true}
showFilterBar={true}
indexPatterns={state.indexPatternsForTopNav}
showSaveQuery={Boolean(application.capabilities.visualize.saveQuery)}
savedQuery={state.savedQuery}
data-test-subj="lnsApp_topNav"
screenTitle={'lens'}
appName={'lens'}
onQuerySubmit={(payload) => {
const { dateRange, query } = payload;
const currentRange = data.query.timefilter.timefilter.getTime();
if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) {
data.query.timefilter.timefilter.setTime(dateRange);
trackUiEvent('app_date_change');
} else {
// Query has changed, renew the session id.
// Time change will be picked up by the time subscription
setState((s) => ({
...s,
searchSessionId: startSession(),
}));
trackUiEvent('app_query_change');
}
setState((s) => ({
...s,
query: query || s.query,
}));
}}
onSaved={(savedQuery) => {
setState((s) => ({ ...s, savedQuery }));
}}
onSavedQueryUpdated={(savedQuery) => {
const savedQueryFilters = savedQuery.attributes.filters || [];
const globalFilters = data.query.filterManager.getGlobalFilters();
data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]);
setState((s) => ({
...s,
savedQuery: { ...savedQuery }, // Shallow query for reference issues
query: savedQuery.attributes.query,
}));
}}
onClearSavedQuery={() => {
data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters());
setState((s) => ({
...s,
savedQuery: undefined,
filters: data.query.filterManager.getGlobalFilters(),
query: data.query.queryString.getDefaultQuery(),
}));
}}
query={state.query}
dateRangeFrom={fromDate}
dateRangeTo={toDate}
<LensTopNavMenu
initialInput={initialInput}
redirectToOrigin={redirectToOrigin}
getIsByValueMode={getIsByValueMode}
onAppLeave={onAppLeave}
runSave={runSave}
setIsSaveModalVisible={setIsSaveModalVisible}
setHeaderActionMenu={setHeaderActionMenu}
indicateNoData={indicateNoData}
/>
{(!state.isLoading || state.persistedDoc) && (
{(!appState.isAppLoading || appState.persistedDoc) && (
<MemoizedEditorFrameWrapper
editorFrame={editorFrame}
resolvedDateRange={resolvedDateRange}
onError={onError}
showNoDataPopover={showNoDataPopover}
initialContext={initialContext}
setState={setState}
data={data}
query={state.query}
filters={state.filters}
searchSessionId={state.searchSessionId}
isSaveable={state.isSaveable}
savedQuery={state.savedQuery}
persistedDoc={state.persistedDoc}
indexPatterns={state.indexPatternsForTopNav}
activeData={activeDataRef}
lastKnownDoc={lastKnownDocRef}
/>
)}
</div>
<SaveModal
isVisible={isSaveModalVisible}
originatingApp={state.isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined}
originatingApp={
appState.isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined
}
savingToLibraryPermitted={savingToLibraryPermitted}
allowByValueEmbeddables={dashboardFeatureFlag.allowByValueEmbeddables}
savedObjectsTagging={savedObjectsTagging}
@ -741,101 +401,28 @@ export function App({
const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({
editorFrame,
query,
filters,
searchSessionId,
isSaveable: oldIsSaveable,
savedQuery,
persistedDoc,
indexPatterns: indexPatternsForTopNav,
resolvedDateRange,
onError,
showNoDataPopover,
initialContext,
setState,
data,
lastKnownDoc,
activeData: activeDataRef,
}: {
editorFrame: EditorFrameInstance;
searchSessionId: string;
query: Query;
filters: Filter[];
isSaveable: boolean;
savedQuery?: SavedQuery;
persistedDoc?: Document | undefined;
indexPatterns: IndexPatternInstance[];
resolvedDateRange: { fromDate: string; toDate: string };
onError: (e: { message: string }) => Toast;
showNoDataPopover: () => void;
initialContext: VisualizeFieldContext | undefined;
setState: React.Dispatch<React.SetStateAction<LensAppState>>;
data: DataPublicPluginStart;
lastKnownDoc: React.MutableRefObject<Document | undefined>;
activeData: React.MutableRefObject<Record<string, Datatable> | undefined>;
}) {
const { EditorFrameContainer } = editorFrame;
return (
<EditorFrameContainer
doc={persistedDoc}
searchSessionId={searchSessionId}
dateRange={resolvedDateRange}
query={query}
filters={filters}
savedQuery={savedQuery}
onError={onError}
showNoDataPopover={showNoDataPopover}
initialContext={initialContext}
onChange={({ filterableIndexPatterns, doc, isSaveable, activeData }) => {
if (isSaveable !== oldIsSaveable) {
setState((s) => ({ ...s, isSaveable }));
}
if (!_.isEqual(persistedDoc, doc) && !_.isEqual(lastKnownDoc.current, doc)) {
setState((s) => ({ ...s, lastKnownDoc: doc }));
}
if (!_.isEqual(activeDataRef.current, activeData)) {
setState((s) => ({ ...s, activeData }));
}
// Update the cached index patterns if the user made a change to any of them
if (
indexPatternsForTopNav.length !== filterableIndexPatterns.length ||
filterableIndexPatterns.some(
(id) => !indexPatternsForTopNav.find((indexPattern) => indexPattern.id === id)
)
) {
getAllIndexPatterns(filterableIndexPatterns, data.indexPatterns).then(
({ indexPatterns }) => {
if (indexPatterns) {
setState((s) => ({ ...s, indexPatternsForTopNav: indexPatterns }));
}
}
);
}
}}
/>
);
});
export async function getAllIndexPatterns(
ids: string[],
indexPatternsService: IndexPatternsContract
): Promise<{ indexPatterns: IndexPatternInstance[]; rejectedIds: string[] }> {
const responses = await Promise.allSettled(ids.map((id) => indexPatternsService.get(id)));
const fullfilled = responses.filter(
(response): response is PromiseFulfilledResult<IndexPatternInstance> =>
response.status === 'fulfilled'
);
const rejectedIds = responses
.map((_response, i) => ids[i])
.filter((id, i) => responses[i].status === 'rejected');
// return also the rejected ids in case we want to show something later on
return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds };
}
function getLastKnownDocWithoutPinnedFilters(doc?: Document) {
if (!doc) return undefined;
const [pinnedFilters, appFilters] = _.partition(
const [pinnedFilters, appFilters] = partition(
injectFilterReferences(doc.state?.filters || [], doc.references),
esFilters.isFilterPinned
);

View file

@ -5,11 +5,25 @@
* 2.0.
*/
import { isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { TopNavMenuData } from '../../../../../src/plugins/navigation/public';
import { LensTopNavActions } from './types';
import { LensAppServices, LensTopNavActions, LensTopNavMenuProps } from './types';
import { downloadMultipleAs } from '../../../../../src/plugins/share/public';
import { trackUiEvent } from '../lens_ui_telemetry';
import { exporters } from '../../../../../src/plugins/data/public';
export function getLensTopNavConfig(options: {
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import {
setState as setAppState,
useLensSelector,
useLensDispatch,
LensAppState,
DispatchSetState,
} from '../state_management';
function getLensTopNavConfig(options: {
showSaveAndReturn: boolean;
enableExportToCSV: boolean;
showCancel: boolean;
@ -101,6 +115,185 @@ export function getLensTopNavConfig(options: {
}),
});
}
return topNavMenu;
}
export const LensTopNavMenu = ({
setHeaderActionMenu,
initialInput,
indicateNoData,
setIsSaveModalVisible,
getIsByValueMode,
runSave,
onAppLeave,
redirectToOrigin,
}: LensTopNavMenuProps) => {
const {
data,
navigation,
uiSettings,
application,
attributeService,
dashboardFeatureFlag,
} = useKibana<LensAppServices>().services;
const dispatch = useLensDispatch();
const dispatchSetState: DispatchSetState = React.useCallback(
(state: Partial<LensAppState>) => dispatch(setAppState(state)),
[dispatch]
);
const {
isSaveable,
isLinkedToOriginatingApp,
indexPatternsForTopNav,
query,
lastKnownDoc,
activeData,
savedQuery,
} = useLensSelector((state) => state.app);
const { TopNavMenu } = navigation.ui;
const { from, to } = data.query.timefilter.timefilter.getTime();
const savingToLibraryPermitted = Boolean(isSaveable && application.capabilities.visualize.save);
const savingToDashboardPermitted = Boolean(
isSaveable && application.capabilities.dashboard?.showWriteControls
);
const unsavedTitle = i18n.translate('xpack.lens.app.unsavedFilename', {
defaultMessage: 'unsaved',
});
const topNavConfig = getLensTopNavConfig({
showSaveAndReturn: Boolean(
isLinkedToOriginatingApp &&
// Temporarily required until the 'by value' paradigm is default.
(dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput))
),
enableExportToCSV: Boolean(isSaveable && activeData && Object.keys(activeData).length),
isByValueMode: getIsByValueMode(),
allowByValue: dashboardFeatureFlag.allowByValueEmbeddables,
showCancel: Boolean(isLinkedToOriginatingApp),
savingToLibraryPermitted,
savingToDashboardPermitted,
actions: {
exportToCSV: () => {
if (!activeData) {
return;
}
const datatables = Object.values(activeData);
const content = datatables.reduce<Record<string, { content: string; type: string }>>(
(memo, datatable, i) => {
// skip empty datatables
if (datatable) {
const postFix = datatables.length > 1 ? `-${i + 1}` : '';
memo[`${lastKnownDoc?.title || unsavedTitle}${postFix}.csv`] = {
content: exporters.datatableToCSV(datatable, {
csvSeparator: uiSettings.get('csv:separator', ','),
quoteValues: uiSettings.get('csv:quoteValues', true),
formatFactory: data.fieldFormats.deserialize,
}),
type: exporters.CSV_MIME_TYPE,
};
}
return memo;
},
{}
);
if (content) {
downloadMultipleAs(content);
}
},
saveAndReturn: () => {
if (savingToDashboardPermitted && lastKnownDoc) {
// disabling the validation on app leave because the document has been saved.
onAppLeave((actions) => {
return actions.default();
});
runSave(
{
newTitle: lastKnownDoc.title,
newCopyOnSave: false,
isTitleDuplicateConfirmed: false,
returnToOrigin: true,
},
{
saveToLibrary:
(initialInput && attributeService.inputIsRefType(initialInput)) ?? false,
}
);
}
},
showSaveModal: () => {
if (savingToDashboardPermitted || savingToLibraryPermitted) {
setIsSaveModalVisible(true);
}
},
cancel: () => {
if (redirectToOrigin) {
redirectToOrigin();
}
},
},
});
return (
<TopNavMenu
setMenuMountPoint={setHeaderActionMenu}
config={topNavConfig}
showSaveQuery={Boolean(application.capabilities.visualize.saveQuery)}
savedQuery={savedQuery}
onQuerySubmit={(payload) => {
const { dateRange, query: newQuery } = payload;
const currentRange = data.query.timefilter.timefilter.getTime();
if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) {
data.query.timefilter.timefilter.setTime(dateRange);
trackUiEvent('app_date_change');
} else {
// Query has changed, renew the session id.
// Time change will be picked up by the time subscription
dispatchSetState({ searchSessionId: data.search.session.start() });
trackUiEvent('app_query_change');
}
if (newQuery) {
if (!isEqual(newQuery, query)) {
dispatchSetState({ query: newQuery });
}
}
}}
onSaved={(newSavedQuery) => {
dispatchSetState({ savedQuery: newSavedQuery });
}}
onSavedQueryUpdated={(newSavedQuery) => {
const savedQueryFilters = newSavedQuery.attributes.filters || [];
const globalFilters = data.query.filterManager.getGlobalFilters();
data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]);
dispatchSetState({
query: newSavedQuery.attributes.query,
savedQuery: { ...newSavedQuery },
}); // Shallow query for reference issues
}}
onClearSavedQuery={() => {
data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters());
dispatchSetState({
filters: data.query.filterManager.getGlobalFilters(),
query: data.query.queryString.getDefaultQuery(),
savedQuery: undefined,
});
}}
indexPatterns={indexPatternsForTopNav}
query={query}
dateRangeFrom={from}
dateRangeTo={to}
indicateNoData={indicateNoData}
showSearchBar={true}
showDatePicker={true}
showQueryBar={true}
showFilterBar={true}
data-test-subj="lnsApp_topNav"
screenTitle={'lens'}
appName={'lens'}
/>
);
};

View file

@ -0,0 +1,150 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { makeDefaultServices, mockLensStore } from '../mocks';
import { act } from 'react-dom/test-utils';
import { loadDocument } from './mounter';
import { LensEmbeddableInput } from '../editor_frame_service/embeddable/embeddable';
const defaultSavedObjectId = '1234';
describe('Mounter', () => {
describe('loadDocument', () => {
it('does not load a document if there is no initial input', async () => {
const services = makeDefaultServices();
const redirectCallback = jest.fn();
const lensStore = mockLensStore({ data: services.data });
await loadDocument(redirectCallback, undefined, services, lensStore);
expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled();
});
it('loads a document and uses query and filters if initial input is provided', async () => {
const services = makeDefaultServices();
const redirectCallback = jest.fn();
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
savedObjectId: defaultSavedObjectId,
state: {
query: 'fake query',
filters: [{ query: { match_phrase: { src: 'test' } } }],
},
references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
});
const lensStore = await mockLensStore({ data: services.data });
await act(async () => {
await loadDocument(
redirectCallback,
{ savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
services,
lensStore
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
savedObjectId: defaultSavedObjectId,
});
expect(services.data.indexPatterns.get).toHaveBeenCalledWith('1');
expect(services.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([
{ query: { match_phrase: { src: 'test' } } },
]);
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
persistedDoc: expect.objectContaining({
savedObjectId: defaultSavedObjectId,
state: expect.objectContaining({
query: 'fake query',
filters: [{ query: { match_phrase: { src: 'test' } } }],
}),
}),
}),
});
});
it('does not load documents on sequential renders unless the id changes', async () => {
const redirectCallback = jest.fn();
const services = makeDefaultServices();
const lensStore = mockLensStore({ data: services.data });
await act(async () => {
await loadDocument(
redirectCallback,
{ savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
services,
lensStore
);
});
await act(async () => {
await loadDocument(
redirectCallback,
{ savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
services,
lensStore
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1);
await act(async () => {
await loadDocument(
redirectCallback,
{ savedObjectId: '5678' } as LensEmbeddableInput,
services,
lensStore
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(2);
});
it('handles document load errors', async () => {
const services = makeDefaultServices();
const redirectCallback = jest.fn();
const lensStore = mockLensStore({ data: services.data });
services.attributeService.unwrapAttributes = jest.fn().mockRejectedValue('failed to load');
await act(async () => {
await loadDocument(
redirectCallback,
{ savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
services,
lensStore
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
savedObjectId: defaultSavedObjectId,
});
expect(services.notifications.toasts.addDanger).toHaveBeenCalled();
expect(redirectCallback).toHaveBeenCalled();
});
it('adds to the recently accessed list on load', async () => {
const redirectCallback = jest.fn();
const services = makeDefaultServices();
const lensStore = mockLensStore({ data: services.data });
await act(async () => {
await loadDocument(
redirectCallback,
({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput,
services,
lensStore
);
});
expect(services.chrome.recentlyAccessed.add).toHaveBeenCalledWith(
'/app/lens#/edit/1234',
'An extremely cool default document!',
'1234'
);
});
});
});

View file

@ -15,6 +15,8 @@ import { render, unmountComponentAtNode } from 'react-dom';
import { i18n } from '@kbn/i18n';
import { DashboardFeatureFlagConfig } from 'src/plugins/dashboard/public';
import { Provider } from 'react-redux';
import { uniq, isEqual } from 'lodash';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry';
@ -23,7 +25,7 @@ import { App } from './app';
import { EditorFrameStart } from '../types';
import { addHelpMenuToAppChrome } from '../help_menu_util';
import { LensPluginStartDependencies } from '../plugin';
import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID } from '../../common';
import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID, getFullPath } from '../../common';
import {
LensEmbeddableInput,
LensByReferenceInput,
@ -34,6 +36,16 @@ import { LensAttributeService } from '../lens_attribute_service';
import { LensAppServices, RedirectToOriginProps, HistoryLocationState } from './types';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import {
makeConfigureStore,
navigateAway,
getPreloadedState,
LensRootStore,
setState,
} from '../state_management';
import { getAllIndexPatterns, getResolvedDateRange } from '../utils';
import { injectFilterReferences } from '../persistence';
export async function mountApp(
core: CoreSetup<LensPluginStartDependencies, void>,
params: AppMountParameters,
@ -149,8 +161,32 @@ export async function mountApp(
coreStart.application.navigateToApp(embeddableEditorIncomingState?.originatingApp);
}
};
const initialContext =
historyLocationState && historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD
? historyLocationState.payload
: undefined;
// Clear app-specific filters when navigating to Lens. Necessary because Lens
// can be loaded without a full page refresh. If the user navigates to Lens from Discover
// we keep the filters
if (!initialContext) {
data.query.filterManager.setAppFilters([]);
}
const preloadedState = getPreloadedState({
query: data.query.queryString.getQuery(),
// Do not use app-specific filters from previous app,
// only if Lens was opened with the intention to visualize a field (e.g. coming from Discover)
filters: !initialContext
? data.query.filterManager.getGlobalFilters()
: data.query.filterManager.getFilters(),
searchSessionId: data.search.session.start(),
resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter),
isLinkedToOriginatingApp: Boolean(embeddableEditorIncomingState?.originatingApp),
});
const lensStore: LensRootStore = makeConfigureStore(preloadedState, { data });
// const featureFlagConfig = await getByValueFeatureFlag();
const EditorRenderer = React.memo(
(props: { id?: string; history: History<unknown>; editByValue?: boolean }) => {
const redirectCallback = useCallback(
@ -160,23 +196,23 @@ export async function mountApp(
[props.history]
);
trackUiEvent('loaded');
const initialInput = getInitialInput(props.id, props.editByValue);
loadDocument(redirectCallback, initialInput, lensServices, lensStore);
return (
<App
incomingState={embeddableEditorIncomingState}
editorFrame={instance}
initialInput={getInitialInput(props.id, props.editByValue)}
redirectTo={redirectCallback}
redirectToOrigin={redirectToOrigin}
redirectToDashboard={redirectToDashboard}
onAppLeave={params.onAppLeave}
setHeaderActionMenu={params.setHeaderActionMenu}
history={props.history}
initialContext={
historyLocationState && historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD
? historyLocationState.payload
: undefined
}
/>
<Provider store={lensStore}>
<App
incomingState={embeddableEditorIncomingState}
editorFrame={instance}
initialInput={initialInput}
redirectTo={redirectCallback}
redirectToOrigin={redirectToOrigin}
redirectToDashboard={redirectToDashboard}
onAppLeave={params.onAppLeave}
setHeaderActionMenu={params.setHeaderActionMenu}
history={props.history}
initialContext={initialContext}
/>
</Provider>
);
}
);
@ -232,5 +268,86 @@ export async function mountApp(
data.search.session.clear();
unmountComponentAtNode(params.element);
unlistenParentHistory();
lensStore.dispatch(navigateAway());
};
}
export function loadDocument(
redirectCallback: (savedObjectId?: string) => void,
initialInput: LensEmbeddableInput | undefined,
lensServices: LensAppServices,
lensStore: LensRootStore
) {
const { attributeService, chrome, notifications, data } = lensServices;
const { persistedDoc } = lensStore.getState().app;
if (
!initialInput ||
(attributeService.inputIsRefType(initialInput) &&
initialInput.savedObjectId === persistedDoc?.savedObjectId)
) {
return;
}
lensStore.dispatch(setState({ isAppLoading: true }));
attributeService
.unwrapAttributes(initialInput)
.then((attributes) => {
if (!initialInput) {
return;
}
const doc = {
...initialInput,
...attributes,
type: LENS_EMBEDDABLE_TYPE,
};
if (attributeService.inputIsRefType(initialInput)) {
chrome.recentlyAccessed.add(
getFullPath(initialInput.savedObjectId),
attributes.title,
initialInput.savedObjectId
);
}
const indexPatternIds = uniq(
doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
);
getAllIndexPatterns(indexPatternIds, data.indexPatterns)
.then(({ indexPatterns }) => {
// Don't overwrite any pinned filters
data.query.filterManager.setAppFilters(
injectFilterReferences(doc.state.filters, doc.references)
);
lensStore.dispatch(
setState({
query: doc.state.query,
isAppLoading: false,
indexPatternsForTopNav: indexPatterns,
lastKnownDoc: doc,
...(!isEqual(persistedDoc, doc) ? { persistedDoc: doc } : null),
})
);
})
.catch((e) => {
lensStore.dispatch(
setState({
isAppLoading: false,
})
);
redirectCallback();
});
})
.catch((e) => {
lensStore.dispatch(
setState({
isAppLoading: false,
})
);
notifications.toasts.addDanger(
i18n.translate('xpack.lens.app.docLoadingError', {
defaultMessage: 'Error loading saved document',
})
);
redirectCallback();
});
}

View file

@ -1,84 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import './app.scss';
import _ from 'lodash';
import moment from 'moment';
import { useEffect, useMemo } from 'react';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { LensAppState } from './types';
import { Document } from '../persistence';
function containsDynamicMath(dateMathString: string) {
return dateMathString.includes('now');
}
const TIME_LAG_PERCENTAGE_LIMIT = 0.02;
/**
* Fetches the current global time range from data plugin and restarts session
* if the fixed "now" parameter is diverging too much from the actual current time.
* @param data data plugin contract to manage current now value, time range and session
* @param lastKnownDoc Current state of the editor
* @param setState state setter for Lens app state
* @param searchSessionId current session id
*/
export function useTimeRange(
data: DataPublicPluginStart,
lastKnownDoc: Document | undefined,
setState: React.Dispatch<React.SetStateAction<LensAppState>>,
searchSessionId: string
) {
const timefilter = data.query.timefilter.timefilter;
const { from, to } = data.query.timefilter.timefilter.getTime();
// Need a stable reference for the frame component of the dateRange
const resolvedDateRange = useMemo(() => {
const { min, max } = timefilter.calculateBounds({
from,
to,
});
return { fromDate: min?.toISOString() || from, toDate: max?.toISOString() || to };
// recalculate current date range if the session gets updated because it
// might change "now" and calculateBounds depends on it internally
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timefilter, searchSessionId, from, to]);
useEffect(() => {
const unresolvedTimeRange = timefilter.getTime();
if (
!containsDynamicMath(unresolvedTimeRange.from) &&
!containsDynamicMath(unresolvedTimeRange.to)
) {
return;
}
const { min, max } = timefilter.getBounds();
if (!min || !max) {
// bounds not fully specified, bailing out
return;
}
// calculate length of currently configured range in ms
const timeRangeLength = moment.duration(max.diff(min)).asMilliseconds();
// calculate lag of managed "now" for date math
const nowDiff = Date.now() - data.nowProvider.get().valueOf();
// if the lag is signifcant, start a new session to clear the cache
if (nowDiff > timeRangeLength * TIME_LAG_PERCENTAGE_LIMIT) {
setState((s) => ({
...s,
searchSessionId: data.search.session.start(),
}));
}
}, [data.nowProvider, data.search.session, timefilter, lastKnownDoc, setState]);
return { resolvedDateRange, from, to };
}

View file

@ -6,6 +6,7 @@
*/
import { History } from 'history';
import { OnSaveProps } from 'src/plugins/saved_objects/public';
import {
ApplicationStart,
AppMountParameters,
@ -16,14 +17,7 @@ import {
OverlayStart,
SavedObjectsStart,
} from '../../../../../src/core/public';
import {
DataPublicPluginStart,
Filter,
IndexPattern,
Query,
SavedQuery,
} from '../../../../../src/plugins/data/public';
import { Document } from '../persistence';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { LensEmbeddableInput } from '../editor_frame_service/embeddable/embeddable';
import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigation/public';
import { LensAttributeService } from '../lens_attribute_service';
@ -38,28 +32,7 @@ import {
EmbeddableEditorState,
EmbeddableStateTransfer,
} from '../../../../../src/plugins/embeddable/public';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { EditorFrameInstance } from '../types';
export interface LensAppState {
isLoading: boolean;
persistedDoc?: Document;
lastKnownDoc?: Document;
// index patterns used to determine which filters are available in the top nav.
indexPatternsForTopNav: IndexPattern[];
// Determines whether the lens editor shows the 'save and return' button, and the originating app breadcrumb.
isLinkedToOriginatingApp?: boolean;
query: Query;
filters: Filter[];
savedQuery?: SavedQuery;
isSaveable: boolean;
activeData?: TableInspectorAdapter;
searchSessionId: string;
}
export interface RedirectToOriginProps {
input?: LensEmbeddableInput;
isCopied?: boolean;
@ -82,6 +55,32 @@ export interface LensAppProps {
initialContext?: VisualizeFieldContext;
}
export type RunSave = (
saveProps: Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
dashboardId?: string | null;
onTitleDuplicate?: OnSaveProps['onTitleDuplicate'];
newDescription?: string;
newTags?: string[];
},
options: {
saveToLibrary: boolean;
}
) => Promise<void>;
export interface LensTopNavMenuProps {
onAppLeave: AppMountParameters['onAppLeave'];
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
redirectToOrigin?: (props?: RedirectToOriginProps) => void;
// The initial input passed in by the container when editing. Can be either by reference or by value.
initialInput?: LensEmbeddableInput;
getIsByValueMode: () => boolean;
indicateNoData: boolean;
setIsSaveModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
runSave: RunSave;
}
export interface HistoryLocationState {
type: typeof ACTION_VISUALIZE_LENS_FIELD;
payload: VisualizeFieldContext;

View file

@ -7,7 +7,10 @@
import React, { useEffect, useReducer, useState, useCallback } from 'react';
import { CoreStart } from 'kibana/public';
import { isEqual } from 'lodash';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { IndexPattern } from '../../../../../../src/plugins/data/public';
import { getAllIndexPatterns } from '../../utils';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
import { Datasource, FramePublicAPI, Visualization } from '../../types';
import { reducer, getInitialState } from './state_management';
@ -20,7 +23,6 @@ import { Document } from '../../persistence/saved_object_store';
import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop';
import { getSavedObjectFormat } from './save';
import { generateId } from '../../id_generator';
import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public';
import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public';
import { EditorFrameStartPlugins } from '../service';
import { initializeDatasources, createDatasourceLayers } from './state_helpers';
@ -30,37 +32,45 @@ import {
switchToSuggestion,
} from './suggestion_helpers';
import { trackUiEvent } from '../../lens_ui_telemetry';
import {
useLensSelector,
useLensDispatch,
LensAppState,
DispatchSetState,
onChangeFromEditorFrame,
} from '../../state_management';
export interface EditorFrameProps {
doc?: Document;
datasourceMap: Record<string, Datasource>;
visualizationMap: Record<string, Visualization>;
initialDatasourceId: string | null;
initialVisualizationId: string | null;
ExpressionRenderer: ReactExpressionRendererType;
palettes: PaletteRegistry;
onError: (e: { message: string }) => void;
core: CoreStart;
plugins: EditorFrameStartPlugins;
dateRange: {
fromDate: string;
toDate: string;
};
query: Query;
filters: Filter[];
savedQuery?: SavedQuery;
searchSessionId: string;
onChange: (arg: {
filterableIndexPatterns: string[];
doc: Document;
isSaveable: boolean;
}) => void;
showNoDataPopover: () => void;
initialContext?: VisualizeFieldContext;
}
export function EditorFrame(props: EditorFrameProps) {
const [state, dispatch] = useReducer(reducer, props, getInitialState);
const {
filters,
searchSessionId,
savedQuery,
query,
persistedDoc,
indexPatternsForTopNav,
lastKnownDoc,
activeData,
isSaveable,
resolvedDateRange: dateRange,
} = useLensSelector((state) => state.app);
const [state, dispatch] = useReducer(reducer, { ...props, doc: persistedDoc }, getInitialState);
const dispatchLens = useLensDispatch();
const dispatchChange: DispatchSetState = useCallback(
(s: Partial<LensAppState>) => dispatchLens(onChangeFromEditorFrame(s)),
[dispatchLens]
);
const [visualizeTriggerFieldContext, setVisualizeTriggerFieldContext] = useState(
props.initialContext
);
@ -81,7 +91,7 @@ export function EditorFrame(props: EditorFrameProps) {
initializeDatasources(
props.datasourceMap,
state.datasourceStates,
props.doc?.references,
persistedDoc?.references,
visualizeTriggerFieldContext,
{ isFullEditor: true }
)
@ -109,11 +119,11 @@ export function EditorFrame(props: EditorFrameProps) {
const framePublicAPI: FramePublicAPI = {
datasourceLayers,
activeData: state.activeData,
dateRange: props.dateRange,
query: props.query,
filters: props.filters,
searchSessionId: props.searchSessionId,
activeData,
dateRange,
query,
filters,
searchSessionId,
availablePalettes: props.palettes,
addNewLayer() {
@ -160,19 +170,19 @@ export function EditorFrame(props: EditorFrameProps) {
useEffect(
() => {
if (props.doc) {
if (persistedDoc) {
dispatch({
type: 'VISUALIZATION_LOADED',
doc: {
...props.doc,
...persistedDoc,
state: {
...props.doc.state,
visualization: props.doc.visualizationType
? props.visualizationMap[props.doc.visualizationType].initialize(
...persistedDoc.state,
visualization: persistedDoc.visualizationType
? props.visualizationMap[persistedDoc.visualizationType].initialize(
framePublicAPI,
props.doc.state.visualization
persistedDoc.state.visualization
)
: props.doc.state.visualization,
: persistedDoc.state.visualization,
},
},
});
@ -184,7 +194,7 @@ export function EditorFrame(props: EditorFrameProps) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.doc]
[persistedDoc]
);
// Initialize visualization as soon as all datasources are ready
@ -205,7 +215,7 @@ export function EditorFrame(props: EditorFrameProps) {
// Get suggestions for visualize field when all datasources are ready
useEffect(() => {
if (allLoaded && visualizeTriggerFieldContext && !props.doc) {
if (allLoaded && visualizeTriggerFieldContext && !persistedDoc) {
applyVisualizeFieldSuggestions({
datasourceMap: props.datasourceMap,
datasourceStates: state.datasourceStates,
@ -220,6 +230,51 @@ export function EditorFrame(props: EditorFrameProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allLoaded]);
const getStateToUpdate: (
arg: {
filterableIndexPatterns: string[];
doc: Document;
isSaveable: boolean;
},
oldState: {
isSaveable: boolean;
indexPatternsForTopNav: IndexPattern[];
persistedDoc?: Document;
lastKnownDoc?: Document;
}
) => Promise<Partial<LensAppState> | undefined> = async (
{ filterableIndexPatterns, doc, isSaveable: incomingIsSaveable },
prevState
) => {
const batchedStateToUpdate: Partial<LensAppState> = {};
if (incomingIsSaveable !== prevState.isSaveable) {
batchedStateToUpdate.isSaveable = incomingIsSaveable;
}
if (!isEqual(prevState.persistedDoc, doc) && !isEqual(prevState.lastKnownDoc, doc)) {
batchedStateToUpdate.lastKnownDoc = doc;
}
const hasIndexPatternsChanged =
prevState.indexPatternsForTopNav.length !== filterableIndexPatterns.length ||
filterableIndexPatterns.some(
(id) => !prevState.indexPatternsForTopNav.find((indexPattern) => indexPattern.id === id)
);
// Update the cached index patterns if the user made a change to any of them
if (hasIndexPatternsChanged) {
const { indexPatterns } = await getAllIndexPatterns(
filterableIndexPatterns,
props.plugins.data.indexPatterns
);
if (indexPatterns) {
batchedStateToUpdate.indexPatternsForTopNav = indexPatterns;
}
}
if (Object.keys(batchedStateToUpdate).length) {
return batchedStateToUpdate;
}
};
// The frame needs to call onChange every time its internal state changes
useEffect(
() => {
@ -232,31 +287,43 @@ export function EditorFrame(props: EditorFrameProps) {
return;
}
props.onChange(
getSavedObjectFormat({
activeDatasources: Object.keys(state.datasourceStates).reduce(
(datasourceMap, datasourceId) => ({
...datasourceMap,
[datasourceId]: props.datasourceMap[datasourceId],
}),
{}
),
visualization: activeVisualization,
state,
framePublicAPI,
})
);
const savedObjectFormat = getSavedObjectFormat({
activeDatasources: Object.keys(state.datasourceStates).reduce(
(datasourceMap, datasourceId) => ({
...datasourceMap,
[datasourceId]: props.datasourceMap[datasourceId],
}),
{}
),
visualization: activeVisualization,
state,
framePublicAPI,
});
// Frame loader (app or embeddable) is expected to call this when it loads and updates
// This should be replaced with a top-down state
getStateToUpdate(savedObjectFormat, {
isSaveable,
persistedDoc,
indexPatternsForTopNav,
lastKnownDoc,
}).then((batchedStateToUpdate) => {
if (batchedStateToUpdate) {
dispatchChange(batchedStateToUpdate);
}
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
activeVisualization,
state.datasourceStates,
state.visualization,
state.activeData,
props.query,
props.filters,
props.savedQuery,
activeData,
query,
filters,
savedQuery,
state.title,
dispatchChange,
]
);
@ -326,9 +393,9 @@ export function EditorFrame(props: EditorFrameProps) {
}
dispatch={dispatch}
core={props.core}
query={props.query}
dateRange={props.dateRange}
filters={props.filters}
query={query}
dateRange={dateRange}
filters={filters}
showNoDataPopover={props.showNoDataPopover}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import _ from 'lodash';
import { uniq } from 'lodash';
import { SavedObjectReference } from 'kibana/public';
import { Datatable } from 'src/plugins/expressions';
import { EditorFrameState } from './state_management';
import { Document } from '../../persistence/saved_object_store';
import { Datasource, Visualization, FramePublicAPI } from '../../types';
@ -30,7 +29,6 @@ export function getSavedObjectFormat({
doc: Document;
filterableIndexPatterns: string[];
isSaveable: boolean;
activeData: Record<string, Datatable> | undefined;
} {
const datasourceStates: Record<string, unknown> = {};
const references: SavedObjectReference[] = [];
@ -42,7 +40,7 @@ export function getSavedObjectFormat({
references.push(...savedObjectReferences);
});
const uniqueFilterableIndexPatternIds = _.uniq(
const uniqueFilterableIndexPatternIds = uniq(
references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
);
@ -77,6 +75,5 @@ export function getSavedObjectFormat({
},
filterableIndexPatterns: uniqueFilterableIndexPatternIds,
isSaveable: expression !== null,
activeData: state.activeData,
};
}

View file

@ -24,10 +24,7 @@ describe('editor_frame state management', () => {
onError: jest.fn(),
datasourceMap: { testDatasource: ({} as unknown) as Datasource },
visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization },
initialDatasourceId: 'testDatasource',
initialVisualizationId: 'testVis',
ExpressionRenderer: createExpressionRendererMock(),
onChange: jest.fn(),
core: coreMock.createStart(),
plugins: {
uiActions: uiActionsPluginMock.createStartContract(),
@ -36,11 +33,7 @@ describe('editor_frame state management', () => {
charts: chartPluginMock.createStartContract(),
},
palettes: chartPluginMock.createPaletteRegistry(),
dateRange: { fromDate: 'now-7d', toDate: 'now' },
query: { query: '', language: 'lucene' },
filters: [],
showNoDataPopover: jest.fn(),
searchSessionId: 'sessionId',
};
});
@ -101,8 +94,8 @@ describe('editor_frame state management', () => {
`);
});
it('should not set active id if no initial visualization is passed in', () => {
const initialState = getInitialState({ ...props, initialVisualizationId: null });
it('should not set active id if initiated with empty document and visualizationMap is empty', () => {
const initialState = getInitialState({ ...props, visualizationMap: {} });
expect(initialState.visualization.state).toEqual(null);
expect(initialState.visualization.activeId).toEqual(null);

View file

@ -7,7 +7,6 @@
import { EditorFrameProps } from './index';
import { Document } from '../../persistence/saved_object_store';
import { TableInspectorAdapter } from '../types';
export interface PreviewState {
visualization: {
@ -23,7 +22,6 @@ export interface EditorFrameState extends PreviewState {
description?: string;
stagedPreview?: PreviewState;
activeDatasourceId: string | null;
activeData?: TableInspectorAdapter;
}
export type Action =
@ -35,10 +33,6 @@ export type Action =
type: 'UPDATE_TITLE';
title: string;
}
| {
type: 'UPDATE_ACTIVE_DATA';
tables: TableInspectorAdapter;
}
| {
type: 'UPDATE_STATE';
// Just for diagnostics, so we can determine what action
@ -103,25 +97,27 @@ export function getActiveDatasourceIdFromDoc(doc?: Document) {
return null;
}
const [initialDatasourceId] = Object.keys(doc.state.datasourceStates);
return initialDatasourceId || null;
const [firstDatasourceFromDoc] = Object.keys(doc.state.datasourceStates);
return firstDatasourceFromDoc || null;
}
function getInitialDatasourceId(props: EditorFrameProps) {
return props.initialDatasourceId
? props.initialDatasourceId
: getActiveDatasourceIdFromDoc(props.doc);
}
export const getInitialState = (props: EditorFrameProps): EditorFrameState => {
export const getInitialState = (
params: EditorFrameProps & { doc?: Document }
): EditorFrameState => {
const datasourceStates: EditorFrameState['datasourceStates'] = {};
if (props.doc) {
Object.entries(props.doc.state.datasourceStates).forEach(([datasourceId, state]) => {
const initialDatasourceId =
getActiveDatasourceIdFromDoc(params.doc) || Object.keys(params.datasourceMap)[0] || null;
const initialVisualizationId =
(params.doc && params.doc.visualizationType) || Object.keys(params.visualizationMap)[0] || null;
if (params.doc) {
Object.entries(params.doc.state.datasourceStates).forEach(([datasourceId, state]) => {
datasourceStates[datasourceId] = { isLoading: true, state };
});
} else if (props.initialDatasourceId) {
datasourceStates[props.initialDatasourceId] = {
} else if (initialDatasourceId) {
datasourceStates[initialDatasourceId] = {
state: null,
isLoading: true,
};
@ -130,10 +126,10 @@ export const getInitialState = (props: EditorFrameProps): EditorFrameState => {
return {
title: '',
datasourceStates,
activeDatasourceId: getInitialDatasourceId(props),
activeDatasourceId: initialDatasourceId,
visualization: {
state: null,
activeId: props.initialVisualizationId,
activeId: initialVisualizationId,
},
};
};
@ -146,11 +142,6 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta
return { ...state, title: action.title };
case 'UPDATE_STATE':
return action.updater(state);
case 'UPDATE_ACTIVE_DATA':
return {
...state,
activeData: { ...action.tables },
};
case 'UPDATE_LAYER':
return {
...state,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import _ from 'lodash';
import { flatten } from 'lodash';
import { Ast } from '@kbn/interpreter/common';
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Datatable } from 'src/plugins/expressions';
@ -79,7 +79,7 @@ export function getSuggestions({
);
// Collect all table suggestions from available datasources
const datasourceTableSuggestions = _.flatten(
const datasourceTableSuggestions = flatten(
datasources.map(([datasourceId, datasource]) => {
const datasourceState = datasourceStates[datasourceId].state;
let dataSourceSuggestions;
@ -103,9 +103,9 @@ export function getSuggestions({
// Pass all table suggestions to all visualization extensions to get visualization suggestions
// and rank them by score
return _.flatten(
return flatten(
Object.entries(visualizationMap).map(([visualizationId, visualization]) =>
_.flatten(
flatten(
datasourceTableSuggestions.map((datasourceSuggestion) => {
const table = datasourceSuggestion.table;
const currentVisualizationState =

View file

@ -16,14 +16,14 @@ import {
DatasourceMock,
createMockFramePublicAPI,
} from '../../mocks';
import { mockDataPlugin, mountWithProvider } from '../../../mocks';
jest.mock('../../../debounced_component', () => {
return {
debouncedComponent: (fn: unknown) => fn,
};
});
import { WorkspacePanel, WorkspacePanelProps } from './workspace_panel';
import { WorkspacePanel } from './workspace_panel';
import { mountWithIntl as mount } from '@kbn/test/jest';
import { ReactWrapper } from 'enzyme';
import { DragDrop, ChildDragDropProvider } from '../../../drag_drop';
@ -34,7 +34,6 @@ import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/publ
import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks';
import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public/embeddable';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
const defaultPermissions: Record<string, Record<string, boolean | Record<string, boolean>>> = {
navLinks: { management: true },
@ -50,24 +49,22 @@ function createCoreStartWithPermissions(newCapabilities = defaultPermissions) {
return core;
}
function getDefaultProps() {
return {
activeDatasourceId: 'mock',
datasourceStates: {},
datasourceMap: {},
framePublicAPI: createMockFramePublicAPI(),
activeVisualizationId: 'vis',
visualizationState: {},
dispatch: () => {},
ExpressionRenderer: createExpressionRendererMock(),
core: createCoreStartWithPermissions(),
plugins: {
uiActions: uiActionsPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
},
getSuggestionForField: () => undefined,
};
}
const defaultProps = {
activeDatasourceId: 'mock',
datasourceStates: {},
datasourceMap: {},
framePublicAPI: createMockFramePublicAPI(),
activeVisualizationId: 'vis',
visualizationState: {},
dispatch: () => {},
ExpressionRenderer: createExpressionRendererMock(),
core: createCoreStartWithPermissions(),
plugins: {
uiActions: uiActionsPluginMock.createStartContract(),
data: mockDataPlugin(),
},
getSuggestionForField: () => undefined,
};
describe('workspace_panel', () => {
let mockVisualization: jest.Mocked<Visualization>;
@ -78,7 +75,7 @@ describe('workspace_panel', () => {
let uiActionsMock: jest.Mocked<UiActionsStart>;
let trigger: jest.Mocked<TriggerContract>;
let instance: ReactWrapper<WorkspacePanelProps>;
let instance: ReactWrapper;
beforeEach(() => {
// These are used in specific tests to assert function calls
@ -95,50 +92,56 @@ describe('workspace_panel', () => {
instance.unmount();
});
it('should render an explanatory text if no visualization is active', () => {
instance = mount(
it('should render an explanatory text if no visualization is active', async () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
activeVisualizationId={null}
visualizationMap={{
vis: mockVisualization,
}}
ExpressionRenderer={expressionRendererMock}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should render an explanatory text if the visualization does not produce an expression', () => {
instance = mount(
it('should render an explanatory text if the visualization does not produce an expression', async () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => null },
}}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should render an explanatory text if the datasource does not produce an expression', () => {
instance = mount(
it('should render an explanatory text if the datasource does not produce an expression', async () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should render the resulting expression using the expression renderer', () => {
it('should render the resulting expression using the expression renderer', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
@ -146,9 +149,9 @@ describe('workspace_panel', () => {
mockDatasource.toExpression.mockReturnValue('datasource');
mockDatasource.getLayers.mockReturnValue(['first']);
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -163,9 +166,12 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={datasource}
@ -173,16 +179,16 @@ describe('workspace_panel', () => {
`);
});
it('should execute a trigger on expression event', () => {
it('should execute a trigger on expression event', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
mockDatasource.toExpression.mockReturnValue('datasource');
mockDatasource.getLayers.mockReturnValue(['first']);
const props = getDefaultProps();
const props = defaultProps;
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...props}
datasourceStates={{
@ -200,8 +206,10 @@ describe('workspace_panel', () => {
}}
ExpressionRenderer={expressionRendererMock}
plugins={{ ...props.plugins, uiActions: uiActionsMock }}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
const onEvent = expressionRendererMock.mock.calls[0][0].onEvent!;
@ -212,7 +220,7 @@ describe('workspace_panel', () => {
expect(trigger.exec).toHaveBeenCalledWith({ data: eventData });
});
it('should push add current data table to state on data$ emitting value', () => {
it('should push add current data table to state on data$ emitting value', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
@ -221,9 +229,9 @@ describe('workspace_panel', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const dispatch = jest.fn();
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -239,18 +247,24 @@ describe('workspace_panel', () => {
}}
dispatch={dispatch}
ExpressionRenderer={expressionRendererMock}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
const onData = expressionRendererMock.mock.calls[0][0].onData$!;
const tableData = { table1: { columns: [], rows: [] } };
onData(undefined, { tables: { tables: tableData } });
expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_ACTIVE_DATA', tables: tableData });
expect(mounted.lensStore.dispatch).toHaveBeenCalledWith({
type: 'app/onActiveDataChange',
payload: { activeData: tableData },
});
});
it('should include data fetching for each layer in the expression', () => {
it('should include data fetching for each layer in the expression', async () => {
const mockDatasource2 = createMockDatasource('a');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
@ -263,9 +277,9 @@ describe('workspace_panel', () => {
mockDatasource2.toExpression.mockReturnValue('datasource2');
mockDatasource2.getLayers.mockReturnValue(['second', 'third']);
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -285,8 +299,10 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
const ast = fromExpression(instance.find(expressionRendererMock).prop('expression') as string);
@ -341,9 +357,9 @@ describe('workspace_panel', () => {
expressionRendererMock = jest.fn((_arg) => <span />);
await act(async () => {
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -358,8 +374,10 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
});
instance.update();
@ -392,9 +410,9 @@ describe('workspace_panel', () => {
expressionRendererMock = jest.fn((_arg) => <span />);
await act(async () => {
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -409,8 +427,10 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
});
instance.update();
@ -434,16 +454,16 @@ describe('workspace_panel', () => {
expect(expressionRendererMock).toHaveBeenCalledTimes(2);
});
it('should show an error message if there are missing indexpatterns in the visualization', () => {
it('should show an error message if there are missing indexpatterns in the visualization', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource.checkIntegrity.mockReturnValue(['a']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
@ -458,23 +478,25 @@ describe('workspace_panel', () => {
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="missing-refs-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should not show the management action in case of missing indexpattern and no navigation permissions', () => {
it('should not show the management action in case of missing indexpattern and no navigation permissions', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
@ -495,24 +517,26 @@ describe('workspace_panel', () => {
navLinks: { management: false },
management: { kibana: { indexPatterns: true } },
})}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(
instance.find('[data-test-subj="configuration-failure-reconfigure-indexpatterns"]').exists()
).toBeFalsy();
});
it('should not show the management action in case of missing indexpattern and no indexPattern specific permissions', () => {
it('should not show the management action in case of missing indexpattern and no indexPattern specific permissions', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
@ -532,15 +556,17 @@ describe('workspace_panel', () => {
navLinks: { management: true },
management: { kibana: { indexPatterns: false } },
})}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(
instance.find('[data-test-subj="configuration-failure-reconfigure-indexpatterns"]').exists()
).toBeFalsy();
});
it('should show an error message if validation on datasource does not pass', () => {
it('should show an error message if validation on datasource does not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
]);
@ -550,9 +576,9 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -566,14 +592,16 @@ describe('workspace_panel', () => {
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should show an error message if validation on visualization does not pass', () => {
it('should show an error message if validation on visualization does not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue(undefined);
mockDatasource.getLayers.mockReturnValue(['first']);
mockVisualization.getErrorMessages.mockReturnValue([
@ -585,9 +613,9 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -601,14 +629,16 @@ describe('workspace_panel', () => {
visualizationMap={{
vis: mockVisualization,
}}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should show an error message if validation on both datasource and visualization do not pass', () => {
it('should show an error message if validation on both datasource and visualization do not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
]);
@ -622,9 +652,9 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -638,8 +668,10 @@ describe('workspace_panel', () => {
visualizationMap={{
vis: mockVisualization,
}}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
// EuiFlexItem duplicates internally the attribute, so we need to filter only the most inner one here
expect(
@ -648,7 +680,7 @@ describe('workspace_panel', () => {
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should show an error message if the expression fails to parse', () => {
it('should show an error message if the expression fails to parse', async () => {
mockDatasource.toExpression.mockReturnValue('|||');
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
@ -656,9 +688,9 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -672,8 +704,10 @@ describe('workspace_panel', () => {
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="expression-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
@ -688,9 +722,9 @@ describe('workspace_panel', () => {
};
await act(async () => {
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -705,8 +739,10 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
});
instance.update();
@ -727,9 +763,9 @@ describe('workspace_panel', () => {
};
await act(async () => {
instance = mount(
const mounted = await mountWithProvider(
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -744,8 +780,10 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
/>
/>,
defaultProps.plugins.data
);
instance = mounted.instance;
});
instance.update();
@ -791,7 +829,7 @@ describe('workspace_panel', () => {
dropTargetsByOrder={undefined}
>
<WorkspacePanel
{...getDefaultProps()}
{...defaultProps}
datasourceStates={{
mock: {
state: {},
@ -813,7 +851,7 @@ describe('workspace_panel', () => {
);
}
it('should immediately transition if exactly one suggestion is returned', () => {
it('should immediately transition if exactly one suggestion is returned', async () => {
mockGetSuggestionForField.mockReturnValue({
visualizationId: 'vis',
datasourceState: {},

View file

@ -54,6 +54,7 @@ import { DropIllustration } from '../../../assets/drop_illustration';
import { getOriginalRequestErrorMessages } from '../../error_helper';
import { getMissingIndexPattern, validateDatasourceAndVisualization } from '../state_helpers';
import { DefaultInspectorAdapters } from '../../../../../../../src/plugins/expressions/common';
import { onActiveDataChange, useLensDispatch } from '../../../state_management';
export interface WorkspacePanelProps {
activeVisualizationId: string | null;
@ -428,16 +429,15 @@ export const VisualizationWrapper = ({
]
);
const dispatchLens = useLensDispatch();
const onData$ = useCallback(
(data: unknown, inspectorAdapters?: Partial<DefaultInspectorAdapters>) => {
if (inspectorAdapters && inspectorAdapters.tables) {
dispatch({
type: 'UPDATE_ACTIVE_DATA',
tables: inspectorAdapters.tables.tables,
});
dispatchLens(onActiveDataChange({ activeData: { ...inspectorAdapters.tables.tables } }));
}
},
[dispatch]
[dispatchLens]
);
if (localState.configurationValidationError?.length) {

View file

@ -126,26 +126,11 @@ export class EditorFrameService {
collectAsyncDefinitions(this.visualizations),
]);
const firstDatasourceId = Object.keys(resolvedDatasources)[0];
const firstVisualizationId = Object.keys(resolvedVisualizations)[0];
const { EditorFrame, getActiveDatasourceIdFromDoc } = await import('../async_services');
const { EditorFrame } = await import('../async_services');
const palettes = await plugins.charts.palettes.getPalettes();
return {
EditorFrameContainer: ({
doc,
onError,
dateRange,
query,
filters,
savedQuery,
onChange,
showNoDataPopover,
initialContext,
searchSessionId,
}) => {
EditorFrameContainer: ({ onError, showNoDataPopover, initialContext }) => {
return (
<div className="lnsApp__frame">
<EditorFrame
@ -153,24 +138,12 @@ export class EditorFrameService {
onError={onError}
datasourceMap={resolvedDatasources}
visualizationMap={resolvedVisualizations}
initialDatasourceId={getActiveDatasourceIdFromDoc(doc) || firstDatasourceId || null}
initialVisualizationId={
(doc && doc.visualizationType) || firstVisualizationId || null
}
key={doc?.savedObjectId} // ensures rerendering when switching to another visualization inside of lens (eg global search)
core={core}
plugins={plugins}
ExpressionRenderer={plugins.expressions.ReactExpressionRenderer}
palettes={palettes}
doc={doc}
dateRange={dateRange}
query={query}
filters={filters}
savedQuery={savedQuery}
onChange={onChange}
showNoDataPopover={showNoDataPopover}
initialContext={initialContext}
searchSessionId={searchSessionId}
/>
</div>
);

View file

@ -6,8 +6,35 @@
*/
import React from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ReactWrapper } from 'enzyme';
// eslint-disable-next-line import/no-extraneous-dependencies
import { mountWithIntl as mount } from '@kbn/test/jest';
import { Observable, Subject } from 'rxjs';
import { coreMock } from 'src/core/public/mocks';
import moment from 'moment';
import { Provider } from 'react-redux';
import { act } from 'react-dom/test-utils';
import { LensPublicStart } from '.';
import { visualizationTypes } from './xy_visualization/types';
import { navigationPluginMock } from '../../../../src/plugins/navigation/public/mocks';
import { LensAppServices } from './app_plugin/types';
import { DOC_TYPE } from '../common';
import { DataPublicPluginStart, esFilters, UI_SETTINGS } from '../../../../src/plugins/data/public';
import {
LensByValueInput,
LensSavedObjectAttributes,
LensByReferenceInput,
} from './editor_frame_service/embeddable/embeddable';
import {
mockAttributeService,
createEmbeddableStateTransferMock,
} from '../../../../src/plugins/embeddable/public/mocks';
import { LensAttributeService } from './lens_attribute_service';
import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public';
import { makeConfigureStore, getPreloadedState, LensAppState } from './state_management/index';
import { getResolvedDateRange } from './utils';
export type Start = jest.Mocked<LensPublicStart>;
@ -26,3 +53,252 @@ const createStartContract = (): Start => {
export const lensPluginMock = {
createStartContract,
};
export const defaultDoc = ({
savedObjectId: '1234',
title: 'An extremely cool default document!',
expression: 'definitely a valid expression',
state: {
query: 'kuery',
filters: [{ query: { match_phrase: { src: 'test' } } }],
},
references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
} as unknown) as Document;
export function createMockTimefilter() {
const unsubscribe = jest.fn();
let timeFilter = { from: 'now-7d', to: 'now' };
let subscriber: () => void;
return {
getTime: jest.fn(() => timeFilter),
setTime: jest.fn((newTimeFilter) => {
timeFilter = newTimeFilter;
if (subscriber) {
subscriber();
}
}),
getTimeUpdate$: () => ({
subscribe: ({ next }: { next: () => void }) => {
subscriber = next;
return unsubscribe;
},
}),
calculateBounds: jest.fn(() => ({
min: moment('2021-01-10T04:00:00.000Z'),
max: moment('2021-01-10T08:00:00.000Z'),
})),
getBounds: jest.fn(() => timeFilter),
getRefreshInterval: () => {},
getRefreshIntervalDefaults: () => {},
getAutoRefreshFetch$: () => new Observable(),
};
}
export function mockDataPlugin(sessionIdSubject = new Subject<string>()) {
function createMockSearchService() {
let sessionIdCounter = 1;
return {
session: {
start: jest.fn(() => `sessionId-${sessionIdCounter++}`),
clear: jest.fn(),
getSessionId: jest.fn(() => `sessionId-${sessionIdCounter}`),
getSession$: jest.fn(() => sessionIdSubject.asObservable()),
},
};
}
function createMockFilterManager() {
const unsubscribe = jest.fn();
let subscriber: () => void;
let filters: unknown = [];
return {
getUpdates$: () => ({
subscribe: ({ next }: { next: () => void }) => {
subscriber = next;
return unsubscribe;
},
}),
setFilters: jest.fn((newFilters: unknown[]) => {
filters = newFilters;
if (subscriber) subscriber();
}),
setAppFilters: jest.fn((newFilters: unknown[]) => {
filters = newFilters;
if (subscriber) subscriber();
}),
getFilters: () => filters,
getGlobalFilters: () => {
// @ts-ignore
return filters.filter(esFilters.isFilterPinned);
},
removeAll: () => {
filters = [];
subscriber();
},
};
}
function createMockQueryString() {
return {
getQuery: jest.fn(() => ({ query: '', language: 'lucene' })),
setQuery: jest.fn(),
getDefaultQuery: jest.fn(() => ({ query: '', language: 'lucene' })),
};
}
return ({
query: {
filterManager: createMockFilterManager(),
timefilter: {
timefilter: createMockTimefilter(),
},
queryString: createMockQueryString(),
state$: new Observable(),
},
indexPatterns: {
get: jest.fn((id) => {
return new Promise((resolve) => resolve({ id }));
}),
},
search: createMockSearchService(),
nowProvider: {
get: jest.fn(),
},
} as unknown) as DataPublicPluginStart;
}
export function makeDefaultServices(
sessionIdSubject = new Subject<string>(),
doc = defaultDoc
): jest.Mocked<LensAppServices> {
const core = coreMock.createStart({ basePath: '/testbasepath' });
core.uiSettings.get.mockImplementation(
jest.fn((type) => {
if (type === UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS) {
return { from: 'now-7d', to: 'now' };
} else if (type === UI_SETTINGS.SEARCH_QUERY_LANGUAGE) {
return 'kuery';
} else if (type === 'state:storeInSessionStorage') {
return false;
} else {
return [];
}
})
);
const navigationStartMock = navigationPluginMock.createStartContract();
jest.spyOn(navigationStartMock.ui.TopNavMenu.prototype, 'constructor').mockImplementation(() => {
return <div className="topNavMenu" />;
});
function makeAttributeService(): LensAttributeService {
const attributeServiceMock = mockAttributeService<
LensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>(
DOC_TYPE,
{
saveMethod: jest.fn(),
unwrapMethod: jest.fn(),
checkForDuplicateTitle: jest.fn(),
},
core
);
attributeServiceMock.unwrapAttributes = jest.fn().mockResolvedValue(doc);
attributeServiceMock.wrapAttributes = jest.fn().mockResolvedValue({
savedObjectId: ((doc as unknown) as LensByReferenceInput).savedObjectId,
});
return attributeServiceMock;
}
return {
http: core.http,
chrome: core.chrome,
overlays: core.overlays,
uiSettings: core.uiSettings,
navigation: navigationStartMock,
notifications: core.notifications,
attributeService: makeAttributeService(),
savedObjectsClient: core.savedObjects.client,
dashboardFeatureFlag: { allowByValueEmbeddables: false },
stateTransfer: createEmbeddableStateTransferMock() as EmbeddableStateTransfer,
getOriginatingAppName: jest.fn(() => 'defaultOriginatingApp'),
application: {
...core.application,
capabilities: {
...core.application.capabilities,
visualize: { save: true, saveQuery: true, show: true },
},
getUrlForApp: jest.fn((appId: string) => `/testbasepath/app/${appId}#/`),
},
data: mockDataPlugin(sessionIdSubject),
storage: {
get: jest.fn(),
set: jest.fn(),
remove: jest.fn(),
clear: jest.fn(),
},
};
}
export function mockLensStore({
data,
storePreloadedState,
}: {
data: DataPublicPluginStart;
storePreloadedState?: Partial<LensAppState>;
}) {
const lensStore = makeConfigureStore(
getPreloadedState({
query: data.query.queryString.getQuery(),
filters: data.query.filterManager.getGlobalFilters(),
searchSessionId: data.search.session.start(),
resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter),
...storePreloadedState,
}),
{
data,
}
);
const origDispatch = lensStore.dispatch;
lensStore.dispatch = jest.fn(origDispatch);
return lensStore;
}
export const mountWithProvider = async (
component: React.ReactElement,
data: DataPublicPluginStart,
storePreloadedState?: Partial<LensAppState>,
extraWrappingComponent?: React.FC<{
children: React.ReactNode;
}>
) => {
const lensStore = mockLensStore({ data, storePreloadedState });
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
if (extraWrappingComponent) {
return extraWrappingComponent({
children: <Provider store={lensStore}>{children}</Provider>,
});
}
return <Provider store={lensStore}>{children}</Provider>;
};
let instance: ReactWrapper = {} as ReactWrapper;
await act(async () => {
instance = mount(component, ({
wrappingComponent,
} as unknown) as ReactWrapper);
});
return { instance, lensStore };
};

View file

@ -6,7 +6,7 @@
*/
import { useState, useMemo, useEffect, useRef } from 'react';
import _ from 'lodash';
import { debounce } from 'lodash';
/**
* Debounces value changes and updates inputValue on root state changes if no debounced changes
@ -27,7 +27,7 @@ export const useDebouncedValue = <T>({
const initialValue = useRef(value);
const onChangeDebounced = useMemo(() => {
const callback = _.debounce((val: T) => {
const callback = debounce((val: T) => {
onChange(val);
unflushedChanges.current = false;
}, 256);

View file

@ -0,0 +1,55 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { LensAppState } from './types';
export const initialState: LensAppState = {
searchSessionId: '',
filters: [],
query: { language: 'kuery', query: '' },
resolvedDateRange: { fromDate: '', toDate: '' },
indexPatternsForTopNav: [],
isSaveable: false,
isAppLoading: false,
isLinkedToOriginatingApp: false,
};
export const appSlice = createSlice({
name: 'app',
initialState,
reducers: {
setState: (state, { payload }: PayloadAction<Partial<LensAppState>>) => {
return {
...state,
...payload,
};
},
onChangeFromEditorFrame: (state, { payload }: PayloadAction<Partial<LensAppState>>) => {
return {
...state,
...payload,
};
},
onActiveDataChange: (state, { payload }: PayloadAction<Partial<LensAppState>>) => {
if (!isEqual(state.activeData, payload?.activeData)) {
return {
...state,
...payload,
};
}
return state;
},
navigateAway: (state) => state,
},
});
export const reducer = {
app: appSlice.reducer,
};

View file

@ -0,0 +1,103 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { delay, finalize, switchMap, tap } from 'rxjs/operators';
import _, { debounce } from 'lodash';
import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit';
import { trackUiEvent } from '../lens_ui_telemetry';
import {
waitUntilNextSessionCompletes$,
DataPublicPluginStart,
} from '../../../../../src/plugins/data/public';
import { setState, LensGetState, LensDispatch } from '.';
import { LensAppState } from './types';
import { getResolvedDateRange } from '../utils';
export const externalContextMiddleware = (data: DataPublicPluginStart) => (
store: MiddlewareAPI
) => {
const unsubscribeFromExternalContext = subscribeToExternalContext(
data,
store.getState,
store.dispatch
);
return (next: Dispatch) => (action: PayloadAction<Partial<LensAppState>>) => {
if (action.type === 'app/navigateAway') {
unsubscribeFromExternalContext();
}
next(action);
};
};
function subscribeToExternalContext(
data: DataPublicPluginStart,
getState: LensGetState,
dispatch: LensDispatch
) {
const { query: queryService, search } = data;
const { filterManager } = queryService;
const dispatchFromExternal = (searchSessionId = search.session.start()) => {
const globalFilters = filterManager.getFilters();
const filters = _.isEqual(getState().app.filters, globalFilters)
? null
: { filters: globalFilters };
dispatch(
setState({
searchSessionId,
...filters,
resolvedDateRange: getResolvedDateRange(queryService.timefilter.timefilter),
})
);
};
const debounceDispatchFromExternal = debounce(dispatchFromExternal, 100);
const sessionSubscription = search.session
.getSession$()
// wait for a tick to filter/timerange subscribers the chance to update the session id in the state
.pipe(delay(0))
// then update if it didn't get updated yet
.subscribe((newSessionId?: string) => {
if (newSessionId && getState().app.searchSessionId !== newSessionId) {
debounceDispatchFromExternal(newSessionId);
}
});
const filterSubscription = filterManager.getUpdates$().subscribe({
next: () => {
debounceDispatchFromExternal();
trackUiEvent('app_filters_updated');
},
});
const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({
next: () => {
debounceDispatchFromExternal();
},
});
const autoRefreshSubscription = data.query.timefilter.timefilter
.getAutoRefreshFetch$()
.pipe(
tap(() => {
debounceDispatchFromExternal();
}),
switchMap((done) =>
// best way in lens to estimate that all panels are updated is to rely on search session service state
waitUntilNextSessionCompletes$(search.session).pipe(finalize(done))
)
)
.subscribe();
return () => {
filterSubscription.unsubscribe();
timeSubscription.unsubscribe();
autoRefreshSubscription.unsubscribe();
sessionSubscription.unsubscribe();
};
}

View file

@ -0,0 +1,76 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { configureStore, DeepPartial, getDefaultMiddleware } from '@reduxjs/toolkit';
import logger from 'redux-logger';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import { appSlice, initialState } from './app_slice';
import { timeRangeMiddleware } from './time_range_middleware';
import { externalContextMiddleware } from './external_context_middleware';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { LensAppState, LensState } from './types';
export * from './types';
export const reducer = {
app: appSlice.reducer,
};
export const {
setState,
navigateAway,
onChangeFromEditorFrame,
onActiveDataChange,
} = appSlice.actions;
export const getPreloadedState = (initializedState: Partial<LensAppState>) => {
const state = {
app: {
...initialState,
...initializedState,
},
} as DeepPartial<LensState>;
return state;
};
type PreloadedState = ReturnType<typeof getPreloadedState>;
export const makeConfigureStore = (
preloadedState: PreloadedState,
{ data }: { data: DataPublicPluginStart }
) => {
const middleware = [
...getDefaultMiddleware({
serializableCheck: {
ignoredActions: [
'app/setState',
'app/onChangeFromEditorFrame',
'app/onActiveDataChange',
'app/navigateAway',
],
},
}),
timeRangeMiddleware(data),
externalContextMiddleware(data),
];
if (process.env.NODE_ENV === 'development') middleware.push(logger);
return configureStore({
reducer,
middleware,
preloadedState,
});
};
export type LensRootStore = ReturnType<typeof makeConfigureStore>;
export type LensDispatch = LensRootStore['dispatch'];
export type LensGetState = LensRootStore['getState'];
export type LensRootState = ReturnType<LensGetState>;
export const useLensDispatch = () => useDispatch<LensDispatch>();
export const useLensSelector: TypedUseSelectorHook<LensRootState> = useSelector;

View file

@ -0,0 +1,198 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// /*
// * 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; you may not use this file except in compliance with the Elastic License
// * 2.0.
// */
import { timeRangeMiddleware } from './time_range_middleware';
import { Observable, Subject } from 'rxjs';
import { DataPublicPluginStart, esFilters } from '../../../../../src/plugins/data/public';
import moment from 'moment';
import { initialState } from './app_slice';
import { LensAppState } from './types';
import { PayloadAction } from '@reduxjs/toolkit';
import { Document } from '../persistence';
const sessionIdSubject = new Subject<string>();
function createMockSearchService() {
let sessionIdCounter = 1;
return {
session: {
start: jest.fn(() => `sessionId-${sessionIdCounter++}`),
clear: jest.fn(),
getSessionId: jest.fn(() => `sessionId-${sessionIdCounter}`),
getSession$: jest.fn(() => sessionIdSubject.asObservable()),
},
};
}
function createMockFilterManager() {
const unsubscribe = jest.fn();
let subscriber: () => void;
let filters: unknown = [];
return {
getUpdates$: () => ({
subscribe: ({ next }: { next: () => void }) => {
subscriber = next;
return unsubscribe;
},
}),
setFilters: jest.fn((newFilters: unknown[]) => {
filters = newFilters;
if (subscriber) subscriber();
}),
setAppFilters: jest.fn((newFilters: unknown[]) => {
filters = newFilters;
if (subscriber) subscriber();
}),
getFilters: () => filters,
getGlobalFilters: () => {
// @ts-ignore
return filters.filter(esFilters.isFilterPinned);
},
removeAll: () => {
filters = [];
subscriber();
},
};
}
function createMockQueryString() {
return {
getQuery: jest.fn(() => ({ query: '', language: 'kuery' })),
setQuery: jest.fn(),
getDefaultQuery: jest.fn(() => ({ query: '', language: 'kuery' })),
};
}
function createMockTimefilter() {
const unsubscribe = jest.fn();
let timeFilter = { from: 'now-7d', to: 'now' };
let subscriber: () => void;
return {
getTime: jest.fn(() => timeFilter),
setTime: jest.fn((newTimeFilter) => {
timeFilter = newTimeFilter;
if (subscriber) {
subscriber();
}
}),
getTimeUpdate$: () => ({
subscribe: ({ next }: { next: () => void }) => {
subscriber = next;
return unsubscribe;
},
}),
calculateBounds: jest.fn(() => ({
min: moment('2021-01-10T04:00:00.000Z'),
max: moment('2021-01-10T08:00:00.000Z'),
})),
getBounds: jest.fn(() => timeFilter),
getRefreshInterval: () => {},
getRefreshIntervalDefaults: () => {},
getAutoRefreshFetch$: () => new Observable(),
};
}
function makeDefaultData(): jest.Mocked<DataPublicPluginStart> {
return ({
query: {
filterManager: createMockFilterManager(),
timefilter: {
timefilter: createMockTimefilter(),
},
queryString: createMockQueryString(),
state$: new Observable(),
},
indexPatterns: {
get: jest.fn((id) => {
return new Promise((resolve) => resolve({ id }));
}),
},
search: createMockSearchService(),
nowProvider: {
get: jest.fn(),
},
} as unknown) as DataPublicPluginStart;
}
const createMiddleware = (data: DataPublicPluginStart) => {
const middleware = timeRangeMiddleware(data);
const store = {
getState: jest.fn(() => ({ app: initialState })),
dispatch: jest.fn(),
};
const next = jest.fn();
const invoke = (action: PayloadAction<Partial<LensAppState>>) => middleware(store)(next)(action);
return { store, next, invoke };
};
describe('timeRangeMiddleware', () => {
describe('time update', () => {
it('does update the searchSessionId when the state changes and too much time passed', () => {
const data = makeDefaultData();
(data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 30000));
(data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({
from: 'now-2m',
to: 'now',
});
(data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({
min: moment(Date.now() - 100000),
max: moment(Date.now() - 30000),
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'app/setState',
payload: { lastKnownDoc: ('new' as unknown) as Document },
};
invoke(action);
expect(store.dispatch).toHaveBeenCalledWith({
payload: {
resolvedDateRange: {
fromDate: '2021-01-10T04:00:00.000Z',
toDate: '2021-01-10T08:00:00.000Z',
},
searchSessionId: 'sessionId-1',
},
type: 'app/setState',
});
expect(next).toHaveBeenCalledWith(action);
});
it('does not update the searchSessionId when the state changes and too little time has passed', () => {
const data = makeDefaultData();
// time range is 100,000ms ago to 300ms ago (that's a lag of .3 percent, not enough to trigger a session update)
(data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 300));
(data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({
from: 'now-2m',
to: 'now',
});
(data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({
min: moment(Date.now() - 100000),
max: moment(Date.now() - 300),
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'app/setState',
payload: { lastKnownDoc: ('new' as unknown) as Document },
};
invoke(action);
expect(store.dispatch).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(action);
});
});
});

View file

@ -0,0 +1,61 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEqual } from 'lodash';
import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit';
import moment from 'moment';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { setState, LensDispatch } from '.';
import { LensAppState } from './types';
import { getResolvedDateRange, containsDynamicMath, TIME_LAG_PERCENTAGE_LIMIT } from '../utils';
export const timeRangeMiddleware = (data: DataPublicPluginStart) => (store: MiddlewareAPI) => {
return (next: Dispatch) => (action: PayloadAction<Partial<LensAppState>>) => {
// if document was modified or sessionId check if too much time passed to update searchSessionId
if (
action.payload?.lastKnownDoc &&
!isEqual(action.payload?.lastKnownDoc, store.getState().app.lastKnownDoc)
) {
updateTimeRange(data, store.dispatch);
}
next(action);
};
};
function updateTimeRange(data: DataPublicPluginStart, dispatch: LensDispatch) {
const timefilter = data.query.timefilter.timefilter;
const unresolvedTimeRange = timefilter.getTime();
if (
!containsDynamicMath(unresolvedTimeRange.from) &&
!containsDynamicMath(unresolvedTimeRange.to)
) {
return;
}
const { min, max } = timefilter.getBounds();
if (!min || !max) {
// bounds not fully specified, bailing out
return;
}
// calculate length of currently configured range in ms
const timeRangeLength = moment.duration(max.diff(min)).asMilliseconds();
// calculate lag of managed "now" for date math
const nowDiff = Date.now() - data.nowProvider.get().valueOf();
// if the lag is signifcant, start a new session to clear the cache
if (nowDiff > timeRangeLength * TIME_LAG_PERCENTAGE_LIMIT) {
dispatch(
setState({
searchSessionId: data.search.session.start(),
resolvedDateRange: getResolvedDateRange(timefilter),
})
);
}
}

View file

@ -0,0 +1,42 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Filter, IndexPattern, Query, SavedQuery } from '../../../../../src/plugins/data/public';
import { Document } from '../persistence';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { DateRange } from '../../common';
export interface LensAppState {
persistedDoc?: Document;
lastKnownDoc?: Document;
// index patterns used to determine which filters are available in the top nav.
indexPatternsForTopNav: IndexPattern[];
// Determines whether the lens editor shows the 'save and return' button, and the originating app breadcrumb.
isLinkedToOriginatingApp?: boolean;
isSaveable: boolean;
activeData?: TableInspectorAdapter;
isAppLoading: boolean;
query: Query;
filters: Filter[];
savedQuery?: SavedQuery;
searchSessionId: string;
resolvedDateRange: DateRange;
}
export type DispatchSetState = (
state: Partial<LensAppState>
) => {
payload: Partial<LensAppState>;
type: string;
};
export interface LensState {
app: LensAppState;
}

View file

@ -18,9 +18,8 @@ import {
SerializedFieldFormat,
} from '../../../../src/plugins/expressions/public';
import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop';
import { Document } from './persistence';
import { DateRange } from '../common';
import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public';
import { Query, Filter, IFieldFormat } from '../../../../src/plugins/data/public';
import { VisualizeFieldContext } from '../../../../src/plugins/ui_actions/public';
import { RangeSelectContext, ValueClickContext } from '../../../../src/plugins/embeddable/public';
import {
@ -46,22 +45,7 @@ export interface PublicAPIProps<T> {
export interface EditorFrameProps {
onError: ErrorCallback;
doc?: Document;
dateRange: DateRange;
query: Query;
filters: Filter[];
savedQuery?: SavedQuery;
searchSessionId: string;
initialContext?: VisualizeFieldContext;
// Frame loader (app or embeddable) is expected to call this when it loads and updates
// This should be replaced with a top-down state
onChange: (newState: {
filterableIndexPatterns: string[];
doc: Document;
isSaveable: boolean;
activeData?: Record<string, Datatable>;
}) => void;
showNoDataPopover: () => void;
}
export interface EditorFrameInstance {

View file

@ -6,6 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plugins/data/public';
import { LensFilterEvent } from './types';
/** replaces the value `(empty) to empty string for proper filtering` */
@ -49,3 +50,32 @@ export function getVisualizeGeoFieldMessage(fieldType: string) {
values: { fieldType },
});
}
export const getResolvedDateRange = function (timefilter: TimefilterContract) {
const { from, to } = timefilter.getTime();
const { min, max } = timefilter.calculateBounds({
from,
to,
});
return { fromDate: min?.toISOString() || from, toDate: max?.toISOString() || to };
};
export function containsDynamicMath(dateMathString: string) {
return dateMathString.includes('now');
}
export const TIME_LAG_PERCENTAGE_LIMIT = 0.02;
export async function getAllIndexPatterns(
ids: string[],
indexPatternsService: IndexPatternsContract
): Promise<{ indexPatterns: IndexPattern[]; rejectedIds: string[] }> {
const responses = await Promise.allSettled(ids.map((id) => indexPatternsService.get(id)));
const fullfilled = responses.filter(
(response): response is PromiseFulfilledResult<IndexPattern> => response.status === 'fulfilled'
);
const rejectedIds = responses
.map((_response, i) => ids[i])
.filter((id, i) => responses[i].status === 'rejected');
// return also the rejected ids in case we want to show something later on
return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds };
}

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import _ from 'lodash';
import { uniq } from 'lodash';
import { render } from 'react-dom';
import { Position } from '@elastic/charts';
import { I18nProvider } from '@kbn/i18n/react';
@ -43,7 +43,7 @@ function getVisualizationType(state: State): VisualizationType | 'mixed' {
);
}
const visualizationType = visualizationTypes.find((t) => t.id === state.layers[0].seriesType);
const seriesTypes = _.uniq(state.layers.map((l) => l.seriesType));
const seriesTypes = uniq(state.layers.map((l) => l.seriesType));
return visualizationType && seriesTypes.length === 1 ? visualizationType : 'mixed';
}
@ -111,7 +111,7 @@ export const getXyVisualization = ({
},
appendLayer(state, layerId) {
const usedSeriesTypes = _.uniq(state.layers.map((layer) => layer.seriesType));
const usedSeriesTypes = uniq(state.layers.map((layer) => layer.seriesType));
return {
...state,
layers: [
@ -255,10 +255,11 @@ export const getXyVisualization = ({
},
setDimension({ prevState, layerId, columnId, groupId }) {
const newLayer = prevState.layers.find((l) => l.layerId === layerId);
if (!newLayer) {
const foundLayer = prevState.layers.find((l) => l.layerId === layerId);
if (!foundLayer) {
return prevState;
}
const newLayer = { ...foundLayer };
if (groupId === 'x') {
newLayer.xAccessor = columnId;
@ -277,11 +278,11 @@ export const getXyVisualization = ({
},
removeDimension({ prevState, layerId, columnId }) {
const newLayer = prevState.layers.find((l) => l.layerId === layerId);
if (!newLayer) {
const foundLayer = prevState.layers.find((l) => l.layerId === layerId);
if (!foundLayer) {
return prevState;
}
const newLayer = { ...foundLayer };
if (newLayer.xAccessor === columnId) {
delete newLayer.xAccessor;
} else if (newLayer.splitAccessor === columnId) {

View file

@ -3587,6 +3587,16 @@
resolved "https://registry.yarnpkg.com/@redux-saga/types/-/types-1.1.0.tgz#0e81ce56b4883b4b2a3001ebe1ab298b84237204"
integrity sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==
"@reduxjs/toolkit@^1.5.1":
version "1.5.1"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.5.1.tgz#05daa2f6eebc70dc18cd98a90421fab7fa565dc5"
integrity sha512-PngZKuwVZsd+mimnmhiOQzoD0FiMjqVks6ituO1//Ft5UEX5Ca9of13NEjo//pU22Jk7z/mdXVsmDfgsig1osA==
dependencies:
immer "^8.0.1"
redux "^4.0.0"
redux-thunk "^2.3.0"
reselect "^4.0.0"
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
@ -5793,6 +5803,13 @@
resolved "https://registry.yarnpkg.com/@types/redux-actions/-/redux-actions-2.6.1.tgz#0940e97fa35ad3004316bddb391d8e01d2efa605"
integrity sha512-zKgK+ATp3sswXs6sOYo1tk8xdXTy4CTaeeYrVQlClCjeOpag5vzPo0ASWiiBJ7vsiQRAdb3VkuFLnDoBimF67g==
"@types/redux-logger@^3.0.8":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.8.tgz#1fb6d26917bb198792bb1cf57feb31cae1532c5d"
integrity sha512-zM+cxiSw6nZtRbxpVp9SE3x/X77Z7e7YAfHD1NkxJyJbAGSXJGF0E9aqajZfPOa/sTYnuwutmlCldveExuCeLw==
dependencies:
redux "^4.0.0"
"@types/request@^2.48.2":
version "2.48.2"
resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.2.tgz#936374cbe1179d7ed529fc02543deb4597450fed"
@ -11284,6 +11301,11 @@ dedent@^0.7.0:
resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=
deep-diff@^0.3.5:
version "0.3.8"
resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84"
integrity sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=
deep-eql@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2"
@ -23625,6 +23647,13 @@ redux-devtools-extension@^2.13.8:
resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1"
integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg==
redux-logger@^3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf"
integrity sha1-91VZZvMJjzyIYExEnPC69XeCdL8=
dependencies:
deep-diff "^0.3.5"
redux-observable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.2.0.tgz#ff51b6c6be2598e9b5e89fc36639186bb0e669c7"