Refactor and improve Visualize Loader (#15157)

* Simplify promise setup logic

* Import template from own file

* Use angular.element instead of jquery

* Add documentation for loader methods

* Add params.append

* Remove params.editorMode

* Clarify when returned promise resolves

* Add element to handler

* Allow setting CSS class via loader

* Use render-counter on visualize

* Use Angular run method to get access to Private service

* Allow adding data-attributes to the vis element

* Refactor loader to return an EmbeddedVisualizeHandler instance

* Use this.destroy for previous API

* Remove fallback then method, due to bugs

* Reject promise from withId when id not found

* Add tests

* Change developer documentation

* Revert "Use Angular run method to get access to Private service"

This reverts commit 160e47d7709484c0478415436b3c2e8a8fc8aed3.

* Rename parameter for more clarity

* Add more documentation about appState

* Fix broken test utils

* Use chrome to get access to Angular

* Move loader to its own folder

* Use a method instead of getter for element

* Add listeners for renderComplete events

* Use typedef to document params

* Fix documentation
This commit is contained in:
Tim Roes 2017-12-02 00:44:28 +01:00 committed by GitHub
parent de109fc344
commit e5d2ff8219
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 529 additions and 146 deletions

View file

@ -1,89 +1,71 @@
[[development-embedding-visualizations]]
=== Embedding Visualizations
There are two different angular directives you can use to insert a visualization in your page.
To display an already saved visualization, use the `<visualize>` directive.
To reuse an existing Visualization implementation for a more custom purpose, use the `<visualization>` directive instead.
There are two different methods you can use to insert a visualization in your page.
To display an already saved visualization, use the `VisualizeLoader`.
To reuse an existing visualization implementation for a more custom purpose,
use the Angular `<visualization>` directive instead.
==== VisualizeLoader
The `VisualizeLoader` class i the easiest way to embed a visualization into your plugin. It exposes
two methods:
- `getVisualizationList()`: which returns promise which gets resolved with list of saved visualizations
- `embedVisualizationWithId(container, savedId, params)`: which embeds visualization by id
- `embedVisualizationWithSavedObject(container, savedObject, params)`: which embeds visualization from saved object
The `VisualizeLoader` class is the easiest way to embed a visualization into your plugin.
It will take care of loading the data and rendering the visualization.
`container` should be a dom element to which visualization should be embedded
`params` is a parameter object where the following properties can be defined:
To get an instance of the loader, do the following:
- `timeRange`: time range to pass to `<visualize>` directive
- `uiState`: uiState to pass to `<visualize>` directive
- `appState`: appState to pass to `<visualize>` directive
- `showSpyPanel`: showSpyPanel property to pass to `<visualize>` directive
==== `<visualize>` directive
The `<visualize>` directive takes care of loading data, parsing data, rendering the editor
(if the Visualization is in edit mode) and rendering the visualization.
The directive takes a savedVis object for its configuration.
It is the easiest way to add visualization to your page under the assumption that
the visualization you are trying to display is saved in kibana.
If that is not the case, take a look at using `<visualization>` directive.
The simplest way is to just pass `saved-id` to `<visualize>`:
`<visualize saved-id="'447d2930-9eb2-11e7-a956-5558df96e706'"></visualize>`
For the above to work with time based visualizations time picker must be present (enabled) on the page. For scenarios
where timepicker is not available time range can be passed in as additional parameter:
`<visualize saved-id="'447d2930-9eb2-11e7-a956-5558df96e706'"
time-range="{ max: '2017-09-21T21:59:59.999Z', min: '2017-09-18T22:00:00.000Z' }"></visualize>`
Once <visualize> is done rendering the element will emit `renderComplete` event.
When more control is required over the visualization you may prefer to load the saved object yourself and then pass it
to `<visualize>`
`<visualize saved-obj='savedVis' app-state='appState' ui-state='uiState' editor-mode='false'></visualize>` where
`savedVis` is an instance of savedVisualization object, which can be retrieved using `savedVisualizations` service
which is explained later in this documentation.
`appState` is an instance of `AppState`. <visualize> is expecting two keys defined on AppState:
- `filters` which is an instance of searchSource filter object and
- `query` which is an instance of searchSource query object
If `appState` is not provided, `<visualize>` will not monitor the `AppState`.
`uiState` should be an instance of `PersistedState`. if not provided visualize will generate one,
but you will have no access to it. It is used to store viewer specific information like whether the legend is toggled on or off.
`editor-mode` defines if <visualize> should render in editor or in view mode.
*code example: Showing a saved visualization, without linking to querybar or filterbar.*
["source","html"]
-----------
<div ng-controller="KbnTestController" class="test_vis">
<visualize saved-obj='savedVis'></visualize>
</div>
-----------
["source","js"]
-----------
import { uiModules } from 'ui/modules';
import { getVisualizeLoader } from 'ui/visualize/loader';
uiModules.get('kibana')
.controller('KbnTestController', function ($scope, AppState, savedVisualizations) {
const visId = 'enter_your_vis_id';
savedVisualizations.get(visId).then(savedVis => $scope.savedObj = savedVis);
getVisualizeLoader().then((loader) => {
// You now have access to the loader
});
-----------
When <visualize> is done rendering it will emit `renderComplete` event on the element.
The loader exposes the following methods:
- `getVisualizationList()`: which returns promise which gets resolved with a list of saved visualizations
- `embedVisualizationWithId(container, savedId, params)`: which embeds visualization by id
- `embedVisualizationWithSavedObject(container, savedObject, params)`: which embeds visualization from saved object
Depending on which embed method you are using, you either pass in the id of the
saved object for the visualization, or a `savedObject`, that you can retrieve via
the `savedVisualizations` Angular service by its id. The `savedObject` give you access
to the filter and query logic and allows you to attach listeners to the visualizations.
For a more complex use-case you usually want to use that method.
`container` should be a DOM element (jQuery wrapped or regular DOM element) into which the visualization should be embedded
`params` is a parameter object specifying several parameters, that influence rendering.
You will find a detailed description of all the parameters in the inline docs
in the {repo}blob/{branch}/src/ui/public/visualize/loader/loader.js[loader source code].
Both methods return an `EmbeddedVisualizeHandler`, that gives you some access
to the visualization. The `embedVisualizationWithSavedObject` method will return
the handler immediately from the method call, whereas the `embedVisualizationWithId`
will return a promise, that resolves with the handler, as soon as the `id` could be
found. It will reject, if the `id` is invalid.
The returned `EmbeddedVisualizeHandler` itself has the following methods and properties:
- `destroy()`: destroys the underlying Angualr scope of the visualization
- `getElement()`: a reference to the jQuery wrapped DOM element, that renders the visualization
- `whenFirstRenderComplete()`: will return a promise, that resolves as soon as the visualization has
finished rendering for the first time
- `addRenderCompleteListener(listener)`: will register a listener to be called whenever
a rendering of this visualization finished (not just the first one)
- `removeRenderCompleteListener(listener)`: removes an event listener from the handler again
You can find the detailed `EmbeddedVisualizeHandler` documentation in its
{repo}blob/{branch}/src/ui/public/visualize/loader/embedded_visualize_handler.js[source code].
We recommend *not* to use the internal `<visualize>` Angular directive directly.
==== `<visualization>` directive
The `<visualization>` directive takes a visualization configuration and data.
It should be used, if you don't want to render a saved visualization, but specify
the config and data directly.
`<visualization vis='vis' vis-data='visData' ui-state='uiState' ></visualization>` where

View file

@ -16,7 +16,7 @@ import sinon from 'sinon';
* This method setups the stub for chrome.dangerouslyGetActiveInjector. You must call it in
* a place where beforeEach is allowed to be called (read: inside your describe)
* method. You must call this AFTER you've called `ngMock.module` to setup the modules,
* but BEFORE you first execute code, that uses chrome.getActiveInjector.
* but BEFORE you first execute code, that uses chrome.dangerouslyGetActiveInjector.
*/
export function setupInjectorStub() {
beforeEach(ngMock.inject(($injector) => {
@ -30,7 +30,7 @@ export function setupInjectorStub() {
*/
export function teardownInjectorStub() {
afterEach(() => {
chrome.getActiveInjector.restore();
chrome.dangerouslyGetActiveInjector.restore();
});
}

View file

@ -1,75 +0,0 @@
import $ from 'jquery';
import uiRoutes from 'ui/routes';
import 'ui/visualize';
const VisualizeLoaderProvider = ($compile, $rootScope, savedVisualizations) => {
const renderVis = (el, savedObj, params) => {
const scope = $rootScope.$new();
scope.savedObj = savedObj;
scope.appState = params.appState;
scope.uiState = params.uiState;
scope.timeRange = params.timeRange;
scope.showSpyPanel = params.showSpyPanel;
scope.editorMode = params.editorMode;
const container = el instanceof $ ? el : $(el);
container.html('');
const visEl = $('<visualize saved-obj="savedObj" app-state="appState" ui-state="uiState" ' +
'time-range="timeRange" editor-mode="editorMode" show-spy-panel="showSpyPanel"></visualize>');
const visHtml = $compile(visEl)(scope);
container.html(visHtml);
const handler = { destroy: scope.$destroy };
return new Promise((resolve) => {
visEl.on('renderComplete', () => {
resolve(handler);
});
});
};
return {
embedVisualizationWithId: async (el, savedVisualizationId, params) => {
return new Promise((resolve) => {
savedVisualizations.get(savedVisualizationId).then(savedObj => {
renderVis(el, savedObj, params).then(handler => {
resolve(handler);
});
});
});
},
embedVisualizationWithSavedObject: (el, savedObj, params) => {
return renderVis(el, savedObj, params);
},
getVisualizationList: () => {
return savedVisualizations.find().then(result => result.hits);
},
};
};
let visualizeLoader = null;
let pendingPromise = null;
let pendingResolve = null;
uiRoutes.addSetupWork(function (Private) {
visualizeLoader = Private(VisualizeLoaderProvider);
if (pendingResolve) {
pendingResolve(visualizeLoader);
}
});
async function getVisualizeLoader() {
if (!pendingResolve) {
pendingPromise = new Promise((resolve)=> {
pendingResolve = resolve;
if (visualizeLoader) resolve(visualizeLoader);
});
}
return pendingPromise;
}
export { getVisualizeLoader, VisualizeLoaderProvider };

View file

@ -0,0 +1,262 @@
import angular from 'angular';
import expect from 'expect.js';
import ngMock from 'ng_mock';
import sinon from 'sinon';
import { setupAndTeardownInjectorStub } from 'test_utils/stub_get_active_injector';
import FixturesStubbedSearchSourceProvider from 'fixtures/stubbed_search_source';
import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern';
import { VisProvider } from 'ui/vis';
import { getVisualizeLoader } from '../loader';
import { EmbeddedVisualizeHandler } from '../embedded_visualize_handler';
describe('visualize loader', () => {
let searchSource;
let vis;
let $rootScope;
let loader;
let mockedSavedObject;
function createSavedObject() {
return {
vis: vis,
searchSource: searchSource
};
}
async function timeout(delay = 0) {
return new Promise(resolve => {
setTimeout(resolve, delay);
});
}
function newContainer() {
return angular.element('<div></div>');
}
function embedWithParams(params) {
const container = newContainer();
loader.embedVisualizationWithSavedObject(container, createSavedObject(), params);
$rootScope.$digest();
return container.find('visualize');
}
beforeEach(ngMock.module('kibana', 'kibana/directive'));
beforeEach(ngMock.inject((_$rootScope_, savedVisualizations, Private) => {
$rootScope = _$rootScope_;
searchSource = Private(FixturesStubbedSearchSourceProvider);
const indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
// Create a new Vis object
const Vis = Private(VisProvider);
vis = new Vis(indexPattern, {
type: 'pie',
params: {},
aggs: [
{ type: 'count', schema: 'metric' },
{
type: 'range',
schema: 'bucket',
params: {
field: 'bytes',
ranges: [
{ from: 0, to: 1000 },
{ from: 1000, to: 2000 }
]
}
}
]
});
vis.type.requestHandler = 'none';
vis.type.responseHandler = 'none';
vis.type.requiresSearch = false;
// Setup savedObject
mockedSavedObject = createSavedObject();
// Mock savedVisualizations.get to return 'mockedSavedObject' when id is 'exists'
sinon.stub(savedVisualizations, 'get', (id) =>
id === 'exists' ? Promise.resolve(mockedSavedObject) : Promise.reject()
);
}));
setupAndTeardownInjectorStub();
beforeEach(async () => {
loader = await getVisualizeLoader();
});
describe('getVisualizeLoader', () => {
it('should return a promise', () => {
expect(getVisualizeLoader().then).to.be.a('function');
});
it('should resolve to an object', async () => {
const visualizeLoader = await getVisualizeLoader();
expect(visualizeLoader).to.be.an('object');
});
});
describe('service', () => {
describe('getVisualizationList', () => {
it('should be a function', async () => {
expect(loader.getVisualizationList).to.be.a('function');
});
});
describe('embedVisualizationWithSavedObject', () => {
it('should be a function', () => {
expect(loader.embedVisualizationWithSavedObject).to.be.a('function');
});
it('should render the visualize element', () => {
const container = newContainer();
loader.embedVisualizationWithSavedObject(container, createSavedObject(), { });
expect(container.find('visualize').length).to.be(1);
});
it('should replace content of container by default', () => {
const container = angular.element('<div><div id="prevContent"></div></div>');
loader.embedVisualizationWithSavedObject(container, createSavedObject(), {});
expect(container.find('#prevContent').length).to.be(0);
});
it('should append content to container when using append parameter', () => {
const container = angular.element('<div><div id="prevContent"></div></div>');
loader.embedVisualizationWithSavedObject(container, createSavedObject(), {
append: true
});
expect(container.children().length).to.be(2);
expect(container.find('#prevContent').length).to.be(1);
});
it('should apply css classes from parameters', () => {
const vis = embedWithParams({ cssClass: 'my-css-class another-class' });
expect(vis.hasClass('my-css-class')).to.be(true);
expect(vis.hasClass('another-class')).to.be(true);
});
it('should apply data attributes from dataAttrs parameter', () => {
const vis = embedWithParams({
dataAttrs: {
'foo': '',
'with-dash': 'value',
}
});
expect(vis.attr('data-foo')).to.be('');
expect(vis.attr('data-with-dash')).to.be('value');
});
it('should hide spy panel control by default', () => {
const vis = embedWithParams({});
expect(vis.find('[data-test-subj="spyToggleButton"]').length).to.be(0);
});
});
describe('embedVisualizationWithId', () => {
it('should be a function', async () => {
expect(loader.embedVisualizationWithId).to.be.a('function');
});
it('should reject if the id was not found', () => {
const resolveSpy = sinon.spy();
const rejectSpy = sinon.spy();
return loader.embedVisualizationWithId(newContainer(), 'not-existing', {})
.then(resolveSpy, rejectSpy)
.then(() => {
expect(resolveSpy.called).to.be(false);
expect(rejectSpy.calledOnce).to.be(true);
});
});
it('should render a visualize element, if id was found', async () => {
const container = newContainer();
await loader.embedVisualizationWithId(container, 'exists', {});
expect(container.find('visualize').length).to.be(1);
});
});
describe('EmbeddedVisualizeHandler', () => {
it('should be returned from embedVisualizationWithId via a promise', async () => {
const handler = await loader.embedVisualizationWithId(newContainer(), 'exists', {});
expect(handler instanceof EmbeddedVisualizeHandler).to.be(true);
});
it('should be returned from embedVisualizationWithSavedObject', async () => {
const handler = loader.embedVisualizationWithSavedObject(newContainer(), createSavedObject(), {});
expect(handler instanceof EmbeddedVisualizeHandler).to.be(true);
});
it('should give access to the visualzie element', () => {
const container = newContainer();
const handler = loader.embedVisualizationWithSavedObject(container, createSavedObject(), {});
expect(handler.getElement()[0]).to.be(container.find('visualize')[0]);
});
it('should use a jquery wrapper for handler.element', () => {
const handler = loader.embedVisualizationWithSavedObject(newContainer(), createSavedObject(), {});
// Every jquery wrapper has a .jquery property with the version number
expect(handler.getElement().jquery).to.be.ok();
});
it('should have whenFirstRenderComplete returns a promise resolving on first renderComplete event', async () => {
const container = newContainer();
const handler = loader.embedVisualizationWithSavedObject(container, createSavedObject(), {});
const spy = sinon.spy();
handler.whenFirstRenderComplete().then(spy);
expect(spy.notCalled).to.be(true);
container.find('visualize').trigger('renderComplete');
await timeout();
expect(spy.calledOnce).to.be(true);
});
it('should add listeners via addRenderCompleteListener that triggers on renderComplete events', async () => {
const container = newContainer();
const handler = loader.embedVisualizationWithSavedObject(container, createSavedObject(), {});
const spy = sinon.spy();
handler.addRenderCompleteListener(spy);
expect(spy.notCalled).to.be(true);
container.find('visualize').trigger('renderComplete');
await timeout();
expect(spy.calledOnce).to.be(true);
});
it('should call render complete listeners once per renderComplete event', async () => {
const container = newContainer();
const handler = loader.embedVisualizationWithSavedObject(container, createSavedObject(), {});
const spy = sinon.spy();
handler.addRenderCompleteListener(spy);
expect(spy.notCalled).to.be(true);
container.find('visualize').trigger('renderComplete');
container.find('visualize').trigger('renderComplete');
container.find('visualize').trigger('renderComplete');
expect(spy.callCount).to.be(3);
});
it('should successfully remove listeners from render complete', async () => {
const container = newContainer();
const handler = loader.embedVisualizationWithSavedObject(container, createSavedObject(), {});
const spy = sinon.spy();
handler.addRenderCompleteListener(spy);
expect(spy.notCalled).to.be(true);
container.find('visualize').trigger('renderComplete');
expect(spy.calledOnce).to.be(true);
spy.reset();
handler.removeRenderCompleteListener(spy);
container.find('visualize').trigger('renderComplete');
expect(spy.notCalled).to.be(true);
});
});
});
});

View file

@ -0,0 +1,73 @@
import { EventEmitter } from 'events';
const RENDER_COMPLETE_EVENT = 'render_complete';
/**
* A handler to the embedded visualization. It offers several methods to interact
* with the visualization.
*/
export class EmbeddedVisualizeHandler {
constructor(element, scope) {
this._element = element;
this._scope = scope;
this._listeners = new EventEmitter();
// Listen to the first RENDER_COMPLETE_EVENT to resolve this promise
this._firstRenderComplete = new Promise(resolve => {
this._listeners.once(RENDER_COMPLETE_EVENT, resolve);
});
this._element.on('renderComplete', () => {
this._listeners.emit(RENDER_COMPLETE_EVENT);
});
}
/**
* Destroy the underlying Angular scope of the visualization. This should be
* called whenever you remove the visualization.
*/
destroy() {
this._scope.$destroy();
}
/**
* Return the actual DOM element (wrapped in jQuery) of the rendered visualization.
* This is especially useful if you used `append: true` in the parameters where
* the visualization will be appended to the specified container.
*/
getElement() {
return this._element;
}
/**
* Returns a promise, that will resolve (without a value) once the first rendering of
* the visualization has finished. If you want to listen to concecutive rendering
* events, look into the `addRenderCompleteListener` method.
*
* @returns {Promise} Promise, that resolves as soon as the visualization is done rendering
* for the first time.
*/
whenFirstRenderComplete() {
return this._firstRenderComplete;
}
/**
* Adds a listener to be called whenever the visualization finished rendering.
* This can be called multiple times, when the visualization rerenders, e.g. due
* to new data.
*
* @param {function} listener The listener to be notified about complete renders.
*/
addRenderCompleteListener(listener) {
this._listeners.addListener(RENDER_COMPLETE_EVENT, listener);
}
/**
* Removes a previously registered render complete listener from this handler.
* This listener will no longer be called when the visualization finished rendering.
*
* @param {function} listener The listener to remove from this handler.
*/
removeRenderCompleteListener(listener) {
this._listeners.removeListener(RENDER_COMPLETE_EVENT, listener);
}
}

View file

@ -0,0 +1 @@
export * from './loader';

View file

@ -0,0 +1,132 @@
/**
* IMPORTANT: If you make changes to this API, please make sure to check that
* the docs (docs/development/visualize/development-create-visualization.asciidoc)
* are up to date.
*/
import angular from 'angular';
import chrome from 'ui/chrome';
import 'ui/visualize';
import visTemplate from './loader_template.html';
import { EmbeddedVisualizeHandler } from './embedded_visualize_handler';
/**
* The parameters accepted by the embedVisualize calls.
* @typedef {object} VisualizeLoaderParams
* @property {AppState} params.appState The appState this visualization should use.
* If you don't spyecify it, the global AppState (that is decoded in the URL)
* will be used. Usually you don't need to overwrite this, unless you don't
* want the visualization to use the global AppState.
* @property {UiState} params.uiState The current uiState of the application. If you
* don't pass a uiState, the visualization will creates it's own uiState to
* store information like whether the legend is open or closed, but you don't
* have access to it from the outside. Pass one in if you need that access.
* @property {object} params.timeRange An object with a min/max key, that must be
* either a date in ISO format, or a valid datetime Elasticsearch expression,
* e.g.: { min: 'now-7d/d', max: 'now' }
* @property {boolean} params.showSpyPanel Whether or not the spy panel should be available
* on this chart. (default: false)
* @property {boolean} params.append If set to true, the visualization will be appended
* to the passed element instead of replacing all its content. (default: false)
* @property {string} params.cssClass If specified this CSS class (or classes with space separated)
* will be set to the root visuzalize element.
* @property {object} params.dataAttrs An object of key-value pairs, that will be set
* as data-{key}="{value}" attributes on the visualization element.
*/
const VisualizeLoaderProvider = ($compile, $rootScope, savedVisualizations) => {
const renderVis = (el, savedObj, params) => {
const scope = $rootScope.$new();
params = params || {};
scope.savedObj = savedObj;
scope.appState = params.appState;
scope.uiState = params.uiState;
scope.timeRange = params.timeRange;
scope.showSpyPanel = params.showSpyPanel;
const container = angular.element(el);
const visHtml = $compile(visTemplate)(scope);
// If params specified cssClass, we will set this to the element.
if (params.cssClass) {
visHtml.addClass(params.cssClass);
}
// Apply data- attributes to the element if specified
if (params.dataAttrs) {
Object.keys(params.dataAttrs).forEach(key => {
visHtml.attr(`data-${key}`, params.dataAttrs[key]);
});
}
// If params.append was true append instead of replace content
if (params.append) {
container.append(visHtml);
} else {
container.html(visHtml);
}
return new EmbeddedVisualizeHandler(visHtml, scope);
};
return {
/**
* Renders a saved visualization specified by its id into a DOM element.
*
* @param {Element} element The DOM element to render the visualization into.
* You can alternatively pass a jQuery element instead.
* @param {String} id The id of the saved visualization. This is the id of the
* saved object that is stored in the .kibana index.
* @param {VisualizeLoaderParams} params A list of parameters that will influence rendering.
*
* @return {Promise.<EmbeddedVisualizeHandler>} A promise that resolves to the
* handler for this visualization as soon as the saved object could be found.
*/
embedVisualizationWithId: async (element, savedVisualizationId, params) => {
return new Promise((resolve, reject) => {
savedVisualizations.get(savedVisualizationId).then(savedObj => {
const handler = renderVis(element, savedObj, params);
resolve(handler);
}, reject);
});
},
/**
* Renders a saved visualization specified by its savedObject into a DOM element.
* In most of the cases you will need this method, since it allows you to specify
* filters, handlers, queries, etc. on the savedObject before rendering.
*
* @param {Element} element The DOM element to render the visualization into.
* You can alternatively pass a jQuery element instead.
* @param {Object} savedObj The savedObject as it could be retrieved by the
* `savedVisualizations` service.
* @param {VisualizeLoaderParams} params A list of paramters that will influence rendering.
*
* @return {EmbeddedVisualizeHandler} The handler to the visualization.
*/
embedVisualizationWithSavedObject: (el, savedObj, params) => {
return renderVis(el, savedObj, params);
},
/**
* Returns a promise, that resolves to a list of all saved visualizations.
*
* @return {Promise} Resolves with a list of all saved visualizations as
* returned by the `savedVisualizations` service in Kibana.
*/
getVisualizationList: () => {
return savedVisualizations.find().then(result => result.hits);
},
};
};
/**
* Returns a promise, that resolves with the visualize loader, once it's ready.
* @return {Promise} A promise, that resolves to the visualize loader.
*/
function getVisualizeLoader() {
return chrome.dangerouslyGetActiveInjector().then($injector => {
const Private = $injector.get('Private');
return Private(VisualizeLoaderProvider);
});
}
export { getVisualizeLoader, VisualizeLoaderProvider };

View file

@ -0,0 +1,8 @@
<visualize
saved-obj="savedObj"
app-state="appState"
ui-state="uiState"
time-range="timeRange"
show-spy-panel="showSpyPanel"
render-counter
></visualize>