Co-authored-by: Marta Bondyra <marta.bondyra@gmail.com>
This commit is contained in:
parent
e8039e188b
commit
c4ad5d86fa
29 changed files with 2912 additions and 2356 deletions
|
@ -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
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
150
x-pack/plugins/lens/public/app_plugin/mounter.test.tsx
Normal file
150
x-pack/plugins/lens/public/app_plugin/mounter.test.tsx
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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: {},
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
55
x-pack/plugins/lens/public/state_management/app_slice.ts
Normal file
55
x-pack/plugins/lens/public/state_management/app_slice.ts
Normal 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,
|
||||
};
|
|
@ -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();
|
||||
};
|
||||
}
|
76
x-pack/plugins/lens/public/state_management/index.ts
Normal file
76
x-pack/plugins/lens/public/state_management/index.ts
Normal 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;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
42
x-pack/plugins/lens/public/state_management/types.ts
Normal file
42
x-pack/plugins/lens/public/state_management/types.ts
Normal 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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
29
yarn.lock
29
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue