├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── embeddable-services.md │ └── enhancement-request.md ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets ├── background.svg ├── background_greyscale.svg ├── github_social_preview.png ├── listing_image_01.png ├── listing_image_02.png ├── listing_image_03.png ├── listing_image_04.png ├── palette_icon_lite.png ├── palette_icon_standalone.png ├── palette_icon_standard.png ├── store_icon.png └── store_icon_lite.png ├── bin └── package-custom.js ├── capabilities.json ├── config ├── package.json └── visual.json ├── karma.conf.ts ├── package-lock.json ├── package.json ├── pbiviz.json ├── src ├── behavior.ts ├── domain-utils.ts ├── landing-page-handler.ts ├── types.ts ├── view-model.ts ├── visual-constants.ts ├── visual-settings.ts └── visual.ts ├── stringResources └── en-US │ └── resources.resjson ├── style └── visual.less ├── test ├── VisualBuilder.ts └── viewModel.spec.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | bin 2 | node_modules 3 | dist 4 | coverage 5 | test 6 | .eslintrc.js 7 | karma.conf.ts 8 | test.webpack.config.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | es2017: true 6 | }, 7 | root: true, 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | project: 'tsconfig.json', 11 | tsconfigRootDir: '.' 12 | }, 13 | plugins: ['powerbi-visuals'], 14 | extends: ['plugin:powerbi-visuals/recommended'], 15 | rules: {} 16 | }; 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: dm-p 2 | buy_me_a_coffee: dmp 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Let us know that something might be wrong, so that we can improve the visual 4 | for others 5 | title: "[BUG]" 6 | labels: bug 7 | assignees: dm-p 8 | 9 | --- 10 | 11 | **Describe the bug** 12 | 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | 25 | A clear and concise description of what you expected to happen. 26 | 27 | **Screenshots/Workbook** 28 | 29 | If applicable, attach screenshots or a sample workbook with data suitable for public viewing to help explain your problem. 30 | 31 | **Power BI Setup (please complete the following information):** 32 | - Platform: [e.g. Power BI Desktop, Power BI Service] 33 | - OS: [e.g. iOS] 34 | - Browser [e.g. chrome, safari] 35 | - Version [e.g. 22] 36 | 37 | **Visual Information (please complete the following information, available by right-clicking the visual in Power BI and selecting *About*):** 38 | - Version: [e.g. 2.0.0.0] 39 | 40 | **Additional context** 41 | 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/embeddable-services.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Embeddable Services 3 | about: Make us aware of any services that can be embedded into the visual, so we can 4 | share this knowledge with other content creators 5 | title: "[EMBED] Name of Service" 6 | labels: documentation 7 | assignees: '' 8 | 9 | --- 10 | 11 | **Service Name** 12 | 13 | The service or provider that supports embedding from the visual, and their website. 14 | 15 | **Sample HTML, or Walkthrough** 16 | 17 | If you have any sample HTML, or a blog post or other walkthrough, please provide it here so that we can test and produce content, or provide users with a link to yours. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement Request 3 | about: Suggest new functionality for the visual 4 | title: "[ENHANCEMENT]" 5 | labels: enhancement 6 | assignees: dm-p 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | 16 | A clear and concise description of what you want to happen. If you have mockups or something to attach that will help to communicate your idea then that will be massively helpful :) 17 | 18 | **Additional context** 19 | 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Local VS Code config 64 | .vscode/ 65 | 66 | # Power BI Custom Visuals SDK-specific stuff 67 | .api/ 68 | .tmp/ 69 | dist/ 70 | webpack.statistics.*.html 71 | webpack.statistics.html 72 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "printWidth": 80, 7 | "tabWidth": 4 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel Marsh-Patrick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTML Content for Power BI 2 | 3 | Documentation and important info for the visual can be found at: html-content.com 4 | -------------------------------------------------------------------------------- /assets/background.svg: -------------------------------------------------------------------------------- 1 | 2 | HTML5 Logo Badge 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /assets/background_greyscale.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | HTML5 Logo Badge 24 | 25 | 26 | 27 | 29 | 33 | 38 | 44 | 49 | 53 | 54 | 58 | 63 | 69 | 74 | 78 | 79 | 83 | 88 | 94 | 99 | 103 | 104 | 108 | 113 | 119 | 124 | 128 | 129 | 130 | 150 | HTML5 Logo Badge 152 | 157 | 162 | 167 | 172 | 173 | -------------------------------------------------------------------------------- /assets/github_social_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/github_social_preview.png -------------------------------------------------------------------------------- /assets/listing_image_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/listing_image_01.png -------------------------------------------------------------------------------- /assets/listing_image_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/listing_image_02.png -------------------------------------------------------------------------------- /assets/listing_image_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/listing_image_03.png -------------------------------------------------------------------------------- /assets/listing_image_04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/listing_image_04.png -------------------------------------------------------------------------------- /assets/palette_icon_lite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/palette_icon_lite.png -------------------------------------------------------------------------------- /assets/palette_icon_standalone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/palette_icon_standalone.png -------------------------------------------------------------------------------- /assets/palette_icon_standard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/palette_icon_standard.png -------------------------------------------------------------------------------- /assets/store_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/store_icon.png -------------------------------------------------------------------------------- /assets/store_icon_lite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dm-p/powerbi-visuals-html-content/1edd7fd615bccf6ecb04af142692fc04e52fbcad/assets/store_icon_lite.png -------------------------------------------------------------------------------- /bin/package-custom.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const exec = require('child_process').exec; 3 | const _ = require('lodash'); 4 | const parseArgs = require('minimist'); 5 | const { exit } = require('process'); 6 | const config = require('../config/package.json'); 7 | const pbivizFile = 'pbiviz.json'; 8 | const pbivizFilePath = '.'; 9 | const configFile = 'visual.json'; 10 | const configFilePath = './config'; 11 | const capabilitiesFile = 'capabilities.json'; 12 | const capabilitiesFilePath = '.'; 13 | const pbivizOriginal = require(`../${pbivizFile}`); 14 | const configOriginal = require(`../config/${configFile}`); 15 | const capabilitiesOriginal = require(`../${capabilitiesFile}`); 16 | 17 | const runNpmScript = (script, callback) => { 18 | // keep track of whether callback has been invoked to prevent multiple invocations 19 | var invoked = false; 20 | var process = exec(script); 21 | // listen for errors as they may prevent the exit event from firing 22 | process.on('error', function(err) { 23 | if (invoked) return; 24 | invoked = true; 25 | callback(err); 26 | }); 27 | // execute the callback once the process has finished running 28 | process.on('exit', function(code) { 29 | if (invoked) return; 30 | invoked = true; 31 | var err = code === 0 ? null : new Error('exit code ' + code); 32 | callback(err); 33 | }); 34 | }; 35 | 36 | // Revert the modified files back to their original state 37 | const cleanup = () => { 38 | console.log('Performing cleanup...'); 39 | writeFile(pbivizFile, pbivizFilePath, pbivizOriginal); 40 | console.log(`${pbivizFile} reverted`); 41 | writeFile(configFile, configFilePath, configOriginal); 42 | console.log(`${configFile} reverted`); 43 | writeFile(capabilitiesFile, capabilitiesFilePath, capabilitiesOriginal); 44 | console.log(`${capabilitiesFile} reverted`); 45 | }; 46 | 47 | // Write a pbiviz.json to the project file system 48 | const writeFile = (name, path, content) => { 49 | console.log(`Writing ${name}...`); 50 | fs.writeFileSync(`${path}/${name}`, JSON.stringify(content, null, 4)); 51 | console.log(`${name}.updated`); 52 | }; 53 | 54 | // Perform necessary patching of pbiviz.json for supplied mode 55 | const getPatchedPbiviz = packageConfig => { 56 | const { guid } = packageConfig.pbiviz.visual; 57 | return { 58 | visual: { 59 | guid: guid.replace(/(.*)(\{0\})/, `$1${pbivizOriginal.visual.guid}`) 60 | } 61 | }; 62 | }; 63 | 64 | try { 65 | console.log('Checking for package configuration...'); 66 | const argv = parseArgs(process.argv.slice(2)); 67 | const packageConfig = config[argv.mode]; 68 | console.log('Configuration', packageConfig); 69 | if (!packageConfig) { 70 | throw new Error('No configuration for package found!'); 71 | } 72 | console.log(`Using configuration for [${argv.mode}]`); 73 | console.log(`Updating ${pbivizFile} with configuration...`); 74 | const pbivizNew = _.merge( 75 | _.cloneDeep(pbivizOriginal), 76 | packageConfig.pbiviz, 77 | getPatchedPbiviz(packageConfig) 78 | ); 79 | writeFile(pbivizFile, pbivizFilePath, pbivizNew); 80 | console.log(`Updating ${configFile} with configuration...`); 81 | const configFileNew = _.merge( 82 | _.cloneDeep(configOriginal), 83 | packageConfig['config'] 84 | ); 85 | writeFile(configFile, configFilePath, configFileNew); 86 | console.log(`Updating ${capabilitiesFile} with configuration...`); 87 | const capabilitiesFileNew = _.merge( 88 | _.cloneDeep(capabilitiesOriginal), 89 | packageConfig.capabilities 90 | ); 91 | writeFile(capabilitiesFile, capabilitiesFilePath, capabilitiesFileNew); 92 | console.log('Running pbiviz package with new options...'); 93 | runNpmScript('pbiviz package', err => { 94 | if (err) throw err; 95 | console.log('Completed package process.'); 96 | cleanup(); 97 | exit(0); 98 | }); 99 | } catch (e) { 100 | console.error(`[ERROR] ${e.message}`); 101 | cleanup(); 102 | exit(1); 103 | } 104 | -------------------------------------------------------------------------------- /capabilities.json: -------------------------------------------------------------------------------- 1 | { 2 | "privileges": [], 3 | "dataRoles": [ 4 | { 5 | "displayNameKey": "Roles_Values", 6 | "descriptionKey": "Roles_Values_Description", 7 | "displayName": "Values", 8 | "description": "Values description.", 9 | "name": "content", 10 | "kind": "GroupingOrMeasure" 11 | }, 12 | { 13 | "displayNameKey": "Roles_Sampling", 14 | "descriptionKey": "Roles_Sampling_Description", 15 | "displayName": "Sampling", 16 | "description": "Sampling description.", 17 | "name": "sampling", 18 | "kind": "GroupingOrMeasure" 19 | }, 20 | { 21 | "displayNameKey": "Roles_Tooltips", 22 | "descriptionKey": "Roles_Tooltips_Description", 23 | "displayName": "Tooltips", 24 | "description": "Tooltips description.", 25 | "name": "tooltips", 26 | "kind": "Measure" 27 | } 28 | ], 29 | "objects": { 30 | "contentFormatting": { 31 | "properties": { 32 | "showRawHtml": { 33 | "type": { 34 | "bool": true 35 | } 36 | }, 37 | "format": { 38 | "type": { 39 | "enumeration": [ 40 | { 41 | "displayNameKey": "Enum_RenderFormat_HTML", 42 | "displayName": "HTML", 43 | "value": "html" 44 | }, 45 | { 46 | "displayNameKey": "Enum_RenderFormat_Mardown", 47 | "displayName": "Markdown", 48 | "value": "markdown" 49 | } 50 | ] 51 | } 52 | }, 53 | "fontFamily": { 54 | "type": { 55 | "formatting": { 56 | "fontFamily": true 57 | } 58 | } 59 | }, 60 | "fontSize": { 61 | "type": { 62 | "formatting": { 63 | "fontSize": true 64 | } 65 | } 66 | }, 67 | "fontColour": { 68 | "type": { 69 | "fill": { 70 | "solid": { 71 | "color": true 72 | } 73 | } 74 | } 75 | }, 76 | "align": { 77 | "type": { 78 | "formatting": { 79 | "alignment": true 80 | } 81 | } 82 | }, 83 | "hyperlinks": { 84 | "type": { 85 | "bool": true 86 | } 87 | }, 88 | "userSelect": { 89 | "type": { 90 | "bool": true 91 | } 92 | }, 93 | "noDataMessage": { 94 | "type": { 95 | "text": true 96 | } 97 | } 98 | } 99 | }, 100 | "stylesheet": { 101 | "properties": { 102 | "stylesheet": { 103 | "type": { 104 | "text": true 105 | } 106 | }, 107 | "test": { 108 | "type": { 109 | "text": true 110 | } 111 | } 112 | } 113 | }, 114 | "crossFilter": { 115 | "properties": { 116 | "enabled": { 117 | "type": { 118 | "bool": true 119 | } 120 | }, 121 | "useTransparency": { 122 | "type": { 123 | "bool": true 124 | } 125 | }, 126 | "transparencyPercent": { 127 | "type": { 128 | "integer": true 129 | } 130 | } 131 | } 132 | } 133 | }, 134 | "dataViewMappings": [ 135 | { 136 | "conditions": [ 137 | { 138 | "content": { 139 | "max": 1 140 | } 141 | } 142 | ], 143 | "table": { 144 | "rows": { 145 | "select": [ 146 | { 147 | "for": { 148 | "in": "sampling" 149 | } 150 | }, 151 | { 152 | "for": { 153 | "in": "content" 154 | } 155 | }, 156 | { 157 | "for": { 158 | "in": "tooltips" 159 | } 160 | } 161 | ], 162 | "dataReductionAlgorithm": { 163 | "top": {} 164 | } 165 | } 166 | } 167 | } 168 | ], 169 | "sorting": { 170 | "default": {} 171 | }, 172 | "supportsLandingPage": true, 173 | "suppressDefaultTitle": true, 174 | "supportsKeyboardFocus": true, 175 | "tooltips": { 176 | "supportedTypes": { 177 | "default": true, 178 | "canvas": true 179 | }, 180 | "supportEnhancedTooltips": true, 181 | "roles": ["tooltips"] 182 | }, 183 | "supportsMultiVisualSelection": true 184 | } 185 | -------------------------------------------------------------------------------- /config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "standard": { 3 | "pbiviz": { 4 | "visual": { 5 | "displayName": "HTML Content", 6 | "guid": "htmlContent443BE3AD55E043BF878BED274D3A6855", 7 | "description": "Visualise column or measure values as HTML in your Power BI reports." 8 | }, 9 | "assets": { 10 | "icon": "assets/palette_icon_standard.png" 11 | } 12 | }, 13 | "config": { 14 | "sanitize": false 15 | }, 16 | "capabilities": { 17 | "privileges": [ 18 | { 19 | "name": "WebAccess", 20 | "parameters": [ 21 | "*" 22 | ] 23 | } 24 | ] 25 | } 26 | }, 27 | "standalone": { 28 | "pbiviz": { 29 | "visual": { 30 | "displayName": "HTML Content - STANDALONE VERSION", 31 | "guid": "STANDALONEhtmlContent443BE3AD55E043BF878BED274D3A6855", 32 | "description": "Visualise column or measure values as HTML in your Power BI reports." 33 | }, 34 | "assets": { 35 | "icon": "assets/palette_icon_standalone.png" 36 | } 37 | }, 38 | "config": { 39 | "sanitize": false 40 | }, 41 | "capabilities": { 42 | "privileges": [ 43 | { 44 | "name": "WebAccess", 45 | "parameters": [ 46 | "*" 47 | ] 48 | } 49 | ] 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /config/visual.json: -------------------------------------------------------------------------------- 1 | { 2 | "sanitize": true 3 | } 4 | -------------------------------------------------------------------------------- /karma.conf.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const webpackConfig = require("./test.webpack.config.js"); 4 | const tsconfig = require("./tsconfig.json"); 5 | const path = require("path"); 6 | 7 | const testRecursivePath = "test/*.spec.ts"; 8 | const srcOriginalRecursivePath = "src/**/*.ts"; 9 | const coverageFolder = "coverage"; 10 | 11 | process.env.CHROME_BIN = require("puppeteer").executablePath(); 12 | 13 | import { Config, ConfigOptions } from "karma"; 14 | 15 | module.exports = (config: Config) => { 16 | config.set({ 17 | mode: "development", 18 | browserNoActivityTimeout: 100000, 19 | browsers: ["ChromeHeadless"], // or Chrome to use locally installed Chrome browser 20 | colors: true, 21 | frameworks: ["jasmine"], 22 | reporters: [ 23 | "progress", 24 | "junit", 25 | "coverage-istanbul" 26 | ], 27 | junitReporter: { 28 | outputDir: path.join(__dirname, coverageFolder), 29 | outputFile: "TESTS-report.xml", 30 | useBrowserName: false 31 | }, 32 | singleRun: true, 33 | plugins: [ 34 | "karma-coverage", 35 | "karma-typescript", 36 | "karma-webpack", 37 | "karma-jasmine", 38 | "karma-sourcemap-loader", 39 | "karma-chrome-launcher", 40 | "karma-junit-reporter", 41 | "karma-coverage-istanbul-reporter" 42 | ], 43 | files: [ 44 | "node_modules/jquery/dist/jquery.min.js", 45 | "node_modules/jasmine-jquery/lib/jasmine-jquery.js", 46 | { 47 | pattern: './capabilities.json', 48 | watched: false, 49 | served: true, 50 | included: false 51 | }, 52 | testRecursivePath, 53 | { 54 | pattern: srcOriginalRecursivePath, 55 | included: false, 56 | served: true 57 | } 58 | ], 59 | preprocessors: { 60 | [testRecursivePath]: ["webpack", "coverage"] 61 | }, 62 | typescriptPreprocessor: { 63 | options: tsconfig.compilerOptions 64 | }, 65 | coverageIstanbulReporter: { 66 | reports: ["html", "lcovonly", "text-summary", "cobertura"], 67 | dir: path.join(__dirname, coverageFolder), 68 | 'report-config': { 69 | html: { 70 | subdir: 'html-report' 71 | } 72 | }, 73 | combineBrowserReports: true, 74 | fixWebpackSourcePaths: true, 75 | verbose: false 76 | }, 77 | coverageReporter: { 78 | dir: path.join(__dirname, coverageFolder), 79 | reporters: [ 80 | // reporters not supporting the `file` property 81 | { type: 'html', subdir: 'html-report' }, 82 | { type: 'lcov', subdir: 'lcov' }, 83 | // reporters supporting the `file` property, use `subdir` to directly 84 | // output them in the `dir` directory 85 | { type: 'cobertura', subdir: '.', file: 'cobertura-coverage.xml' }, 86 | { type: 'lcovonly', subdir: '.', file: 'report-lcovonly.txt' }, 87 | { type: 'text-summary', subdir: '.', file: 'text-summary.txt' }, 88 | ] 89 | }, 90 | mime: { 91 | "text/x-typescript": ["ts", "tsx"] 92 | }, 93 | webpack: webpackConfig, 94 | webpackMiddleware: { 95 | stats: "errors-only" 96 | } 97 | }); 98 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "visual", 3 | "scripts": { 4 | "pbiviz": "pbiviz", 5 | "start": "pbiviz start", 6 | "package": "pbiviz package", 7 | "package-standard": "node bin/package-custom --mode standard", 8 | "package-standalone": "node bin/package-custom --mode standalone", 9 | "eslint": "npx eslint . --ext .js,.jsx,.ts,.tsx", 10 | "prettier-check": "prettier --config .prettierrc {src,spec,style}/**/{*.ts*,*.css,*.less} --check", 11 | "prettier-format": "prettier --config .prettierrc {src,spec,style}/**/{*.ts*,*.css,*.less} --write" 12 | }, 13 | "devDependencies": { 14 | "@types/d3": "^5.7.2", 15 | "@types/d3-selection": "^3.0.5", 16 | "@types/node": "^20.12.2", 17 | "@types/overlayscrollbars": "^1.12.0", 18 | "@types/pretty": "^2.0.0", 19 | "@types/sanitize-html": "^2.9.0", 20 | "@typescript-eslint/eslint-plugin": "^5.62.0", 21 | "@typescript-eslint/parser": "^5.62.0", 22 | "child-process": "^1.0.2", 23 | "d3-selection": "^3.0.0", 24 | "eslint": "^8.46.0", 25 | "eslint-plugin-powerbi-visuals": "^0.8.1", 26 | "fs": "^0.0.1-security", 27 | "lodash": "^4.17.21", 28 | "marked": "^15.0.7", 29 | "overlayscrollbars": "^1.13.3", 30 | "powerbi-visuals-api": "~5.10.0", 31 | "powerbi-visuals-tools": "^5.4.3", 32 | "powerbi-visuals-utils-dataviewutils": "^2.4.0", 33 | "powerbi-visuals-utils-formattingmodel": "^6.0.1", 34 | "powerbi-visuals-utils-formattingutils": "^6.0.1", 35 | "powerbi-visuals-utils-interactivityutils": "^6.0.2", 36 | "prettier": "^1.19.1", 37 | "pretty": "^2.0.0", 38 | "process": "^0.11.10", 39 | "sanitize-html": "^2.11.0", 40 | "ts-loader": "6.1.0", 41 | "typescript": "^5.4.3", 42 | "w3-css": "^4.1.0" 43 | } 44 | } -------------------------------------------------------------------------------- /pbiviz.json: -------------------------------------------------------------------------------- 1 | { 2 | "visual": { 3 | "name": "htmlContent", 4 | "displayName": "HTML Content (lite)", 5 | "guid": "htmlContent443BE3AD55E043BF878BED274D3A6865", 6 | "visualClassName": "Visual", 7 | "version": "1.6.0.0", 8 | "description": "Visualise column or measure values as HTML in your Power BI reports. This version does not allow loading of remote content, and allows a smaller subset of HTML tags.", 9 | "supportUrl": "https://www.html-content.com", 10 | "gitHubUrl": "https://github.com/dm-p/powerbi-visuals-html-content" 11 | }, 12 | "apiVersion": "5.10.0", 13 | "author": { 14 | "name": "Daniel Marsh-Patrick", 15 | "email": "daniel@coacervo.co" 16 | }, 17 | "assets": { 18 | "icon": "assets/palette_icon_lite.png" 19 | }, 20 | "externalJS": [], 21 | "style": "style/visual.less", 22 | "capabilities": "capabilities.json", 23 | "dependencies": null, 24 | "stringResources": [] 25 | } -------------------------------------------------------------------------------- /src/behavior.ts: -------------------------------------------------------------------------------- 1 | import { interactivityBaseService } from 'powerbi-visuals-utils-interactivityutils'; 2 | import IBehaviorOptions = interactivityBaseService.IBehaviorOptions; 3 | import BaseDataPoint = interactivityBaseService.BaseDataPoint; 4 | import IInteractiveBehavior = interactivityBaseService.IInteractiveBehavior; 5 | import ISelectionHandler = interactivityBaseService.ISelectionHandler; 6 | 7 | import { IHtmlEntry, IViewModel } from './view-model'; 8 | import { VisualConstants } from './visual-constants'; 9 | import { shouldDimPoint } from './domain-utils'; 10 | 11 | /** 12 | * Behavior options for interactivity. 13 | */ 14 | export interface IHtmlBehaviorOptions< 15 | SelectableDataPoint extends BaseDataPoint 16 | > extends IBehaviorOptions { 17 | // Elements denoting a selectable data point in the visual 18 | pointSelection: d3.Selection; 19 | // Element performing the role of clear-catcher (clears selection) 20 | clearCatcherSelection: d3.Selection; 21 | // Visual ViewModel 22 | viewModel: IViewModel; 23 | } 24 | 25 | /** 26 | * Used to control and bind visual interaction and behavior. 27 | */ 28 | export class BehaviorManager 29 | implements IInteractiveBehavior { 30 | // Interactivity options 31 | protected options: IHtmlBehaviorOptions; 32 | // Handles selection event delegation to the visual host 33 | protected selectionHandler: ISelectionHandler; 34 | 35 | /** 36 | * Apply click behavior to selections as necessary. 37 | */ 38 | protected bindClick() { 39 | const { 40 | pointSelection, 41 | viewModel: { hasCrossFiltering } 42 | } = this.options; 43 | pointSelection.on('click', (event, d) => 44 | hasCrossFiltering ? this.handleSelectionClick(event, d) : null 45 | ); 46 | } 47 | 48 | /** 49 | * Apply context menu behavior to selections as necessary. 50 | */ 51 | protected bindContextMenu() { 52 | const { pointSelection, clearCatcherSelection } = this.options; 53 | pointSelection.on('contextmenu', (event, d) => 54 | this.handleContextMenu(event, d) 55 | ); 56 | clearCatcherSelection.on('contextmenu', event => 57 | this.handleContextMenu(event, null) 58 | ); 59 | } 60 | 61 | /** 62 | * Abstraction of common click event handling for a `SelectableDataPoint` 63 | * 64 | * @param event - click event 65 | * @param d - datum from selection 66 | */ 67 | private handleSelectionClick(event: MouseEvent, d: IHtmlEntry) { 68 | event.preventDefault(); 69 | event.stopPropagation(); 70 | this.selectionHandler.handleSelection(d, event.ctrlKey); 71 | } 72 | 73 | /** 74 | * Abstraction of common context menu event handling for a `SelectableDataPoint`. 75 | * 76 | * @param event - click event 77 | * @param d - datum from selection 78 | */ 79 | handleContextMenu(event: MouseEvent, d: IHtmlEntry) { 80 | event.preventDefault(); 81 | event.stopPropagation(); 82 | event && 83 | this.selectionHandler.handleContextMenu(d, { 84 | x: event.clientX, 85 | y: event.clientY 86 | }); 87 | } 88 | 89 | /** 90 | * Apply click behavior to the clear-catcher (clearing active selections if clicked). 91 | */ 92 | protected bindClearCatcher() { 93 | const { 94 | clearCatcherSelection, 95 | viewModel: { hasCrossFiltering } 96 | } = this.options; 97 | clearCatcherSelection.on('click', event => { 98 | if (hasCrossFiltering) { 99 | event.preventDefault(); 100 | event.stopPropagation(); 101 | const mouseEvent: MouseEvent = event; 102 | mouseEvent && this.selectionHandler.handleClearSelection(); 103 | } 104 | }); 105 | } 106 | 107 | /** 108 | * Ensure that class has necessary options and tooling to perform interactivity/behavior requirements as needed. 109 | * 110 | * @param options - interactivity & behavior options 111 | * @param selectionHandler - selection handler instance 112 | */ 113 | public bindEvents( 114 | options: IHtmlBehaviorOptions, 115 | selectionHandler: ISelectionHandler 116 | ): void { 117 | this.options = options; 118 | this.selectionHandler = selectionHandler; 119 | this.bindClick(); 120 | this.bindContextMenu(); 121 | this.bindClearCatcher(); 122 | } 123 | 124 | /** 125 | * Handle visual effects on selection and interactivity events. 126 | * 127 | * @param hasSelection - whether visual has selection state or not 128 | */ 129 | public renderSelection(hasSelection: boolean): void { 130 | const { pointSelection, viewModel } = this.options; 131 | // Update viewModel selection state to match current state 132 | viewModel.hasSelection = hasSelection; 133 | pointSelection.classed(VisualConstants.dom.unselectedClassSelector, d => 134 | shouldDimPoint(hasSelection, d.selected) 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/domain-utils.ts: -------------------------------------------------------------------------------- 1 | // Power BI API Dependencies 2 | import powerbi from 'powerbi-visuals-api'; 3 | import IVisualHost = powerbi.extensibility.visual.IVisualHost; 4 | import TooltipShowOptions = powerbi.extensibility.TooltipShowOptions; 5 | import VisualTooltipDataItem = powerbi.extensibility.VisualTooltipDataItem; 6 | 7 | // External dependencies 8 | import { select, Selection } from 'd3-selection'; 9 | import * as OverlayScrollbars from 'overlayscrollbars'; 10 | import * as config from '../config/visual.json'; 11 | import * as sanitizeHtml from 'sanitize-html'; 12 | import { marked } from 'marked'; 13 | const pretty = require('pretty'); 14 | 15 | // Internal dependencies 16 | import { VisualConstants } from './visual-constants'; 17 | import { 18 | StylesheetSettings, 19 | VisualFormattingSettingsModel 20 | } from './visual-settings'; 21 | import { IHtmlEntry } from './view-model'; 22 | import { RenderFormat } from './types'; 23 | 24 | /** 25 | * Parse the supplied HTML string and then return as a DOM fragment that we can 26 | * use in the visual for our data. If we're specifying in the configuration that 27 | * we should sanitie, do this also, so that we're not injecting any malicious 28 | * code into the DOM and keep to certification requirements. 29 | */ 30 | export const getParsedHtmlAsDom = (content: string, format: RenderFormat) => { 31 | const parse = Range.prototype.createContextualFragment.bind( 32 | document.createRange() 33 | ); 34 | const { 35 | allowedSchemes, 36 | allowedSchemesByTag, 37 | allowedTags 38 | } = VisualConstants; 39 | const converted = 40 | format === 'markdown' ? marked.parse(content).toString() : content; 41 | const dom = config.sanitize 42 | ? sanitizeHtml(converted, { 43 | allowedAttributes: { '*': ['*'] }, 44 | allowedTags, 45 | allowedSchemes, 46 | allowedSchemesByTag, 47 | transformTags: { 48 | '*': (tagName, attribs) => { 49 | return { 50 | tagName, 51 | attribs: getStrippedAttributes(attribs) 52 | }; 53 | } 54 | } 55 | }) 56 | : converted; 57 | return parse(dom); 58 | }; 59 | 60 | /** 61 | * It still might be possible to encode 'javascript' into an attribute, so 62 | * we'll strip out any attributes that contain this, or any other potential 63 | * scripting patterns. 64 | */ 65 | const getStrippedAttributes = ( 66 | attribs: sanitizeHtml.Attributes 67 | ): sanitizeHtml.Attributes => { 68 | for (const [key, value] of Object.entries(attribs)) { 69 | if ( 70 | typeof value === 'string' && 71 | VisualConstants.scriptingPatterns.some(pattern => 72 | value.includes(pattern) 73 | ) 74 | ) { 75 | delete attribs[key]; 76 | } 77 | } 78 | return attribs; 79 | }; 80 | 81 | /** 82 | * Use to determine if we should include stylesheet logic, based on whether it has been supplied or not. 83 | */ 84 | export const shouldUseStylesheet = (stylesheet: StylesheetSettings) => 85 | stylesheet.stylesheetCardMain.stylesheet.value ? true : false; 86 | 87 | /** 88 | * Resolve how styling should be applied, based on supplied properties. Basically, if user has supplied 89 | * their own stylesheet via properties, we will defer to this rather than the standard content formatting 90 | * ones. 91 | */ 92 | export const resolveStyling = ( 93 | styleSheetContainer: Selection, 94 | bodyContainer: Selection, 95 | settings: VisualFormattingSettingsModel 96 | ) => { 97 | const useSS = shouldUseStylesheet(settings.stylesheet); 98 | const bodyProps = settings.contentFormatting; 99 | const { 100 | crossFilter: { 101 | crossFilterCardMain: { 102 | enabled, 103 | useTransparency, 104 | transparencyPercent 105 | } 106 | } 107 | } = settings; 108 | const crossFilterStyles = 109 | enabled.value && useTransparency.value 110 | ? `.${VisualConstants.dom.entryClassSelector}.${ 111 | VisualConstants.dom.unselectedClassSelector 112 | } { opacity: ${1 - transparencyPercent.value / 100}; }` 113 | : ''; 114 | const customStyles = `${(useSS && 115 | settings.stylesheet.stylesheetCardMain.stylesheet.value) || 116 | ''}`; 117 | styleSheetContainer.text(`${crossFilterStyles} ${customStyles}`); 118 | resolveUserSelect( 119 | bodyProps.contentFormattingCardBehavior.userSelect.value, 120 | bodyContainer 121 | ); 122 | bodyContainer 123 | .style( 124 | 'font-family', 125 | resolveBodyStyle( 126 | useSS, 127 | bodyProps.contentFormattingCardDefaultBodyStyling.fontFamily 128 | .value 129 | ) 130 | ) 131 | .style( 132 | 'font-size', 133 | resolveBodyStyle( 134 | useSS, 135 | `${bodyProps.contentFormattingCardDefaultBodyStyling.fontSize.value}pt` 136 | ) 137 | ) 138 | .style( 139 | 'color', 140 | resolveBodyStyle( 141 | useSS, 142 | bodyProps.contentFormattingCardDefaultBodyStyling.fontColour 143 | .value.value 144 | ) 145 | ) 146 | .style( 147 | 'text-align', 148 | resolveBodyStyle( 149 | useSS, 150 | bodyProps.contentFormattingCardDefaultBodyStyling.align.value 151 | ) 152 | ); 153 | }; 154 | 155 | /** 156 | * For the supplied stylesheet container, settings and body container (could be standard content, or the 157 | * "no data" message container), ensure that the content is resolved, and the correct element (readonly 158 | * textarea) is added to the DOM, as well as caretaking any existing elements. 159 | */ 160 | export const resolveForRawHtml = ( 161 | styleSheetContainer: Selection, 162 | contentContainer: Selection, 163 | settings: VisualFormattingSettingsModel 164 | ) => { 165 | if ( 166 | settings.contentFormatting.contentFormattingCardBehavior.showRawHtml 167 | .value 168 | ) { 169 | const output = getRawHtml( 170 | styleSheetContainer, 171 | contentContainer, 172 | settings.stylesheet 173 | ); 174 | contentContainer.selectAll('*').remove(); 175 | contentContainer 176 | .append('textarea') 177 | .attr('id', VisualConstants.dom.rawOutputIdSelector) 178 | .attr('readonly', true) 179 | .text(output); 180 | } 181 | }; 182 | 183 | /** 184 | * For the specified element, process all hyperlinks so that they are either explicitly denied, 185 | * or delegated to the Power BI visual host for permission to open. 186 | * 187 | * @param host - The Power BI visual host services object. 188 | * @param container - The container to process. 189 | * @param allowDelegation - Allow hyperlinks to be delegated to Power BI. 190 | */ 191 | export function resolveHyperlinkHandling( 192 | host: IVisualHost, 193 | container: Selection, 194 | allowDelegation?: boolean 195 | ) { 196 | container.selectAll('a').on('click', event => { 197 | event.preventDefault(); 198 | if (allowDelegation) { 199 | const url = select(event.currentTarget).attr('href') || ''; 200 | host.launchUrl(url); 201 | } 202 | }); 203 | } 204 | 205 | /** 206 | * As we want to display different types of element for each entry/grouping, we will clear down the 207 | * existing children and rebuild with our desired element for handling raw vs. rendered HTML. 208 | * 209 | * @param dataElements - The elements to analyse and process. 210 | */ 211 | export function resolveHtmlGroupElement( 212 | dataElements: Selection, 213 | format: RenderFormat 214 | ) { 215 | // Remove any applied elements 216 | dataElements.selectAll('*').remove(); 217 | // Add the correct element 218 | dataElements.append('div').append(function(d) { 219 | return this.appendChild(getParsedHtmlAsDom(d.content, format)); 220 | }); 221 | } 222 | 223 | /** 224 | * Use OverlayScrollbars to apply nicer scrolling to the supplied element. 225 | * 226 | * @param element - HTML element to apply scrolling to. 227 | */ 228 | export function resolveScrollableContent(element: HTMLElement) { 229 | OverlayScrollbars(element, { 230 | scrollbars: { 231 | clickScrolling: true 232 | } 233 | }); 234 | } 235 | 236 | /** 237 | * Handle eventing when a data element is hovred over. This includes showing 238 | * the tooltip and toggling appropriate class names for style hooks. 239 | * 240 | * @param dataElements - The elements to analyse and process. 241 | * @param host - Visual host services. 242 | * @param hasGranularity - Whether we have granularity or not. 243 | */ 244 | export function resolveHover( 245 | dataElements: Selection, 246 | host: IVisualHost, 247 | hasGranularity: boolean 248 | ) { 249 | bindStandardTooltips(dataElements, host, hasGranularity); 250 | bindManualTooltips(dataElements, host); 251 | } 252 | 253 | /** 254 | * If we don't have any granularity, we will look for elements that have 255 | * a tooltip attribute and use this to show the tooltip. 256 | * 257 | * @param dataElements - The elements to analyse and process. 258 | * @param host - Visual host services. 259 | */ 260 | function bindManualTooltips( 261 | dataElements: Selection, 262 | host: IVisualHost 263 | ) { 264 | const { tooltipService } = host; 265 | const { 266 | manualTooltipSelector, 267 | manualTooltipDataPrefix, 268 | manualTooltipDataTitle, 269 | manualTooltipDataValue 270 | } = VisualConstants.dom; 271 | const manualTooltipElements = dataElements.selectAll( 272 | `.${manualTooltipSelector}` 273 | ); 274 | const titleExp = new RegExp( 275 | `${manualTooltipDataPrefix}${manualTooltipDataTitle}`, 276 | 'g' 277 | ); 278 | const valueExp = new RegExp( 279 | `${manualTooltipDataPrefix}${manualTooltipDataValue}`, 280 | 'g' 281 | ); 282 | manualTooltipElements.on('mouseover mousemove', event => { 283 | const dataset = event.currentTarget.dataset; 284 | const keys = Object.keys(dataset).map(key => 285 | key.replace(titleExp, '').replace(valueExp, '') 286 | ); 287 | const uniqueKeys = [...new Set(keys)]; 288 | const dataItems: VisualTooltipDataItem[] = uniqueKeys.map(key => ({ 289 | displayName: 290 | dataset[ 291 | `${manualTooltipDataPrefix}${manualTooltipDataTitle}${key}` 292 | ] || '', 293 | value: 294 | dataset[ 295 | `${manualTooltipDataPrefix}${manualTooltipDataValue}${key}` 296 | ] || '' 297 | })); 298 | if (dataItems.length > 0) { 299 | const options: TooltipShowOptions = { 300 | coordinates: [event.clientX, event.clientY], 301 | isTouchEvent: true, 302 | dataItems, 303 | identities: [] 304 | }; 305 | tooltipService.show(options); 306 | } 307 | }); 308 | manualTooltipElements.on('mouseout', () => 309 | tooltipService.hide({ immediately: true, isTouchEvent: true }) 310 | ); 311 | } 312 | 313 | /** 314 | * For standard data elements, working with the data roles and correct 315 | * rules, we will apply the regular tooltip handling. 316 | * 317 | * @param dataElements - The elements to analyse and process. 318 | * @param host - Visual host services. 319 | * @param hasGranularity - Whether we have granularity or not. 320 | */ 321 | function bindStandardTooltips( 322 | dataElements: Selection, 323 | host: IVisualHost, 324 | hasGranularity: boolean 325 | ) { 326 | const { tooltipService } = host; 327 | dataElements.on('mouseover mousemove', (event, d) => { 328 | select(event.currentTarget).classed( 329 | VisualConstants.dom.hoverClassSelector, 330 | true 331 | ); 332 | if (hasGranularity || d.tooltips.length > 0) { 333 | const options: TooltipShowOptions = { 334 | coordinates: [event.clientX, event.clientY], 335 | isTouchEvent: true, 336 | dataItems: d.tooltips, 337 | identities: [d.identity] 338 | }; 339 | tooltipService.show(options); 340 | } 341 | }); 342 | dataElements.on('mouseout', event => { 343 | select(event.currentTarget).classed( 344 | VisualConstants.dom.hoverClassSelector, 345 | false 346 | ); 347 | tooltipService.hide({ immediately: true, isTouchEvent: true }); 348 | }); 349 | } 350 | 351 | /** 352 | * Creates the d3 elements and data binding for the specified view model data. 353 | * 354 | * @param container - The container to process. 355 | * @param data - Array of view model data to bind. 356 | */ 357 | export function bindVisualDataToDom( 358 | container: Selection, 359 | data: IHtmlEntry[], 360 | hasSelection: boolean 361 | ) { 362 | const { entryClassSelector, unselectedClassSelector } = VisualConstants.dom; 363 | return container 364 | .selectAll(`.${entryClassSelector}`) 365 | .data(data) 366 | .join(enter => 367 | enter 368 | .append('div') 369 | .classed(entryClassSelector, true) 370 | .classed(unselectedClassSelector, d => 371 | shouldDimPoint(hasSelection, d.selected) 372 | ) 373 | ); 374 | } 375 | 376 | /** 377 | * For the current selection state of the view model and the data point, 378 | * determine whether the point should be dimmed or not. 379 | * 380 | * @param hasSelection 381 | * @param isSelected 382 | */ 383 | export function shouldDimPoint(hasSelection: boolean, isSelected: boolean) { 384 | return hasSelection && !isSelected; 385 | } 386 | 387 | /** 388 | * For the supplied stylesheet container, settings and body container (could be standard content, or the 389 | * "no data" message container), get raw HTML and pretty print it. 390 | */ 391 | const getRawHtml = ( 392 | styleSheetContainer: Selection, 393 | container: Selection, 394 | stylesheet: StylesheetSettings 395 | ) => 396 | pretty( 397 | `${((shouldUseStylesheet(stylesheet) && 398 | stylesheet.stylesheetCardMain.stylesheet.value) || 399 | '') && 400 | styleSheetContainer.node().outerHTML} ${container.node().outerHTML}` 401 | ); 402 | 403 | /** 404 | * Ensure that inline CSS is set correctly, based on whether user has assigned their own stylesheet, 405 | * or fall back to the standard content formatting properties if not. 406 | */ 407 | const resolveBodyStyle = (useSS: boolean, prop: string) => 408 | (!useSS && prop) || null; 409 | 410 | /** 411 | * Set the `user-select` CSS property based on user preference. 412 | */ 413 | const resolveUserSelect = ( 414 | enabled: boolean, 415 | bodyContainer: Selection 416 | ) => { 417 | const value = (enabled && 'text') || 'none'; 418 | bodyContainer 419 | .style('user-select', value) 420 | .style('-moz-user-select', value) 421 | .style('-webkit-user-select', value) 422 | .style('-ms-user-select', value); 423 | }; 424 | -------------------------------------------------------------------------------- /src/landing-page-handler.ts: -------------------------------------------------------------------------------- 1 | // Power BI API references 2 | import powerbiVisualsApi from 'powerbi-visuals-api'; 3 | import powerbi = powerbiVisualsApi; 4 | import IVisualHost = powerbi.extensibility.visual.IVisualHost; 5 | import ILocalizationManager = powerbi.extensibility.ILocalizationManager; 6 | 7 | // External dependencies 8 | import { Selection } from 'd3-selection'; 9 | 10 | // Internal dependencies 11 | import { VisualConstants } from './visual-constants'; 12 | import { resolveScrollableContent } from './domain-utils'; 13 | 14 | /** 15 | * Manages the handling and placement of the visual landing page if no data is present. 16 | */ 17 | export default class LandingPageHandler { 18 | // Specifies that the landing page is currently on. 19 | landingPageEnabled: boolean = false; 20 | // Specifies that the landing page has been removed since being displayed. 21 | landingPageRemoved: boolean = false; 22 | // Element to bind the landing page to. 23 | private element: Selection; 24 | // Handle localisation of visual text. 25 | private localisationManager: ILocalizationManager; 26 | 27 | /** 28 | * @param element - main visual element 29 | * @param localisationManager - Power BI localisation manager instance 30 | */ 31 | constructor( 32 | element: Selection, 33 | localisationManager: ILocalizationManager 34 | ) { 35 | this.element = element; 36 | this.localisationManager = localisationManager; 37 | } 38 | 39 | /** 40 | * Handles the display or removal of the landing page elements 41 | * @param options - visual update options 42 | * @param host - Power BI visual host services 43 | */ 44 | handleLandingPage(viewModelIsValid: boolean, host: IVisualHost) { 45 | // Conditions for showing landing page 46 | if (!viewModelIsValid) { 47 | if (!this.landingPageEnabled) { 48 | this.landingPageEnabled = true; 49 | this.render(host); 50 | } 51 | } else { 52 | this.clear(); 53 | } 54 | } 55 | 56 | /** 57 | * Clears down the landing page of elements 58 | */ 59 | clear() { 60 | this.element.selectAll('*').remove(); 61 | if (this.landingPageEnabled && !this.landingPageRemoved) { 62 | this.landingPageRemoved = true; 63 | } 64 | this.landingPageEnabled = false; 65 | } 66 | 67 | /** 68 | * Renders the landing page content 69 | * 70 | * @param host - Power BI visual host services 71 | */ 72 | render(host: IVisualHost) { 73 | // Top-level elements 74 | const container = this.element 75 | .append('div') 76 | .classed( 77 | `${VisualConstants.dom.landingPageClassPrefix}-landing-page`, 78 | true 79 | ) 80 | .classed('w3-card-4', true); 81 | 82 | const heading = container 83 | .append('div') 84 | .classed('w3-container', true) 85 | .classed('w3-theme', true); 86 | 87 | const version = container 88 | .append('div') 89 | .classed('w3-container', true) 90 | .classed('w3-theme-l3', true) 91 | .classed('w3-small', true); 92 | 93 | const helpBox = container 94 | .append('div') 95 | .classed('w3-container', true) 96 | .classed('w3-theme-l5', true) 97 | .classed( 98 | `${VisualConstants.dom.landingPageClassPrefix}-watermark`, 99 | true 100 | ) 101 | .classed( 102 | `${VisualConstants.dom.landingPageClassPrefix}-help`, 103 | true 104 | ); 105 | 106 | // Add title 107 | heading.append('h5').text(VisualConstants.visual.displayName); 108 | 109 | // Add version number 110 | version.text(VisualConstants.visual.version); 111 | 112 | // Help box content 113 | 114 | // Button / remote link 115 | helpBox 116 | .append('button') 117 | .classed('w3-button', true) 118 | .classed('w3-theme-action', true) 119 | .classed('w3-circle', true) 120 | .style('position', 'fixed') 121 | .style('top', '24px') 122 | .style('right', '12px') 123 | .on('click', () => 124 | host.launchUrl(VisualConstants.visual.supportUrl) 125 | ) 126 | .text('?'); 127 | 128 | // Overview 129 | helpBox 130 | .append('p') 131 | .classed('w3-small', true) 132 | .text( 133 | this.localisationManager.getDisplayName( 134 | 'Landing_Page_Overview_1' 135 | ) 136 | ); 137 | helpBox 138 | .append('p') 139 | .classed('w3-small', true) 140 | .text( 141 | this.localisationManager.getDisplayName( 142 | 'Landing_Page_Overview_2' 143 | ) 144 | ); 145 | helpBox 146 | .append('p') 147 | .classed('w3-small', true) 148 | .text( 149 | this.localisationManager.getDisplayName( 150 | 'Landing_Page_Overview_3' 151 | ) 152 | ); 153 | helpBox 154 | .append('p') 155 | .classed('w3-small', true) 156 | .text( 157 | this.localisationManager.getDisplayName( 158 | 'Landing_Page_Overview_4' 159 | ) 160 | ); 161 | 162 | resolveScrollableContent(container.node()); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Valid renderer types for content 2 | export type RenderFormat = 'html' | 'markdown'; 3 | -------------------------------------------------------------------------------- /src/view-model.ts: -------------------------------------------------------------------------------- 1 | // Power BI API Dependencies 2 | import powerbi from 'powerbi-visuals-api'; 3 | import DataView = powerbi.DataView; 4 | import DataViewMetadataColumn = powerbi.DataViewMetadataColumn; 5 | import IVisualHost = powerbi.extensibility.visual.IVisualHost; 6 | import ISelectionId = powerbi.visuals.ISelectionId; 7 | import VisualTooltipDataItem = powerbi.extensibility.VisualTooltipDataItem; 8 | import DataViewTableRow = powerbi.DataViewTableRow; 9 | import { valueFormatter } from 'powerbi-visuals-utils-formattingutils'; 10 | import { interactivitySelectionService } from 'powerbi-visuals-utils-interactivityutils'; 11 | import SelectableDataPoint = interactivitySelectionService.SelectableDataPoint; 12 | 13 | // Internal dependencies 14 | import { 15 | ContentFormattingSettings, 16 | VisualFormattingSettingsModel 17 | } from './visual-settings'; 18 | 19 | /** 20 | * View model structure 21 | */ 22 | export interface IViewModel { 23 | isValid: boolean; 24 | isEmpty: boolean; 25 | hasCrossFiltering: boolean; 26 | hasGranularity: boolean; 27 | hasSelection: boolean; 28 | contentIndex: number; 29 | contentFormatting?: ContentFormattingSettings; 30 | htmlEntries: IHtmlEntry[]; 31 | } 32 | 33 | export interface IHtmlEntry extends SelectableDataPoint { 34 | content: string; 35 | tooltips: VisualTooltipDataItem[]; 36 | } 37 | 38 | /** 39 | * Visual view model and necessary logic to manage its state. 40 | */ 41 | export class ViewModelHandler { 42 | viewModel: IViewModel; 43 | 44 | constructor() { 45 | this.reset(); 46 | } 47 | 48 | /** 49 | * Initialises an empty view model for the visual. 50 | */ 51 | reset() { 52 | this.viewModel = { 53 | isValid: false, 54 | isEmpty: true, 55 | hasCrossFiltering: false, 56 | hasGranularity: false, 57 | hasSelection: false, 58 | contentIndex: -1, 59 | htmlEntries: [] 60 | }; 61 | } 62 | 63 | /** 64 | * Checks that the supplied data view contains the correct combination of data roles and values, and sets the isValid flag 65 | * for the view model accordingly. 66 | * 67 | * @param dataViews - Data views from the visual's update method. 68 | */ 69 | validateDataView(dataViews: DataView[]) { 70 | const hasBasicDataView = 71 | (dataViews && 72 | dataViews[0] && 73 | dataViews[0].table && 74 | dataViews[0].metadata && 75 | dataViews[0].metadata.columns && 76 | true) || 77 | false; 78 | this.viewModel.contentIndex = hasBasicDataView 79 | ? this.getContentMetadataIndex(dataViews[0].metadata.columns) 80 | : -1; 81 | this.viewModel.isValid = 82 | hasBasicDataView && this.viewModel.contentIndex > -1; 83 | } 84 | 85 | /** 86 | * Maps a set of values from the data view and sets the necessary objects in the view model to handle them later on (including flags). 87 | * 88 | * @param dataViews - Data views from the visual's update method. 89 | * @param settings - Parsed visual settings. 90 | */ 91 | mapDataView( 92 | dataViews: DataView[], 93 | settings: VisualFormattingSettingsModel, 94 | host: IVisualHost 95 | ) { 96 | if (this.viewModel.isValid) { 97 | const hasGranularity = dataViews[0].table.columns.some( 98 | c => c.roles.sampling 99 | ); 100 | const hasCrossFiltering = 101 | hasGranularity && 102 | settings.crossFilter.crossFilterCardMain.enabled.value; 103 | const { columns, rows } = dataViews[0].table; 104 | const initialSelection = this.viewModel.htmlEntries; 105 | const hasSelection = 106 | (initialSelection.some(dp => dp.selected) && 107 | hasCrossFiltering) || 108 | false; 109 | const htmlEntries: IHtmlEntry[] = rows.map((row, index) => { 110 | const value = row[this.viewModel.contentIndex]; 111 | const selectionIdBuilder = host.createSelectionIdBuilder(); 112 | const identity = selectionIdBuilder 113 | .withTable(dataViews[0].table, index) 114 | .createSelectionId(); 115 | return { 116 | content: value ? value.toString() : '', 117 | identity, 118 | selected: this.isSelected(initialSelection, identity), 119 | tooltips: [ 120 | ...this.getTooltipData('sampling', columns, row, host), 121 | ...this.getTooltipData('tooltips', columns, row, host) 122 | ] 123 | }; 124 | }); 125 | this.viewModel.hasCrossFiltering = hasCrossFiltering; 126 | this.viewModel.hasGranularity = hasGranularity; 127 | this.viewModel.hasSelection = hasSelection; 128 | this.viewModel.contentFormatting = settings.contentFormatting; 129 | this.viewModel.htmlEntries = htmlEntries; 130 | this.viewModel.isEmpty = rows.length === 0; 131 | } 132 | } 133 | 134 | /** 135 | * Checks the supplied columns for the correct index of the content column, so that we can map it correctly later. 136 | * 137 | * @param columns - Array of metadata columns from the Power BI data view. 138 | */ 139 | private getContentMetadataIndex(columns: DataViewMetadataColumn[]) { 140 | return columns.findIndex(c => c.roles.content); 141 | } 142 | 143 | /** 144 | * For a data row, extract the columns that have been assigned to the 145 | * tooltips role and return their corresponding values. 146 | * 147 | * @param columns - Array of metadata columns from the Power BI data view. 148 | * @param row - Current table row from the data view. 149 | */ 150 | private getTooltipData( 151 | role: string, 152 | columns: DataViewMetadataColumn[], 153 | row: DataViewTableRow, 154 | host: IVisualHost 155 | ) { 156 | const tooltipValues: VisualTooltipDataItem[] = []; 157 | columns.forEach((c, i) => { 158 | const formatter = valueFormatter.create({ 159 | cultureSelector: host.locale, 160 | format: c.format 161 | }); 162 | if (c.roles[role]) { 163 | tooltipValues.push({ 164 | displayName: c.displayName, 165 | value: formatter.format(row[i]) 166 | }); 167 | } 168 | }); 169 | return tooltipValues; 170 | } 171 | 172 | /** 173 | * For an array of selectable data points, determine if the specificed selectionId is currently selected or not. 174 | * 175 | * @param initialSelection - all selectable data points to inspect 176 | * @param selectionId - selectionId to search for 177 | */ 178 | private isSelected( 179 | initialSelection: interactivitySelectionService.SelectableDataPoint[], 180 | selectionId: ISelectionId 181 | ): boolean { 182 | const selectedDataPoint = (initialSelection || []).find(dp => 183 | selectionId.equals(dp.identity) 184 | ); 185 | return selectedDataPoint ? selectedDataPoint.selected : false; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/visual-constants.ts: -------------------------------------------------------------------------------- 1 | // External dependencies 2 | import * as sanitizeHtml from 'sanitize-html'; 3 | // Internal dependencies 4 | import { visual } from '../pbiviz.json'; 5 | 6 | import { RenderFormat } from './types'; 7 | 8 | export const VisualConstants = { 9 | visual: visual, 10 | contentFormatting: { 11 | format: 'html', 12 | showRawHtml: false, 13 | font: { 14 | family: 15 | "'Segoe UI', wf_segoe-ui_normal, helvetica, arial, sans-serif", 16 | colour: '#000000', 17 | size: 11 18 | }, 19 | align: 'left', 20 | separation: 'none', 21 | hyperlinks: false, 22 | userSelect: false, 23 | noDataMessage: 'No data available to display' 24 | }, 25 | stylesheet: { 26 | stylesheet: '' 27 | }, 28 | crossFilter: { 29 | enabled: false, 30 | useTransparency: true, 31 | transparencyPercent: 70 32 | }, 33 | dom: { 34 | viewerIdSelector: 'htmlViewer', 35 | entryClassSelector: 'htmlViewerEntry', 36 | statusIdSelector: 'statusMessage', 37 | contentIdSelector: 'htmlContent', 38 | landingIdSelector: 'landingPage', 39 | landingPageClassPrefix: 'html-display', 40 | stylesheetIdSelector: 'visualUserStylesheet', 41 | rawOutputIdSelector: 'rawHtmlOutput', 42 | hoverClassSelector: 'hover', 43 | manualTooltipSelector: 'tooltipEnabled', 44 | manualTooltipDataPrefix: 'tooltip', 45 | manualTooltipDataTitle: 'Title', // Will be camel-cased by HTML data API 46 | manualTooltipDataValue: 'Value', // Will be camel-cased by HTML data API 47 | unselectedClassSelector: 'unselected' 48 | }, 49 | allowedSchemes: [], 50 | allowedSchemesByTag: <{ [index: string]: string[] }>{ 51 | a: ['http', 'https'], 52 | img: ['data'] 53 | }, 54 | allowedTags: [ 55 | ...sanitizeHtml.defaults.allowedTags, 56 | 'img', 57 | 'svg', 58 | 'animate', 59 | 'animatemotion', 60 | 'animatetransform', 61 | 'circle', 62 | 'clippath', 63 | 'defs', 64 | 'desc', 65 | 'ellipse', 66 | 'feblend', 67 | 'fecolormatrix', 68 | 'fecomponenttransfer', 69 | 'fecomposite', 70 | 'feconvolvematrix', 71 | 'fediffuselighting', 72 | 'fedisplacementmap', 73 | 'fedistantlight', 74 | 'fedropshadow', 75 | 'feflood', 76 | 'fefunca', 77 | 'fefuncb', 78 | 'fefuncg', 79 | 'fefuncr', 80 | 'fegaussianblur', 81 | 'feimage', 82 | 'femerge', 83 | 'femergemode', 84 | 'femorphology', 85 | 'feoffset', 86 | 'fepointlight', 87 | 'fespecularlighting', 88 | 'fespotlight', 89 | 'fetile', 90 | 'feturbulence', 91 | 'filter', 92 | 'g', 93 | 'image', 94 | 'line', 95 | 'lineargradient', 96 | 'marker', 97 | 'mask', 98 | 'metadata', 99 | 'path', 100 | 'pattern', 101 | 'polygon', 102 | 'polyline', 103 | 'radialgradient', 104 | 'rect', 105 | 'set', 106 | 'stop', 107 | 'style', 108 | 'symbol', 109 | 'text', 110 | 'textpath', 111 | 'title', 112 | 'tspan', 113 | 'view' 114 | ], 115 | scriptingPatterns: [ 116 | 'javascript', 117 | 'javas\x00script', 118 | 'javas\x07cript', 119 | 'javas\x0Dcript', 120 | 'javas\x0Acript', 121 | 'javas\x08cript' 122 | ] 123 | }; 124 | -------------------------------------------------------------------------------- /src/visual-settings.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Power BI Visualizations 3 | * 4 | * Copyright (c) Microsoft Corporation 5 | * All rights reserved. 6 | * MIT License 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy 9 | * of this software and associated documentation files (the ""Software""), to deal 10 | * in the Software without restriction, including without limitation the rights 11 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | * copies of the Software, and to permit persons to whom the Software is 13 | * furnished to do so, subject to the following conditions: 14 | * 15 | * The above copyright notice and this permission notice shall be included in 16 | * all copies or substantial portions of the Software. 17 | * 18 | * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | * THE SOFTWARE. 25 | */ 26 | 27 | 'use strict'; 28 | 29 | import { formattingSettings } from 'powerbi-visuals-utils-formattingmodel'; 30 | import FormattingSettingsCompositeCard = formattingSettings.CompositeCard; 31 | import FormattingSettingsGroup = formattingSettings.Group; 32 | import FormattingSettingsSlice = formattingSettings.Slice; 33 | import FormattingSettingsModel = formattingSettings.Model; 34 | import { VisualConstants } from './visual-constants'; 35 | import { IViewModel } from './view-model'; 36 | import { shouldUseStylesheet } from './domain-utils'; 37 | import { RenderFormat } from './types'; 38 | 39 | export class VisualFormattingSettingsModel extends FormattingSettingsModel { 40 | contentFormatting = new ContentFormattingSettings(); 41 | stylesheet = new StylesheetSettings(); 42 | crossFilter = new CrossFilterSettings(); 43 | cards = [this.contentFormatting, this.stylesheet, this.crossFilter]; 44 | handlePropertyVisibility(viewModel: IViewModel) { 45 | // Handle visibility of default body formatting properties if stylesheet is used 46 | if ( 47 | this.contentFormatting.contentFormattingCardBehavior.showRawHtml 48 | .value || 49 | shouldUseStylesheet(this.stylesheet) 50 | ) { 51 | this.contentFormatting.contentFormattingCardDefaultBodyStyling.visible = false; 52 | } else { 53 | this.contentFormatting.contentFormattingCardDefaultBodyStyling.visible = true; 54 | } 55 | // Cross-filtering properties 56 | if (viewModel.hasGranularity) { 57 | this.crossFilter.crossFilterCardMain.useTransparency.visible = this.crossFilter.crossFilterCardMain.enabled.value; 58 | this.crossFilter.crossFilterCardMain.transparencyPercent.visible = 59 | this.crossFilter.crossFilterCardMain.enabled.value && 60 | this.crossFilter.crossFilterCardMain.useTransparency.value; 61 | } else { 62 | this.crossFilter.visible = false; 63 | } 64 | } 65 | } 66 | 67 | export class ContentFormattingSettings extends FormattingSettingsCompositeCard { 68 | name = 'contentFormatting'; 69 | displayNameKey = 'Objects_ContentFormatting'; 70 | descriptionKey = 'Objects_ContentFormatting_Description'; 71 | contentFormattingCardBehavior = new ContentFormattingCardBehavior(Object()); 72 | contentFormattingCardNoData = new ContentFormattingCardNoData(Object()); 73 | contentFormattingCardDefaultBodyStyling = new ContentFormattingCardDefaultBodyStyling( 74 | Object() 75 | ); 76 | groups: Array = [ 77 | this.contentFormattingCardBehavior, 78 | this.contentFormattingCardNoData, 79 | this.contentFormattingCardDefaultBodyStyling 80 | ]; 81 | } 82 | 83 | class ContentFormattingCardBehavior extends FormattingSettingsGroup { 84 | name = 'contentFormatting-behavior'; 85 | displayNameKey = 'Objects_ContentFormatting_Behavior'; 86 | descriptionKey = 'Objects_ContentFormatting_Behavior_Description'; 87 | // Render format 88 | format = new formattingSettings.AutoDropdown({ 89 | name: 'format', 90 | displayNameKey: 'Objects_ContentFormatting_Format', 91 | descriptionKey: 'Objects_ContentFormatting_Format_Description', 92 | value: VisualConstants.contentFormatting.format 93 | }); 94 | // Whether to render as HTML or show raw code 95 | showRawHtml = new formattingSettings.ToggleSwitch({ 96 | name: 'showRawHtml', 97 | displayNameKey: 'Objects_ContentFormatting_ShowRawHTML', 98 | descriptionKey: 'Objects_ContentFormatting_ShowRawHTML_Description', 99 | value: false 100 | }); 101 | // Allow hyperlinks to be opened using the visual host 102 | hyperlinks = new formattingSettings.ToggleSwitch({ 103 | name: 'hyperlinks', 104 | displayNameKey: 'Objects_ContentFormatting_Hyperlinks', 105 | descriptionKey: 'Objects_ContentFormatting_Hyperlinks_Description', 106 | value: VisualConstants.contentFormatting.hyperlinks 107 | }); 108 | // Allow text select using the mouse rather than standard visual behavior 109 | userSelect = new formattingSettings.ToggleSwitch({ 110 | name: 'userSelect', 111 | displayNameKey: 'Objects_ContentFormatting_UserSelect', 112 | descriptionKey: 'Objects_ContentFormatting_UserSelect_Description', 113 | value: VisualConstants.contentFormatting.userSelect 114 | }); 115 | slices: Array = [ 116 | this.format, 117 | this.showRawHtml, 118 | this.hyperlinks, 119 | this.userSelect 120 | ]; 121 | } 122 | 123 | class ContentFormattingCardNoData extends FormattingSettingsGroup { 124 | name = 'contentFormatting-noData'; 125 | displayNameKey = 'Objects_ContentFormatting_NoDataMessage'; 126 | descriptionKey = 'Objects_ContentFormatting_NoDataMessage_Description'; 127 | // No data message 128 | noDataMessage = new formattingSettings.TextArea({ 129 | name: 'noDataMessage', 130 | value: VisualConstants.contentFormatting.noDataMessage, 131 | placeholder: ' ', 132 | selector: undefined, 133 | instanceKind: powerbi.VisualEnumerationInstanceKinds.ConstantOrRule 134 | }); 135 | slices: Array = [this.noDataMessage]; 136 | } 137 | 138 | class ContentFormattingCardDefaultBodyStyling extends FormattingSettingsGroup { 139 | name = 'contentFormatting-defaultBodyStyling'; 140 | displayNameKey = 'Objects_ContentFormatting_DefaultBodyStyling'; 141 | descriptionKey = 'Objects_ContentFormatting_DefaultBodyStyling_Description'; 142 | // Default font family; used if no explicity styling in HTML body 143 | fontFamily = new formattingSettings.FontPicker({ 144 | name: 'fontFamily', 145 | displayNameKey: 'Objects_ContentFormatting_FontFamily', 146 | descriptionKey: 'Objects_ContentFormatting_FontFamily_Description', 147 | value: VisualConstants.contentFormatting.font.family 148 | }); 149 | // Default font size; used if no explicity styling in HTML body 150 | fontSize = new formattingSettings.Slider({ 151 | name: 'fontSize', 152 | displayNameKey: 'Objects_ContentFormatting_FontSize', 153 | descriptionKey: 'Objects_ContentFormatting_FontSize_Description', 154 | value: VisualConstants.contentFormatting.font.size, 155 | options: { 156 | minValue: { value: 8, type: powerbi.visuals.ValidatorType.Min }, 157 | maxValue: { value: 32, type: powerbi.visuals.ValidatorType.Max }, 158 | unitSymbol: 'px' 159 | } 160 | }); 161 | // Default font color; used if no explicity styling in HTML body 162 | fontColour = new formattingSettings.ColorPicker({ 163 | name: 'fontColour', 164 | displayNameKey: 'Objects_ContentFormatting_FontColour', 165 | descriptionKey: 'Objects_ContentFormatting_FontColour_Description', 166 | value: { value: VisualConstants.contentFormatting.font.colour } 167 | }); 168 | // Default font size; used if no explicity styling in HTML body 169 | align = new formattingSettings.AlignmentGroup({ 170 | name: 'align', 171 | displayNameKey: 'Objects_ContentFormatting_Align', 172 | descriptionKey: 'Objects_ContentFormatting_Align_Description', 173 | value: VisualConstants.contentFormatting.align, 174 | mode: powerbi.visuals.AlignmentGroupMode.Horizonal 175 | }); 176 | slices: Array = [ 177 | this.fontFamily, 178 | this.fontSize, 179 | this.fontColour, 180 | this.align 181 | ]; 182 | } 183 | 184 | export class StylesheetSettings extends FormattingSettingsCompositeCard { 185 | name = 'stylesheet'; 186 | displayNameKey = 'Objects_Stylesheet'; 187 | descriptionKey = 'Objects_Stylesheet_Description'; 188 | stylesheetCardMain = new StylesheetCardMain(Object()); 189 | groups: Array = [this.stylesheetCardMain]; 190 | } 191 | 192 | class StylesheetCardMain extends FormattingSettingsGroup { 193 | name = 'stylesheet-main'; 194 | // Custom stylesheet for the HTML body 195 | stylesheet = new formattingSettings.TextArea({ 196 | name: 'stylesheet', 197 | placeholder: ' ', 198 | value: VisualConstants.stylesheet.stylesheet, 199 | selector: undefined, 200 | instanceKind: powerbi.VisualEnumerationInstanceKinds.ConstantOrRule 201 | }); 202 | slices: Array = [this.stylesheet]; 203 | } 204 | 205 | export class CrossFilterSettings extends FormattingSettingsCompositeCard { 206 | name = 'crossFilter'; 207 | displayNameKey = 'Objects_CrossFilter'; 208 | descriptionKey = 'Objects_CrossFilter_Description'; 209 | crossFilterCardMain = new CrossFilterCardMain(Object()); 210 | groups: Array = [this.crossFilterCardMain]; 211 | } 212 | 213 | class CrossFilterCardMain extends FormattingSettingsGroup { 214 | name = 'crossFilter-main'; 215 | // Whether to enable cross-filtering 216 | enabled = new formattingSettings.ToggleSwitch({ 217 | name: 'enabled', 218 | displayNameKey: 'Objects_CrossFilter_Enabled', 219 | descriptionKey: 'Objects_CrossFilter_Enabled_Description', 220 | value: VisualConstants.crossFilter.enabled 221 | }); 222 | // Whether to use transparency on non-selected items 223 | useTransparency = new formattingSettings.ToggleSwitch({ 224 | name: 'useTransparency', 225 | displayNameKey: 'Objects_CrossFilter_UseTransparency', 226 | descriptionKey: 'Objects_CrossFilter_UseTransparency_Description', 227 | value: VisualConstants.crossFilter.useTransparency 228 | }); 229 | // The percentage of transparency to apply to non-selected items (if using transparency) 230 | transparencyPercent = new formattingSettings.Slider({ 231 | name: 'transparencyPercent', 232 | displayNameKey: 'Objects_CrossFilter_TransparencyPercent', 233 | descriptionKey: 'Objects_CrossFilter_TransparencyPercent_Description', 234 | value: VisualConstants.crossFilter.transparencyPercent, 235 | options: { 236 | minValue: { value: 0, type: powerbi.visuals.ValidatorType.Min }, 237 | maxValue: { value: 100, type: powerbi.visuals.ValidatorType.Max }, 238 | unitSymbol: '%' 239 | } 240 | }); 241 | slices: Array = [ 242 | this.enabled, 243 | this.useTransparency, 244 | this.transparencyPercent 245 | ]; 246 | } 247 | -------------------------------------------------------------------------------- /src/visual.ts: -------------------------------------------------------------------------------- 1 | // Power BI API Dependencies 2 | import './../style/visual.less'; 3 | import 'overlayscrollbars/css/OverlayScrollbars.css'; 4 | import 'w3-css/w3.css'; 5 | import powerbi from 'powerbi-visuals-api'; 6 | import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructorOptions; 7 | import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions; 8 | import IVisual = powerbi.extensibility.visual.IVisual; 9 | import IVisualHost = powerbi.extensibility.visual.IVisualHost; 10 | import IVisualEventService = powerbi.extensibility.IVisualEventService; 11 | import ILocalizationManager = powerbi.extensibility.ILocalizationManager; 12 | import { 13 | interactivitySelectionService, 14 | interactivityBaseService 15 | } from 'powerbi-visuals-utils-interactivityutils'; 16 | import IInteractivityService = interactivityBaseService.IInteractivityService; 17 | import SelectableDataPoint = interactivitySelectionService.SelectableDataPoint; 18 | import { FormattingSettingsService } from 'powerbi-visuals-utils-formattingmodel'; 19 | 20 | // External dependencies 21 | import { select, Selection } from 'd3-selection'; 22 | 23 | // Internal Dependencies 24 | import { VisualFormattingSettingsModel } from './visual-settings'; 25 | import { VisualConstants } from './visual-constants'; 26 | import { ViewModelHandler } from './view-model'; 27 | import { 28 | bindVisualDataToDom, 29 | getParsedHtmlAsDom, 30 | resolveForRawHtml, 31 | resolveHtmlGroupElement, 32 | resolveHyperlinkHandling, 33 | resolveScrollableContent, 34 | resolveStyling, 35 | resolveHover 36 | } from './domain-utils'; 37 | import LandingPageHandler from './landing-page-handler'; 38 | import { BehaviorManager, IHtmlBehaviorOptions } from './behavior'; 39 | import { RenderFormat } from './types'; 40 | 41 | export class Visual implements IVisual { 42 | // The root element for the entire visual 43 | private container: Selection; 44 | // Used for displaying landing page 45 | private landingContainer: Selection; 46 | // Used for handling issues in the visual 47 | private statusContainer: Selection; 48 | // Used for HTML content from data model 49 | private contentContainer: Selection; 50 | // Visual host services 51 | private host: IVisualHost; 52 | // Parsed visual settings 53 | private formattingSettings: VisualFormattingSettingsModel; 54 | // Formatting settings service 55 | private formattingSettingsService: FormattingSettingsService; 56 | // Handle rendering events 57 | private events: IVisualEventService; 58 | // Handle localisation of visual text 59 | private localisationManager: ILocalizationManager; 60 | // Visual view model 61 | private viewModelHandler: ViewModelHandler; 62 | // Handles landing page 63 | private landingPageHandler: LandingPageHandler; 64 | // Manages custom styling from the user 65 | private styleSheetContainer: Selection; 66 | // Interactivity for data points 67 | private interactivity: IInteractivityService; 68 | // Behavior of data points 69 | private behavior: BehaviorManager; 70 | // Flag whether the user clicked into the visual or not (for focus management) 71 | private bodyFocusedWithClick = false; 72 | 73 | // Runs when the visual is initialised 74 | constructor(options: VisualConstructorOptions) { 75 | this.container = select(options.element) 76 | .append('div') 77 | .attr('id', VisualConstants.dom.viewerIdSelector); 78 | this.host = options.host; 79 | this.viewModelHandler = new ViewModelHandler(); 80 | this.localisationManager = this.host.createLocalizationManager(); 81 | this.interactivity = interactivitySelectionService.createInteractivitySelectionService( 82 | this.host 83 | ); 84 | this.behavior = new BehaviorManager(); 85 | this.styleSheetContainer = select('head') 86 | .append('style') 87 | .attr('id', VisualConstants.dom.stylesheetIdSelector) 88 | .attr('name', VisualConstants.dom.stylesheetIdSelector) 89 | .attr('type', 'text/css'); 90 | this.landingContainer = this.container 91 | .append('div') 92 | .attr('id', VisualConstants.dom.landingIdSelector); 93 | this.statusContainer = this.container 94 | .append('div') 95 | .attr('id', VisualConstants.dom.statusIdSelector); 96 | this.contentContainer = this.container 97 | .append('div') 98 | .attr('tabindex', 0) 99 | .attr('id', VisualConstants.dom.contentIdSelector); 100 | this.formattingSettingsService = new FormattingSettingsService( 101 | this.localisationManager 102 | ); 103 | this.landingPageHandler = new LandingPageHandler( 104 | this.landingContainer, 105 | this.localisationManager 106 | ); 107 | this.bindFocusEvents(); 108 | this.events = this.host.eventService; 109 | this.viewModelHandler.reset(); 110 | } 111 | 112 | /** 113 | * Returns properties pane formatting model content hierarchies, properties and latest formatting values, Then populate properties pane. 114 | * This method is called once every time we open properties pane or when the user edit any format property. 115 | */ 116 | public getFormattingModel(): powerbi.visuals.FormattingModel { 117 | return this.formattingSettingsService.buildFormattingModel( 118 | this.formattingSettings 119 | ); 120 | } 121 | 122 | /** 123 | * Runs when data roles added or something changes 124 | */ 125 | public update(options: VisualUpdateOptions) { 126 | const { viewModel } = this.viewModelHandler; 127 | // Parse the settings for use in the visual 128 | this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel( 129 | VisualFormattingSettingsModel, 130 | options.dataViews?.[0] 131 | ); 132 | 133 | // Handle main update flow 134 | try { 135 | // Signal we've begun rendering 136 | this.events.renderingStarted(options); 137 | this.updateStatus(); 138 | this.contentContainer.selectAll('*').remove(); 139 | 140 | // If new data, we need to re-map it 141 | if ( 142 | powerbi.VisualUpdateType.Data === 143 | (options.type & powerbi.VisualUpdateType.Data) 144 | ) { 145 | this.updateStatus( 146 | this.localisationManager.getDisplayName( 147 | 'Status_Mapping_DataView' 148 | ) 149 | ); 150 | this.viewModelHandler.validateDataView(options.dataViews); 151 | viewModel.isValid && 152 | this.viewModelHandler.mapDataView( 153 | options.dataViews, 154 | this.formattingSettings, 155 | this.host 156 | ); 157 | this.updateStatus(); 158 | } 159 | this.formattingSettings.handlePropertyVisibility(viewModel); 160 | 161 | this.landingPageHandler.handleLandingPage( 162 | this.viewModelHandler.viewModel.isValid, 163 | this.host 164 | ); 165 | 166 | // Do checks on potential outcomes and handle accordingly 167 | if (!viewModel.isValid) { 168 | throw new Error('View model mapping error'); 169 | } 170 | resolveStyling( 171 | this.styleSheetContainer, 172 | this.container, 173 | this.formattingSettings 174 | ); 175 | if (viewModel.isEmpty) { 176 | this.updateStatus( 177 | this.formattingSettings.contentFormatting 178 | .contentFormattingCardNoData.noDataMessage.value, 179 | viewModel.contentFormatting.contentFormattingCardBehavior 180 | .showRawHtml.value 181 | ); 182 | } else { 183 | const dataElements = bindVisualDataToDom( 184 | this.contentContainer, 185 | viewModel.htmlEntries, 186 | viewModel.hasSelection 187 | ); 188 | resolveHtmlGroupElement( 189 | dataElements, 190 | this.formattingSettings.contentFormatting 191 | .contentFormattingCardBehavior.format 192 | .value as RenderFormat 193 | ); 194 | resolveForRawHtml( 195 | this.styleSheetContainer, 196 | this.contentContainer, 197 | this.formattingSettings 198 | ); 199 | if (this.host.hostCapabilities.allowInteractions) { 200 | this.interactivity.bind(< 201 | IHtmlBehaviorOptions 202 | >{ 203 | behavior: this.behavior, 204 | dataPoints: viewModel.htmlEntries, 205 | clearCatcherSelection: this.container, 206 | pointSelection: dataElements, 207 | viewModel 208 | }); 209 | } 210 | resolveHover(dataElements, this.host, viewModel.hasGranularity); 211 | } 212 | resolveHyperlinkHandling( 213 | this.host, 214 | this.container, 215 | viewModel.contentFormatting.contentFormattingCardBehavior 216 | .hyperlinks.value 217 | ); 218 | resolveScrollableContent(this.container.node()); 219 | 220 | // Signal that we've finished rendering 221 | this.events.renderingFinished(options); 222 | return; 223 | } catch (e) { 224 | // Signal that we've encountered an error 225 | this.events.renderingFailed(options, e); 226 | this.contentContainer.selectAll('*').remove(); 227 | this.updateStatus(); 228 | } 229 | } 230 | 231 | /** 232 | * Ensure that when the user navigates to the visual using Power BI-supported keyboard shortcuts, the visual is focused accordingly. If 233 | * the user clicks on the body of the page, we should behave as normal. 234 | */ 235 | private bindFocusEvents() { 236 | document.body.onmousedown = () => { 237 | this.bodyFocusedWithClick = true; 238 | }; 239 | document.body.onfocus = () => { 240 | if (!this.bodyFocusedWithClick) { 241 | this.contentContainer.node().focus(); 242 | } 243 | this.bodyFocusedWithClick = false; 244 | }; 245 | } 246 | 247 | /** 248 | * Generic function to manage update of text within status container. 249 | * 250 | * @param message - Simple message to display. Omit to remove current content. 251 | * @param showRawHtml - Flag to confirm whether we should show Raw HTML or not 252 | */ 253 | private updateStatus(message?: string, showRawHtml?: boolean) { 254 | this.statusContainer.selectAll('*').remove(); 255 | if (message) { 256 | this.statusContainer.append('div').append(function() { 257 | return this.appendChild(getParsedHtmlAsDom(message, 'html')); 258 | }); 259 | } 260 | if (showRawHtml) { 261 | resolveForRawHtml( 262 | this.styleSheetContainer, 263 | this.statusContainer, 264 | this.formattingSettings 265 | ); 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /stringResources/en-US/resources.resjson: -------------------------------------------------------------------------------- 1 | { 2 | "Landing_Page_Overview_1": "Thanks very much for using this visual!", 3 | "Landing_Page_Overview_2": "To get started you simply need to add a measure or field that contains your HTML to the Values field in the properties pane, and the visual will attempt to render it for you.", 4 | "Landing_Page_Overview_3": "Please note that there are some limitations to this, due to the security limitations imposed upon Power BI custom visuals.", 5 | "Landing_Page_Overview_4": "You can visit the visual's home page by clicking the question mark icon in the top-right of this page.", 6 | "Roles_Values": "Values", 7 | "Roles_Values_Description": "Field values or measures containing the desired HTML data to render.", 8 | "Roles_Sampling": "Granularity", 9 | "Roles_Sampling_Description": "Because some results can aggregate to a single value, you can add a grouping value to this field to ensure that all discrete values are displayed in the visual.", 10 | "Objects_ContentFormatting": "Content formatting", 11 | "Objects_ContentFormatting_Description": "HTML content formatting options.", 12 | "Objects_ContentFormatting_Behavior": "Behavior", 13 | "Objects_ContentFormatting_Behavior_Description": "Options to control the behavior of the rendered HTML.", 14 | "Objects_ContentFormatting_DefaultBodyStyling": "Default body styling", 15 | "Objects_ContentFormatting_DefaultBodyStyling_Description": "Options to control the default styling of the rendered HTML body.", 16 | "Objects_ContentFormatting_Separation": "Value separation method", 17 | "Objects_ContentFormatting_Separation_Description": "Options to separate multiple values in the rendered output.", 18 | "Objects_ContentFormatting_Format": "Renderer", 19 | "Objects_ContentFormatting_Format_Description": "The renderer to apply to supplied content. HTML is the default, but you can also use Github Flavored Markdown. Note that detected HTML will be subject to the same sanitization rules if you are using using HTML Content (lite).", 20 | "Objects_ContentFormatting_ShowRawHTML": "Show raw HTML", 21 | "Objects_ContentFormatting_ShowRawHTML_Description": "Display the data as raw HTML, rather than rendered HTML.", 22 | "Objects_ContentFormatting_FontFamily": "Font family", 23 | "Objects_ContentFormatting_FontFamily_Description": "The default font family to apply to the HTML body text.\n\n𝙉𝙤𝙩𝙚 𝘵𝘩𝘢𝘵 𝘢𝘱𝘱𝘭𝘺𝘪𝘯𝘨 𝘪𝘯𝘭𝘪𝘯𝘦 𝘴𝘵𝘺𝘭𝘦𝘴 𝘵𝘰 𝘦𝘭𝘦𝘮𝘦𝘯𝘵𝘴 𝘮𝘢𝘺 𝘰𝘷𝘦𝘳𝘳𝘪𝘥𝘦 𝘵𝘩𝘪𝘴.", 24 | "Objects_ContentFormatting_FontSize": "Font size", 25 | "Objects_ContentFormatting_FontSize_Description": "Thie default size to apply to the HTML body text font.\n\n𝙉𝙤𝙩𝙚 𝘵𝘩𝘢𝘵 𝘢𝘱𝘱𝘭𝘺𝘪𝘯𝘨 𝘪𝘯𝘭𝘪𝘯𝘦 𝘴𝘵𝘺𝘭𝘦𝘴 𝘵𝘰 𝘦𝘭𝘦𝘮𝘦𝘯𝘵𝘴 𝘮𝘢𝘺 𝘰𝘷𝘦𝘳𝘳𝘪𝘥𝘦 𝘵𝘩𝘪𝘴.", 26 | "Objects_ContentFormatting_FontColour": "Font color", 27 | "Objects_ContentFormatting_FontColour_Description": "The default color to apply to the HTML body text.\n\n𝙉𝙤𝙩𝙚 𝘵𝘩𝘢𝘵 𝘢𝘱𝘱𝘭𝘺𝘪𝘯𝘨 𝘪𝘯𝘭𝘪𝘯𝘦 𝘴𝘵𝘺𝘭𝘦𝘴 𝘵𝘰 𝘦𝘭𝘦𝘮𝘦𝘯𝘵𝘴 𝘮𝘢𝘺 𝘰𝘷𝘦𝘳𝘳𝘪𝘥𝘦 𝘵𝘩𝘪𝘴.", 28 | "Objects_ContentFormatting_Align": "Text alignment", 29 | "Objects_ContentFormatting_Alig_Description": "The default text alignment to apply to the the HTML Body.\n\n𝙉𝙤𝙩𝙚 𝘵𝘩𝘢𝘵 𝘢𝘱𝘱𝘭𝘺𝘪𝘯𝘨 𝘪𝘯𝘭𝘪𝘯𝘦 𝘴𝘵𝘺𝘭𝘦𝘴 𝘵𝘰 𝘦𝘭𝘦𝘮𝘦𝘯𝘵𝘴 𝘮𝘢𝘺 𝘰𝘷𝘦𝘳𝘳𝘪𝘥𝘦 𝘵𝘩𝘪𝘴.", 30 | "Objects_ContentFormatting_Hyperlinks": "Allow opening URLs", 31 | "Objects_ContentFormatting_Hyperlinks_Description": "For security reasons, custom visuals cannot directly open hyperlinks that redirect to a URL.\n\nBy setting this option, the visual will 𝗱𝗲𝗹𝗲𝗴𝗮𝘁𝗲 𝘁𝗵𝗲 𝘂𝘀𝗲𝗿'𝘀 𝗿𝗲𝗾𝘂𝗲𝘀𝘁 𝘁𝗼 𝗻𝗮𝘃𝗶𝗴𝗮𝘁𝗲 𝘁𝗼 𝗮 𝗨𝗥𝗟 𝘁𝗼 𝗣𝗼𝘄𝗲𝗿 𝗕𝗜, which if permitted, will prompt the user for their approval for any remote navigation.", 32 | "Objects_ContentFormatting_UserSelect": "Allow text select", 33 | "Objects_ContentFormatting_UserSelect_Description": "If enabled, you can use the mouse to select text within the visual container. You can copy this to the clipboard with Ctrl + C, or your operating system's default.", 34 | "Objects_ContentFormatting_NoDataMessage": "\"No Data\" message", 35 | "Objects_ContentFormatting_NoDataMessage_Description": "If your visual contains no data, you are able to specify the message that is displayed. You can assign this a measure containing HTML using conditional formatting, if you so wish.", 36 | "Objects_Stylesheet": "Stylesheet", 37 | "Objects_Stylesheet_Description": "Here you can use conditional formatting to apply valid CSS for the entire visual's stylesheet using a measure with the desired content.", 38 | "Objects_CrossFilter": "Cross-filtering", 39 | "Objects_CrossFilter_Description": "If enabled, this will invoke cross-filtering of other visuals, based on the Granularity of your dataset.", 40 | "Objects_CrossFilter_Enabled": "Enable", 41 | "Objects_CrossFilter_Enabled_Description": "Enable cross-filtering of other visuals, if Granularity if provided.", 42 | "Objects_CrossFilter_UseTransparency": "Set transparency of unselected items", 43 | "Objects_CrossFilter_UseTransparency_Description": "If enabled, this will set the use transparency to indicate non-selected items. Alternatively, you can manually set the styling of non-selected items by using the .𝚑𝚝𝚖𝚕𝚅𝚒𝚎𝚠𝚎𝚛𝙴𝚗𝚝𝚛𝚢.𝚞𝚗𝚜𝚎𝚕𝚎𝚌𝚝𝚎𝚍 CSS class in your stylesheet.", 44 | "Objects_CrossFilter_TransparencyPercent": "Transparency", 45 | "Objects_CrossFilter_TransparencyPercent_Description": "The transparency percentage to apply to unselected items.", 46 | "Status_Mapping_DataView": "Parsing HTML...", 47 | "Status_Invalid_View_Model": "No data available to display.", 48 | "Status_No_Data": "No data available to display.", 49 | "Enum_Separation_None": "None", 50 | "Enum_Separation_HR": "Horizontal Rule (
)", 51 | "Enum_RenderFormat_HTML": "HTML", 52 | "Enum_RenderFormat_Markdown": "Markdown" 53 | } 54 | -------------------------------------------------------------------------------- /style/visual.less: -------------------------------------------------------------------------------- 1 | #htmlViewer { 2 | width: 100vw; 3 | height: 100vh; 4 | overflow: auto; 5 | 6 | li { 7 | list-style: unset; 8 | } 9 | } 10 | 11 | #rawHtmlOutput { 12 | height: 97vh; 13 | width: 100vw; 14 | resize: none; 15 | font-family: monospace; 16 | font-size: 10pt; 17 | } 18 | 19 | .w3-theme-l5 { 20 | color: #000 !important; 21 | background-color: #fcfcfc !important; 22 | } 23 | .w3-theme-l4 { 24 | color: #000 !important; 25 | background-color: #f6f6f6 !important; 26 | } 27 | .w3-theme-l3 { 28 | color: #000 !important; 29 | background-color: #eceded !important; 30 | } 31 | .w3-theme-l2 { 32 | color: #000 !important; 33 | background-color: #e3e4e4 !important; 34 | } 35 | .w3-theme-l1 { 36 | color: #000 !important; 37 | background-color: #d9dbdb !important; 38 | } 39 | .w3-theme-d1 { 40 | color: #000 !important; 41 | background-color: #babdbe !important; 42 | } 43 | .w3-theme-d2 { 44 | color: #000 !important; 45 | background-color: #a5a8aa !important; 46 | } 47 | .w3-theme-d3 { 48 | color: #fff !important; 49 | background-color: #8f9396 !important; 50 | } 51 | .w3-theme-d4 { 52 | color: #fff !important; 53 | background-color: #7a7f81 !important; 54 | } 55 | .w3-theme-d5 { 56 | color: #fff !important; 57 | background-color: #656a6c !important; 58 | } 59 | 60 | .w3-theme-light { 61 | color: #000 !important; 62 | background-color: #fcfcfc !important; 63 | } 64 | .w3-theme-dark { 65 | color: #fff !important; 66 | background-color: #656a6c !important; 67 | } 68 | .w3-theme-action { 69 | color: #fff !important; 70 | background-color: #656a6c !important; 71 | } 72 | 73 | .w3-theme { 74 | color: #000 !important; 75 | background-color: #d0d2d3 !important; 76 | } 77 | .w3-text-theme { 78 | color: #d0d2d3 !important; 79 | } 80 | .w3-border-theme { 81 | border-color: #d0d2d3 !important; 82 | } 83 | 84 | .w3-hover-theme:hover { 85 | color: #000 !important; 86 | background-color: #d0d2d3 !important; 87 | } 88 | .w3-hover-text-theme:hover { 89 | color: #d0d2d3 !important; 90 | } 91 | .w3-hover-border-theme:hover { 92 | border-color: #d0d2d3 !important; 93 | } 94 | 95 | .html-display-landing-page { 96 | width: 100vw; 97 | height: 100vh; 98 | overflow: auto; 99 | } 100 | 101 | .html-display-watermark { 102 | background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iYmFja2dyb3VuZF9ncmV5c2NhbGUuc3ZnIgogICBpZD0ic3ZnMjAiCiAgIHZlcnNpb249IjEuMSIKICAgdmlld0JveD0iMCAwIDUxMiA1MTIiPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTI2Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT5IVE1MNSBMb2dvIEJhZGdlPC9kYzp0aXRsZT4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczI0Ij4KICAgIDxmaWx0ZXIKICAgICAgIGlkPSJmaWx0ZXI1ODQiCiAgICAgICBzdHlsZT0iY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzOnNSR0I7IgogICAgICAgaW5rc2NhcGU6bGFiZWw9IkdyZXlzY2FsZSI+CiAgICAgIDxmZUNvbG9yTWF0cml4CiAgICAgICAgIGlkPSJmZUNvbG9yTWF0cml4NTgwIgogICAgICAgICByZXN1bHQ9ImNvbG9ybWF0cml4IgogICAgICAgICBpbj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgdmFsdWVzPSIxIDAgMCAwIDAgMCAxIDAgMCAwIDAgMCAxIDAgMCAtMSAwLjUgMC41IDAuODkgMCAiIC8+CiAgICAgIDxmZUNvbXBvc2l0ZQogICAgICAgICBpZD0iZmVDb21wb3NpdGU1ODIiCiAgICAgICAgIHJlc3VsdD0iZmJTb3VyY2VHcmFwaGljIgogICAgICAgICBvcGVyYXRvcj0iaW4iCiAgICAgICAgIGluMj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgaW49ImNvbG9ybWF0cml4IiAvPgogICAgICA8ZmVDb2xvck1hdHJpeAogICAgICAgICBpZD0iZmVDb2xvck1hdHJpeDc2NCIKICAgICAgICAgdmFsdWVzPSIwIDAgMCAtMSAwIDAgMCAwIC0xIDAgMCAwIDAgLTEgMCAwIDAgMCAxIDAiCiAgICAgICAgIGluPSJmYlNvdXJjZUdyYXBoaWMiCiAgICAgICAgIHJlc3VsdD0iZmJTb3VyY2VHcmFwaGljQWxwaGEiIC8+CiAgICAgIDxmZUNvbG9yTWF0cml4CiAgICAgICAgIGluPSJmYlNvdXJjZUdyYXBoaWMiCiAgICAgICAgIHZhbHVlcz0iMC4yMSAwLjcyIDAuMDcyIDEuNiAwIDAuMjEgMC43MiAwLjA3MiAxLjYgMCAwLjIxIDAuNzIgMC4wNzIgMS42IDAgMCAwIDAgMSAwICIKICAgICAgICAgaWQ9ImZlQ29sb3JNYXRyaXg3NjYiIC8+CiAgICA8L2ZpbHRlcj4KICAgIDxmaWx0ZXIKICAgICAgIGlkPSJmaWx0ZXI1OTAiCiAgICAgICBzdHlsZT0iY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzOnNSR0I7IgogICAgICAgaW5rc2NhcGU6bGFiZWw9IkdyZXlzY2FsZSI+CiAgICAgIDxmZUNvbG9yTWF0cml4CiAgICAgICAgIGlkPSJmZUNvbG9yTWF0cml4NTg2IgogICAgICAgICByZXN1bHQ9ImNvbG9ybWF0cml4IgogICAgICAgICBpbj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgdmFsdWVzPSIxIDAgMCAwIDAgMCAxIDAgMCAwIDAgMCAxIDAgMCAtMSAwLjUgMC41IDAuODkgMCAiIC8+CiAgICAgIDxmZUNvbXBvc2l0ZQogICAgICAgICBpZD0iZmVDb21wb3NpdGU1ODgiCiAgICAgICAgIHJlc3VsdD0iZmJTb3VyY2VHcmFwaGljIgogICAgICAgICBvcGVyYXRvcj0iaW4iCiAgICAgICAgIGluMj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgaW49ImNvbG9ybWF0cml4IiAvPgogICAgICA8ZmVDb2xvck1hdHJpeAogICAgICAgICBpZD0iZmVDb2xvck1hdHJpeDc2OCIKICAgICAgICAgdmFsdWVzPSIwIDAgMCAtMSAwIDAgMCAwIC0xIDAgMCAwIDAgLTEgMCAwIDAgMCAxIDAiCiAgICAgICAgIGluPSJmYlNvdXJjZUdyYXBoaWMiCiAgICAgICAgIHJlc3VsdD0iZmJTb3VyY2VHcmFwaGljQWxwaGEiIC8+CiAgICAgIDxmZUNvbG9yTWF0cml4CiAgICAgICAgIGluPSJmYlNvdXJjZUdyYXBoaWMiCiAgICAgICAgIHZhbHVlcz0iMC4yMSAwLjcyIDAuMDcyIDEuNiAwIDAuMjEgMC43MiAwLjA3MiAxLjYgMCAwLjIxIDAuNzIgMC4wNzIgMS42IDAgMCAwIDAgMSAwICIKICAgICAgICAgaWQ9ImZlQ29sb3JNYXRyaXg3NzAiIC8+CiAgICA8L2ZpbHRlcj4KICAgIDxmaWx0ZXIKICAgICAgIGlkPSJmaWx0ZXI1OTYiCiAgICAgICBzdHlsZT0iY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzOnNSR0I7IgogICAgICAgaW5rc2NhcGU6bGFiZWw9IkdyZXlzY2FsZSI+CiAgICAgIDxmZUNvbG9yTWF0cml4CiAgICAgICAgIGlkPSJmZUNvbG9yTWF0cml4NTkyIgogICAgICAgICByZXN1bHQ9ImNvbG9ybWF0cml4IgogICAgICAgICBpbj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgdmFsdWVzPSIxIDAgMCAwIDAgMCAxIDAgMCAwIDAgMCAxIDAgMCAtMSAwLjUgMC41IDAuODkgMCAiIC8+CiAgICAgIDxmZUNvbXBvc2l0ZQogICAgICAgICBpZD0iZmVDb21wb3NpdGU1OTQiCiAgICAgICAgIHJlc3VsdD0iZmJTb3VyY2VHcmFwaGljIgogICAgICAgICBvcGVyYXRvcj0iaW4iCiAgICAgICAgIGluMj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgaW49ImNvbG9ybWF0cml4IiAvPgogICAgICA8ZmVDb2xvck1hdHJpeAogICAgICAgICBpZD0iZmVDb2xvck1hdHJpeDc3MiIKICAgICAgICAgdmFsdWVzPSIwIDAgMCAtMSAwIDAgMCAwIC0xIDAgMCAwIDAgLTEgMCAwIDAgMCAxIDAiCiAgICAgICAgIGluPSJmYlNvdXJjZUdyYXBoaWMiCiAgICAgICAgIHJlc3VsdD0iZmJTb3VyY2VHcmFwaGljQWxwaGEiIC8+CiAgICAgIDxmZUNvbG9yTWF0cml4CiAgICAgICAgIGluPSJmYlNvdXJjZUdyYXBoaWMiCiAgICAgICAgIHZhbHVlcz0iMC4yMSAwLjcyIDAuMDcyIDEuNiAwIDAuMjEgMC43MiAwLjA3MiAxLjYgMCAwLjIxIDAuNzIgMC4wNzIgMS42IDAgMCAwIDAgMSAwICIKICAgICAgICAgaWQ9ImZlQ29sb3JNYXRyaXg3NzQiIC8+CiAgICA8L2ZpbHRlcj4KICAgIDxmaWx0ZXIKICAgICAgIGlkPSJmaWx0ZXI2MDIiCiAgICAgICBzdHlsZT0iY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzOnNSR0I7IgogICAgICAgaW5rc2NhcGU6bGFiZWw9IkdyZXlzY2FsZSI+CiAgICAgIDxmZUNvbG9yTWF0cml4CiAgICAgICAgIGlkPSJmZUNvbG9yTWF0cml4NTk4IgogICAgICAgICByZXN1bHQ9ImNvbG9ybWF0cml4IgogICAgICAgICBpbj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgdmFsdWVzPSIxIDAgMCAwIDAgMCAxIDAgMCAwIDAgMCAxIDAgMCAtMSAwLjUgMC41IDAuODkgMCAiIC8+CiAgICAgIDxmZUNvbXBvc2l0ZQogICAgICAgICBpZD0iZmVDb21wb3NpdGU2MDAiCiAgICAgICAgIHJlc3VsdD0iZmJTb3VyY2VHcmFwaGljIgogICAgICAgICBvcGVyYXRvcj0iaW4iCiAgICAgICAgIGluMj0iU291cmNlR3JhcGhpYyIKICAgICAgICAgaW49ImNvbG9ybWF0cml4IiAvPgogICAgICA8ZmVDb2xvck1hdHJpeAogICAgICAgICBpZD0iZmVDb2xvck1hdHJpeDc3NiIKICAgICAgICAgdmFsdWVzPSIwIDAgMCAtMSAwIDAgMCAwIC0xIDAgMCAwIDAgLTEgMCAwIDAgMCAxIDAiCiAgICAgICAgIGluPSJmYlNvdXJjZUdyYXBoaWMiCiAgICAgICAgIHJlc3VsdD0iZmJTb3VyY2VHcmFwaGljQWxwaGEiIC8+CiAgICAgIDxmZUNvbG9yTWF0cml4CiAgICAgICAgIGluPSJmYlNvdXJjZUdyYXBoaWMiCiAgICAgICAgIHZhbHVlcz0iMC4yMSAwLjcyIDAuMDcyIDEuNiAwIDAuMjEgMC43MiAwLjA3MiAxLjYgMCAwLjIxIDAuNzIgMC4wNzIgMS42IDAgMCAwIDAgMSAwICIKICAgICAgICAgaWQ9ImZlQ29sb3JNYXRyaXg3NzgiIC8+CiAgICA8L2ZpbHRlcj4KICA8L2RlZnM+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzIwIgogICAgIGlua3NjYXBlOndpbmRvdy1tYXhpbWl6ZWQ9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LXk9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9IjM4NDAiCiAgICAgaW5rc2NhcGU6Y3k9IjI1NiIKICAgICBpbmtzY2FwZTpjeD0iMjU2IgogICAgIGlua3NjYXBlOnpvb209IjEuMzU5Mzc1IgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3MjIiCiAgICAgaW5rc2NhcGU6d2luZG93LWhlaWdodD0iMjA4MSIKICAgICBpbmtzY2FwZTp3aW5kb3ctd2lkdGg9IjE5MDQiCiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMCIKICAgICBndWlkZXRvbGVyYW5jZT0iMTAiCiAgICAgZ3JpZHRvbGVyYW5jZT0iMTAiCiAgICAgb2JqZWN0dG9sZXJhbmNlPSIxMCIKICAgICBib3JkZXJvcGFjaXR5PSIxIgogICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IgogICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIgLz4KICA8dGl0bGUKICAgICBpZD0idGl0bGUxMCI+SFRNTDUgTG9nbyBCYWRnZTwvdGl0bGU+CiAgPHBhdGgKICAgICBzdHlsZT0iZmlsdGVyOnVybCgjZmlsdGVyNjAyKSIKICAgICBpZD0icGF0aDEyIgogICAgIGQ9Ik03MSw0NjAgTDMwLDAgNDgxLDAgNDQwLDQ2MCAyNTUsNTEyIgogICAgIGZpbGw9IiNFMzRGMjYiIC8+CiAgPHBhdGgKICAgICBzdHlsZT0iZmlsdGVyOnVybCgjZmlsdGVyNTk2KSIKICAgICBpZD0icGF0aDE0IgogICAgIGQ9Ik0yNTYsNDcyIEw0MDUsNDMxIDQ0MCwzNyAyNTYsMzciCiAgICAgZmlsbD0iI0VGNjUyQSIgLz4KICA8cGF0aAogICAgIHN0eWxlPSJmaWx0ZXI6dXJsKCNmaWx0ZXI1OTApIgogICAgIGlkPSJwYXRoMTYiCiAgICAgZD0iTTI1NiwyMDggTDE4MSwyMDggMTc2LDE1MCAyNTYsMTUwIDI1Niw5NCAyNTUsOTQgMTE0LDk0IDExNSwxMDkgMTI5LDI2NSAyNTYsMjY1ek0yNTYsMzU1IEwyNTUsMzU1IDE5MiwzMzggMTg4LDI5MyAxNTgsMjkzIDEzMiwyOTMgMTM5LDM4MiAyNTUsNDE0IDI1Niw0MTR6IgogICAgIGZpbGw9IiNFQkVCRUIiIC8+CiAgPHBhdGgKICAgICBzdHlsZT0iZmlsdGVyOnVybCgjZmlsdGVyNTg0KSIKICAgICBpZD0icGF0aDE4IgogICAgIGQ9Ik0yNTUsMjA4IEwyNTUsMjY1IDMyNSwyNjUgMzE4LDMzOCAyNTUsMzU1IDI1NSw0MTQgMzcxLDM4MiAzNzIsMzcyIDM4NSwyMjMgMzg3LDIwOCAzNzEsMjA4ek0yNTUsOTQgTDI1NSwxMjkgMjU1LDE1MCAyNTUsMTUwIDM5MiwxNTAgMzkyLDE1MCAzOTIsMTUwIDM5MywxMzggMzk2LDEwOSAzOTcsOTR6IgogICAgIGZpbGw9IiNGRkYiIC8+Cjwvc3ZnPgo='); 103 | background-repeat: no-repeat; 104 | background-position: center; 105 | background-size: auto 85%; 106 | } 107 | 108 | .html-display-minimised { 109 | width: 100vw; 110 | height: 100vh; 111 | } 112 | 113 | .html-display-help { 114 | height: 60%; 115 | } 116 | 117 | @font-face { 118 | font-family: din; 119 | src: @din-base64 format('woff'); 120 | font-weight: 400; 121 | font-style: normal; 122 | } 123 | @font-face { 124 | font-family: wf_standard-font; 125 | src: @din-base64 format('woff'); 126 | font-weight: 400; 127 | font-style: normal; 128 | } 129 | 130 | @din-base64: url(data:application/font-woff;charset=utf-8;base64,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); 131 | -------------------------------------------------------------------------------- /test/VisualBuilder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VisualBuilderBase 3 | } from 'powerbi-visuals-utils-testutils'; 4 | 5 | import { 6 | Visual as VisualClass 7 | } from '../src/visual'; 8 | 9 | import powerbi from 'powerbi-visuals-api'; 10 | import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructorOptions; 11 | 12 | export class CustomVisualBuilder extends VisualBuilderBase { 13 | constructor(width: number, height: number) { 14 | super(width, height); 15 | } 16 | 17 | protected build(options: VisualConstructorOptions) { 18 | return new VisualClass(options); 19 | } 20 | 21 | public get mainElement() { 22 | return this.element; 23 | } 24 | } -------------------------------------------------------------------------------- /test/viewModel.spec.ts: -------------------------------------------------------------------------------- 1 | // Power BI API Dependencies 2 | import powerbi from 'powerbi-visuals-api'; 3 | import DataView = powerbi.DataView; 4 | 5 | // Internal dependencies 6 | import { ViewModelHandler, IViewModel } from '../src/ViewModel'; 7 | import { VisualSettings } from '../src/VisualSettings'; 8 | 9 | // Seed constants for tests 10 | const 11 | vmEmpty: IViewModel = { 12 | isValid: false, 13 | isEmpty: true, 14 | contentIndex: -1, 15 | htmlEntries: [] 16 | }, 17 | dataViewEmpty: DataView[] = [], 18 | contentMetadata = { 19 | displayName: 'HTML', 20 | roles: { 21 | content: true 22 | } 23 | }, 24 | samplingMetadata = { 25 | displayName: 'HTML', 26 | roles: { 27 | sampling: true 28 | } 29 | }, 30 | dataViewNoValues: DataView[] = [ 31 | { 32 | table: { 33 | columns: [], 34 | rows: [] 35 | }, 36 | metadata: { 37 | columns: [ 38 | contentMetadata 39 | ] 40 | } 41 | } 42 | ], 43 | dataViewNoValuesSamplingOnly: DataView[] = [ 44 | { 45 | table: { 46 | columns: [], 47 | rows: [] 48 | }, 49 | metadata: { 50 | columns: [ 51 | samplingMetadata 52 | ] 53 | } 54 | } 55 | ], 56 | dataViewSimpleValues: DataView[] = [ 57 | { 58 | table: { 59 | columns: [], 60 | rows: [ 61 | [ 62 | "

