[Lens] Switch to SavedObjectClient.resolve (#110059)

* Step 2: Update client code to use resolve() method instead of get()

Following sharing Saved Objects developer guide: Step 2
This step demonstrates the changes to update client code to use the new
SavedObjectsClient `resolve()` method instead of `get()`.

* Step 3 Lens
This commit is contained in:
Marta Bondyra 2021-09-03 16:44:12 +02:00 committed by GitHub
parent 9a459806ad
commit d4c03eb9b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 466 additions and 108 deletions

View file

@ -24,7 +24,8 @@
"usageCollection",
"taskManager",
"globalSearch",
"savedObjectsTagging"
"savedObjectsTagging",
"spaces"
],
"configPath": [
"xpack",

View file

@ -383,6 +383,9 @@ describe('Lens App', () => {
savedObjectId: savedObjectId || 'aaa',
}));
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
sharingSavedObjectProps: {
outcome: 'exactMatch',
},
savedObjectId: initialSavedObjectId ?? 'aaa',
references: [],
state: {
@ -1256,4 +1259,32 @@ describe('Lens App', () => {
expect(defaultLeave).not.toHaveBeenCalled();
});
});
it('should display a conflict callout if saved object conflicts', async () => {
const history = createMemoryHistory();
const { services } = await mountWith({
props: {
...makeDefaultProps(),
history: {
...history,
location: {
...history.location,
search: '?_g=test',
},
},
},
preloadedState: {
persistedDoc: defaultDoc,
sharingSavedObjectProps: {
outcome: 'conflict',
aliasTargetId: '2',
},
},
});
expect(services.spaces.ui.components.getLegacyUrlConflict).toHaveBeenCalledWith({
currentObjectId: '1234',
objectNoun: 'Lens visualization',
otherObjectId: '2',
otherObjectPath: '#/edit/2?_g=test',
});
});
});

View file

