├── .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 |
8 |
--------------------------------------------------------------------------------
/assets/background_greyscale.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
--------------------------------------------------------------------------------