This is value one

" 63 | ], 64 | [ 65 | "

This is value two

" 66 | ], 67 | [ 68 | "

This is value three

" 69 | ] 70 | ] 71 | }, 72 | metadata: { 73 | columns: [ 74 | contentMetadata 75 | ] 76 | } 77 | } 78 | ], 79 | dataViewSimpleValuesWithSampling: DataView[] = [ 80 | { 81 | table: { 82 | columns: [], 83 | rows: [ 84 | [ 85 | "1", 86 | "

This is value one

" 87 | ], 88 | [ 89 | "2", 90 | "

This is value two

" 91 | ], 92 | [ 93 | "3", 94 | "

This is value three

" 95 | ] 96 | ] 97 | }, 98 | metadata: { 99 | columns: [ 100 | samplingMetadata, 101 | contentMetadata 102 | ] 103 | } 104 | } 105 | ]; 106 | 107 | // Common setup 108 | function newVm(): ViewModelHandler { 109 | return new ViewModelHandler(); 110 | } 111 | function newVmValidate(dv: DataView[]): ViewModelHandler { 112 | const vm = newVm(); 113 | vm.validateDataView(dv); 114 | return vm; 115 | } 116 | function newVmValidateMap(dv: DataView[]): ViewModelHandler { 117 | const vm = newVmValidate(dv); 118 | vm.mapDataView(dv, VisualSettings.parse(dv[0])); 119 | return vm; 120 | } 121 | 122 | // View model unit tests 123 | describe('View Model', () => { 124 | 125 | describe('| Initialisation', () => { 126 | 127 | it('| Empty view model', () => { 128 | expect(newVm().viewModel).toEqual(vmEmpty); 129 | }); 130 | 131 | }); 132 | 133 | describe('| Validate data view', () => { 134 | 135 | it('| Empty data view', () => { 136 | expect(newVmValidate(dataViewEmpty).viewModel.isValid).toBeFalse(); 137 | }); 138 | 139 | it('| Valid data view with no results', () => { 140 | const vm = newVmValidate(dataViewNoValuesSamplingOnly); 141 | expect(vm.viewModel.contentIndex).toEqual(-1); 142 | expect(vm.viewModel.isValid).toBeFalse(); 143 | }); 144 | 145 | it('| Valid data view with no content', () => { 146 | const vm = newVmValidate(dataViewNoValues) 147 | expect(vm.viewModel.contentIndex).toEqual(0); 148 | expect(vm.viewModel.isValid).toBeTrue(); 149 | }); 150 | 151 | it('| Valid data view with some results', () => { 152 | const vm = newVmValidate(dataViewSimpleValues); 153 | expect(vm.viewModel.contentIndex).toEqual(0); 154 | expect(vm.viewModel.isValid).toBeTrue(); 155 | }); 156 | 157 | it('| Valid data view with sampling and some results', () => { 158 | const vm = newVmValidate(dataViewSimpleValuesWithSampling); 159 | expect(vm.viewModel.contentIndex).toEqual(1); 160 | expect(vm.viewModel.isValid).toBeTrue(); 161 | }); 162 | 163 | }); 164 | 165 | describe('| Map data view', () => { 166 | 167 | it('| Empty data view', () => { 168 | expect(newVmValidateMap(dataViewEmpty).viewModel).toEqual(vmEmpty); 169 | }); 170 | 171 | it('| Valid data view with no results', () => { 172 | const vm = newVmValidateMap(dataViewNoValues); 173 | expect(vm.viewModel.isEmpty).toBeTrue(); 174 | expect(vm.viewModel.htmlEntries.length).toEqual(0); 175 | }); 176 | 177 | it('| Valid data view with some results', () => { 178 | const vm = newVmValidateMap(dataViewSimpleValues); 179 | expect(vm.viewModel.isEmpty).toBeFalse(); 180 | expect(vm.viewModel.htmlEntries.length).toEqual(3); 181 | }); 182 | 183 | it('| Valid data view with sampling and some results', () => { 184 | const vm = newVmValidateMap(dataViewSimpleValuesWithSampling); 185 | expect(vm.viewModel.isEmpty).toBeFalse(); 186 | expect(vm.viewModel.htmlEntries.length).toEqual(3); 187 | }); 188 | 189 | }); 190 | 191 | // TODO: Settings 192 | 193 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "module": "es6", 7 | "target": "es6", 8 | "sourceMap": true, 9 | "outDir": "./.tmp/build/", 10 | "moduleResolution": "node", 11 | "declaration": true, 12 | "lib": ["es2019", "dom"], 13 | "alwaysStrict": true, 14 | "resolveJsonModule": true 15 | }, 16 | "files": ["./src/visual.ts"] 17 | } 18 | --------------------------------------------------------------------------------