UNPKG

@mongodb-js/charts-embed-dom

Version:

JavaScript library for embedding MongoDB Charts

1,304 lines (1,042 loc) 38.3 kB
import { Chatty } from '@looker/chatty'; import { EJSON } from 'bson'; import _isEqual from 'lodash/isEqual'; import _isEmpty from 'lodash/isEmpty'; function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } // Given an object `Target`, find all property names of type `Type` // Given an object `Target`, filter out all properties that aren't of type `Type` function createElement(name) { let props = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; let children = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : []; const element = document.createElement(name); for (const [name, value] of Object.entries(props)) { if (name === 'style') { Object.assign(element.style, props.style); } else { element.setAttribute(name, value); } } for (const child of Array.isArray(children) ? children : [children]) { element.append(child); } return element; } let THEME; (function (THEME) { THEME["DARK"] = "dark"; THEME["LIGHT"] = "light"; })(THEME || (THEME = {})); let SCALING; (function (SCALING) { SCALING["FIXED"] = "fixed"; SCALING["SCALE"] = "scale"; })(SCALING || (SCALING = {})); let ENCODING; (function (ENCODING) { ENCODING["BASE64"] = "base64"; ENCODING["BINARY"] = "binary"; })(ENCODING || (ENCODING = {})); const SDK_PROTOCOL_CHART = '3'; const SDK_PROTOCOL_DASHBOARD = '3'; const isPlainObject = value => { return typeof value === 'object' && value !== null && !Array.isArray(value); }; /** * Retrieves embed options that are shared. * * Validates the values passed in as well. */ const getSharedEmbedOptions = options => { const { background, baseUrl, autoRefresh, maxDataAge, width, height, theme, showAttribution, getUserToken, filter } = options; if (typeof baseUrl !== 'string' || baseUrl.length === 0) { throw new Error('Base URL must be a valid URL'); } if (background !== undefined && typeof background !== 'string') { throw new Error('background must be a string if specified'); } if (autoRefresh !== undefined && typeof autoRefresh !== 'boolean') { throw new Error('autoRefresh must be a boolean if specified'); } if (maxDataAge !== undefined && typeof maxDataAge !== 'number') { throw new Error('maxDataAge must be a number if specified'); } if (width !== undefined && !['number', 'string'].includes(typeof width)) { throw new Error('Width must be a string or number if specified'); } if (height !== undefined && !['number', 'string'].includes(typeof height)) { throw new Error('Height must be a string or number if specified'); } if (theme !== undefined && typeof theme !== 'string') { throw new Error('Theme must be a string if specified'); } if (showAttribution !== undefined && typeof showAttribution !== 'boolean') { throw new Error('Attribution must be a boolean value if specified'); } if (getUserToken !== undefined && typeof getUserToken !== 'function') { throw new Error('getUserToken must be a function'); } if (filter !== undefined && !isPlainObject(filter)) { throw new Error('Filter must be an object if specified'); } return { background, baseUrl, autoRefresh, maxDataAge, width, height, theme, showAttribution, getUserToken, filter }; }; const getPathname = (url, pathname) => { return [url.pathname, url.pathname.slice(-1) === '/' ? '' : '/', // Add trailing slash if not there pathname].join(''); }; /** * Constructs the chart iframe URL from the baseUrl, chartId & tenantId */ const getChartUrl = options => { try { const url = new URL(options.baseUrl); url.pathname = getPathname(url, 'embed/charts'); url.search = `id=${options.chartId}&sdk=${SDK_PROTOCOL_CHART}`; if (options.autoRefresh === false) { url.search += `&autorefresh=false`; } else if (options.autoRefresh === undefined) { url.search += options.refreshInterval ? `&autorefresh=${options.refreshInterval}` : ''; } if (options.maxDataAge !== undefined) { url.search += `&maxDataAge=${options.maxDataAge}`; } if (options.filter) { url.search += `&filter=${encodeURIComponent(EJSON.stringify(options.filter, { relaxed: false }))}`; } if (options.theme) { url.search += `&theme=${options.theme}`; } if (options.showAttribution === false) { url.search += `&attribution=false`; } return url.toString(); } catch (e) { throw new Error('Base URL must be a valid URL'); } }; /** * Constructs the dashboard iframe URL from the baseUrl, dashboardId & tenantId */ const getDashboardUrl = options => { try { const url = new URL(options.baseUrl); url.pathname = getPathname(url, 'embed/dashboards'); url.search = `id=${options.dashboardId}&sdk=${SDK_PROTOCOL_DASHBOARD}`; if (options.autoRefresh === false) { url.search += `&autoRefresh=false`; } if (options.maxDataAge !== undefined) { url.search += `&maxDataAge=${options.maxDataAge}`; } if (options.showTitleAndDesc === true) { url.search += `&showTitleAndDesc=true`; } if (options.widthMode) { url.search += `&scalingWidth=${options.widthMode}`; } if (options.heightMode) { url.search += `&scalingHeight=${options.heightMode}`; } if (options.theme) { url.search += `&theme=${options.theme}`; } if (options.chartsBackground) { url.search += `&chartsBackground=${options.chartsBackground}`; } if (options.background) { url.search += `&background=${options.background}`; } if (options.showAttribution === false) { url.search += `&attribution=false`; } if (options.filter) { url.search += `&filter=${encodeURIComponent(EJSON.stringify(options.filter, { relaxed: false }))}`; } return url.toString(); } catch (e) { throw new Error('Base URL must be a valid URL'); } }; /* Parses a CSS Measurement from an unknown value - if it's a string, we trust that it is well-formed - if it's a number, we assume the units are pixels - otherwise we return null */ const parseCSSMeasurement = value => { if (typeof value === 'string') return value; if (typeof value === 'number') return `${value}px`; return null; }; /** * Returns the background after validation checks * or default background based on theme if not set */ const getBackground = (background, theme, lightBackground, darkBackground) => { if (typeof background === 'string' && background.length > 0) return background; if (theme === 'dark') return darkBackground; return lightBackground; }; class BaseEmbedItem { constructor() { _defineProperty(this, "iframe", void 0); _defineProperty(this, "connection", void 0); _defineProperty(this, "name", void 0); _defineProperty(this, "ERRORS", void 0); _defineProperty(this, "COLOUR", void 0); _defineProperty(this, "options", void 0); } /** * Renders an embeddable item into the given `container`. * * This method should only be called once, and successive attempts to call `render` * will fail with an error. * * @returns a promise that will resolve once the item has successfully been embedded */ async render(container) { if (this.iframe) { throw new Error(this.ERRORS.IFRAME); } // Create styled container const embedRoot = this._configureEmbedRoot(createElement('div', { style: { position: 'relative', overflow: 'hidden', minHeight: Boolean(this.options.height) ? 0 : '15px', width: parseCSSMeasurement(this.options.width) || '100%', height: parseCSSMeasurement(this.options.height) || '100%' } })); // Create host const host = this._configureHost(Chatty.createHost(this.getEmbedUrl()).withSandboxAttribute('allow-scripts').withSandboxAttribute('allow-same-origin').withSandboxAttribute('allow-popups').withSandboxAttribute('allow-popups-to-escape-sandbox').appendTo(embedRoot)).build(); // Customise IFrame styles host.iframe.setAttribute('aria-label', this.name); Object.assign(host.iframe.style, { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%' }); // Remove any existing nodes in our target container while (container.firstChild) container.removeChild(container.firstChild); container.appendChild(embedRoot); // connect to iframe this.connection = await host.connect(); this.iframe = host.iframe; this._setBackground(this.options.background, this.options.theme); // configure token if needed await this._retrieveAndSetToken(); } /** * @returns whether auto refreshing is enabled */ async isAutoRefresh() { const [result] = await this._send('get', 'autoRefresh'); // autoRefresh from embed chart may be a number when refreshInterval is set return typeof result === 'number' || typeof result === 'boolean' ? Boolean(result) : Promise.reject('unexpected response received from iframe'); } /** * Enable/Disable auto refreshing. */ async setAutoRefresh(value) { if (typeof value !== 'boolean') { return Promise.reject('autoRefresh property value should be a boolean'); } await this._send('set', 'autoRefresh', value); } /** * @returns the number of seconds before a chart or dashboard's data expires */ async getMaxDataAge() { const [result] = await this._send('get', 'maxDataAge'); return typeof result === 'number' ? result : Promise.reject('unexpected response received from iframe'); } /** * Set the number of seconds a chart or dashboard's data expires. */ async setMaxDataAge(value) { if (typeof value !== 'number') { return Promise.reject('maxDataAge property value should be a number'); } await this._send('set', 'maxDataAge', value); } /** * Sets the color scheme to apply to the chart or dashboard. * * If the theme is set to 'dark' and you have specified a custom background color, you should ensure that your background color has appropriate contrast. */ async setTheme(value) { if (typeof value !== 'string') { return Promise.reject('theme property value should be a string'); } // if invalid theme string is provided, default it to light const newTheme = Object.values(THEME).includes(value) ? value : THEME.LIGHT; await this._send('set', 'theme', newTheme); this._setBackground(this.options.background, newTheme); } /** * @returns the current theme applied to the chart or dashboard */ async getTheme() { const [result] = await this._send('get', 'theme'); return typeof result === 'string' ? result : Promise.reject('unexpected response received from iframe'); } _configureHost(hostBuilder) { return hostBuilder.on('refreshToken', () => this._retrieveAndSetToken()); } _configureEmbedRoot(embedRoot) { return embedRoot; } _setBackground(background, theme) { this.iframe.style.backgroundColor = getBackground(background, theme, this.COLOUR.LIGHT, this.COLOUR.DARK); } async _retrieveAndSetToken() { if (this.options.getUserToken) { const token = await this.options.getUserToken(); await this._send('set', 'token', token); } } /** * Send message to embedded app. */ _send(eventName) { if (this.connection) { for (var _len = arguments.length, payload = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { payload[_key - 1] = arguments[_key]; } return this.connection.sendAndReceive(eventName, ...payload); } return Promise.reject(this.ERRORS.SEND); } /** * Get the image data of embeded entity in base64 or binary encoding * @param {GetImageOptions} options options for image generation * @returns {string | Blob} image encoded with base64 or binary */ async getImage(options) { const { encoding } = options || {}; if (encoding !== ENCODING.BASE64 && encoding !== ENCODING.BINARY) { return Promise.reject('Encoding must be either "base64" or "binary"'); } const [result] = await this._send('get', 'image', { background: this.options.background, encoding }); return typeof result === 'string' || result instanceof Blob ? result : Promise.reject('unexpected response received from iframe'); } } let eventHandlerIndex = Date.now(); function EventSource(Sender) { return class extends Sender { constructor() { super(...arguments); _defineProperty(this, "_eventHandlers", { click: {} // refresh: {} To be added soon }); } /** * Handle the event sent from embedded app. */ _handleEvent(event, payload, handlerIds) { const handlers = this._eventHandlers[event]; for (const id of handlerIds) { try { var _handlers$id; // since communication between host and SDK is async, // it's possible that some handlers have been removed; // thus needs to check if handler still exists before calling (_handlers$id = handlers[id]) === null || _handlers$id === void 0 ? void 0 : _handlers$id.handle(payload); } catch (error) { console.warn(`Error calling handler for event [${event}]: ${error}`); } } } /** * Sets an event listener * @param event - the event you are subscribing to * @param eventHandler - the callback to be executed when the event is triggered * @param options - optional options object, can be used to customise when handler is called */ addEventListener(event, eventHandler, options) { var _h$options$includes; const handlers = this._eventHandlers[event]; if (!handlers) { throw new Error(`Not supported event: ${event}`); } const h = { handle: eventHandler, options: { includes: options === null || options === void 0 ? void 0 : options.includes } }; if ((_h$options$includes = h.options.includes) !== null && _h$options$includes !== void 0 && _h$options$includes.every(f => _isEmpty(f))) { // eslint-disable-next-line no-console console.warn('Empty includes filters out all events. Event handler will never be called. Is this intended?'); } // ignore if same handler and options have been added already if (!Object.keys(handlers).some(id => _isEqual(handlers[id], h))) { const handlerId = (++eventHandlerIndex).toString(36); handlers[handlerId] = h; return this._send('eventHandler', event, { handlerId, options: h.options }); } return Promise.resolve(); } /** * Removes an event listener * @param event - the event you are unsubscribing from * @param eventHandler - the event listener function you are unsubscribing from * @param options - optional options object used when addEventListener */ removeEventListener(event, eventHandler, options) { const handlers = this._eventHandlers[event]; if (!handlers) { throw new Error(`Not supported event: ${event}`); } const h = { handle: eventHandler, options: { includes: options === null || options === void 0 ? void 0 : options.includes } }; const handlerId = Object.keys(handlers).find(id => _isEqual(handlers[id], h)); if (handlerId) { delete handlers[handlerId]; return this._send('eventHandler', event, { handlerId }); } return Promise.resolve(); } }; } function Refreshable(Sender) { return class extends Sender { /** * Triggers a refresh of the chart or dashboard (if it has been embedded). * * @returns a promise that resolves once the chart or dashboard updated its data */ async refresh() { await this._send('refresh'); } }; } /** * A class extender for common chart methods * @param Sender the parent class to be extended */ function CommonChart(Sender) { return class extends Sender { /** * Sets a highlight to the state of the chart entity in the Charts app component. * The chart entity can be an embedded chart or an embedded dashboard chart. * The highlight gets applied to the embedded chart or dashboard chart. * * This is the exact same object that can be used in 'setFilter'. * However, it [doesn't support some query expressions] (https://www.mongodb.com/docs/charts/filter-embedded-charts/#filter-syntax) * And there are some specifics about what is highlightable (https://www.mongodb.com/docs/charts/highlight-chart-elements/) * * @param value The highlight object to be applied to the chart * @returns a promise that resolves once the highlight is saved and the component rerendered */ async setHighlight(value) { if (!isPlainObject(value)) { return Promise.reject('highlight property value should be an object'); } await this._send('set', 'highlight', EJSON.stringify(value, { relaxed: false })); } /** * Returns the current highlight applied to the embedded chart or dashboard chart * The highlight value is taken from the state of the chart entity in the Charts app component. * * @returns a promise that resolves once the highlight is taken from the Charts state */ async getHighlight() { const [result] = await this._send('get', 'highlight'); return isPlainObject(result) ? result : Promise.reject('unexpected response received from iframe'); } /** * @returns the data of the embedded chart or dashboard chart */ async getData() { const [result] = await this._send('get', 'data'); return typeof result === 'object' && result !== null ? result : Promise.reject('unexpected response received from iframe'); } }; } function Filterable(Sender) { return class extends Sender { /** * Sets a filter to the state of the chart/dashboard entity in the Charts app component. * The chart entity can be an embedded chart, embedded dashboard chart or embedded dashboard. * The filter gets applied to the embedded chart/dashboard. * * This expects an object that contains a valid [query operators](https://www.mongodb.com/docs/manual/reference/operator/query/#query-selectors). * Any fields referenced in this filter are expected to be allowed for filtering in the "Embed Chart" dialog or "Embed Dashboard" dialog for each chart/dashboard you wish to filter on. * * @param value The filter object to be applied to the chart/dashboard * @returns a promise that resolves once the filter is saved and the component rerendered */ async setFilter(value) { if (!isPlainObject(value)) { return Promise.reject('filter property value should be an object'); } await this._send('set', 'filter', EJSON.stringify(value, { relaxed: false })); } /** * Returns the current filter applied to the embedded chart * The filter value is taken from the state of the chart entity in the Charts app component. * * @returns a promise that resolves once the filter is taken from the Charts state */ async getFilter() { const [result] = await this._send('get', 'filter'); return isPlainObject(result) ? result : Promise.reject('unexpected response received from iframe'); } }; } const getChartOptions = options => { if (typeof options !== 'object' || options === null) { throw new Error('Options argument must be an object'); } const sharedEmbedOptions = getSharedEmbedOptions(options); const { chartId, refreshInterval, renderingSpec } = options; // Verify chart embed options if (typeof chartId !== 'string' || chartId.length === 0) { throw new Error('chartId must be specified'); } if (refreshInterval !== undefined && typeof refreshInterval !== 'number') { throw new Error('refreshInterval interval must be a number if specified'); } if (renderingSpec !== undefined && !isPlainObject(renderingSpec)) { throw new Error('renderingSpec must be an object if specified'); } if (renderingSpec !== undefined && !renderingSpec.version) { throw new Error('renderingSpec must contain a version key'); } return { ...sharedEmbedOptions, chartId, refreshInterval, renderingSpec }; }; class ChartEventSender extends BaseEmbedItem { /** @ignore */ constructor(options) { super(); _defineProperty(this, "name", 'Embedded Chart'); _defineProperty(this, "ERRORS", { SEND: 'Chart has not been rendered. Ensure that you wait for the promise returned by `chart.render()` before trying to manipulate a chart.', IFRAME: 'A chart can only be rendered into a container once' }); _defineProperty(this, "COLOUR", { LIGHT: '#FFFFFF', DARK: '#21313C' }); _defineProperty(this, "options", void 0); this.options = getChartOptions(options); } getEmbedUrl() { return getChartUrl(this.options); } } /** * # Chart * * Allows you to interact and embed charts into your application. * * ```js * const sdk = new EmbedSDK({ ... }); * const chart = sdk.createChart({ ... }); * * // renders a chart * chart.render(document.getElementById('embed-chart')); * * // dynamically set a filter * chart.setFilter({ age: { $gt: 50 } }); * ``` */ class Chart extends CommonChart(Filterable(Refreshable(EventSource(ChartEventSender)))) { constructor() { super(...arguments); _defineProperty(this, "renderingSpec", void 0); } /** * Sends the `ready` event to Charts to render the embedded chart in the component * @param container where the chart will render */ async render(container) { await super.render(container); const renderingSpec = this.options.renderingSpec; const initialState = renderingSpec ? { renderingSpec } : {}; // Ready to actually render Embedded Chart await this._send('ready', initialState); if (initialState.renderingSpec) { // store users rendering spec this.renderingSpec = initialState.renderingSpec; } } /** * @returns the number of seconds a chart will wait before refreshing * @deprecated This method is deprecated. Please use the 'autoRefresh' option with the 'maxDataAge' option to configure how often the chart refreshes. */ async getRefreshInterval() { const [result] = await this._send('get', 'autorefresh'); console.warn("The 'getRefreshInterval' method is deprecated. Please use the 'autoRefresh' option with the 'maxDataAge' option to configure how often the chart refreshes."); return typeof result === 'number' ? result : Promise.reject('unexpected response received from iframe'); } /** * Set the number of seconds a chart will wait before refreshing. * * The minimum refresh interval is 10 seconds. To disable, set the refresh interval to 0. * @deprecated This method is deprecated. Please use the 'autoRefresh' option with the 'maxDataAge' option to configure how often the chart refreshes. */ async setRefreshInterval(value) { if (typeof value !== 'number') { return Promise.reject('refreshInterval property value should be a number'); } console.warn("The 'setRefreshInterval' method is deprecated. Please use the 'autoRefresh' option with the 'maxDataAge' option to configure how often the chart refreshes."); await this._send('set', 'autorefresh', value); } _configureHost(hostBuilder) { return super._configureHost(hostBuilder).on('event', this._handleEvent.bind(this)); } /** * Sets a set of customizations on the rendered chart * @param value customization settings and values * @returns a promise that resolves once the rendering spec is saved and the component rerendered */ async setRenderingSpecOverride(value) { if (!isPlainObject(value)) { return Promise.reject('renderingSpec property value should be an object'); } if (!value.version) { return Promise.reject('renderingSpec should contain a "version" key'); } await this._send('set', 'renderingSpec', value); this.renderingSpec = value; } /** * Get the channel data from the current chart * @returns a promise that resolves to the channel data on the current chart */ async getChannels() { const [result] = await this._send('get', 'channels'); return !!result && typeof result === 'object' ? result : Promise.reject('Unexpected response from iframe'); } /** * Get the customizable axes data from the current chart * @returns a promise that resolves to the axis data on the current chart */ async getCustomizableAxes() { const [result] = await this._send('get', 'axes'); return !!result && typeof result === 'object' ? result : Promise.reject('Unexpected response from iframe'); } /** * Gets the customizations applied to a chart after initial render * @returns the customized rendering spec or undefined. */ getRenderingSpecOverride() { return this.renderingSpec; } } class DashboardChartEventSender { constructor(chartId, dashboard) { this.chartId = chartId; this.dashboard = dashboard; } /** * Send message to embedded app via dashboard. */ _send(msgName) { for (var _len = arguments.length, payload = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { payload[_key - 1] = arguments[_key]; } return this.dashboard._send(msgName, ...payload, this.chartId); } } class DashboardChart extends CommonChart(Filterable(Refreshable(EventSource(DashboardChartEventSender)))) {} const getDashboardOptions = options => { if (typeof options !== 'object' || options === null) { throw new Error('Options argument must be an object'); } const sharedEmbedOptions = getSharedEmbedOptions(options); const { dashboardId, chartsBackground, widthMode, heightMode, showTitleAndDesc, charts } = options; // Verify dashboard embed options if (typeof dashboardId !== 'string' || dashboardId.length === 0) { throw new Error('dashboardId must be specified'); } if (chartsBackground !== undefined && typeof chartsBackground !== 'string') { throw new Error('chartsBackground must be a string if specified'); } if (widthMode !== undefined && typeof widthMode !== 'string') { throw new Error('widthMode must be a string if specified'); } if (widthMode !== undefined && widthMode !== SCALING.FIXED && widthMode !== SCALING.SCALE) { throw new Error(`widthMode must be "${SCALING.FIXED}" or "${SCALING.SCALE}"`); } if (heightMode !== undefined && typeof heightMode !== 'string') { throw new Error('heightMode must be a string if specified'); } if (heightMode !== undefined && heightMode !== SCALING.FIXED && heightMode !== SCALING.SCALE) { throw new Error(`heightMode must be "${SCALING.FIXED}" or "${SCALING.SCALE}"`); } if (showTitleAndDesc !== undefined && typeof showTitleAndDesc !== 'boolean') { throw new Error('showTitleAndDesc must be a boolean value if specified'); } if (charts !== undefined && !Array.isArray(charts)) { throw new Error('charts embedding option must be an array if specified'); } if (charts !== undefined) { // check if chartIds are unique const uniqueIds = {}; charts.forEach(ch => uniqueIds[ch.chartId] = uniqueIds.chartId); if (Object.keys(uniqueIds).length !== charts.length) { throw new Error('charts embedding option must contain unique chartIds'); } } return { ...sharedEmbedOptions, dashboardId, chartsBackground, widthMode, heightMode, showTitleAndDesc, charts }; }; class DashboardEventSender extends BaseEmbedItem { /** @ignore */ constructor(options) { super(); _defineProperty(this, "name", 'Embedded Dashboard'); _defineProperty(this, "ERRORS", { SEND: 'Dashboard has not been rendered. Ensure that you wait for the promise returned by `dashboard.render()` before trying to manipulate a dashboard.', IFRAME: 'A dashboard can only be rendered into a container once' }); _defineProperty(this, "COLOUR", { LIGHT: '#F1F5F4', DARK: '#12212C' }); _defineProperty(this, "options", void 0); this.options = getDashboardOptions(options); } getEmbedUrl() { return getDashboardUrl(this.options); } } /** * # Dashboard * * Allows you to interact and embed dashboards into your application. * * ```js * const sdk = new EmbedSDK({ ... }); * const dashboard = sdk.createDashboard({ ... }); * * // renders a dashboard * dashboard.render(document.getElementById('embed-dashboard')); * * ``` */ class Dashboard extends Filterable(Refreshable(DashboardEventSender)) { constructor() { super(...arguments); _defineProperty(this, "charts", {}); } /** * Sends the `ready` event to Charts together with the chart options * to render the embedded dashboard in the container * @param container where the dashboard will render */ async render(container) { await super.render(container); // Ready to actually render Embedded Dashboard await this._send('ready', this.options.charts); } /** * @returns current chartsBackground or empty string if not set */ async getChartsBackground() { const [result] = await this._send('get', 'chartsBackground'); return typeof result === 'string' ? result : Promise.reject('unexpected response received from iframe'); } /** * Set a custom background color for all charts. * To clear existing value, set it to empty string. */ async setChartsBackground(value) { if (typeof value !== 'string') { return Promise.reject('chartsBackground property value should be a string'); } await this._send('set', 'chartsBackground', value); } /** * @returns whether attribution logo should be shown */ async isShowAttribution() { const [result] = await this._send('get', 'attribution'); return typeof result === 'boolean' ? Boolean(result) : Promise.reject('unexpected response received from iframe'); } /** * Enable/Disable attribution logo. */ async setShowAttribution(value) { if (typeof value !== 'boolean') { return Promise.reject('showAttribution property value should be a boolean'); } await this._send('set', 'attribution', value); } /** * @returns get width scaling mode of embedded dashboard */ async getWidthMode() { const [result] = await this._send('get', 'scalingWidth'); return result === SCALING.FIXED || result === SCALING.SCALE ? result : Promise.reject('unexpected response received from iframe'); } /** * Set width scaling mode for embedded dashboard */ async setWidthMode(value) { if (!['fixed', 'scale'].includes(value)) { return Promise.reject('widthMode property value should be a string value of "fixed" or "scale"'); } await this._send('set', 'scalingWidth', value); } /** * @returns get height scaling mode of embedded dashboard */ async getHeightMode() { const [result] = await this._send('get', 'scalingHeight'); return result === 'fixed' || result === 'scale' ? result : Promise.reject('unexpected response received from iframe'); } /** * Set height scaling mode for embedded dashboard */ async setHeightMode(value) { if (!['fixed', 'scale'].includes(value)) { return Promise.reject('heightMode property value should be a string value of "fixed" or "scale"'); } await this._send('set', 'scalingHeight', value); } /** * @returns get the dashboard chart with specified id */ async getChart(id) { if (!this.charts[id]) { const [chartIds] = await this._send('get', 'charts', [id]); if (!Array.isArray(chartIds)) { return Promise.reject('unexpected response received from iframe'); } if (chartIds.length !== 1) { return Promise.reject('Invalid chart id: ' + id); } this.charts[id] = new DashboardChart(id, this); } return this.charts[id]; } /** * @returns all charts on the dashboard */ async getAllCharts() { const [chartIds] = await this._send('get', 'charts'); if (!Array.isArray(chartIds)) { return Promise.reject('unexpected response received from iframe'); } const charts = []; chartIds.forEach(id => { if (!this.charts[id]) { this.charts[id] = new DashboardChart(id, this); } charts.push(this.charts[id]); }); return charts; } _configureHost(hostBuilder) { return super._configureHost(hostBuilder).on('event', (event, payload, handlerIds) => { const chartId = payload.chartId; this.charts[chartId]._handleEvent(event, payload, handlerIds); }); } } // Disabled temporarily to fix: https://github.com/mongodb-js/charts-embed-sdk/issues/14 // Until we come up with a better way to have strong typing for the Stitch client, while // also not breaking normal TSC compiles of the SDK // import type { StitchAppClient } from 'mongodb-stitch-browser-sdk'; const isJWTExpired = jwt => { try { const [header, payload, signature] = jwt.split('.'); const { exp } = JSON.parse(atob(payload)); // Check the current time against the expiry (minus 5 minutes) in the token return Date.now() / 1000 >= exp - 300; } catch (e) { throw new Error('Not a vaid JWT token. Is the StitchClient/RealmClient configured correctly?'); } }; /** * A helper utility to support using [Realm Authentication](https://www.mongodb.com/docs/realm/) with MongoDB Charts with two npm packages: * * Using "mongodb-stitch-browser-sdk" * * ```js * const client = Stitch.initializeDefaultAppClient('<your-client-app-id>'); * client.auth.loginWithCredential(...) * * const sdk = new ChartsEmbedSDK({ * getUserToken: () => getRealmUserToken(client) * }) * ``` * * or using "realm-web" * * ```js * const client = new Realm.App({id: '<your-client-app-id>'}); * client.logIn(...) * * const sdk = new ChartsEmbedSDK({ * getUserToken: () => getRealmUserToken(client) * }) * ``` * */ async function getRealmUserToken(appClient) { // Authentication with Realm was first implemented to work for apps using mongodb-stitch-browser-sdk. // Later, it got deprecated and a new sdk was published by the Realm team - realm-web. // Here, we are checking which Stitch/Realm sdk is used and use that path to pass the auth token successfully let token; // if the user is using the "mongodb-stitch-browser-sdk" if ('auth' in appClient) { const stitchClient = appClient; if (!stitchClient.auth.authInfo) { throw new Error('Unfamiliar Stitch client version'); } if (!stitchClient.auth.isLoggedIn) { throw new Error('Could not find a logged-in StitchUser. Is the StitchClient configured correctly?'); } if (!stitchClient.auth.authInfo.accessToken) { throw new Error('Could not find a valid JWT. Is the StitchClient configured correctly?'); } if (isJWTExpired(stitchClient.auth.authInfo.accessToken)) { // Attempt to refresh token using progression from public -> private apis if (stitchClient.auth.refreshCustomData) { await stitchClient.auth.refreshCustomData(); // supported from 4.8.0 } else if (stitchClient.auth.refreshAccessToken) { await stitchClient.auth.refreshAccessToken(); // supported from 4.0.0 } else { throw new Error('Could not refresh token. Unfamiliar Stitch client version'); } } token = stitchClient.auth.authInfo.accessToken; } // if the user is using realm-web else if ('authenticator' in appClient) { const realmClient = appClient; if (!realmClient.currentUser) { throw new Error('Unfamiliar Realm client version'); } if (!realmClient.currentUser.isLoggedIn) { throw new Error('Could not find a logged-in RealmUser. Is the RealmClient configured correctly?'); } if (!realmClient.currentUser.accessToken) { throw new Error('Could not find a valid JWT. Is the RealmClient configured correctly?'); } if (isJWTExpired(realmClient.currentUser.accessToken)) { // Attempt to refresh token if (realmClient.currentUser.refreshCustomData) { await realmClient.currentUser.refreshCustomData(); } else { throw new Error('Could not refresh token. Unfamiliar Realm client version'); } } token = realmClient.currentUser.accessToken; } else { throw new Error('Unfamiliar Stitch or Realm client version'); } return token; } /** * Creates an instance of the embedding SDK */ class EmbedSDK { /** * Accepts an optional {@link EmbedChartOptions} object to use as the * default options for any charts created using this SDK instance. * * ```js * const sdk = new EmbedSDK({ * baseUrl: "https://charts.mongodb.com", * }) * ``` */ constructor(options) { _defineProperty(this, "defaultOptions", void 0); this.defaultOptions = options; } /** * Creates a new {@link Chart} instance that allows you to * interact with and embed charts into your application */ createChart(options) { return new Chart({ ...this.defaultOptions, ...options }); } /** * Creates a new {@link Dashboard} instance that allows you * to embed a dashboard into your application */ createDashboard(options) { return new Dashboard({ ...this.defaultOptions, ...options }); } } export { EmbedSDK as default, getRealmUserToken };