@ -38,6 +38,7 @@ import {
runSaveLensVisualization,
} from './save_modal_container';
import { getLensInspectorService, LensInspector } from '../lens_inspector_service';
import { getEditPath } from '../../common';
export type SaveProps = Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
@ -70,6 +71,8 @@ export function App({
notifications,
savedObjectsTagging,
getOriginatingAppName,
spaces,
http,
// Temporarily required until the 'by value' paradigm is default.
dashboardFeatureFlag,
} = lensAppServices;
@ -82,6 +85,7 @@ export function App({
const {
persistedDoc,
sharingSavedObjectProps,
isLinkedToOriginatingApp,
searchSessionId,
isLoading,
@ -166,6 +170,28 @@ export function App({
});
}, [onAppLeave, lastKnownDoc, isSaveable, persistedDoc, application.capabilities.visualize.save]);
const getLegacyUrlConflictCallout = useCallback(() => {
// This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario
if (spaces && sharingSavedObjectProps?.outcome === 'conflict' && persistedDoc?.savedObjectId) {
// We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
// callout with a warning for the user, and provide a way for them to navigate to the other object.
const currentObjectId = persistedDoc.savedObjectId;
const otherObjectId = sharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'conflict'
const otherObjectPath = http.basePath.prepend(
`${getEditPath(otherObjectId)}${history.location.search}`
);
return spaces.ui.components.getLegacyUrlConflict({
objectNoun: i18n.translate('xpack.lens.appName', {
defaultMessage: 'Lens visualization',
}),
currentObjectId,
otherObjectId,
otherObjectPath,
});
}
return null;
}, [persistedDoc, sharingSavedObjectProps, spaces, http, history]);
// Sync Kibana breadcrumbs any time the saved document's title changes
useEffect(() => {
const isByValueMode = getIsByValueMode();
@ -273,6 +299,8 @@ export function App({
title={persistedDoc?.title}
lensInspector={lensInspector}
/>
{getLegacyUrlConflictCallout()}
{(!isLoading || persistedDoc) && (
<MemoizedEditorFrameWrapper
editorFrame={editorFrame}

View file

@ -55,6 +55,7 @@ export async function getLensServices(
savedObjectsTagging,
usageCollection,
fieldFormats,
spaces,
} = startDependencies;
const storage = new Storage(localStorage);
@ -87,6 +88,7 @@ export async function getLensServices(
},
// Temporarily required until the 'by value' paradigm is default.
dashboardFeatureFlag: startDependencies.dashboard.dashboardFeatureFlagConfig,
spaces,
};
}
@ -203,7 +205,9 @@ export async function mountApp(
trackUiEvent('loaded');
const initialInput = getInitialInput(props.id, props.editByValue);
lensStore.dispatch(loadInitial({ redirectCallback, initialInput, emptyState }));
lensStore.dispatch(
loadInitial({ redirectCallback, initialInput, emptyState, history: props.history })
);
return (
<Provider store={lensStore}>

View file

@ -7,6 +7,7 @@
import type { History } from 'history';
import type { OnSaveProps } from 'src/plugins/saved_objects/public';
import { SpacesApi } from '../../../spaces/public';
import type {
ApplicationStart,
AppMountParameters,
@ -116,6 +117,8 @@ export interface LensAppServices {
savedObjectsTagging?: SavedObjectTaggingPluginStart;
getOriginatingAppName: () => string | undefined;
presentationUtil: PresentationUtilPluginStart;
spaces: SpacesApi;
// Temporarily required until the 'by value' paradigm is default.
dashboardFeatureFlag: DashboardFeatureFlagConfig;
}

View file

@ -79,7 +79,7 @@ export interface WorkspacePanelProps {
interface WorkspaceState {
expressionBuildError?: Array<{
shortMessage: string;
longMessage: string;
longMessage: React.ReactNode;
fixAction?: DatasourceFixAction<unknown>;
}>;
expandError: boolean;
@ -416,10 +416,10 @@ export const VisualizationWrapper = ({
localState: WorkspaceState & {
configurationValidationError?: Array<{
shortMessage: string;
longMessage: string;
longMessage: React.ReactNode;
fixAction?: DatasourceFixAction<unknown>;
}>;
missingRefsErrors?: Array<{ shortMessage: string; longMessage: string }>;
missingRefsErrors?: Array<{ shortMessage: string; longMessage: React.ReactNode }>;
};
ExpressionRendererComponent: ReactExpressionRendererType;
application: ApplicationStart;
@ -454,7 +454,7 @@ export const VisualizationWrapper = ({
validationError:
| {
shortMessage: string;
longMessage: string;
longMessage: React.ReactNode;
fixAction?: DatasourceFixAction<unknown>;
}
| undefined
@ -499,7 +499,7 @@ export const VisualizationWrapper = ({
.map((validationError) => (
<>
<p
key={validationError.longMessage}
key={validationError.shortMessage}
className="eui-textBreakWord"
data-test-subj="configuration-failure-error"
>

View file

@ -11,6 +11,6 @@ export type TableInspectorAdapter = Record<string, Datatable>;
export interface ErrorMessage {
shortMessage: string;
longMessage: string;
longMessage: React.ReactNode;
type?: 'fixable' | 'critical';
}

View file

@ -11,6 +11,7 @@ import {
LensByReferenceInput,
LensSavedObjectAttributes,
LensEmbeddableInput,
ResolvedLensSavedObjectAttributes,
} from './embeddable';
import { ReactExpressionRendererProps } from 'src/plugins/expressions/public';
import { Query, TimeRange, Filter, IndexPatternsContract } from 'src/plugins/data/public';
@ -68,12 +69,17 @@ const options = {
const attributeServiceMockFromSavedVis = (document: Document): LensAttributeService => {
const core = coreMock.createStart();
const service = new AttributeService<
LensSavedObjectAttributes,
ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>('lens', jest.fn(), core.i18n.Context, core.notifications.toasts, options);
service.unwrapAttributes = jest.fn((input: LensByValueInput | LensByReferenceInput) => {
return Promise.resolve({ ...document } as LensSavedObjectAttributes);
return Promise.resolve({
...document,
sharingSavedObjectProps: {
outcome: 'exactMatch',
},
} as ResolvedLensSavedObjectAttributes);
});
service.wrapAttributes = jest.fn();
return service;
@ -86,7 +92,7 @@ describe('embeddable', () => {
let trigger: { exec: jest.Mock };
let basePath: IBasePath;
let attributeService: AttributeService<
LensSavedObjectAttributes,
ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>;
@ -223,6 +229,50 @@ describe('embeddable', () => {
expect(expressionRenderer).toHaveBeenCalledTimes(0);
});
it('should not render the vis if loaded saved object conflicts', async () => {
attributeService.unwrapAttributes = jest.fn(
(input: LensByValueInput | LensByReferenceInput) => {
return Promise.resolve({
...savedVis,
sharingSavedObjectProps: {
outcome: 'conflict',
errorJSON: '{targetType: "lens", sourceId: "1", targetSpace: "space"}',
aliasTargetId: '2',
},
} as ResolvedLensSavedObjectAttributes);
}
);
const embeddable = new Embeddable(
{
timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter,
attributeService,
inspector: inspectorPluginMock.createStartContract(),
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
ast: {
type: 'expression',
chain: [
{ type: 'function', function: 'my', arguments: {} },
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
}),
},
{} as LensEmbeddableInput
);
await embeddable.initializeSavedVis({} as LensEmbeddableInput);
expect(expressionRenderer).toHaveBeenCalledTimes(0);
});
it('should initialize output with deduped list of index patterns', async () => {
attributeService = attributeServiceMockFromSavedVis({
...savedVis,

View file

@ -41,7 +41,11 @@ import {
ReferenceOrValueEmbeddable,
} from '../../../../../src/plugins/embeddable/public';
import { Document, injectFilterReferences } from '../persistence';
import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper';
import {
ExpressionWrapper,
ExpressionWrapperProps,
savedObjectConflictError,
} from './expression_wrapper';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import {
isLensBrushEvent,
@ -58,8 +62,12 @@ import { IBasePath } from '../../../../../src/core/public';
import { LensAttributeService } from '../lens_attribute_service';
import type { ErrorMessage } from '../editor_frame_service/types';
import { getLensInspectorService, LensInspector } from '../lens_inspector_service';
import { SharingSavedObjectProps } from '../types';
export type LensSavedObjectAttributes = Omit<Document, 'savedObjectId' | 'type'>;
export interface ResolvedLensSavedObjectAttributes extends LensSavedObjectAttributes {
sharingSavedObjectProps?: SharingSavedObjectProps;
}
interface LensBaseEmbeddableInput extends EmbeddableInput {
filters?: Filter[];
@ -76,7 +84,7 @@ interface LensBaseEmbeddableInput extends EmbeddableInput {
}
export type LensByValueInput = {
attributes: LensSavedObjectAttributes;
attributes: ResolvedLensSavedObjectAttributes;
} & LensBaseEmbeddableInput;
export type LensByReferenceInput = SavedObjectEmbeddableInput & LensBaseEmbeddableInput;
@ -253,15 +261,18 @@ export class Embeddable
}
async initializeSavedVis(input: LensEmbeddableInput) {
const attributes:
| LensSavedObjectAttributes
const attrs:
| ResolvedLensSavedObjectAttributes
| false = await this.deps.attributeService.unwrapAttributes(input).catch((e: Error) => {
this.onFatalError(e);
return false;
});
if (!attributes || this.isDestroyed) {
if (!attrs || this.isDestroyed) {
return;
}
const { sharingSavedObjectProps, ...attributes } = attrs;
this.savedVis = {
...attributes,
type: this.type,
@ -269,8 +280,12 @@ export class Embeddable
};
const { ast, errors } = await this.deps.documentToExpression(this.savedVis);
this.errors = errors;
if (sharingSavedObjectProps?.outcome === 'conflict') {
const conflictError = savedObjectConflictError(sharingSavedObjectProps.errorJSON!);
this.errors = this.errors ? [...this.errors, conflictError] : [conflictError];
}
this.expression = ast ? toExpression(ast) : null;
if (errors) {
if (this.errors) {
this.logError('validation');
}
await this.initializeOutput();

View file

@ -5,10 +5,20 @@
* 2.0.
*/
import React from 'react';
import React, { useState } from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon, EuiEmptyPrompt } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiIcon,
EuiEmptyPrompt,
EuiButtonEmpty,
EuiCallOut,
EuiSpacer,
EuiLink,
} from '@elastic/eui';
import {
ExpressionRendererEvent,
ReactExpressionRendererType,
@ -18,6 +28,7 @@ import type { KibanaExecutionContext } from 'src/core/public';
import { ExecutionContextSearch } from 'src/plugins/data/public';
import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions';
import classNames from 'classnames';
import { i18n } from '@kbn/i18n';
import { getOriginalRequestErrorMessages } from '../editor_frame_service/error_helper';
import { ErrorMessage } from '../editor_frame_service/types';
import { LensInspector } from '../lens_inspector_service';
@ -158,3 +169,52 @@ export function ExpressionWrapper({
</I18nProvider>
);
}
const SavedObjectConflictMessage = ({ json }: { json: string }) => {
const [expandError, setExpandError] = useState(false);
return (
<>
<FormattedMessage
id="xpack.lens.embeddable.legacyURLConflict.longMessage"
defaultMessage="Disable the {documentationLink} associated with this object."
values={{
documentationLink: (
<EuiLink
external
href="https://www.elastic.co/guide/en/kibana/master/legacy-url-aliases.html"
target="_blank"
>
{i18n.translate('xpack.lens.embeddable.legacyURLConflict.documentationLinkText', {
defaultMessage: 'legacy URL alias',
})}
</EuiLink>
),
}}
/>
<EuiSpacer />
{expandError ? (
<EuiCallOut
title={i18n.translate('xpack.lens.embeddable.legacyURLConflict.expandErrorText', {
defaultMessage: `This object has the same URL as a legacy alias. Disable the alias to resolve this error : {json}`,
values: { json },
})}
color="danger"
iconType="alert"
/>
) : (
<EuiButtonEmpty onClick={() => setExpandError(true)}>
{i18n.translate('xpack.lens.embeddable.legacyURLConflict.expandError', {
defaultMessage: `Show more`,
})}
</EuiButtonEmpty>
)}
</>
);
};
export const savedObjectConflictError = (json: string): ErrorMessage => ({
shortMessage: i18n.translate('xpack.lens.embeddable.legacyURLConflict.shortMessage', {
defaultMessage: `You've encountered a URL conflict`,
}),
longMessage: <SavedObjectConflictMessage json={json} />,
});

View file

@ -9,47 +9,68 @@ import { CoreStart } from '../../../../src/core/public';
import { LensPluginStartDependencies } from './plugin';
import { AttributeService } from '../../../../src/plugins/embeddable/public';
import {
LensSavedObjectAttributes,
ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput,
} from './embeddable/embeddable';
import { SavedObjectIndexStore, Document } from './persistence';
import { SavedObjectIndexStore } from './persistence';
import { checkForDuplicateTitle, OnSaveProps } from '../../../../src/plugins/saved_objects/public';
import { DOC_TYPE } from '../common';
export type LensAttributeService = AttributeService<
LensSavedObjectAttributes,
ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>;
function documentToAttributes(doc: Document): LensSavedObjectAttributes {
delete doc.savedObjectId;
delete doc.type;
return { ...doc };
}
export function getLensAttributeService(
core: CoreStart,
startDependencies: LensPluginStartDependencies
): LensAttributeService {
const savedObjectStore = new SavedObjectIndexStore(core.savedObjects.client);
return startDependencies.embeddable.getAttributeService<
LensSavedObjectAttributes,
ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>(DOC_TYPE, {
saveMethod: async (attributes: LensSavedObjectAttributes, savedObjectId?: string) => {
saveMethod: async (attributes: ResolvedLensSavedObjectAttributes, savedObjectId?: string) => {
const { sharingSavedObjectProps, ...attributesToSave } = attributes;
const savedDoc = await savedObjectStore.save({
...attributes,
...attributesToSave,
savedObjectId,
type: DOC_TYPE,
});
return { id: savedDoc.savedObjectId };
},
unwrapMethod: async (savedObjectId: string): Promise<LensSavedObjectAttributes> => {
const attributes = documentToAttributes(await savedObjectStore.load(savedObjectId));
return attributes;
unwrapMethod: async (savedObjectId: string): Promise<ResolvedLensSavedObjectAttributes> => {
const {
saved_object: savedObject,
outcome,
alias_target_id: aliasTargetId,
} = await savedObjectStore.load(savedObjectId);
const { attributes, references, type, id } = savedObject;
const document = {
...attributes,
references,
};
const sharingSavedObjectProps = {
aliasTargetId,
outcome,
errorJSON:
outcome === 'conflict'
? JSON.stringify({
targetType: type,
sourceId: id,
targetSpace: (await startDependencies.spaces.getActiveSpace()).id,
})
: undefined,
};
return {
sharingSavedObjectProps,
...document,
};
},
checkForDuplicateTitle: (props: OnSaveProps) => {
const savedObjectsClient = core.savedObjects.client;

View file

@ -24,11 +24,12 @@ import { LensAppServices } from './app_plugin/types';
import { DOC_TYPE, layerTypes } from '../common';
import { DataPublicPluginStart, esFilters, UI_SETTINGS } from '../../../../src/plugins/data/public';
import { inspectorPluginMock } from '../../../../src/plugins/inspector/public/mocks';
import { spacesPluginMock } from '../../spaces/public/mocks';
import { dashboardPluginMock } from '../../../../src/plugins/dashboard/public/mocks';
import type {
LensByValueInput,
LensSavedObjectAttributes,
LensByReferenceInput,
ResolvedLensSavedObjectAttributes,
} from './embeddable/embeddable';
import {
mockAttributeService,
@ -352,7 +353,7 @@ export function makeDefaultServices(
function makeAttributeService(): LensAttributeService {
const attributeServiceMock = mockAttributeService<
LensSavedObjectAttributes,
ResolvedLensSavedObjectAttributes,
LensByValueInput,
LensByReferenceInput
>(
@ -365,7 +366,12 @@ export function makeDefaultServices(
core
);
attributeServiceMock.unwrapAttributes = jest.fn().mockResolvedValue(doc);
attributeServiceMock.unwrapAttributes = jest.fn().mockResolvedValue({
...doc,
sharingSavedObjectProps: {
outcome: 'exactMatch',
},
});
attributeServiceMock.wrapAttributes = jest.fn().mockResolvedValue({
savedObjectId: ((doc as unknown) as LensByReferenceInput).savedObjectId,
});
@ -404,6 +410,7 @@ export function makeDefaultServices(
remove: jest.fn(),
clear: jest.fn(),
},
spaces: spacesPluginMock.createStartContract(),
};
}

View file

@ -15,7 +15,7 @@ describe('LensStore', () => {
bulkUpdate: jest.fn(([{ id }]: SavedObjectsBulkUpdateObject[]) =>
Promise.resolve({ savedObjects: [{ id }, { id }] })
),
get: jest.fn(),
resolve: jest.fn(),
};
return {
@ -142,15 +142,18 @@ describe('LensStore', () => {
describe('load', () => {
test('throws if an error is returned', async () => {
const { client, store } = testStore();
client.get = jest.fn(async () => ({
id: 'Paul',
type: 'lens',
attributes: {
title: 'Hope clouds observation.',
visualizationType: 'dune',
state: '{ "datasource": { "giantWorms": true } }',
client.resolve = jest.fn(async () => ({
outcome: 'exactMatch',
saved_object: {
id: 'Paul',
type: 'lens',
attributes: {
title: 'Hope clouds observation.',
visualizationType: 'dune',
state: '{ "datasource": { "giantWorms": true } }',
},
error: new Error('shoot dang!'),
},
error: new Error('shoot dang!'),
}));
await expect(store.load('Paul')).rejects.toThrow('shoot dang!');

View file

@ -9,9 +9,11 @@ import {
SavedObjectAttributes,
SavedObjectsClientContract,
SavedObjectReference,
ResolvedSimpleSavedObject,
} from 'kibana/public';
import { Query } from '../../../../../src/plugins/data/public';
import { DOC_TYPE, PersistableFilter } from '../../common';
import { LensSavedObjectAttributes } from '../async_services';
export interface Document {
savedObjectId?: string;
@ -37,7 +39,7 @@ export interface DocumentSaver {
}
export interface DocumentLoader {
load: (savedObjectId: string) => Promise<Document>;
load: (savedObjectId: string) => Promise<ResolvedSimpleSavedObject>;
}
export type SavedObjectStore = DocumentLoader & DocumentSaver;
@ -87,18 +89,16 @@ export class SavedObjectIndexStore implements SavedObjectStore {
).savedObjects[1];
}
async load(savedObjectId: string): Promise<Document> {
const { type, attributes, references, error } = await this.client.get(DOC_TYPE, savedObjectId);
async load(savedObjectId: string): Promise<ResolvedSimpleSavedObject<LensSavedObjectAttributes>> {
const resolveResult = await this.client.resolve<LensSavedObjectAttributes>(
DOC_TYPE,
savedObjectId
);
if (error) {
throw error;
if (resolveResult.saved_object.error) {
throw resolveResult.saved_object.error;
}
return {
...(attributes as SavedObjectAttributes),
references,
savedObjectId,
type,
} as Document;
return resolveResult;
}
}

View file

@ -9,6 +9,7 @@ import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public';
import type { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import type { FieldFormatsSetup, FieldFormatsStart } from 'src/plugins/field_formats/public';
import { UsageCollectionSetup, UsageCollectionStart } from 'src/plugins/usage_collection/public';
import { SpacesPluginStart } from '../../spaces/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public';
import { DashboardStart } from '../../../../src/plugins/dashboard/public';
@ -100,6 +101,7 @@ export interface LensPluginStartDependencies {
presentationUtil: PresentationUtilPluginStart;
indexPatternFieldEditor: IndexPatternFieldEditorStart;
inspector: InspectorStartContract;
spaces: SpacesPluginStart;
usageCollection?: UsageCollectionStart;
}

View file

@ -19,13 +19,7 @@ export const initMiddleware = (storeDeps: LensStoreDeps) => (store: MiddlewareAP
);
return (next: Dispatch) => (action: PayloadAction) => {
if (lensSlice.actions.loadInitial.match(action)) {
return loadInitial(
store,
storeDeps,
action.payload.redirectCallback,
action.payload.initialInput,
action.payload.emptyState
);
return loadInitial(store, storeDeps, action.payload);
} else if (lensSlice.actions.navigateAway.match(action)) {
return unsubscribeFromExternalContext();
}

View file

@ -12,6 +12,7 @@ import {
createMockDatasource,
DatasourceMock,
} from '../../mocks';
import { Location, History } from 'history';
import { act } from 'react-dom/test-utils';
import { loadInitial } from './load_initial';
import { LensEmbeddableInput } from '../../embeddable';
@ -65,7 +66,12 @@ describe('Mounter', () => {
it('should initialize initial datasource', async () => {
const services = makeDefaultServices();
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
...defaultDoc,
sharingSavedObjectProps: {
outcome: 'exactMatch',
},
});
const lensStore = await makeLensStore({
data: services.data,
@ -79,8 +85,10 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
jest.fn(),
({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
{
redirectCallback: jest.fn(),
initialInput: ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput,
}
);
});
expect(mockDatasource.initialize).toHaveBeenCalled();
@ -88,7 +96,12 @@ describe('Mounter', () => {
it('should have initialized only the initial datasource and visualization', async () => {
const services = makeDefaultServices();
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
...defaultDoc,
sharingSavedObjectProps: {
outcome: 'exactMatch',
},
});
const lensStore = await makeLensStore({ data: services.data, preloadedState });
await act(async () => {
@ -99,7 +112,7 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
jest.fn()
{ redirectCallback: jest.fn() }
);
});
expect(mockDatasource.initialize).toHaveBeenCalled();
@ -129,7 +142,7 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
jest.fn()
{ redirectCallback: jest.fn() }
);
expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled();
});
@ -170,7 +183,11 @@ describe('Mounter', () => {
const emptyState = getPreloadedState(storeDeps) as LensAppState;
services.attributeService.unwrapAttributes = jest.fn();
await act(async () => {
await loadInitial(lensStore, storeDeps, jest.fn(), undefined, emptyState);
await loadInitial(lensStore, storeDeps, {
redirectCallback: jest.fn(),
initialInput: undefined,
emptyState,
});
});
expect(lensStore.getState()).toEqual({
@ -189,20 +206,28 @@ describe('Mounter', () => {
it('loads a document and uses query and filters if initial input is provided', async () => {
const services = makeDefaultServices();
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
...defaultDoc,
sharingSavedObjectProps: {
outcome: 'exactMatch',
},
});
const storeDeps = {
lensServices: services,
datasourceMap,
visualizationMap,
};
const emptyState = getPreloadedState(storeDeps) as LensAppState;
const lensStore = await makeLensStore({ data: services.data, preloadedState });
await act(async () => {
await loadInitial(
lensStore,
{
lensServices: services,
datasourceMap,
visualizationMap,
},
jest.fn(),
({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
);
await loadInitial(lensStore, storeDeps, {
redirectCallback: jest.fn(),
initialInput: ({
savedObjectId: defaultSavedObjectId,
} as unknown) as LensEmbeddableInput,
emptyState,
});
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
@ -235,8 +260,12 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
jest.fn(),
({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
{
redirectCallback: jest.fn(),
initialInput: ({
savedObjectId: defaultSavedObjectId,
} as unknown) as LensEmbeddableInput,
}
);
});
@ -248,8 +277,12 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
jest.fn(),
({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
{
redirectCallback: jest.fn(),
initialInput: ({
savedObjectId: defaultSavedObjectId,
} as unknown) as LensEmbeddableInput,
}
);
});
@ -263,8 +296,10 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
jest.fn(),
({ savedObjectId: '5678' } as unknown) as LensEmbeddableInput
{
redirectCallback: jest.fn(),
initialInput: ({ savedObjectId: '5678' } as unknown) as LensEmbeddableInput,
}
);
});
@ -287,8 +322,12 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
redirectCallback,
({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
{
redirectCallback,
initialInput: ({
savedObjectId: defaultSavedObjectId,
} as unknown) as LensEmbeddableInput,
}
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
@ -298,6 +337,50 @@ describe('Mounter', () => {
expect(redirectCallback).toHaveBeenCalled();
});
it('redirects if saved object is an aliasMatch', async () => {
const services = makeDefaultServices();
const lensStore = makeLensStore({ data: services.data, preloadedState });
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
...defaultDoc,
sharingSavedObjectProps: {
outcome: 'aliasMatch',
aliasTargetId: 'id2',
},
});
await act(async () => {
await loadInitial(
lensStore,
{
lensServices: services,
datasourceMap,
visualizationMap,
},
{
redirectCallback: jest.fn(),
initialInput: ({
savedObjectId: defaultSavedObjectId,
} as unknown) as LensEmbeddableInput,
history: {
location: {
search: '?search',
} as Location,
} as History,
}
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
savedObjectId: defaultSavedObjectId,
});
expect(services.spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith(
'#/edit/id2?search',
'Lens visualization'
);
});
it('adds to the recently accessed list on load', async () => {
const services = makeDefaultServices();
const lensStore = makeLensStore({ data: services.data, preloadedState });
@ -309,8 +392,12 @@ describe('Mounter', () => {
datasourceMap,
visualizationMap,
},
jest.fn(),
({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput
{
redirectCallback: jest.fn(),
initialInput: ({
savedObjectId: defaultSavedObjectId,
} as unknown) as LensEmbeddableInput,
}
);
});

View file

@ -8,8 +8,10 @@
import { MiddlewareAPI } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
import { History } from 'history';
import { LensAppState, setState } from '..';
import { updateLayer, updateVisualizationState, LensStoreDeps } from '..';
import { SharingSavedObjectProps } from '../../types';
import { LensEmbeddableInput, LensByReferenceInput } from '../../embeddable/embeddable';
import { getInitialDatasourceId } from '../../utils';
import { initializeDatasources } from '../../editor_frame_service/editor_frame';
@ -19,22 +21,50 @@ import {
switchToSuggestion,
} from '../../editor_frame_service/editor_frame/suggestion_helpers';
import { LensAppServices } from '../../app_plugin/types';
import { getFullPath, LENS_EMBEDDABLE_TYPE } from '../../../common/constants';
import { getEditPath, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../../common/constants';
import { Document, injectFilterReferences } from '../../persistence';
export const getPersisted = async ({
initialInput,
lensServices,
history,
}: {
initialInput: LensEmbeddableInput;
lensServices: LensAppServices;
}): Promise<{ doc: Document } | undefined> => {
const { notifications, attributeService } = lensServices;
history?: History<unknown>;
}): Promise<
{ doc: Document; sharingSavedObjectProps: Omit<SharingSavedObjectProps, 'errorJSON'> } | undefined
> => {
const { notifications, spaces, attributeService } = lensServices;
let doc: Document;
try {
const attributes = await attributeService.unwrapAttributes(initialInput);
const result = await attributeService.unwrapAttributes(initialInput);
if (!result) {
return {
doc: ({
...initialInput,
type: LENS_EMBEDDABLE_TYPE,
} as unknown) as Document,
sharingSavedObjectProps: {
outcome: 'exactMatch',
},
};
}
const { sharingSavedObjectProps, ...attributes } = result;
if (spaces && sharingSavedObjectProps?.outcome === 'aliasMatch' && history) {
// We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash
const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch'
const newPath = lensServices.http.basePath.prepend(
`${getEditPath(newObjectId)}${history.location.search}`
);
await spaces.ui.redirectLegacyUrl(
newPath,
i18n.translate('xpack.lens.legacyUrlConflict.objectNoun', {
defaultMessage: 'Lens visualization',
})
);
}
doc = {
...initialInput,
...attributes,
@ -43,6 +73,10 @@ export const getPersisted = async ({
return {
doc,
sharingSavedObjectProps: {
aliasTargetId: sharingSavedObjectProps?.aliasTargetId,
outcome: sharingSavedObjectProps?.outcome,
},
};
} catch (e) {
notifications.toasts.addDanger(
@ -62,9 +96,17 @@ export function loadInitial(
embeddableEditorIncomingState,
initialContext,
}: LensStoreDeps,
redirectCallback: (savedObjectId?: string) => void,
initialInput?: LensEmbeddableInput,
emptyState?: LensAppState
{
redirectCallback,
initialInput,
emptyState,
history,
}: {
redirectCallback: (savedObjectId?: string) => void;
initialInput?: LensEmbeddableInput;
emptyState?: LensAppState;
history?: History<unknown>;
}
) {
const { getState, dispatch } = store;
const { attributeService, notifications, data, dashboardFeatureFlag } = lensServices;
@ -146,11 +188,11 @@ export function loadInitial(
redirectCallback();
});
}
getPersisted({ initialInput, lensServices })
getPersisted({ initialInput, lensServices, history })
.then(
(persisted) => {
if (persisted) {
const { doc } = persisted;
const { doc, sharingSavedObjectProps } = persisted;
if (attributeService.inputIsRefType(initialInput)) {
lensServices.chrome.recentlyAccessed.add(
getFullPath(initialInput.savedObjectId),
@ -190,6 +232,7 @@ export function loadInitial(
dispatch(
setState({
sharingSavedObjectProps,
query: doc.state.query,
searchSessionId:
dashboardFeatureFlag.allowByValueEmbeddables &&

View file

@ -6,6 +6,7 @@
*/
import { createSlice, current, PayloadAction } from '@reduxjs/toolkit';
import { History } from 'history';
import { LensEmbeddableInput } from '..';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { getInitialDatasourceId, getResolvedDateRange } from '../utils';
@ -301,6 +302,7 @@ export const lensSlice = createSlice({
initialInput?: LensEmbeddableInput;
redirectCallback: (savedObjectId?: string) => void;
emptyState: LensAppState;
history: History<unknown>;
}>
) => state,
},

View file

@ -13,8 +13,7 @@ import { Document } from '../persistence';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { DateRange } from '../../common';
import { LensAppServices } from '../app_plugin/types';
import { DatasourceMap, VisualizationMap } from '../types';
import { DatasourceMap, VisualizationMap, SharingSavedObjectProps } from '../types';
export interface VisualizationState {
activeId: string | null;
state: unknown;
@ -44,6 +43,7 @@ export interface LensAppState extends EditorFrameState {
savedQuery?: SavedQuery;
searchSessionId: string;
resolvedDateRange: DateRange;
sharingSavedObjectProps?: Omit<SharingSavedObjectProps, 'errorJSON'>;
}
export type DispatchSetState = (

View file

@ -256,7 +256,7 @@ export interface Datasource<T = unknown, P = unknown> {
) =>
| Array<{
shortMessage: string;
longMessage: string;
longMessage: React.ReactNode;
fixAction?: { label: string; newState: () => Promise<T> };
}>
| undefined;
@ -729,7 +729,7 @@ export interface Visualization<T = unknown> {
) =>
| Array<{
shortMessage: string;
longMessage: string;
longMessage: React.ReactNode;
}>
| undefined;
@ -813,3 +813,9 @@ export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandle
| LensTableRowContextMenuEvent
) => void;
}
export interface SharingSavedObjectProps {
outcome?: 'aliasMatch' | 'exactMatch' | 'conflict';
aliasTargetId?: string;
errorJSON?: string;
}

View file

@ -383,7 +383,7 @@ export const getXyVisualization = ({
const errors: Array<{
shortMessage: string;
longMessage: string;
longMessage: React.ReactNode;
}> = [];
// check if the layers in the state are compatible with this type of chart
@ -488,7 +488,7 @@ function validateLayersForDimension(
| { valid: true }
| {
valid: false;
payload: { shortMessage: string; longMessage: string };
payload: { shortMessage: string; longMessage: React.ReactNode };
} {
// Multiple layers must be consistent:
// * either a dimension is missing in ALL of them

View file

@ -15,6 +15,7 @@
"../../../typings/**/*"
],
"references": [
{ "path": "../spaces/tsconfig.json" },
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../task_manager/tsconfig.json" },
{ "path": "../global_search/tsconfig.json"},