From 57dd3e651a1e97cc438829fae894efc618503310 Mon Sep 17 00:00:00 2001 From: Anant Singh Date: Tue, 2 Jun 2020 10:00:31 +0000 Subject: [PATCH] Merged PR 83686: React Wrapper React Wrapper library for PowerBI-JavaScript ###Limitations as discussed: |Sr. no.| Limitation| |:------|:------| |1|`powerbi.preload()` is not supported| |2|`powerbi.load()` is not supported as of now| |3|Power BI Report Authoring support is removed as of now, until the import issue gets fixed| ## Licenses (Dependency) |Package| License | |:------|:------:| |powerbi-client|MIT| |powerbi-report-authoring|MIT| ## Licenses (Dev dependency) |Package| License | |:------|:------:| |@types/react
react|MIT| |@types/react-dom
react-dom|MIT| |@types/jasmine
jasmine-core|MIT| |eslint|MIT| |eslint-plugin-react|MIT| |karma|MIT| |karma-chrome-launcher|MIT| |karma-jasmine|MIT| |ts-loader|MIT| |typescript|Apache-2.0| |webpack|MIT| |webpack-cli|MIT| |@typescript-eslint/eslint-plugin|MIT| |@typescript-eslint/parser|MIT| ## Licenses (Demo) |Package| License | |:------|:------:| |webpack-dev-server|MIT| |style-loader|MIT| |css-loader|MIT| Related work items: #299176, #354581, #355208, #355675, #356714, #357149, #357176, #357181, #357906, #362139, #366497, #367147, #368022 --- .eslintrc.js | 34 ++ .gitignore | 27 ++ .pipelines/build.ps1 | 20 + .pipelines/cdpx_run_ps.cmd | 6 + .pipelines/package.ps1 | 13 + .pipelines/pipeline.user.windows.yml | 83 ++++ .pipelines/restore.ps1 | 25 + .pipelines/test.ps1 | 9 + .pipelines/version.ps1 | 15 + CONTRIBUTING.md | 42 ++ LICENCE | 13 + README.md | 146 +++++- config/src/tsconfig.json | 21 + config/src/webpack.config.js | 32 ++ config/test/karma.conf.js | 53 +++ config/test/tsconfig.json | 20 + config/test/webpack.config.js | 33 ++ demo/DemoApp.css | 24 + demo/DemoApp.tsx | 105 +++++ demo/index.html | 7 + demo/index.tsx | 8 + demo/package.json | 28 ++ demo/tsconfig.json | 18 + demo/webpack.config.js | 33 ++ package.json | 48 ++ resources/react_wrapper_flow_diagram.png | Bin 0 -> 37412 bytes src/PowerBIEmbed.tsx | 281 +++++++++++ src/powerbi-client-react.ts | 5 + src/utils.ts | 40 ++ test/PowerBIEmbed.spec.tsx | 569 +++++++++++++++++++++++ test/mockService.ts | 5 + test/utils.spec.ts | 68 +++ 32 files changed, 1815 insertions(+), 16 deletions(-) create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .pipelines/build.ps1 create mode 100644 .pipelines/cdpx_run_ps.cmd create mode 100644 .pipelines/package.ps1 create mode 100644 .pipelines/pipeline.user.windows.yml create mode 100644 .pipelines/restore.ps1 create mode 100644 .pipelines/test.ps1 create mode 100644 .pipelines/version.ps1 create mode 100644 CONTRIBUTING.md create mode 100644 LICENCE create mode 100644 config/src/tsconfig.json create mode 100644 config/src/webpack.config.js create mode 100644 config/test/karma.conf.js create mode 100644 config/test/tsconfig.json create mode 100644 config/test/webpack.config.js create mode 100644 demo/DemoApp.css create mode 100644 demo/DemoApp.tsx create mode 100644 demo/index.html create mode 100644 demo/index.tsx create mode 100644 demo/package.json create mode 100644 demo/tsconfig.json create mode 100644 demo/webpack.config.js create mode 100644 package.json create mode 100644 resources/react_wrapper_flow_diagram.png create mode 100644 src/PowerBIEmbed.tsx create mode 100644 src/powerbi-client-react.ts create mode 100644 src/utils.ts create mode 100644 test/PowerBIEmbed.spec.tsx create mode 100644 test/mockService.ts create mode 100644 test/utils.spec.ts diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..2207e68 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,34 @@ +module.exports = { + parser: "@typescript-eslint/parser", // Specifies the ESLint parser + extends: [ + "plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react + "plugin:@typescript-eslint/recommended" // Uses the recommended rules from @typescript-eslint/eslint-plugin + ], + parserOptions: { + ecmaFeatures: { + jsx: true // Allows for the parsing of JSX + } + }, + rules: { + '@typescript-eslint/no-this-alias': [ + 'error', + { + allowDestructuring: true, // Allow `const { props, state } = this`; false by default + allowedNames: ['thisObj'], // Allow `const self = this`; `[]` by default + }, + ], + '@typescript-eslint/no-empty-interface': [ + 'error', + { + allowSingleExtends: true + } + ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-extra-semi": "off" + }, + settings: { + react: { + version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use + } + } +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed1e181 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# dependencies +**/node_modules +/.pnp +.pnp.js +**/package-lock.json + +# testing +/coverage +/compiledTests + +# production +/dist + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.vscode + +*.tgz \ No newline at end of file diff --git a/.pipelines/build.ps1 b/.pipelines/build.ps1 new file mode 100644 index 0000000..718af0a --- /dev/null +++ b/.pipelines/build.ps1 @@ -0,0 +1,20 @@ +$exitCode = 0; + +Write-Host "start: npm run build" +& npm run build +Write-Host "done: npm run build" + +$exitCode += $LASTEXITCODE; + +# Check linting +Write-Host "start: npm run lint" +& npm run lint +Write-Host "done: npm run lint" + +$exitCode += $LASTEXITCODE; + +Write-Host "start: Get dist folder files" +& dir "dist" +Write-Host "Done: Get dist folder files" + +exit $exitCode \ No newline at end of file diff --git a/.pipelines/cdpx_run_ps.cmd b/.pipelines/cdpx_run_ps.cmd new file mode 100644 index 0000000..64ddad9 --- /dev/null +++ b/.pipelines/cdpx_run_ps.cmd @@ -0,0 +1,6 @@ +setlocal enabledelayedexpansion +pushd "%~dp0\.." +powershell.exe -ExecutionPolicy Unrestricted -NoProfile -WindowStyle Hidden -NonInteractive -File "%~dp0%~1" +endlocal +popd +exit /B %ERRORLEVEL% diff --git a/.pipelines/package.ps1 b/.pipelines/package.ps1 new file mode 100644 index 0000000..5440165 --- /dev/null +++ b/.pipelines/package.ps1 @@ -0,0 +1,13 @@ +$exitCode = 0; + +Write-Host "start: npm pack" +& npm pack +Write-Host "done: npm pack" + +$exitCode += $LASTEXITCODE; + +Write-Host "start: Get content of current folder" +& dir +Write-Host "done: Get content of current folder" + +exit $exitCode \ No newline at end of file diff --git a/.pipelines/pipeline.user.windows.yml b/.pipelines/pipeline.user.windows.yml new file mode 100644 index 0000000..7f14790 --- /dev/null +++ b/.pipelines/pipeline.user.windows.yml @@ -0,0 +1,83 @@ +environment: + host: + os: 'windows' + flavor: 'server' + version: '2016' + runtime: + provider: 'appcontainer' + image: 'cdpxwinrs5.azurecr.io/global/vse2019/16.3.7:latest' + source_mode: 'map' + +artifact_publish_options: + publish_to_legacy_artifacts: false + publish_to_pipeline_artifacts: true + publish_to_cloudvault_artifacts: false + +package_sources: + npm: + feeds: + registry: https://powerbi.pkgs.visualstudio.com/_packaging/SDK.Externals/npm/registry/ + +version: + major: 1 # <---- Required field but not being used for 'custom' scheme + minor: 0 # <---- Required field but not being used for 'custom' scheme + system: 'custom' # <---- Set this to 'custom'. we will build the version using package.json in versioning commands. + +versioning: + commands: + - !!defaultcommand + name: 'Set Version' + arguments: 'version.ps1' + command: '.pipelines\cdpx_run_ps.cmd' + +restore: + commands: + - !!defaultcommand + name: 'NPM Install' + arguments: 'restore.ps1' + command: '.pipelines\cdpx_run_ps.cmd' + +build: + commands: + - !!buildcommand + name: 'Build' + arguments: 'build.ps1' + command: '.pipelines\cdpx_run_ps.cmd' + artifacts: + - from: 'dist' + to: 'build_artifacts' + include: + - '**/*' + exclude: + - '**/node_modules/**/*.*' + - to: 'source' + include: + - '**/*' + exclude: + - '**/.pipelines/**/*.*' + - '**/.vscode/**/*.*' + - '**/test/**/*.*' + - '**/demo/**/*.*' + - '**/dist/**/*.*' + - '**/node_modules/**/*.*' + + - !!buildcommand + name: 'Package' + arguments: 'package.ps1' + command: '.pipelines\cdpx_run_ps.cmd' + artifacts: + - include: + - "**/*.tgz" + +test: + commands: + - !!testcommand + name: 'Test powerbi-client-react' + arguments: 'test.ps1' + command: '.pipelines\cdpx_run_ps.cmd' + testresults: + - title: 'powerbi-client-react test results' + type: 'jasmine' + from: 'reports' + # include: + # - "**coverage/**/index.html" diff --git a/.pipelines/restore.ps1 b/.pipelines/restore.ps1 new file mode 100644 index 0000000..3aa88f6 --- /dev/null +++ b/.pipelines/restore.ps1 @@ -0,0 +1,25 @@ +Write-Host "Start build ..." +Write-Host "Global node/npm paths ..." +& where.exe npm +& where.exe node + +Write-Host "Global node version" +& node -v + +Write-Host "Global npm version" +& npm -v + +$exitCode = 0; + +Write-Host "start: try install latest npm version" +& npm install npm@latest -g +Write-Host "done: try install latest npm version" + +# Do not update $exitCode because we do not want to fail if install latest npm version fails. + +Write-Host "start: npm install" +& npm install --no-audit --no-save +Write-Host "done: npm install" +$exitCode += $LASTEXITCODE; + +exit $exitCode \ No newline at end of file diff --git a/.pipelines/test.ps1 b/.pipelines/test.ps1 new file mode 100644 index 0000000..625ca89 --- /dev/null +++ b/.pipelines/test.ps1 @@ -0,0 +1,9 @@ +$exitCode = 0; + +Write-Host "start: npm run test" +& npm run test +Write-Host "done: npm run test" + +$exitCode += $LASTEXITCODE; + +exit $exitCode; \ No newline at end of file diff --git a/.pipelines/version.ps1 b/.pipelines/version.ps1 new file mode 100644 index 0000000..9a13881 --- /dev/null +++ b/.pipelines/version.ps1 @@ -0,0 +1,15 @@ +try { + # package.json is in root folder, while version.ps1 runs in .pipelines folder. + $version = (Get-Content "package.json") -join "`n" | ConvertFrom-Json | Select -ExpandProperty "version" + $revision = $env:CDP_DEFINITION_BUILD_COUNT_DAY + $buildNumber = "$version.$revision" + + Write-Host "Build Number is" $buildNumber + + [Environment]::SetEnvironmentVariable("CustomBuildNumber", $buildNumber, "User") # This will allow you to use it from env var in later steps of the same phase + Write-Host "##vso[build.updatebuildnumber]${buildNumber}" # This will update build number on your build +} +catch { + Write-Error $_.Exception + exit 1; +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..918441a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing + +## Setup + +Clone the repository: +``` +git clone +``` + +Navigate to the cloned directory + +Install local dependencies: +``` +npm install +``` + +## Build: +``` +npm run build +``` +Or if using VScode: `Ctrl + Shift + B` + +## Test +``` +npm test +``` +By default the tests run using Chrome + +The build and tests use webpack to compile all the source modules into one bundled module that can be executed in the browser. + +## Running the demo + +If you want to embed any powerbi artifact in demo, set the `embedUrl` and `accessToken` in the [config file](demo\config.ts) for that artifact type. + +Serve the demo: +``` +npm run demo +``` + +Open the address to view in the browser: + +http://localhost:8080/ diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..eed6601 --- /dev/null +++ b/LICENCE @@ -0,0 +1,13 @@ +powerbi-client-react + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 0ca446a..b8027a1 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,134 @@ -# Introduction -TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. +# powerbi-client-react +A React wrapper library for embedding PowerBI artifacts. -# Getting Started -TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: -1. Installation process -2. Software dependencies -3. Latest releases -4. API references +## Table of contents -# Build and Test -TODO: Describe and show how to build your code and run the tests. + +* [Sample Usage](#sample-usage) +* [Run Demo](#run-demo) +* [Docs](#docs) + * Props interface + * PowerBI Embed + * Get reference to embedded object + * How to set new accessToken + * Set event handlers + * Reset event handlers + * Apply style class + * Update settings (Report only) + * PowerBI Bootstrap +* [Flow diagram](#flow-diagram-for-the-wrapper-component) +* [Dependencies](#dependencies) + -# Contribute -TODO: Explain how other users and developers can contribute to make your code better. +## Sample Usage -If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files: -- [ASP.NET Core](https://github.com/aspnet/Home) -- [Visual Studio Code](https://github.com/Microsoft/vscode) -- [Chakra Core](https://github.com/Microsoft/ChakraCore) \ No newline at end of file +How to import: + +```jsx +import { PowerBIEmbed } from 'powerbi-client-react'; +``` + +How to bootstrap a PowerBI report: +```jsx + +``` + +How to embed a PowerBI report: +```jsx +', + embedUrl: '', + accessToken: '', + tokenType: models.TokenType.Embed, + settings: { + panes: { + filters: { + expanded: false, + visible: false + } + }, + background: models.BackgroundType.Transparent, + } + }} + + eventHandlers = { + new Map([ + ['loaded', function () {console.log('Report loaded');}], + ['rendered', function () {console.log('Report rendered');}], + ['error', function (event) {console.log(event.detail);}] + ])} + + cssClassName = { "report-style-class" } + + getEmbed = { (embeddedReport) => { + this.report = embeddedReport as Report; + }} +/> +``` + +## Run Demo + +To run the demo on localhost, run the following commands: + +``` +npm install +npm run install:demo +npm run demo +``` + +Redirect to http://localhost:8080/ to view in the browser. + +## Docs +|Topic|Details| +|:------|:------| +|PowerBI Embed|To embed your powerbi artifact, pass the component with atleast _type_, _embedUrl_ and _accessToken_ in _embedConfig_ prop.| +|Get reference to embedded object|Pass a callback method which accepts the embedded object as parameter to the _getEmbed_ of props.
Refer to the _getEmbed_ prop in [Sample Usage](#sample-usage).| +|Apply style class|Pass the name(s) of classes to be set as "classname" for the embed container div via _className_ of props.| +|Set event handlers|Pass a map object of event name (string) and event handler (function) to the _eventHandlers_ of props.
Key: Event name
Value: Method to be triggered| +|Reset event handlers|To reset event handler for an event, set the event handler's value as `null` in the _eventHandlers_ map of props.| +|How to set new accessToken|To set new accessToken in the same embedded powerbi artifact, pass the updated _accessToken_ in _embedConfig_ of props.
Example scenario: _Current token has expired_.| +|Update settings (Report type only)|To update the report settings, update the _embedConfig.settings_ property of props.
Refer to the _embedConfig.settings_ prop in [Sample Usage](#sample-usage).| +|PowerBI Bootstrap|To [bootstrap your powerbi entity](https://github.com/microsoft/PowerBI-JavaScript/wiki/Bootstrap-For-Better-Performance), call the component without _accessToken_ in _embedConfig_ of props.
__Note__: _embedConfig_ of props should atleast contain __type__ of the powerbi artifact being embedded.
Eg: "report", "dashboard", "tile", "visual" or "qna".
Refer How to bootstrap a report section in [Sample Usage](#sample-usage).| + +### Props interface: + +```ts +interface EmbedProps { + + // Configuration for embedding the PowerBI entity + embedConfig: IEmbedConfiguration | IQnaEmbedConfiguration + + // Callback method to get the embedded PowerBI entity object (Optional) + getEmbed?: { (embeddedComponent: Embed): void } + + // Map of pair of event name and its handler method to be triggered on the event (Optional) + eventHandlers?: Map | null> + + // CSS class to be set on the embedding container (Optional) + cssClassName?: string + + // Provide a custom implementation of PowerBI service (Optional) + service?: service.Service +} +``` + +### Flow Diagram for the Wrapper Component: +![Flow Diagram](./resources/react_wrapper_flow_diagram.png) + +## Dependencies + +1. powerbi-client + +## Peer-Dependencies + +1. react diff --git a/config/src/tsconfig.json b/config/src/tsconfig.json new file mode 100644 index 0000000..69cad8f --- /dev/null +++ b/config/src/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": [ + "../../src/**/*.tsx", + "../../src/**/*.ts" + ], + "compilerOptions": { + "lib": ["ES2016"], + "target": "es5", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "noErrorTruncation": true, + "module": "ES6", + "moduleResolution": "node", + "jsx": "react", + "sourceMap": true, + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist", + } +} \ No newline at end of file diff --git a/config/src/webpack.config.js b/config/src/webpack.config.js new file mode 100644 index 0000000..95ebb1a --- /dev/null +++ b/config/src/webpack.config.js @@ -0,0 +1,32 @@ +let path = require('path'); + +module.exports = { + entry: path.resolve('src/PowerBIEmbed.tsx'), + output: { + library: 'powerbi-client-react', + libraryTarget: 'amd', + path: path.resolve('dist'), + filename: 'powerbi-client-react.js' + }, + module: { + rules: [ + { + test: /\.ts(x)?$/, + loader: 'ts-loader', + options: { + configFile: path.resolve('config/src/tsconfig.json') + }, + exclude: /node_modules/ + }, + ] + }, + resolve: { + modules: ['node_modules'], + extensions: [ + '.tsx', + '.ts', + '.js' + ] + }, + devtool: 'source-map', +}; \ No newline at end of file diff --git a/config/test/karma.conf.js b/config/test/karma.conf.js new file mode 100644 index 0000000..c50bb9b --- /dev/null +++ b/config/test/karma.conf.js @@ -0,0 +1,53 @@ +let path = require('path'); + +module.exports = function(config) { + config.set({ + + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: '', + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['jasmine'], + + // list of files / patterns to load in the browser + files: [ + path.resolve('compiledTests/**/*spec.js') + ], + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + }, + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ['progress'], + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ['ChromeHeadless'], + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + singleRun: true, + + // Concurrency level + // how many browser should be started simultaneous + concurrency: Infinity + }) +} \ No newline at end of file diff --git a/config/test/tsconfig.json b/config/test/tsconfig.json new file mode 100644 index 0000000..c071dc6 --- /dev/null +++ b/config/test/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": [ + "../../test/**/*.tsx", + "../../test/**/*.ts" + ], + "compilerOptions": { + "target": "es6", + "lib": [ + "ES2016", + "dom" + ], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "jsx": "react" + } +} \ No newline at end of file diff --git a/config/test/webpack.config.js b/config/test/webpack.config.js new file mode 100644 index 0000000..d6647b8 --- /dev/null +++ b/config/test/webpack.config.js @@ -0,0 +1,33 @@ +let path = require('path'); + +module.exports = { + mode: 'development', + entry: { + PowerBIEmbedTest: path.resolve('test/PowerBIEmbed.spec.tsx'), + utilsTest: path.resolve('test/utils.spec.ts'), + }, + output: { + path: path.resolve('compiledTests'), + filename: '[name].spec.js' + }, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.ts(x)?$/, + loader: 'ts-loader', + options: { + configFile: path.resolve('config/test/tsconfig.json') + }, + exclude: /node_modules/ + }, + ] + }, + resolve: { + extensions: [ + '.tsx', + '.ts', + '.js' + ] + }, +}; \ No newline at end of file diff --git a/demo/DemoApp.css b/demo/DemoApp.css new file mode 100644 index 0000000..0052c1d --- /dev/null +++ b/demo/DemoApp.css @@ -0,0 +1,24 @@ +.report-style-class { + height: 560px; + width: 960px; +} + +body { + padding: 24px; + font-family: 'Segoe UI'; +} + +h3 { + color: #175C97; +} + +button { + background: #337AB7; + border-radius: 5px; + margin-right: 15px; + color: #FFFFFF; + height: 35px; + width: 150px; + font-size: medium; + border: 0; +} \ No newline at end of file diff --git a/demo/DemoApp.tsx b/demo/DemoApp.tsx new file mode 100644 index 0000000..9d3b848 --- /dev/null +++ b/demo/DemoApp.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { models, Report } from 'powerbi-client'; +import { PowerBIEmbed } from 'powerbi-client-react'; +import './DemoApp.css'; + +// Root Component to demonstrate usage of wrapper component +function DemoApp () { + + // PowerBI Report object (to be received via callback) + let report: Report; + + // API end-point url to get embed config for a sample report + const sampleReportUrl = 'https://aka.ms/sampleReportEmbedConfig'; + + // Report config useState hook + // Values for properties like embedUrl, accessToken and settings will be set on click of buttons below + const [sampleReportConfig, setReportConfig] = useState({ + type: 'report', + embedUrl: undefined, + tokenType: models.TokenType.Embed, + accessToken: undefined, + settings: undefined, + }); + + // Map of event handlers to be applied to the embedding report + const eventHandlersMap = new Map([ + ['loaded', function () {console.log('Report has loaded');}], + ['rendered', function () { + console.log('Report has rendered'); + // Update display message + setMessage('Report is Embedded!') + }], + ['error', function (event) { console.error(event.detail); }] + ]); + + // Fetch sample report's config (eg. embedUrl and AccessToken) for embedding + const mockSignIn = async () => { + + // Update display message + setMessage('Fetching accessToken') + + const reportConfigResponse = await fetch(sampleReportUrl); + + if (!reportConfigResponse.ok) { + console.error(`Failed to fetch config for report. Status: ${ reportConfigResponse.status } ${ reportConfigResponse.statusText }`); + return; + } + + const reportConfig = await reportConfigResponse.json(); + + // Update display message + setMessage('AccessToken is set successfully. Loading the PowerBI Report') + + // Update the state "sampleReportConfig" and re-render DemoApp component + setReportConfig({ + ...sampleReportConfig, + embedUrl: reportConfig.embedUrl, + accessToken: reportConfig.embedToken.token + }); + } + + const changeSettings = () => { + + // Update the state "sampleReportConfig" and re-render DemoApp component + setReportConfig({ + ...sampleReportConfig, + settings: { + panes: { + filters: { + expanded: false, + visible: false + } + } + } + }); + } + + const [displayMessage, setMessage] = useState(`The report is bootstraped. Click 'Embed Report' button below to provide Access Token`); + + return ( +
+

Sample Report:

+ { + report = embedObject; + console.log(`Embedded object of type "${ report.embedtype }" received`); + } } + /> +

+ { displayMessage } +

+ + + + +
+ ); +} + +export default DemoApp; \ No newline at end of file diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..34f5154 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,7 @@ + + + +
+ + + \ No newline at end of file diff --git a/demo/index.tsx b/demo/index.tsx new file mode 100644 index 0000000..4c70665 --- /dev/null +++ b/demo/index.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import DemoApp from './DemoApp'; + +ReactDOM.render( + , + document.getElementById('root') +); \ No newline at end of file diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..426788b --- /dev/null +++ b/demo/package.json @@ -0,0 +1,28 @@ +{ + "name": "powerbi-client-react-demo", + "version": "0.1.0", + "description": "Demo for usage of powerbi-client-react", + "scripts": { + "demo": "webpack-dev-server --content-base ./ --config ./webpack.config.js" + }, + "license": "MIT", + "dependencies": { + "powerbi-client-react": "file:.." + }, + "peerDependencies": { + "react": ">= 16.8" + }, + "devDependencies": { + "@types/react": "^16.9.35", + "@types/react-dom": "^16.9.8", + "css-loader": "^3.5.3", + "style-loader": "^1.2.1", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "ts-loader": "^7.0.5", + "typescript": "^3.9.3", + "webpack": "^4.43.0", + "webpack-cli": "^3.3.11", + "webpack-dev-server": "^3.11.0" + } +} diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 0000000..937085c --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": [ + "./**/*.tsx", + "./**/*.ts" + ], + "compilerOptions": { + "target": "es5", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "noErrorTruncation": true, + "forceConsistentCasingInFileNames": true, + "module": "ES6", + "moduleResolution": "node", + "resolveJsonModule": true, + "jsx": "react", + } +} \ No newline at end of file diff --git a/demo/webpack.config.js b/demo/webpack.config.js new file mode 100644 index 0000000..cb5d9f5 --- /dev/null +++ b/demo/webpack.config.js @@ -0,0 +1,33 @@ +let path = require('path'); + +module.exports = { + mode: 'development', + entry: path.resolve('index.tsx'), + output: { + path: __dirname, + filename: 'bundle.js' + }, + module: { + rules: [ + { + test: /\.ts(x)?$/, + loader: 'ts-loader' + }, + { + test: /\.css$/, + use: [ + 'style-loader', + 'css-loader' + ] + }, + ] + }, + resolve: { + extensions: [ + '.tsx', + '.ts', + '.js', + ] + }, + devtool: 'source-map', +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..97d3dc7 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "powerbi-client-react", + "version": "0.1.0", + "description": "React wrapper for powerbi-client library", + "main": "dist/powerbi-client-react.js", + "types": "dist/powerbi-client-react.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "npm run lint", + "build": "webpack --mode=production --config config/src/webpack.config.js", + "build:dev": "webpack --mode=development --config config/src/webpack.config.js", + "pretest": "webpack --config config/test/webpack.config.js", + "test": "karma start config/test/karma.conf.js", + "install:demo": "cd demo && npm install", + "demo": "cd demo && npm run demo", + "lint": "eslint src/**/*.{ts,tsx}" + }, + "keywords": [], + "license": "MIT", + "dependencies": { + "powerbi-client": "^2.11.0" + }, + "peerDependencies": { + "react": ">= 16" + }, + "devDependencies": { + "@types/jasmine": "^3.5.10", + "@types/node": "^14.0.5", + "@types/react": "^16.9.35", + "@types/react-dom": "^16.9.8", + "@typescript-eslint/eslint-plugin": "^3.1.0", + "@typescript-eslint/parser": "^3.0.2", + "eslint": "^7.1.0", + "eslint-plugin-react": "^7.20.0", + "jasmine-core": "^3.5.0", + "karma": "^5.0.9", + "karma-chrome-launcher": "^3.1.0", + "karma-jasmine": "^3.1.1", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "ts-loader": "^7.0.5", + "typescript": "^3.9.3", + "webpack": "^4.43.0", + "webpack-cli": "^3.3.11" + } +} diff --git a/resources/react_wrapper_flow_diagram.png b/resources/react_wrapper_flow_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..8699fb92fd45d7d76e62984cce5dddb567ff45e1 GIT binary patch literal 37412 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 literal 0 HcmV?d00001 diff --git a/src/PowerBIEmbed.tsx b/src/PowerBIEmbed.tsx new file mode 100644 index 0000000..4f1d30c --- /dev/null +++ b/src/PowerBIEmbed.tsx @@ -0,0 +1,281 @@ +import React from "react"; +import { service, factories, Report, Embed, Dashboard, Tile, Qna, IEmbedConfiguration, Visual } from 'powerbi-client'; +import { IQnaEmbedConfiguration, IEmbedSettings, IVisualEmbedConfiguration } from "embed"; +import { stringifyMap } from './utils'; + +/** + * Props interface for PowerBIEmbed component + */ +export interface EmbedProps { + + // Configuration for embedding the PowerBI entity + embedConfig: IEmbedConfiguration | IQnaEmbedConfiguration | IVisualEmbedConfiguration; + + // Callback method to get the embedded PowerBI entity object (Optional) + getEmbeddedComponent?: { (embeddedComponent: Embed): void }; + + // Map of pair of event name and its handler method to be triggered on the event (Optional) + eventHandlers?: Map | null>; + + // CSS class to be set on the embedding container (Optional) + cssClassName?: string; + + // Provide a custom implementation of PowerBI service (Optional) + service?: service.Service; +} + +export enum EmbedType { + Report = 'report', + Dashboard = 'dashboard', + Tile = 'tile', + Qna = 'qna', + Visual = 'visual' +} + +/** + * Base react component to embed Power BI entities like: reports, dashboards, tiles, visual and qna containers. + */ +export class PowerBIEmbed extends React.Component { + + // Embedded entity + private embed?: Embed; + + // Powerbi service + private powerbi: service.Service; + + // Ref to the HTML div element + private containerRef = React.createRef(); + + // JSON stringify of prev event handler map + private prevEventHandlerMapString = ''; + + constructor(props: EmbedProps) { + super(props); + + if (this.props.service) { + this.powerbi = this.props.service; + } + else { + this.powerbi = new service.Service( + factories.hpmFactory, + factories.wpmpFactory, + factories.routerFactory); + } + }; + + componentDidMount(): void { + + // Check if HTML container is available + if (this.containerRef.current) { + + // Decide to bootstrap or embed + if (this.props.embedConfig.accessToken && this.props.embedConfig.embedUrl) { + this.embed = this.powerbi.embed(this.containerRef.current, this.props.embedConfig); + } + else { + this.embed = this.powerbi.bootstrap(this.containerRef.current, this.props.embedConfig); + } + } + + // Invoke callback method in Props + this.getEmbedCallback(); + + // Set event handlers if available + if (this.props.eventHandlers && this.embed) { + this.setEventHandlers(this.embed, this.props.eventHandlers); + } + }; + + componentDidUpdate(prevProps: EmbedProps): void { + + this.embedOrUpdateAccessToken(prevProps); + + // Set event handlers if available + if (this.props.eventHandlers && this.embed) { + this.setEventHandlers(this.embed, this.props.eventHandlers); + } + + // Update settings in embedConfig of props + this.updateSettings(); + }; + + componentWillUnmount(): void { + // Clean Up + if (this.containerRef.current) { + this.powerbi.reset(this.containerRef.current); + } + }; + + render(): JSX.Element { + return ( +
+
+ ) + }; + + /** + * Choose to _embed_ the powerbi entity or _update the accessToken_ in the embedded entity + * or do nothing when the embedUrl and accessToken did not update in the new props + * + * @param prevProps EmbedProps + * @returns void + */ + private embedOrUpdateAccessToken(prevProps: EmbedProps): void { + + // Check if Embed URL and Access Token are present in current props + if (!this.props.embedConfig.accessToken || !this.props.embedConfig.embedUrl) { + return; + } + + // Embed in the following scenarios + // 1. AccessToken was not provided in prev props (E.g. Report was bootstrapped earlier) + // 2. Embed URL is updated (E.g. New report is to be embedded) + if (this.containerRef.current + && (!prevProps.embedConfig.accessToken + || this.props.embedConfig.embedUrl !== prevProps.embedConfig.embedUrl)) { + this.embed = this.powerbi.embed(this.containerRef.current, this.props.embedConfig); + } + + // Set new access token, + // when access token is updated but embed Url is same + else if (this.props.embedConfig.accessToken !== prevProps.embedConfig.accessToken + && this.props.embedConfig.embedUrl === prevProps.embedConfig.embedUrl + && this.embed) { + + this.embed.setAccessToken(this.props.embedConfig.accessToken) + .catch(error => { + console.error(`setAccessToken error: ${error}`); + }); + } + + // Invoke callback method in Props + this.getEmbedCallback(); + } + + /** + * Sets all event handlers from the props on the embedded entity + * + * @param embed Embedded object + * @param eventHandlers Array of eventhandlers to be set on embedded entity + * @returns void + */ + private setEventHandlers( + embed: Embed, + eventHandlerMap: Map | null>): void { + + // Get string representation of eventHandlerMap + const eventHandlerMapString = stringifyMap(this.props.eventHandlers); + + // Check if event handler map changed + if (this.prevEventHandlerMapString === eventHandlerMapString) { + return; + } + + // Update prev string representation of event handler map + this.prevEventHandlerMapString = eventHandlerMapString; + + // List of allowed events + let allowedEvents = Embed.allowedEvents; + + const entityType = embed.embedtype; + + // Append entity specific events + switch (entityType) { + case EmbedType.Report: + allowedEvents = [...allowedEvents, ...Report.allowedEvents] + break; + case EmbedType.Dashboard: + allowedEvents = [...allowedEvents, ...Dashboard.allowedEvents] + break; + case EmbedType.Tile: + allowedEvents = [...allowedEvents, ...Tile.allowedEvents] + break; + case EmbedType.Qna: + allowedEvents = [...allowedEvents, ...Qna.allowedEvents] + break; + case EmbedType.Visual: + allowedEvents = [...allowedEvents, ...Visual.allowedEvents] + break; + default: + console.error(`Invalid embed type ${entityType}`); + } + + // Holds list of events which are not allowed + const invalidEvents: Array = []; + + // Apply all provided event handlers + eventHandlerMap.forEach(function (eventHandlerMethod, eventName) { + + // Check if this event is allowed + if (allowedEvents.includes(eventName)) { + + // Removes event handler for this event + embed.off(eventName); + + if (eventHandlerMethod) { + + // Set single event handler + embed.on(eventName, eventHandlerMethod); + } + } + else { + + // Add this event name to the list of invalid events + invalidEvents.push(eventName); + } + }); + + // Handle invalid events + if (invalidEvents.length) { + console.error(`Following events are invalid: ${invalidEvents.join(',')}`); + } + }; + + /** + * Returns the embedded object via _getEmbed_ callback method provided in props + * + * @returns void + */ + private getEmbedCallback(): void { + if (this.props.getEmbeddedComponent && this.embed) { + this.props.getEmbeddedComponent(this.embed); + } + }; + + /** + * Update settings from props of the embedded artifact + * + * @returns void + */ + private updateSettings(): void { + if (!this.embed || !this.props.embedConfig.settings) { + return; + } + + switch (this.props.embedConfig.type) { + case EmbedType.Report: + + // Typecasted to IEmbedSettings as props.embedConfig.settings can be ISettings via IQnaEmbedConfiguration + const settings = this.props.embedConfig.settings as IEmbedSettings; + + // Upcast to Report and call updateSettings + (this.embed as Report).updateSettings(settings) + .catch((error: any) => { + console.error(`Error in method updateSettings: ${error}`); + }); + break; + + case EmbedType.Dashboard: + case EmbedType.Tile: + case EmbedType.Qna: + case EmbedType.Visual: + // updateSettings not applicable for these embedding types + break; + + default: + console.error(`Invalid embed type ${this.props.embedConfig.type}`); + } + }; +} \ No newline at end of file diff --git a/src/powerbi-client-react.ts b/src/powerbi-client-react.ts new file mode 100644 index 0000000..bc203ec --- /dev/null +++ b/src/powerbi-client-react.ts @@ -0,0 +1,5 @@ +export { + PowerBIEmbed, + EmbedProps, + EmbedType +} from './PowerBIEmbed' \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..c8a8680 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,40 @@ +import { EmbedProps } from "./PowerBIEmbed"; + +/** + * Get JSON string representation of the given map. + * + * @param map Map of event and corresponding handler method + * + * For example: + * Input: + * ``` + * Map([ + ['loaded', null], + ['rendered', function () { console.log('Rendered'); }] + ]); + * ``` + * Output: + * ``` + * `[["loaded",""],["rendered","function () { console.log('Rendered'); }"]]` + * ``` + */ +export function stringifyMap(map: EmbedProps['eventHandlers']): string { + + // Return empty string for empty/null map + if (!map) { + return ''; + } + + // Get entries of map as array + const mapEntries = Array.from(map); + + // Return JSON string + return JSON.stringify(mapEntries.map((mapEntry) => { + + // Convert event handler method to a string containing its source code for comparison + return [ + mapEntry[0], + mapEntry[1] ? mapEntry[1].toString() : '' + ]; + })); +}; \ No newline at end of file diff --git a/test/PowerBIEmbed.spec.tsx b/test/PowerBIEmbed.spec.tsx new file mode 100644 index 0000000..9a9d2b7 --- /dev/null +++ b/test/PowerBIEmbed.spec.tsx @@ -0,0 +1,569 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { act, isElement } from 'react-dom/test-utils'; +import { Report, Dashboard, service, factories} from 'powerbi-client'; +import { PowerBIEmbed } from '../src/PowerBIEmbed'; +import { mockPowerBIService, mockedMethods } from "./mockService"; + +describe('tests of PowerBIEmbed', function () { + + let container: HTMLDivElement | null; + + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + + // Reset all methods in PowerBI Service spy object + mockedMethods.forEach(mockedMethod => { + mockPowerBIService[mockedMethod].calls.reset(); + }); + }); + + afterEach(function () { + if (container){ + document.body.removeChild(container); + container = null; + } + }); + + describe('basic tests', function () { + + it('is a react component', () => { + const component = + expect(isElement(component)).toBe(true); + }); + + it('renders exactly one div', () => { + act(() => { + ReactDOM.render(, container); + }); + + const divCount = container.querySelectorAll('div').length; + expect(divCount).toBe(1); + }); + + it('renders exactly one iframe', () => { + act(() => { + ReactDOM.render(, container); + }); + + const divCount = container.querySelectorAll('iframe').length; + expect(divCount).toBe(1); + }); + + it('sets the css classes', () => { + const inputCssClass = 'test-class another-test-class'; + + act(() => { + ReactDOM.render( + + , container); + }); + + const divClass = container.querySelectorAll('div')[0].className; + expect(divClass).toBe(inputCssClass); + }); + + it('gets the embedded report object', () => { + let testReport = undefined; + + act(() => { + + ReactDOM.render( + { + testReport = callbackReport; + }} + />, container); + }); + + expect(testReport).not.toBe(undefined); + expect(testReport instanceof Report).toBe(true); + }); + + it('gets the embedded dashboard object', () => { + let testReport = undefined; + + act(() => { + + ReactDOM.render( + { + testReport = callbackReport; + }} + />, container); + }); + + expect(testReport).not.toBe(undefined); + expect(testReport instanceof Dashboard).toBe(true); + }); + }); + + it('updates powerbi report settings', () => { + let testReport: Report = undefined; + + act(() => { + + ReactDOM.render( + { + testReport = callbackReport; + }} + />, container); + }); + + spyOn(testReport, 'updateSettings').and.callThrough(); + + // Update settings via props + act(() => { + + ReactDOM.render( + { + testReport = callbackReport; + }} + />, container); + }); + + expect(testReport.updateSettings).toHaveBeenCalledTimes(1); + expect(testReport.updateSettings).toHaveBeenCalledWith({ filterPaneEnabled: false }); + }); + + it('sets new token received in updated props (case: Token expired)', () => { + + // Arrange + let testReport:Report = undefined; + let config = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: 'fakeToken' + }; + + // New accessToken + let newConfig = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: 'newfakeToken' + }; + + act(() => { + + ReactDOM.render( + { + testReport = callbackReport; + }} + />, container); + }); + + spyOn(testReport, 'setAccessToken').and.callThrough(); + + // Act + // Update accessToken via props + act(() => { + + ReactDOM.render( + { + testReport = callbackReport; + }} + />, container); + }); + + // Assert + expect(testReport).toBeDefined(); + expect(testReport.setAccessToken).toHaveBeenCalledTimes(1); + expect(testReport.setAccessToken).toHaveBeenCalledWith(newConfig.accessToken); + }); + + describe('test powerbi service interaction',() => { + + it('embeds report when accessToken provided', () => { + let config = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: 'fakeToken' + }; + + act(() => { + + ReactDOM.render( + , container); + }); + + expect(mockPowerBIService.bootstrap).toHaveBeenCalledTimes(0); + expect(mockPowerBIService.embed).toHaveBeenCalledTimes(1); + }); + + it('bootstraps report when no accessToken provided', () => { + let config = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl' + }; + + act(() => { + + ReactDOM.render( + , container); + }); + + expect(mockPowerBIService.embed).toHaveBeenCalledTimes(0); + expect(mockPowerBIService.bootstrap).toHaveBeenCalledTimes(1); + }); + + it('first bootstraps, then embeds when accessToken is available', () => { + + // Arrange + const config = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: null + }; + const newConfig = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: 'fakeToken' + }; + + // Act + // Without accessToken (bootstrap) + act(() => { + + ReactDOM.render( + , container); + }); + + // Assert + expect(mockPowerBIService.embed).toHaveBeenCalledTimes(0); + expect(mockPowerBIService.bootstrap).toHaveBeenCalledTimes(1); + + // Reset for next Act + mockPowerBIService.embed.calls.reset(); + mockPowerBIService.bootstrap.calls.reset(); + + // With accessToken (embed) + act(() => { + + ReactDOM.render( + , container); + }); + + expect(mockPowerBIService.bootstrap).toHaveBeenCalledTimes(0); + expect(mockPowerBIService.embed).toHaveBeenCalledTimes(1); + }); + + it('does not embed again when accessToken and embedUrl are same', () => { + const config = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: 'fakeToken', + }; + const newConfig = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: 'fakeToken', + settings: { filterPaneEnabled: false } + }; + + act(() => { + + ReactDOM.render( + , container); + }); + + expect(mockPowerBIService.embed).toHaveBeenCalledTimes(1); + mockPowerBIService.embed.calls.reset(); + + // With accessToken (embed) + act(() => { + + ReactDOM.render( + , container); + }); + + expect(mockPowerBIService.embed).not.toHaveBeenCalled(); + }); + + it('powerbi.reset called when component unmounts', () => { + let config = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: 'fakeToken' + }; + + act(() => { + + ReactDOM.render( + , container); + }); + + act(() => { + + ReactDOM.unmountComponentAtNode(container); + }); + + expect(mockPowerBIService.reset).toHaveBeenCalled(); + }); + + it("new report's url in updated props", () => { + let config = { + type: 'report', + id: 'fakeId', + embedUrl: 'fakeUrl', + accessToken: 'fakeToken' + }; + + act(() => { + + ReactDOM.render( + , container); + }); + + config.embedUrl = 'newFakeUrl'; + + act(() => { + + ReactDOM.render( + , container); + }); + + expect(mockPowerBIService.embed).toHaveBeenCalled(); + }); + }); + + describe('tests for setting event handlers', () => { + it('clears and sets the event handlers', () => { + // Arrange + let testReport:Report = undefined; + let eventHandlers = new Map([ + ['loaded', function () { }], + ['rendered', function () { }], + ['error', function () { }] + ]); + + // Initialise testReport + act(() => { + + ReactDOM.render( + { + testReport = callbackReport; + }} + />, container); + }); + + spyOn(testReport, 'off'); + spyOn(testReport, 'on'); + + // Act + act(() => { + + ReactDOM.render( + , container); + }); + + // Assert + expect(testReport.off).toHaveBeenCalledTimes(eventHandlers.size); + expect(testReport.on).toHaveBeenCalledTimes(eventHandlers.size); + }); + + it('clears the already set event handlers in case of null provided for handler', () => { + // Arrange + let testReport:Report = undefined; + const eventHandlers = new Map([ + ['loaded', function () { }], + ['rendered', function () { }], + ['error', function () { }] + ]); + const newEventHandlers = new Map([ + ['loaded', null], + ['rendered', null], + ['error', function () { }] + ]); + + // Initialise testReport + act(() => { + ReactDOM.render( + { + testReport = callbackReport; + }} + />, container); + }); + + spyOn(testReport, 'off'); + spyOn(testReport, 'on'); + + // Act + act(() => { + ReactDOM.render( + , container); + }); + + // Assert + expect(testReport.off).toHaveBeenCalledTimes(eventHandlers.size); + // Two events are turned off in new eventhandlers + expect(testReport.on).toHaveBeenCalledTimes(eventHandlers.size - 2); + }); + + it('does not console error for valid events for report', () => { + const eventHandlers = new Map([ + ['loaded', function () { }], + ['saved', function () { }], + ['rendered', function () { }], + ['saveAsTriggered', function () { }], + ['dataSelected', function () { }], + ['buttonClicked', function () { }], + ['filtersApplied', function () { }], + ['pageChanged', function () { }], + ['commandTriggered', function () { }], + ['swipeStart', function () { }], + ['swipeEnd', function () { }], + ['bookmarkApplied', function () { }], + ['dataHyperlinkClicked', function () { }], + ['error', function () { }] + ]); + + spyOn(console, 'error'); + + act(() => { + ReactDOM.render( + , container); + }); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it('consoles error for invalid events', () => { + // Arrange + const invalidEvent1 = 'invalidEvent1'; + const invalidEvent2 = 'invalidEvent2'; + const errorMessage = `Following events are invalid: ${invalidEvent1},${invalidEvent2}`; + + const eventHandlers = new Map([ + [invalidEvent1, function () { }], + ['rendered', function () { }], + ['error', function () { }], + [invalidEvent2, function () { }], + ]); + + const powerbi = new service.Service( + factories.hpmFactory, + factories.wpmpFactory, + factories.routerFactory); + const embed = powerbi.bootstrap(container, {type:'tile'}); + + spyOn(console, 'error'); + + // Act + const powerbiembed = new PowerBIEmbed({ + embedConfig: { type: 'tile' }, + eventHandlers: eventHandlers + }); + + // Ignoring next line as setEventHandlers is a private method + // @ts-ignore + powerbiembed.setEventHandlers(embed, eventHandlers); + + expect(console.error).toHaveBeenCalledWith(errorMessage); + }); + + it('does not set the same eventhandler map again', () => { + // Arrange + let testReport:Report = undefined; + const eventHandlers = new Map([ + ['loaded', function () { }], + ['rendered', function () { }], + ['error', function () { }] + ]); + const newSameEventHandlers = new Map([ + ['loaded', function () { }], + ['rendered', function () { }], + ['error', function () { }] + ]); + + // Initialise testReport + act(() => { + ReactDOM.render( + { + testReport = callbackReport; + }} + eventHandlers = { eventHandlers } + />, container); + }); + + spyOn(testReport, 'off'); + spyOn(testReport, 'on'); + + // Act + act(() => { + ReactDOM.render( + , container); + }); + + // Assert + expect(testReport.off).not.toHaveBeenCalled(); + expect(testReport.on).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/mockService.ts b/test/mockService.ts new file mode 100644 index 0000000..8c85b5f --- /dev/null +++ b/test/mockService.ts @@ -0,0 +1,5 @@ +const mockedMethods = ['init', 'embed', 'bootstrap', 'load', 'get', 'reset', 'preload']; + +const mockPowerBIService = jasmine.createSpyObj('mockService', mockedMethods); + +export { mockPowerBIService, mockedMethods }; \ No newline at end of file diff --git a/test/utils.spec.ts b/test/utils.spec.ts new file mode 100644 index 0000000..9d2ec6e --- /dev/null +++ b/test/utils.spec.ts @@ -0,0 +1,68 @@ +import { service } from 'powerbi-client'; +import { stringifyMap } from '../src/utils'; + +describe('tests of PowerBIEmbed', function () { + + let container: HTMLDivElement | null; + + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(function () { + if (container){ + document.body.removeChild(container); + container = null; + } + }); + + // Tests for utils stringifyMap + describe('tests PowerBIEmbed stringifyMap method', () => { + + it('stringifies the event handler map', () => { + + // Arrange + const eventHandlerMap = new Map([ + ['loaded', function () { console.log('Report loaded'); }], + ['rendered', function () { console.log('Rendered'); }] + ]); + const expectedString = `[["loaded","function () { console.log('Report loaded'); }"],["rendered","function () { console.log('Rendered'); }"]]`; + + // Act + const jsonStringOutput = stringifyMap(eventHandlerMap); + + // Assert + expect(jsonStringOutput).toBe(expectedString); + }); + + it('stringifies empty event handler map', () => { + + // Arrange + const eventHandlerMap = new Map>([]); + const expectedString = `[]`; + + // Act + const jsonStringOutput = stringifyMap(eventHandlerMap); + + // Assert + expect(jsonStringOutput).toBe(expectedString); + }); + + it('stringifies null in event handler map', () => { + + // Arrange + const eventHandlerMap = new Map([ + ['loaded', null], + ['rendered', function () { console.log('Rendered'); }] + ]); + const expectedString = `[["loaded",""],["rendered","function () { console.log('Rendered'); }"]]`; + + // Act + const jsonStringOutput = stringifyMap(eventHandlerMap); + + // Assert + expect(jsonStringOutput).toBe(expectedString); + }); + }); +}); \ No newline at end of file