├── .babelrc ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── assets ├── 1-preview.png ├── 2-reader.png ├── 3-metadata.png ├── 4-alt-nocontent.png ├── Arial.js ├── BaseballCards.pbix ├── icon.png ├── icon.svg └── thumbnail.png ├── bin ├── buildOSSReport.js ├── installPrivateSubmodules.js ├── openCert.js ├── packageVisual.js ├── pbiPluginLoader.js └── startDev.js ├── capabilities.json ├── certs ├── PowerBICustomVisualTest_private.key └── PowerBICustomVisualTest_public.crt ├── karma.conf.js ├── lib └── @uncharted │ └── cards │ ├── example │ ├── index.html │ ├── sampledata.js │ └── style.css │ ├── package.json │ ├── src │ ├── components │ │ ├── _variables.scss │ │ ├── card │ │ │ ├── _card.scss │ │ │ ├── card.handlebars │ │ │ └── card.js │ │ ├── cards │ │ │ ├── _cards.scss │ │ │ ├── cards.handlebars │ │ │ └── cards.js │ │ ├── constants.js │ │ ├── headerImage │ │ │ ├── _headerImage.scss │ │ │ └── headerImage.js │ │ ├── inlineCardsView │ │ │ ├── _inlineCardsView.scss │ │ │ ├── inlineCardsView.handlebars │ │ │ └── inlineCardsView.js │ │ ├── readerContent │ │ │ ├── _readerContent.scss │ │ │ ├── readerContent.handlebars │ │ │ └── readerContent.js │ │ ├── verticalReader │ │ │ ├── _verticalReader.scss │ │ │ ├── verticalReader.handlebars │ │ │ └── verticalReader.js │ │ └── wrappedCardsView │ │ │ ├── _wrappedCardsView.scss │ │ │ ├── wrappedCardsView.handlebars │ │ │ └── wrappedCardsView.js │ ├── handlebarHelper │ │ ├── cardImages.js │ │ ├── isImageDataUrl.js │ │ └── linkify.js │ ├── index.js │ ├── main.scss │ ├── style │ │ └── _base.scss │ └── util │ │ ├── IBindable.js │ │ ├── images.js │ │ ├── index.js │ │ └── scroll.js │ └── webpack.config.js ├── package.json ├── pbiviz.json ├── src ├── VisualMain.spec.ts ├── VisualMain.ts ├── constants.ts ├── dataConversion.ts ├── loader.handlebars ├── sandboxPolyfill.js ├── test_data │ ├── colors.js │ ├── mockdataview.ts │ ├── table.js │ ├── testDataUtils.ts │ └── testHtmlStrings.js ├── types.ts ├── utils.spec.ts ├── utils.ts └── visual.handlebars ├── style └── visual.scss ├── tsconfig.json ├── tslint.json ├── types └── PowerBI-Visuals-2.6.0.d.ts ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ "env", { 4 | "targets": { 5 | "browsers": ["ie 11"] 6 | }, 7 | "modules": false 8 | }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ develop ] 9 | pull_request: 10 | branches: [ develop ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: Install Deps 28 | run: yarn 29 | - name: Lint 30 | run: yarn run lint 31 | - name: Build 32 | run: NODE_ENV=production yarn run package 33 | - name: Test 34 | run: yarn test 35 | - name: Archive pbiviz 36 | uses: actions/upload-artifact@v2 37 | with: 38 | name: pbiviz 39 | path: dist/essex.cardbrowser.pbiviz -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .npmrc 3 | .tmp 4 | node_modules 5 | typings 6 | dist 7 | coverage 8 | package-lock.json 9 | 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 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 | 2 | [![Node.js CI](https://github.com/microsoft/PowerBI-visuals-CardBrowser/workflows/Node.js%20CI/badge.svg)](https://github.com/microsoft/PowerBI-visuals-CardBrowser/actions) 3 | 4 | # Card Browser 5 | Browse documents using double-sided cards, and click to view in place. 6 | 7 | Card Browser is a document set viewer featuring flippable, double-sided cards for natural navigation of media collections. 8 | 9 | The Preview face of each card renders the headline image, title, and origin of the story with a text sample, enabling rapid discovery of documents of interest. Flipping the cards reveals the MetaData face, which lists document properties. Clicking on a card expands it in place for detailed reading. 10 | 11 | ![Alt text](assets/2-reader.png?raw=true "Card Browser Reader") 12 | 13 | ![Alt text](assets/3-metadata.png?raw=true "Card Browser Metadata") 14 | # Contributing 15 | 16 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 17 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 18 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 19 | 20 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 21 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 22 | provided by the bot. You will only need to do this once across all repos using our CLA. 23 | 24 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 25 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 26 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 27 | 28 | ## Debugging 29 | 30 | * Install ssl certificate by running `yarn run install-certificate` and following the steps from: [https://github.com/Microsoft/PowerBI-visuals/blob/master/tools/CertificateSetup.md](https://github.com/Microsoft/PowerBI-visuals/blob/master/tools/CertificateSetup.md) 31 | * Enable Developer Tools in PowerBI: [https://github.com/Microsoft/PowerBI-visuals/blob/master/tools/DebugVisualSetup.md](https://github.com/Microsoft/PowerBI-visuals/blob/master/tools/DebugVisualSetup.md) 32 | * Run `yarn start` to start development. 33 | 34 | ## Building 35 | 36 | * Run `yarn run package` to package the visual. 37 | * `.pbiviz` file will be generated in the `dist` folder 38 | 39 | ## Testing 40 | 41 | * Run `yarn test` 42 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /assets/1-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/PowerBI-visuals-CardBrowser/b58fc4dfc254c86350a4fdf99253c913ebb40bbe/assets/1-preview.png -------------------------------------------------------------------------------- /assets/2-reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/PowerBI-visuals-CardBrowser/b58fc4dfc254c86350a4fdf99253c913ebb40bbe/assets/2-reader.png -------------------------------------------------------------------------------- /assets/3-metadata.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/PowerBI-visuals-CardBrowser/b58fc4dfc254c86350a4fdf99253c913ebb40bbe/assets/3-metadata.png -------------------------------------------------------------------------------- /assets/4-alt-nocontent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/PowerBI-visuals-CardBrowser/b58fc4dfc254c86350a4fdf99253c913ebb40bbe/assets/4-alt-nocontent.png -------------------------------------------------------------------------------- /assets/BaseballCards.pbix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/PowerBI-visuals-CardBrowser/b58fc4dfc254c86350a4fdf99253c913ebb40bbe/assets/BaseballCards.pbix -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/PowerBI-visuals-CardBrowser/b58fc4dfc254c86350a4fdf99253c913ebb40bbe/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /assets/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/PowerBI-visuals-CardBrowser/b58fc4dfc254c86350a4fdf99253c913ebb40bbe/assets/thumbnail.png -------------------------------------------------------------------------------- /bin/buildOSSReport.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018 Uncharted Software Inc. 3 | * http://www.uncharted.software/ 4 | */ 5 | 6 | const path = require('path'); 7 | const cp = require('child_process'); 8 | const fs = require('fs'); 9 | 10 | /** 11 | * Object containing the modules that should always be considered as runtime modules. 12 | * 13 | * @type {Object} 14 | */ 15 | const alwaysRuntimeDependencies = { 16 | 'font-awesome': true 17 | }; 18 | 19 | /** 20 | * Parses the output of `npm ll` making sure that the private sub-modules are included in the report. 21 | * 22 | * @method parseNPMLL 23 | * @param {Function} cb - Callback to be invoked when the operation is complete. 24 | */ 25 | function parseNPMLL(cb) { 26 | /* read the original package.json file and parse it */ 27 | const packagePath = path.resolve('./package.json'); 28 | const packageJsonContent = fs.readFileSync(packagePath, 'utf8'); 29 | const packageJson = JSON.parse(packageJsonContent); 30 | 31 | /* save a version of package.json with the privateSubmodules added to the dependencies object */ 32 | const privateSubmodules = packageJson.privateSubmodules; 33 | const dependencies = packageJson.dependencies; 34 | Object.keys(privateSubmodules || {}).forEach(key => { 35 | if (!dependencies.hasOwnProperty(key)) { 36 | dependencies[key] = privateSubmodules[key]; 37 | } 38 | }); 39 | fs.writeFile(packagePath, JSON.stringify(packageJson), err => { 40 | if (err) throw(err); 41 | 42 | /* process the output of `npm ll` */ 43 | let output; 44 | try { 45 | output = cp.execSync('npm ll --json', {env: process.env}); 46 | } catch (e) { 47 | output = e.stdout; 48 | } 49 | 50 | /* restore the previous package.json */ 51 | fs.writeFile(packagePath, packageJsonContent, err => { 52 | if (err) throw(err); 53 | 54 | /* parse the output and call the callback */ 55 | let parsed; 56 | try { 57 | parsed = JSON.parse(output); 58 | } catch (e) { 59 | console.warn('Cannot parse `npm ll` output:', e); 60 | parsed = null; 61 | } 62 | cb(parsed); 63 | }); 64 | }); 65 | } 66 | 67 | /** 68 | * Converts an object representing a JSON property to a CSV compatible string. 69 | * 70 | * @method readObjectValue 71 | * @param {Object} object - The object to read as a string. 72 | * @returns {String} 73 | */ 74 | function readObjectValue(object) { 75 | let result = ''; 76 | 77 | if (typeof object === 'string' || object instanceof String) { 78 | result = object; 79 | } else if (typeof object === 'number' || object instanceof Number) { 80 | result = object.toString(); 81 | } else if (object) { 82 | Object.keys(object).forEach(key => { 83 | result += key + ': ' + object[key] + '\t'; 84 | }); 85 | } 86 | 87 | if (!result.length) { 88 | result = 'Not Specified'; 89 | } 90 | 91 | return result.replace(new RegExp('"', 'g'), '""'); 92 | } 93 | 94 | /** 95 | * Recursively parses the dependencies object withing the given info object and returns them as a CSV string. 96 | * 97 | * @method parseDependencies 98 | * @param {Object} info - An info object containing the `dependencies` property to parse. 99 | * @param {Object} runtimeDependencies - An Object containing the runtime dependencies of this project. 100 | * @param {Boolean} isParentRoot - Flag defining if the parent object of this dependencies is the root of the project. 101 | * @returns {String} 102 | */ 103 | function parseDependencies(info, runtimeDependencies, isParentRoot) { 104 | const dependencies = info.dependencies; 105 | const devDependencies = info.devDependencies; 106 | const nodeModulesPath = path.join(path.resolve('./'), 'node_modules/'); 107 | let csv = ''; 108 | 109 | /* dependency */ /* version */ /* type */ /* usage */ /* included in product */ /* in git repository */ /* licence */ /* URL */ /* description */ 110 | Object.keys(dependencies).forEach(key => { 111 | const dependency = dependencies[key]; 112 | const name = readObjectValue(dependency.name); 113 | const version = readObjectValue(dependency.version); 114 | 115 | if (dependency.name && dependency.version) { 116 | const type = isParentRoot ? 'dependency' : 'sub-dependency'; 117 | const usage = devDependencies.hasOwnProperty(key) ? 'development' : 'runtime'; 118 | const included = (!runtimeDependencies.hasOwnProperty(key) || !runtimeDependencies[key]) ? 'No' : 'Yes'; 119 | /* TODO: This could be done using `git check-ignore` for better results */ 120 | const inRepo = (dependency.link && !dependency.link.startsWith(nodeModulesPath)) ? 'Yes' : 'No'; 121 | const license = readObjectValue(dependency.license); 122 | const url = readObjectValue(dependency.homepage || dependency.repository || null); 123 | const description = readObjectValue(dependency.description); 124 | csv += '"' + name + '","' + version + '","' + type + '","' + usage + '","' + included + '","' + inRepo + '","' + license + '","' + url + '","' + description + '"\n'; 125 | } 126 | 127 | if (dependency.dependencies) { 128 | csv += parseDependencies(dependency, runtimeDependencies, false); 129 | } 130 | }); 131 | 132 | return csv; 133 | } 134 | 135 | /** 136 | * Finds the runtime dependencies in the given webpack modules and adds the dependencies defined in the 137 | * `alwaysRuntimeDependencies` object. 138 | * 139 | * @method findRuntimeDependencies 140 | * @param {Object} webpackModules - An object containing the dependencies as obtaining from compiling the project using webpack. 141 | * @returns {Object} 142 | */ 143 | function findRuntimeDependencies(webpackModules) { 144 | const runtimeDependencies = {}; 145 | webpackModules.forEach(module => { 146 | const name = module.name; 147 | if (name.startsWith('./~/') || name.startsWith('./node_modules/') || name.startsWith('./lib/')) { 148 | const components = name.split('/'); 149 | let i = 2; 150 | let moduleName = ''; 151 | let moduleComponent = components[i++]; 152 | while (moduleComponent.startsWith('@')) { 153 | moduleName += moduleComponent + '/'; 154 | moduleComponent = components[i++]; 155 | } 156 | moduleName += moduleComponent; 157 | runtimeDependencies[moduleName] = module.built && module.cacheable; 158 | } 159 | }); 160 | return Object.assign({}, runtimeDependencies, alwaysRuntimeDependencies); 161 | } 162 | 163 | /** 164 | * Builds an OSS report for this project taking into account the specified webpack dependencies. 165 | * 166 | * @method buildOSSReport 167 | * @param {Object} webpackModules - An object containing the dependencies as obtaining from compiling the project using webpack. 168 | * @param {Function} cb - A callback function to be invoked qhen the process is complete. 169 | */ 170 | function buildOSSReport(webpackModules, cb) { 171 | parseNPMLL(dependencies => { 172 | const runtimeDependencies = findRuntimeDependencies(webpackModules); 173 | const dependenciesCSV = parseDependencies(dependencies, runtimeDependencies, true); 174 | cb('"Dependency","Version","Type","Usage","Included In Product","In Git Repository","License","URL","Description"\n' + dependenciesCSV); 175 | }); 176 | } 177 | 178 | module.exports = buildOSSReport; 179 | -------------------------------------------------------------------------------- /bin/installPrivateSubmodules.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const { execSync } = require('child_process'); 3 | 4 | const subModules = require("../package.json").privateSubmodules; 5 | 6 | Object.entries(subModules).forEach(entry => { 7 | const [name, version] = entry; 8 | console.log(`Installing ${name}@${version}...`); 9 | try { 10 | execSync(`yarn add ${name}@${version} --no-lockfile --ignore-scripts`); 11 | fs.ensureDirSync(`lib/${name}`); 12 | fs.moveSync(`node_modules/${name}`, `lib/${name}`, { overwrite: true }); 13 | execSync(`yarn remove ${name}`); 14 | } catch (e) { 15 | console.log(e.message); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /bin/openCert.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018 Uncharted Software Inc. 3 | * http://www.uncharted.software/ 4 | */ 5 | 6 | const os = require('os'); 7 | const path = require('path'); 8 | const exec = require('child_process').execSync; 9 | 10 | function openCertFile() { 11 | const certPath = path.join(process.cwd(), 'certs/PowerBICustomVisualTest_public.crt'); 12 | const openCmds = { 13 | linux: 'xdg-open', 14 | darwin: 'open', 15 | win32: 'powershell start' 16 | }; 17 | const startCmd = openCmds[os.platform()]; 18 | if (startCmd) { 19 | try { 20 | exec(`${startCmd} "${certPath}"`); 21 | } catch (e) { 22 | console.info('Certificate path:', certPath); 23 | } 24 | } else { 25 | console.info('Certificate path:', certPath); 26 | } 27 | } 28 | 29 | openCertFile(); -------------------------------------------------------------------------------- /bin/packageVisual.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018 Uncharted Software Inc. 3 | * http://www.uncharted.software/ 4 | */ 5 | 6 | "use strict"; 7 | 8 | const fs = require('fs'); 9 | const zip = require('node-zip')(); 10 | const path = require('path'); 11 | const sass = require('node-sass'); 12 | const CleanCSS = require('clean-css'); 13 | const mkdirp = require('mkdirp'); 14 | const webpack = require("webpack"); 15 | const MemoryFS = require("memory-fs"); 16 | const pbivizJson = require('../pbiviz.json'); 17 | const packageJson = require('../package.json'); 18 | const capabilities = require(path.join('..', pbivizJson.capabilities)); 19 | const webpackConfig = require('../webpack.config'); 20 | const buildOSSReport = require('./buildOSSReport.js'); 21 | 22 | const packagingWebpackConfig = { 23 | output: { 24 | filename: 'visual.js', path: '/' 25 | }, 26 | }; 27 | 28 | const _buildLegacyPackageJson = () => { 29 | const pack = { 30 | version: packageJson.version, 31 | author: pbivizJson.author, 32 | licenseTerms: packageJson.license, 33 | privacyTerms: packageJson.privacyTerms, 34 | resources: [ 35 | { 36 | "resourceId": "rId0", 37 | "sourceType": 5, 38 | "file": `resources/${ pbivizJson.visual.guid }.ts` 39 | }, 40 | { 41 | "resourceId": "rId1", 42 | "sourceType": 0, 43 | "file": `resources/${ pbivizJson.visual.guid }.js` 44 | }, 45 | { 46 | "resourceId": "rId2", 47 | "sourceType": 1, 48 | "file": `resources/${ pbivizJson.visual.guid }.css` 49 | }, 50 | { 51 | "resourceId": "rId3", 52 | "sourceType": 3, 53 | "file": `resources/${path.basename(pbivizJson.assets.icon)}` 54 | }, 55 | { 56 | "resourceId": "rId4", 57 | "sourceType": 6, 58 | "file": `resources/${path.basename(pbivizJson.assets.thumbnail)}` 59 | }, 60 | { 61 | "resourceId": "rId5", 62 | "sourceType": 2, 63 | "file": `resources/${path.basename(pbivizJson.assets.screenshot)}` 64 | } 65 | ], 66 | visual: Object.assign({ version: packageJson.version }, pbivizJson.visual), 67 | "code": { 68 | "typeScript": { 69 | "resourceId": "rId0" 70 | }, 71 | "javaScript": { 72 | "resourceId": "rId1" 73 | }, 74 | "css": { 75 | "resourceId": "rId2" 76 | } 77 | }, 78 | "images": { 79 | "icon": { 80 | "resourceId": "rId3" 81 | }, 82 | "thumbnail": { 83 | "resourceId": "rId4" 84 | }, 85 | "screenshots": [ 86 | { 87 | "resourceId": "rId5" 88 | } 89 | ] 90 | } 91 | }; 92 | 93 | delete pack.visual.visualClassName; 94 | 95 | const date = new Date(); 96 | pack.build = date.getUTCFullYear().toString().substring(2) + '.' + (date.getUTCMonth() + 1) + '.' + date.getUTCDate() + '.' + ((date.getUTCHours() * 3600) + (date.getUTCMinutes() * 60) + date.getUTCSeconds()); 97 | 98 | return pack; 99 | }; 100 | 101 | const _buildPackageJson = () => { 102 | return { 103 | version: packageJson.version, 104 | author: pbivizJson.author, 105 | licenseTerms: packageJson.license, 106 | privacyTerms: packageJson.privacyTerms, 107 | resources: [ 108 | { 109 | resourceId: 'rId0', 110 | sourceType: 5, 111 | file: `resources/${ pbivizJson.visual.guid }.pbiviz.json`, 112 | } 113 | ], 114 | visual: Object.assign({ version: packageJson.version }, pbivizJson.visual), 115 | metadata: { 116 | pbivizjson: { 117 | resourceId: 'rId0' 118 | } 119 | } 120 | }; 121 | }; 122 | 123 | const buildPackageJson = pbivizJson.apiVersion ? _buildPackageJson() : _buildLegacyPackageJson(); 124 | 125 | const compileSass = () => { 126 | const sassOutput = sass.renderSync({ file: pbivizJson.style }).css.toString(); 127 | const options = { 128 | level: { 129 | 2: { 130 | all: true, 131 | mergeNonAdjacentRules: false, 132 | }, 133 | }, 134 | }; 135 | const cssContent = new CleanCSS(options).minify(sassOutput).styles; 136 | return cssContent; 137 | }; 138 | 139 | const compileScripts = (callback) => { 140 | const regex = new RegExp("\\bpowerbi-visuals.d.ts\\b"); 141 | const fs = new MemoryFS(); 142 | const compiler = webpack(Object.assign(webpackConfig, packagingWebpackConfig)); 143 | compiler.outputFileSystem = fs; 144 | compiler.run((err, stats) => { 145 | if (err) throw err; 146 | const jsonStats = stats.toJson(true); 147 | const errors = jsonStats.errors.filter(error => !regex.test(error)); 148 | console.info('Time:', jsonStats.time); 149 | console.info('Hash:', jsonStats.hash); 150 | jsonStats.warnings.forEach(warning => console.warn('WARNING:', warning)); 151 | errors.forEach(error => !regex.test(error) && console.error('ERROR:', error)); 152 | if (errors.length > 0) { 153 | return process.exit(1); 154 | } 155 | const fileContent = fs.readFileSync("/visual.js").toString(); 156 | callback(err, fileContent); 157 | }); 158 | }; 159 | 160 | const _buildLegacyPackage = (fileContent) => { 161 | const icon = fs.readFileSync(pbivizJson.assets.icon); 162 | const thumbnail = fs.readFileSync(pbivizJson.assets.thumbnail); 163 | const screenshot = fs.readFileSync(pbivizJson.assets.screenshot); 164 | const iconType = pbivizJson.assets.icon.indexOf('.svg') >= 0 ? 'svg+xml' : 'png'; 165 | const iconBase64 = `data:image/${iconType};base64,` + icon.toString('base64'); 166 | const cssContent = compileSass() + `\n.visual-icon.${pbivizJson.visual.guid} {background-image: url(${iconBase64});}`; 167 | zip.file('package.json', JSON.stringify(buildPackageJson, null, 2)); 168 | zip.file(`resources/${pbivizJson.visual.guid}.js`, fileContent); 169 | zip.file(`resources/${pbivizJson.visual.guid}.ts`, `/** See ${pbivizJson.visual.guid}.js **/`); 170 | zip.file(`resources/${pbivizJson.visual.guid}.css`, cssContent + `\n`); 171 | zip.file(`resources/${path.basename(pbivizJson.assets.icon)}`, icon); 172 | zip.file(`resources/${path.basename(pbivizJson.assets.thumbnail)}`, thumbnail); 173 | zip.file(`resources/${path.basename(pbivizJson.assets.screenshot)}`, screenshot); 174 | fs.writeFileSync(pbivizJson.output, zip.generate({ base64:false,compression:'DEFLATE' }), 'binary'); 175 | }; 176 | 177 | const _buildPackage = (fileContent) => { 178 | const jsContent = 'var window = window.document.defaultView;\nvar $ = window.$;\n var _ = window._;\n' + fileContent; 179 | const cssContent = compileSass(); 180 | const icon = fs.readFileSync(pbivizJson.assets.icon); 181 | const iconType = pbivizJson.assets.icon.indexOf('.svg') >= 0 ? 'svg+xml' : 'png'; 182 | const iconBase64 = `data:image/${iconType};base64,` + icon.toString('base64'); 183 | 184 | pbivizJson.capabilities = capabilities; 185 | pbivizJson.content = { 186 | js: jsContent, 187 | css: cssContent, 188 | iconBase64: iconBase64 189 | }; 190 | pbivizJson.visual.version = packageJson.version; 191 | 192 | zip.file('package.json', JSON.stringify(buildPackageJson, null, 2)); 193 | zip.file(`resources/${pbivizJson.visual.guid}.pbiviz.json`, JSON.stringify(pbivizJson, null, 2)); 194 | fs.writeFileSync(pbivizJson.output, zip.generate({ base64:false,compression:'DEFLATE' }), 'binary'); 195 | }; 196 | 197 | const buildPackage = () => { 198 | mkdirp.sync(path.parse(pbivizJson.output).dir); 199 | compileScripts((err, result) => { 200 | if (err) throw err; 201 | 202 | if (!pbivizJson.apiVersion) { 203 | _buildLegacyPackage(result); 204 | } else { 205 | _buildPackage(result); 206 | } 207 | }); 208 | }; 209 | 210 | buildPackage(); 211 | -------------------------------------------------------------------------------- /bin/pbiPluginLoader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018 Uncharted Software Inc. 3 | * http://www.uncharted.software/ 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const path = require('path'); 9 | const cp = require('child_process'); 10 | const crypto = require('crypto'); 11 | const pbiviz = require(path.join(process.cwd(), 'pbiviz.json')); 12 | const packageJson = require(path.join(process.cwd(), 'package.json')); 13 | 14 | const userName = cp.execSync('whoami').toString(); 15 | const userHash = crypto.createHash('md5').update(userName).digest('hex'); 16 | 17 | function pbivizPluginTemplate(pbiviz) { 18 | return `(function (powerbi) { 19 | var visuals; 20 | (function (visuals) { 21 | var plugins; 22 | (function (plugins) { 23 | /* ESSEX Capabilities Patcher */ 24 | plugins['${pbiviz.visual.guid}'] = { 25 | name: '${pbiviz.visual.guid}', 26 | displayName: '${pbiviz.visual.name}', 27 | class: '${pbiviz.visual.visualClassName}', 28 | version: '${packageJson.version}', 29 | apiVersion: '${pbiviz.apiVersion}', 30 | capabilities: '{}', 31 | create: function (/*options*/) { 32 | var instance = Object.create(${pbiviz.visual.visualClassName}.prototype); 33 | ${pbiviz.visual.visualClassName}.apply(instance, arguments); 34 | return instance; 35 | }, 36 | custom: true 37 | }; 38 | 39 | /* save version number to visual */ 40 | ${pbiviz.visual.visualClassName}.__essex_build_info__ = '${packageJson.version} ${Date.now()} [${userHash}]'; 41 | Object.defineProperty(${pbiviz.visual.visualClassName}.prototype, '__essex_build_info__', { get: function() { return ${pbiviz.visual.visualClassName}.__essex_build_info__; } } ); 42 | })(plugins = visuals.plugins || (visuals.plugins = {})); 43 | })(visuals = powerbi.visuals || (powerbi.visuals = {})); 44 | })(window['powerbi'] || (window['powerbi'] = {}));`; 45 | } 46 | 47 | /** 48 | * Webpack loader function that appends pbiviz plugin code at the end of the provided source 49 | */ 50 | function pluginLoader(source, map) { 51 | this.cacheable(); 52 | source = source + '\n' + pbivizPluginTemplate(pbiviz); 53 | this.callback(null, source, map); 54 | } 55 | 56 | module.exports = pluginLoader; 57 | -------------------------------------------------------------------------------- /bin/startDev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018 Uncharted Software Inc. 3 | * http://www.uncharted.software/ 4 | */ 5 | 6 | const fs = require('fs'); 7 | const path = require('path'); 8 | const https = require('https'); 9 | const sass = require('node-sass'); 10 | const mkdirp = require('mkdirp'); 11 | const connect = require('connect'); 12 | const webpack = require('webpack'); 13 | const chokidar = require('chokidar'); 14 | const serveStatic = require('serve-static'); 15 | const webpackConfig = require('../webpack.config'); 16 | const pbivizJson = require('../pbiviz.json'); 17 | 18 | const config = { 19 | pbivizJsonPath: 'pbiviz.json', 20 | capabilitiesJsonPath: pbivizJson.capabilities, 21 | tmpDropDir: '.tmp/drop', 22 | sassEntry: pbivizJson.style, 23 | sassPaths: [ 24 | 'lib/@uncharted/strippets/sass', 25 | ], 26 | server: { 27 | cert: 'certs/PowerBICustomVisualTest_public.crt', 28 | key: 'certs/PowerBICustomVisualTest_private.key', 29 | port: 8080 30 | } 31 | }; 32 | const pbiResource = { 33 | jsFile: `${ config.tmpDropDir }/visual.js`, 34 | cssFile: `${ config.tmpDropDir }/visual.css`, 35 | pbivizJsonFile: `${ config.tmpDropDir }/pbiviz.json`, 36 | statusFile: `${ config.tmpDropDir }/status`, 37 | }; 38 | 39 | const compileSass = () => { 40 | console.info('Building css...'); 41 | const cssContent = sass.renderSync({ file: config.sassEntry, includePaths: config.sassPaths }).css; 42 | fs.writeFileSync(pbiResource.cssFile, cssContent); 43 | }; 44 | 45 | const emitPbivizJson = () => { 46 | console.info('Updating pbiviz.json...'); 47 | const pbiviz = JSON.parse(fs.readFileSync(config.pbivizJsonPath)); 48 | const capabilities = JSON.parse(fs.readFileSync(config.capabilitiesJsonPath)) 49 | pbiviz.capabilities = capabilities; 50 | fs.writeFileSync(pbiResource.pbivizJsonFile, JSON.stringify(pbiviz, null, 2)); 51 | }; 52 | 53 | const updateStatus = () => { 54 | fs.writeFileSync(pbiResource.statusFile, Date.now().toString()); 55 | console.info('Visual updated.'); 56 | }; 57 | 58 | const runWatchTask = (task, isSass) => { 59 | try { 60 | task(); 61 | } catch (e) { 62 | isSass 63 | ? console.info(`ERROR: ${e.message}\n at ${e.file}:${e.line}:${e.column}`) 64 | : console.info(`ERROR: ${e.message}`); 65 | } 66 | }; 67 | 68 | const startWatchers = () => { 69 | // watch script change and re-compile 70 | const compiler = webpack(Object.assign({ output: { filename: pbiResource.jsFile }}, webpackConfig)); 71 | compiler.watch({}, (err, stats) => { 72 | let log = stats.toString({ 73 | chunks: false, 74 | color: true 75 | }); 76 | log = log.split('\n\n').filter(msg => msg.indexOf('node_module') === -1 ).join('\n\n'); 77 | console.info(log); 78 | }); 79 | 80 | // watch for pbiviz.json or capabilities.json change 81 | chokidar 82 | .watch([config.pbivizJsonPath, config.capabilitiesJsonPath]) 83 | .on('change', path => runWatchTask(emitPbivizJson)); 84 | 85 | // watch for sass file changes 86 | chokidar 87 | .watch(['**/*.scss', '**/*.sass']) 88 | .on('change', path => runWatchTask(compileSass, true)); 89 | 90 | // watch pbi resource change and update status to trigger debug visual update 91 | chokidar 92 | .watch([pbiResource.jsFile, pbiResource.cssFile, pbiResource.pbivizJsonFile]) 93 | .on('change', path => runWatchTask(updateStatus)); 94 | }; 95 | 96 | const startServer = () => { 97 | const options = { 98 | key: fs.readFileSync(config.server.key), 99 | cert: fs.readFileSync(config.server.cert) 100 | }; 101 | const app = connect(); 102 | app.use((req, res, next) => { 103 | res.setHeader('Access-Control-Allow-Origin', '*'); 104 | next(); 105 | }); 106 | app.use('/assets', serveStatic(config.tmpDropDir)); 107 | 108 | https.createServer(options, app).listen(config.server.port, (err) => { 109 | console.info('Server listening on port ', config.server.port + '.'); 110 | }); 111 | }; 112 | 113 | const start = () => { 114 | mkdirp.sync(config.tmpDropDir); 115 | compileSass(); 116 | emitPbivizJson(); 117 | updateStatus(); 118 | startWatchers(); 119 | startServer(); 120 | }; 121 | 122 | start(); 123 | -------------------------------------------------------------------------------- /capabilities.json: -------------------------------------------------------------------------------- 1 | { 2 | "dataRoles": [ 3 | { 4 | "name": "id", 5 | "kind": 0, 6 | "displayName": "Document Id", 7 | "description": "Unique ID for the document" 8 | }, 9 | { 10 | "name": "title", 11 | "kind": 0, 12 | "displayName": "Title", 13 | "description": "Title of the document; shown on cards and in the reader" 14 | }, 15 | { 16 | "name": "summary", 17 | "displayName": "Preview", 18 | "description": "Preview text; only displayed on cards", 19 | "kind": 1 20 | }, 21 | { 22 | "name": "content", 23 | "displayName": "Content", 24 | "description": "Full content to display in the reader", 25 | "kind": 1 26 | }, 27 | { 28 | "name": "imageUrl", 29 | "displayName": "Title Image (URL)", 30 | "description": "Main image(s) for the document; displayed on cards and in the reader", 31 | "kind": 0 32 | }, 33 | { 34 | "name": "subtitle", 35 | "displayName": "Subtitle Fields", 36 | "description": "Text items to display as a subtitle", 37 | "kind": 0 38 | }, 39 | { 40 | "name": "sourceImage", 41 | "displayName": "Badge (Image URL)", 42 | "description": "URL for an image to display in the top-right corner of the card", 43 | "kind": 0 44 | }, 45 | { 46 | "name": "metadata", 47 | "displayName": "MetaData Fields", 48 | "description": "Key-value pairs for the table on the back of the cards", 49 | "kind": 1 50 | }, 51 | { 52 | "name": "topBarColor", 53 | "displayName": "Top Bar Color", 54 | "description": "Color of the bar at the top of the card", 55 | "kind": 0 56 | }, 57 | { 58 | "name": "dummySortingField", 59 | "displayName": "Sorting Field", 60 | "description": "Field that can be used to sort the visual's data based on an arbitrary column", 61 | "kind": 2 62 | } 63 | ], 64 | "dataViewMappings": [ 65 | { 66 | "conditions": [ 67 | { 68 | "id": { 69 | "max": 1 70 | }, 71 | "title": { 72 | "max": 10 73 | }, 74 | "summary": { 75 | "max": 1 76 | }, 77 | "content": { 78 | "max": 1 79 | }, 80 | "imageUrl": { 81 | "max": 4 82 | }, 83 | "subtitle": { 84 | "max": 6 85 | }, 86 | "sourceImage": { 87 | "max": 1 88 | }, 89 | "metadata": { 90 | "max": 10 91 | }, 92 | "topBarColor": { 93 | "max": 1 94 | }, 95 | "dummySortingField": { 96 | "max": 1 97 | } 98 | } 99 | ], 100 | "categorical": { 101 | "categories": { 102 | "for": { 103 | "in": "id" 104 | }, 105 | "select": [ 106 | { 107 | "bind": { 108 | "to": "title" 109 | } 110 | }, 111 | { 112 | "bind": { 113 | "to": "summary" 114 | } 115 | }, 116 | { 117 | "bind": { 118 | "to": "content" 119 | } 120 | }, 121 | { 122 | "bind": { 123 | "to": "imageUrl" 124 | } 125 | }, 126 | { 127 | "bind": { 128 | "to": "subtitle" 129 | } 130 | }, 131 | { 132 | "bind": { 133 | "to": "sourceImage" 134 | } 135 | }, 136 | { 137 | "bind": { 138 | "to": "metadata" 139 | } 140 | }, 141 | { 142 | "bind": { 143 | "to": "topBarColor" 144 | } 145 | } 146 | ], 147 | "dataReductionAlgorithm": { 148 | "window": { 149 | "count": 500 150 | } 151 | } 152 | }, 153 | "values": { 154 | "select": [ 155 | { 156 | "for": { 157 | "in": "id" 158 | } 159 | } 160 | ], 161 | "dataReductionAlgorithm": { "window": { "count": 20000 } } 162 | }, 163 | "rowCount": { 164 | "preferred": { 165 | "min": 500, 166 | "max": 6000 167 | }, 168 | "supported": { 169 | "min": 0, 170 | "max": 20000 171 | } 172 | } 173 | } 174 | } 175 | ], 176 | "objects": { 177 | "general": { 178 | "displayName": "General", 179 | "properties": { 180 | "version": { 181 | "displayName": "Version", 182 | "description": "The version of Card Browser", 183 | "type": { 184 | "text": true 185 | } 186 | } 187 | } 188 | }, 189 | "presentation": { 190 | "properties": { 191 | "dateFormat": { 192 | "displayName": "Date Format", 193 | "description": "Subtitle date format using tokens; e.g. YYYY MM DD h:mm:ss", 194 | "type": { 195 | "text": "MMM D, YYYY" 196 | } 197 | }, 198 | "separator": { 199 | "displayName": "Separator", 200 | "description": "Character to place between subtitle fields", 201 | "type": { 202 | "text": " \u2022 " 203 | } 204 | }, 205 | "cardWidth": { 206 | "displayName": "Card Width", 207 | "description": "Width, in pixels, of each card", 208 | "type": { 209 | "numeric": 200 210 | } 211 | }, 212 | "cardHeight": { 213 | "displayName": "Card Height", 214 | "description": "Height, in pixels, of each card when using wrapped layout", 215 | "type": { 216 | "numeric": 300 217 | } 218 | }, 219 | "filter": { 220 | "type": { 221 | "bool": true 222 | }, 223 | "displayName": "Filter", 224 | "description": "If on, selecting a card filters other visuals to that article." 225 | }, 226 | "cropImages": { 227 | "displayName": "Crop Images", 228 | "description": "If on, crop and recenter images to fill the top of the card", 229 | "type": { 230 | "bool": true 231 | } 232 | }, 233 | "shadow": { 234 | "displayName": "Shadow", 235 | "description": "Control the style of border around cards.", 236 | "type": { 237 | "bool": "true" 238 | } 239 | }, 240 | "fade": { 241 | "displayName": "Fade Text", 242 | "description": "Fade text toward the bottom of the card", 243 | "type": { 244 | "bool": "true" 245 | } 246 | }, 247 | "showImageOnBack": { 248 | "description": "Whether to show Title Images on the card's MetaData face", 249 | "displayName": "Title Image on MetaData", 250 | "type": { 251 | "bool": "true" 252 | } 253 | } 254 | }, 255 | "displayName": "Cards" 256 | }, 257 | "reader": { 258 | "properties": { 259 | "headerBackgroundColor": { 260 | "description": "Background color for the reader header", 261 | "displayName": "Header Color", 262 | "type": { 263 | "fill": { 264 | "solid": { 265 | "color": "#000" 266 | } 267 | } 268 | } 269 | }, 270 | "width": { 271 | "description": "Expanded width of the inline reader", 272 | "displayName": "Expanded Width", 273 | "type": { 274 | "numeric": 520 275 | } 276 | }, 277 | "height": { 278 | "description": "Height of the vertical (wrapped) reader", 279 | "displayName": "Height", 280 | "type": { 281 | "numeric": 500 282 | } 283 | } 284 | }, 285 | "displayName": "Reader" 286 | }, 287 | "metadata": { 288 | "properties": { 289 | "fontSize": { 290 | "description": "MetaData text size", 291 | "displayName": "Text Size", 292 | "type": { 293 | "formatting": { 294 | "fontSize": true 295 | } 296 | } 297 | }, 298 | "titleFontFamily": { 299 | "displayName": "Title font family", 300 | "type": { 301 | "formatting": { 302 | "fontFamily": true 303 | } 304 | } 305 | }, 306 | "valueFontFamily": { 307 | "displayName": "Value font family", 308 | "type": { 309 | "formatting": { 310 | "fontFamily": true 311 | } 312 | } 313 | }, 314 | "titleColor": { 315 | "description": "Color for the row titles", 316 | "displayName": "Titles Color", 317 | "type": { 318 | "fill": { 319 | "solid": { 320 | "color": "#bbb" 321 | } 322 | } 323 | } 324 | }, 325 | "valueColor": { 326 | "description": "Color for the MetaData values", 327 | "displayName": "Values Color", 328 | "type": { 329 | "fill": { 330 | "solid": { 331 | "color": "#000" 332 | } 333 | } 334 | } 335 | } 336 | }, 337 | "displayName": "MetaData Format" 338 | }, 339 | "flipState": { 340 | "properties": { 341 | "show": { 342 | "description": "Whether to enable card flipping", 343 | "displayName": "Enable Flip", 344 | "type": { 345 | "bool": "true" 346 | } 347 | }, 348 | "cardFaceDefault": { 349 | "description": "Which face of the card to show by default", 350 | "displayName": "Card Face", 351 | "type": { 352 | "enumeration": [ 353 | { "value": "preview", "displayName": "Preview" }, 354 | { "value": "metadata", "displayName": "MetaData" } 355 | ] 356 | } 357 | } 358 | }, 359 | "description": "Cards flipping configuration", 360 | "displayName": "Flip" 361 | }, 362 | "loadMoreData": { 363 | "properties": { 364 | "show": { 365 | "description": "Whether to load more data than the initial rows", 366 | "displayName": "Enable", 367 | "type": { 368 | "bool": "false" 369 | } 370 | }, 371 | "limit": { 372 | "description": "Stop after loading this many Documents", 373 | "displayName": "Limit", 374 | "type": { 375 | "numeric": 500 376 | } 377 | } 378 | }, 379 | "description": "Control over data loading", 380 | "displayName": "Load More Data" 381 | } 382 | }, 383 | "sorting": { 384 | "default": {} 385 | }, 386 | "suppressDefaultTitle": true, 387 | "supportsHighlight": false 388 | } 389 | -------------------------------------------------------------------------------- /certs/PowerBICustomVisualTest_private.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDxYnqKr9KKwxbP 3 | p/H156sO89OBN88tqbSShKWKrk5ktAja1xAv+6/xnWvoKNJoGLS3V5Z/rlVa/m2Y 4 | rBcCaSQZvcWp28+xYcqrh+SabeQhyDGgjTE1nMmAOTBXLPsy9O5p2/pRn9+j+noi 5 | CL3R8j99Q1/uZDArDBWit00SzYJGuFdUb0DVoWcWb+btAtpJe4Q/SepZKfMVDNOs 6 | LNeM6AQzWDFp5FHFBIOYHHijNwhR4LyROc2TT6oR2DzQgByOk/Ot/zwEn8Vn5528 7 | 57EKoC4RBWws0vCcTIvCVPuWXGwr524dyCOCEiwiK34la45Hs4UMOInbJKSB7Keh 8 | cImp5qE1AgMBAAECggEASp/8bIXg3F9l3Pr59eESZEQDPm6nkWFm3uk9WHt85us1 9 | 1Zopefwgr9zQMGz2Z5JDxG7Cq2SVJNWFwm5piqhAreiahGHdeuRVyOPxS8Dvh3Yh 10 | yZX9AasgLJEBneHdIyrPzlBguf1oIYTdX0o+jPqMoEhFDylQLu7EfZKXtFlujjfz 11 | qoP6FjdIW00G0QA+xmFmEL4TfqBDJXwefFzQYAj22YmUYYdJ7OVNhlx7flyAyxHn 12 | ebn4MDg4mI5z75uC82ivWL8DpPvrXXnqSxioDktJk70mIHZwbEg/pgH5UQB2l/Cg 13 | REBoiW8bT9GTF6x4JSXsKwdgl9GuwhU9+/iqFtGK3QKBgQD6TR6egyyu+5u52hma 14 | zOhsYjg8Qshj1+UnToZBx5cB/sJrxnskIRQFUmEakPdOmUHV1XOMGmDJZvhSXVQJ 15 | g3AHe8yme7nlbyNcZf7gOUxum9SuVZOJgmcuiuEu5PCIStSUNYELRxlYKjL3vJwO 16 | kqTK17Ng7e5ioLB5V5q4x2nfDwKBgQD24WONyaAqAMH64wkxQGdrrt+Q8SYDW9eh 17 | V4gTFnRwXVcqhARLN4PACtopO7Rr4bOHDW32cVWQxA5LCCwhaR/4j3cmYjo1uKPP 18 | qUdC4B5XRLAKwXHoHy58AVDPEZpKmPmUD94ct8LHOIMr1nnpMKQFFNmEQ2r2t2zt 19 | HSL6UmA7ewKBgQDvxI5PM8LLAFUFbIpYgm8m29OYzjRdiEOIKq1rN8FM9PjS+vec 20 | /V7LVkWUiEeO8DpjlywvilkqtMutQp+s2U6orIu28xB5WsQZz86eheTUk8vhEDLb 21 | Z6JlsD3TiRVsyZnnO3WZEwuRLCNUs0UepJTdhlDbyjAwJFPIeQXKeaPOOwKBgQCg 22 | WQfWIazbWx6imy9vQ8toT255r4bnC5HkAvwomZ8LFDT3MkOvruDtrJ7BxTuMDk4S 23 | W5CeTkIrAoveA/LVyHexc00KKyZvmfsbd3EHaJWMTNqiQb5/6zC/7gLUWzSBWxZP 24 | Kncy48+ooXXg1S7dXHBLtJ0KoNcqYzxmTVRYjYRfXQKBgQCtWN60lpb4qf5tCtSj 25 | UIYJ4+BYZ0blW2HpCkUAqh4H860MRlkcEHAwCSKHxU9QjifV7e7cfAvX2VKOPcFQ 26 | TtVrs4ltamA6cRms/SWJQlkboXVRcPjfZt9WRYuqLoDnrODqXX4DF1fouGcuAJ1+ 27 | 7N6eq1wXbSPFFsR5K19JV7di1A== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /certs/PowerBICustomVisualTest_public.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDrzCCApegAwIBAgIJAJoHcouCtktvMA0GCSqGSIb3DQEBCwUAMG4xKzApBgNV 3 | BAoMIkRPX05PVF9UUlVTVF9Qb3dlckJJVmlzdWFsVGVzdFJvb3QxKzApBgNVBAsM 4 | IkRPX05PVF9UUlVTVF9Qb3dlckJJVmlzdWFsVGVzdFJvb3QxEjAQBgNVBAMMCWxv 5 | Y2FsaG9zdDAeFw0xNjA1MDkyMzQ2MzBaFw0xODA1MDkyMzQ2MzBaMG4xKzApBgNV 6 | BAoMIkRPX05PVF9UUlVTVF9Qb3dlckJJVmlzdWFsVGVzdFJvb3QxKzApBgNVBAsM 7 | IkRPX05PVF9UUlVTVF9Qb3dlckJJVmlzdWFsVGVzdFJvb3QxEjAQBgNVBAMMCWxv 8 | Y2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPFieoqv0orD 9 | Fs+n8fXnqw7z04E3zy2ptJKEpYquTmS0CNrXEC/7r/Gda+go0mgYtLdXln+uVVr+ 10 | bZisFwJpJBm9xanbz7FhyquH5Jpt5CHIMaCNMTWcyYA5MFcs+zL07mnb+lGf36P6 11 | eiIIvdHyP31DX+5kMCsMFaK3TRLNgka4V1RvQNWhZxZv5u0C2kl7hD9J6lkp8xUM 12 | 06ws14zoBDNYMWnkUcUEg5gceKM3CFHgvJE5zZNPqhHYPNCAHI6T863/PASfxWfn 13 | nbznsQqgLhEFbCzS8JxMi8JU+5ZcbCvnbh3II4ISLCIrfiVrjkezhQw4idskpIHs 14 | p6FwianmoTUCAwEAAaNQME4wHQYDVR0OBBYEFCWXalZgOJfwtYxsfTxZ2Ok8MFMl 15 | MB8GA1UdIwQYMBaAFCWXalZgOJfwtYxsfTxZ2Ok8MFMlMAwGA1UdEwQFMAMBAf8w 16 | DQYJKoZIhvcNAQELBQADggEBAMPC7CahK5lw5BkO991tejQMu8JElkqEOQCj6Vbc 17 | yG+1gs+55sMxLgBg4GBUYqNepK+hMGzBDawG4V3c6lxx3nR8fSdPQdqcxEfTYvgD 18 | W9eKk2QLMcg4iZzHfSAcHeUIZc6RLVlDcf3KzFIxs9wTeVThwI29erDNHwxqRBkD 19 | QKvTMYXB7SeTpIEdTELwOh/xrSe5eq6TUWgCcOMqLO0auYosqlJsivHPKZiA5Z7/ 20 | NhOaDpI5BQ5gxHglX7faCXnI0mBU3yRABZZkS4roXf4FgBewwOLeT6Vjkxoy8P+W 21 | kPRQrZlZR4pcKY5E15fxenStD01d8O86wA3oJwYFwLmUthg= 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const isTddMode = process.argv.indexOf("--tdd") > -1; 8 | const webpackConfig = require('./webpack.config'); 9 | const webpack = require('webpack'); 10 | const path = require('path'); 11 | 12 | module.exports = function (config) { 13 | config.set({ 14 | basePath: '', 15 | frameworks: ['mocha', 'sinon-chai', 'jquery-1.8.3'], 16 | files: [ 17 | 'node_modules/babel-polyfill/dist/polyfill.min.js', 18 | 'src/**/*.spec.js', 19 | 'src/**/*.spec.ts', 20 | ], 21 | exclude: [ 22 | ], 23 | preprocessors: { 24 | 'src/**/*.spec.js': ['webpack', 'sourcemap'], 25 | 'src/**/*.spec.ts': ['webpack', 'typescript'], 26 | }, 27 | webpackMiddleware: { 28 | stats: 'errors-only', 29 | }, 30 | webpack: { 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.handlebars$/, 35 | loader: 'handlebars-loader', 36 | query: { 37 | helperDirs: [ 38 | path.resolve(__dirname, 'lib/@uncharted/cards/src/handlebarHelper'), 39 | ], 40 | }, 41 | }, 42 | { 43 | test: /\.js$/, 44 | loader: 'babel-loader', 45 | options: { 46 | presets: [ 47 | ['latest', { es2015: { modules: false } }], 48 | ], 49 | }, 50 | exclude: /node_modules/, 51 | }, 52 | { 53 | test: /\.ts?$/, 54 | loaders: [{ 55 | loader: 'babel-loader', 56 | options: { 57 | presets: [ 58 | ['latest', {es2015: {modules: false}}], 59 | ], 60 | }, 61 | }, 'ts-loader'], 62 | }, 63 | ], 64 | }, 65 | resolve: webpackConfig.resolve, 66 | externals: [ 67 | { 68 | sinon: "sinon", 69 | chai: "chai", 70 | }, 71 | ], 72 | plugins: [ 73 | new webpack.SourceMapDevToolPlugin({ 74 | filename: null, // if no value is provided the sourcemap is inlined 75 | test: /\.(js)($|\?)/i, // process .js files only 76 | }), 77 | ], 78 | }, 79 | reporters: ['mocha'], 80 | port: 9876, 81 | colors: true, 82 | logLevel: config.LOG_INFO, 83 | autoWatch: true, 84 | browsers: isTddMode ? ['Chrome'] : ['PhantomJS'], 85 | singleRun: !isTddMode, 86 | concurrency: Infinity, 87 | }); 88 | }; 89 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Cards 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 |
16 |
17 |
18 | 21 | 22 |
23 |
24 | 27 | 28 |
29 |
30 |
31 | 32 |
33 | 34 | 35 | 36 | 52 | 171 | 172 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/example/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | html, body { 18 | margin: 0; 19 | padding: 0; 20 | display: flex; 21 | position: relative; 22 | width: 100%; 23 | height: 100%; 24 | box-sizing: border-box; 25 | } 26 | 27 | .input-panel { 28 | width: 300px; 29 | height: 100%; 30 | position: relative; 31 | border-right: 1px solid #ddd; 32 | display: flex; 33 | flex-direction: column; 34 | } 35 | 36 | .buttons-box { 37 | padding: 5px; 38 | } 39 | 40 | .text-box-container { 41 | flex-grow: 1; 42 | display: flex; 43 | flex-direction: column; 44 | } 45 | 46 | .data-box, .config-box { 47 | display: flex; 48 | flex-direction: column; 49 | flex-grow: 1; 50 | padding: 5px; 51 | } 52 | 53 | .config-box { 54 | height: 300px; 55 | } 56 | 57 | textarea { 58 | width: 100%; 59 | height: 100%; 60 | resize: none; 61 | } 62 | 63 | .cards-panel { 64 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 65 | font-size: 14px; 66 | line-height: 1.42857; 67 | width: calc(100% - 300px); 68 | } 69 | ::-webkit-scrollbar { 70 | width: 8px; 71 | height: 8px; 72 | border-radius: 100px; 73 | background-color: rgba(0, 0, 0, 0); 74 | } 75 | 76 | ::-webkit-scrollbar:hover { 77 | background-color: rgba(0, 0, 0, 0.09); 78 | } 79 | 80 | ::-webkit-scrollbar-thumb { 81 | background: rgba(0, 0, 0, 0.2); 82 | border-radius: 100px; 83 | } 84 | 85 | ::-webkit-scrollbar-thumb:hover, 86 | ::-webkit-scrollbar-thumb:active 87 | { 88 | background: rgba(0, 0, 0, 0.5); 89 | } -------------------------------------------------------------------------------- /lib/@uncharted/cards/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uncharted/cards", 3 | "version": "0.13.9", 4 | "main": "dist/uncharted.cards.js", 5 | "scripts": { 6 | "test": "./node_modules/.bin/karma start", 7 | "build": "npm-run-all --parallel build:**", 8 | "build:dev": "webpack", 9 | "build:prod": "cross-env NODE_ENV='production' webpack -p", 10 | "start": "cross-env WEBPACK_ENV='serve' webpack-dev-server --host 0.0.0.0 --port 8090 --content-base ./example --hot --open", 11 | "clean": "rm -rf node_modules" 12 | }, 13 | "publishConfig": { 14 | "registry": "https://npm.uncharted.software" 15 | }, 16 | "author": "Uncharted Software Inc.", 17 | "description": "", 18 | "dependencies": { 19 | "lodash": "^4.17.11" 20 | }, 21 | "devDependencies": { 22 | "babel-core": "^6.26.0", 23 | "babel-loader": "^7.1.2", 24 | "babel-polyfill": "^6.26.0", 25 | "babel-preset-latest": "^6.24.1", 26 | "chai": "^4.1.2", 27 | "clean-webpack-plugin": "^0.1.16", 28 | "cross-env": "^5.0.5", 29 | "css-loader": "^0.28.4", 30 | "eslint": "^5.15.1", 31 | "eslint-loader": "^2.1.2", 32 | "handlebars": "^4.0.11", 33 | "handlebars-loader": "^1.7.0", 34 | "karma": "^4.0.1", 35 | "karma-chrome-launcher": "^2.2.0", 36 | "karma-mocha": "^1.3.0", 37 | "karma-mocha-reporter": "^2.2.5", 38 | "karma-sinon-chai": "^1.3.3", 39 | "karma-sourcemap-loader": "^0.3.7", 40 | "karma-webpack": "^2.0.4", 41 | "mocha": "^5.0.4", 42 | "node-sass": "^4.5.3", 43 | "npm-run-all": "^4.0.2", 44 | "puppeteer": "^1.2.0", 45 | "sass-loader": "^6.0.6", 46 | "sinon": "^4.4.6", 47 | "sinon-chai": "^3.0.0", 48 | "style-loader": "^0.18.2", 49 | "webpack": "^4.29.6", 50 | "webpack-cli": "^3.2.3", 51 | "webpack-dev-server": "^3.2.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/_variables.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | // card 6 | $card-height: 300px !default; 7 | $card-border-width: 1px !default; 8 | $card-border: $card-border-width solid #F2F2F2 !default; 9 | $card-shadow: -1px 0 0 rgba(0,0,0,.12), 0 0 2px rgba(0,0,0,.12), 2px 0 4px rgba(0,0,0,.24) !default; 10 | $card-margin: 5px !default; 11 | $card-expanding-animation: 0.3s ease-out !default; 12 | $card-flipping-animation-duration: 0.3s !default; 13 | 14 | $inline-card-max-height: 400px !default; 15 | $inline-card-min-height: 150px !default; 16 | $inline-card-height: 100% !default; 17 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/card/_card.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | @import '../variables'; 6 | 7 | .flip-tag-hidden { 8 | .flip-tag, .back .flip-tag { 9 | display: none; 10 | } 11 | } 12 | 13 | .uncharted-cards-card { 14 | $card-outer: $card-margin + $card-border-width; 15 | height: $card-height; 16 | padding: $card-margin; 17 | flex-shrink: 0; 18 | transition: width $card-expanding-animation; 19 | 20 | &.title-only { 21 | .card-image { 22 | display: none; 23 | } 24 | .card-body h1 { 25 | margin-right: 13px; 26 | } 27 | .metadata-content { 28 | .image { 29 | display: none; 30 | } 31 | } 32 | } 33 | 34 | &.shadow-enabled { 35 | .card { 36 | border: none; 37 | box-shadow: $card-shadow; 38 | } 39 | .card-content-container { 40 | padding-left: $card-margin * 2; 41 | left: -(2 * $card-margin); 42 | &:after { 43 | width: calc(100% - #{$card-margin * 2}); 44 | } 45 | } 46 | } 47 | 48 | &.flip-container.shadow-enabled { 49 | .back { 50 | box-shadow: none; 51 | } 52 | .flipped { 53 | .back { 54 | box-shadow: $card-shadow; 55 | } 56 | .front { 57 | box-shadow: none; 58 | } 59 | } 60 | } 61 | 62 | .card { 63 | border: $card-border; 64 | background-color: #fff; 65 | position: relative; 66 | overflow: hidden; 67 | } 68 | 69 | .card-content-container { 70 | position: absolute; 71 | height: 100%; 72 | padding-left: $card-outer * 2; 73 | left: -(2 * $card-outer); 74 | transition: opacity $card-expanding-animation; 75 | opacity: 1; 76 | z-index: 1; 77 | &.card-reader-container { 78 | opacity: 0; 79 | z-index: 0; 80 | } 81 | } 82 | 83 | &.expanded .card-content-container { 84 | opacity: 0; 85 | 86 | &.card-reader-container { 87 | opacity: 1; 88 | z-index: 2; 89 | } 90 | } 91 | 92 | .card-content-container:after { 93 | content: ""; 94 | pointer-events: none; 95 | position: absolute; 96 | background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #fff 85%); 97 | width: calc(100% - #{$card-outer * 2}); 98 | height: 50px; 99 | bottom: 0; 100 | } 101 | 102 | .metadata-content-container.has-image:after { 103 | bottom: 50px; 104 | } 105 | 106 | .card-reader-container:after { 107 | display: none; 108 | } 109 | 110 | .card-header { 111 | height: 28px; 112 | } 113 | 114 | .card-image + .card-body { 115 | padding-top: 8px; 116 | } 117 | 118 | .card-content-container { 119 | 120 | h1 { 121 | font-size: 13px; 122 | margin: 0; 123 | cursor: pointer; 124 | } 125 | 126 | .meta-line { 127 | flex-shrink: 0; 128 | } 129 | 130 | .summary { 131 | margin-top: 10px; 132 | font: inherit; 133 | font-size: 12px; 134 | overflow: hidden; 135 | } 136 | } 137 | 138 | // Card body 139 | .card-body { 140 | display: flex; 141 | flex-direction: column; 142 | overflow: hidden; 143 | overflow-wrap: break-word; 144 | padding: 16px 16px 8px 16px; 145 | } 146 | 147 | // Card icon 148 | .card-icon { 149 | height: 28px; 150 | width: 28px; 151 | padding: 6px; 152 | background: #f6f6f6 no-repeat 50% 50% / 16px 16px; 153 | border-radius: 14px; 154 | position: absolute; 155 | top: 2px; 156 | right: 3px; 157 | z-index: 999; 158 | transition: opacity $card-expanding-animation; 159 | } 160 | 161 | &.expanded .card-icon { 162 | opacity: 0; 163 | } 164 | 165 | .meta-data-content { 166 | position: relative; 167 | height: 100%; 168 | $title-line-height: 1.25em; 169 | 170 | .overflow-box { 171 | position: relative; 172 | overflow: hidden; 173 | white-space: nowrap; 174 | } 175 | 176 | .overflow-box.overflow:after { 177 | content: ""; 178 | pointer-events: none; 179 | position: absolute; 180 | background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, #fff 85%); 181 | right: 0; 182 | top: 0; 183 | height: 100%; 184 | width: 5em; 185 | } 186 | 187 | .title-box { 188 | font-size: 13px; 189 | max-height: #{$title-line-height * 2}; 190 | white-space: normal; 191 | 192 | h1 { 193 | font-size: inherit; 194 | } 195 | 196 | &.overflow:after { 197 | top: $title-line-height; 198 | height: $title-line-height; 199 | } 200 | } 201 | 202 | .subtitle-box { 203 | margin-top: 2px; 204 | .subtitle { 205 | margin: 0; 206 | display: inline; 207 | } 208 | } 209 | } 210 | 211 | .meta-data-table { 212 | margin-top: 10px; 213 | font-size: 12px; 214 | vertical-align: bottom; 215 | table { 216 | table-layout: fixed; 217 | } 218 | .value-box { 219 | &.overflow:after { 220 | width: 15px; 221 | } 222 | } 223 | 224 | .key-box { 225 | max-width: 38.2%; 226 | text-align: right; 227 | padding-right: 2px; 228 | color: #bbb; 229 | } 230 | 231 | .meta-data-box { 232 | overflow: hidden; 233 | .value { 234 | padding-left: 2px; 235 | } 236 | 237 | .value img { 238 | max-height: 1em; 239 | max-width: 100%; 240 | } 241 | } 242 | } 243 | 244 | .meta-data-images-container { 245 | position: absolute; 246 | width: 100%; 247 | height: 50px; 248 | bottom: 0; 249 | background-color: #F2F2F2; 250 | display: flex; 251 | flex-wrap: nowrap; 252 | .image { 253 | width: 50px; 254 | height: 50px; 255 | background-position: 50% 50%; 256 | background-repeat: no-repeat; 257 | background-size: cover; 258 | margin-left: 1px; 259 | flex-shrink: 0; 260 | } 261 | } 262 | } 263 | 264 | 265 | .uncharted-cards-card { 266 | .flipper, 267 | .front, 268 | .back { 269 | width: 100%; 270 | height: 100%; 271 | } 272 | .flipper { 273 | position: relative; 274 | } 275 | } 276 | 277 | // Flipping animation 278 | .uncharted-cards-card.flip-container { 279 | perspective: 1000px; 280 | .flipper { 281 | transition: $card-flipping-animation-duration; 282 | transform-style: preserve-3d; 283 | &.flipped { 284 | transform: rotateY(180deg); 285 | } 286 | } 287 | .front, 288 | .back { 289 | backface-visibility: hidden; 290 | position: absolute; 291 | top: 0; 292 | left: 0; 293 | } 294 | .front { 295 | z-index: 2; 296 | transform: rotateY(0deg); 297 | } 298 | .back { 299 | transform: rotateY(180deg); 300 | } 301 | } 302 | 303 | .uncharted-cards-card.flip-container.expanded { 304 | .flipper { 305 | transition: none; 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/card/card.handlebars: -------------------------------------------------------------------------------- 1 | {{!-- 2 | /* 3 | * Copyright 2018 Uncharted Software Inc. 4 | */ 5 | --}} 6 | 7 |
8 |
9 | {{#unless removeFrontCard}} 10 |
11 | {{#if iconUrl}} 12 |
13 | {{/if}} 14 |
15 |
16 |
17 |
18 | {{#if title}} 19 |

{{title}}

20 | {{/if}} 21 | {{#if subtitle.length }} 22 |
23 | {{#each subtitle}}{{#unless @first}}{{../subtitleDelimiter}}{{/unless}}{{this}}{{/each}} 24 |
25 | {{/if}} 26 |
{{{summary}}}
27 |
28 |
29 |
30 |
31 |
32 | {{/unless}} 33 | {{#unless removeBackCard}} 34 |
35 | 82 |
83 |
84 | {{/unless}} 85 |
86 |
87 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/card/card.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import cardTemplate from './card.handlebars'; 6 | import ReaderContent from '../readerContent/readerContent'; 7 | import HeaderImage from '../headerImage/headerImage'; 8 | import { IBindable, createFallbackIconURL } from '../../util'; 9 | import { DEFAULT_CONFIG, EVENTS } from '../constants'; 10 | 11 | export default class Card extends IBindable { 12 | constructor(spec = {}) { 13 | super(); 14 | this.headerImage = new HeaderImage(); 15 | this.readerContent = new ReaderContent(); 16 | this.forward(this.readerContent); 17 | this.reset(spec); 18 | } 19 | 20 | reset(spec = {}) { 21 | this.$element = undefined; 22 | this._config = Object.assign({}, DEFAULT_CONFIG, spec.config); 23 | this.data = spec.data || {}; 24 | 25 | const imageHeight = (this.data.summary && !this._config['card.displayLargeImage']) ? 26 | undefined : this.initialWidth - 10; // card margins from css TODO: move css to config object 27 | 28 | this.headerImage.reset({ imageUrls: this.data.imageUrl, imageHeight: imageHeight, config: this._config }); 29 | this.readerContent.reset({ data: this.data, config: this._config }); 30 | this.forward(this.readerContent); 31 | 32 | this.isExpanded = false; 33 | this.isFlipped = this._config['card.displayBackCardByDefault']; 34 | return this; 35 | } 36 | 37 | get expandedWidth() { 38 | return this._config['card.expandedWidth']; 39 | } 40 | 41 | get initialWidth() { 42 | return this._config['card.width']; 43 | } 44 | 45 | get height() { 46 | return this._config['card.height']; 47 | } 48 | 49 | _getIconUrl() { 50 | const source = this.data.sourceIconName || this.data.source; 51 | return this.data.sourceImage || (this.data.source && createFallbackIconURL(50, 50, source)); 52 | } 53 | 54 | render() { 55 | const noImages = !this.data.imageUrl && (this.data.source || this.data.sourceUrl); 56 | const displayBackCardByDefault = this._config['card.displayBackCardByDefault']; 57 | const disableFlipping = this._config['card.disableFlipping']; 58 | const enableBoxShadow = this._config['card.enableBoxShadow']; 59 | const metaDataFontSize = this._config['card.metadata.fontSize']; 60 | const metaDataTitleColor = this._config['card.metadata.title.color']; 61 | const metaDataTitleFontFamily = this._config['card.metadata.title.fontFamily']; 62 | const metaDataValueColor = this._config['card.metadata.value.color']; 63 | const metaDataValueFontFamily = this._config['card.metadata.value.fontFamily']; 64 | 65 | const data = Object.assign({ 66 | titleOnly: noImages, 67 | boxShadow: enableBoxShadow, 68 | width: this.isExpanded ? this.expandedWidth : this.initialWidth, 69 | height: this.height, 70 | expandedWidth: this.expandedWidth, 71 | cardContentWidth: this.initialWidth, 72 | isExpanded: this.isExpanded, 73 | isFlipped: this.isFlipped, 74 | disableFlipping, 75 | subtitleDelimiter: this._config.subtitleDelimiter, 76 | iconUrl: this._getIconUrl(), 77 | removeFrontCard: disableFlipping && displayBackCardByDefault, 78 | removeBackCard: disableFlipping && !displayBackCardByDefault, 79 | tooltip: $('
').html(this.data.summary).text(), 80 | metaDataFontSize: metaDataFontSize, 81 | metaDataTitleColor: metaDataTitleColor, 82 | metaDataTitleFontFamily: metaDataTitleFontFamily, 83 | metaDataValueColor: metaDataValueColor, 84 | metaDataValueFontFamily: metaDataValueFontFamily, 85 | }, this.data); 86 | 87 | this.$element = $(cardTemplate(data)); 88 | 89 | this._$cardImage = this.$element.find('.card-image'); 90 | this.headerImage.hasImages() && this._$cardImage.append(this.headerImage.render()); 91 | 92 | this.isExpanded && this._renderReaderContent(); 93 | 94 | this._registerDomEvents(); 95 | return this.$element; 96 | } 97 | 98 | _renderReaderContent() { 99 | if (this.$element) { 100 | const readerContainerSelector = this.isFlipped ? '.back.card .card-reader-container' : '.front.card .card-reader-container'; 101 | this._$readerContent = this.readerContent.render(); 102 | this.$element.find(this._config['card.disableFlipping'] ? '.card-reader-container' : readerContainerSelector).html(this._$readerContent); 103 | } 104 | } 105 | 106 | _moveReaderContent() { 107 | if (this.readerContent && this.readerContent.$element && this.readerContent.$element.parent().hasClass('card-reader-container')) { 108 | const readerContainerSelector = this.isFlipped ? '.back.card .card-reader-container' : '.front.card .card-reader-container'; 109 | this.$element.find(this._config['card.disableFlipping'] ? '.card-reader-container' : readerContainerSelector).append(this.readerContent.$element); 110 | } 111 | } 112 | 113 | _registerDomEvents() { 114 | this.$element.on('transitionend', event => { 115 | const originalEvent = event.originalEvent; 116 | if (event.target !== this.$element[0]) { 117 | return; 118 | } 119 | if (event.target === this.$element[0] && originalEvent.propertyName === 'width' && !this.isExpanded) { 120 | this.emit(EVENTS.CARD_SHRINK, this); 121 | } else if (event.target === this.$element[0] && originalEvent.propertyName === 'width' && this.isExpanded) { 122 | this.emit(EVENTS.CARD_EXPAND, this); 123 | } 124 | }); 125 | this.$element.on('click', '.card', event => { 126 | event.stopImmediatePropagation(); 127 | this.emit(EVENTS.CARD_CLICK, this); 128 | }); 129 | 130 | this.$element.on('click', '.meta-data-table a', event => { 131 | event.stopImmediatePropagation(); 132 | this.emit(EVENTS.CARD_CLICK_LINK, event); 133 | return !this._config['card.disableLinkNavigation']; 134 | }); 135 | 136 | this.$element.on('mouseenter', '.overflow-box', event => $(event.currentTarget).attr('title', $(event.currentTarget).find('.overflow-value').text().trim())); 137 | this.$element.on('mouseleave', '.overflow-box', event => $(event.currentTarget).removeAttr('title')); 138 | } 139 | 140 | expand() { 141 | this.isExpanded = true; 142 | if (this.$element) { 143 | this.$element.addClass('expanded'); 144 | this.$element.css('width', this.expandedWidth); 145 | } 146 | } 147 | 148 | shrink() { 149 | this.isExpanded = false; 150 | if (this.$element) { 151 | this.$element.removeClass('expanded'); 152 | this.$element.css('width', this.initialWidth); 153 | } 154 | } 155 | 156 | flip(state) { 157 | this.isFlipped = state === undefined ? !this.isFlipped : Boolean(state); 158 | if (this.$element) { 159 | this.$element.find('.flipper').toggleClass('flipped', this.isFlipped); 160 | this._moveReaderContent(); 161 | } 162 | } 163 | 164 | updateReaderContent(readerContentData = {}) { 165 | this.readerContent.updateData(readerContentData); 166 | this._renderReaderContent(); 167 | } 168 | 169 | scaleHeaderImages() { 170 | this.headerImage.hasImages() && this._$cardImage[0] && this.headerImage.scaleImages(this._$cardImage[0].offsetWidth, this._$cardImage[0].offsetHeight); 171 | } 172 | } 173 | 174 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/cards/_cards.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | @import '../variables'; 6 | 7 | .uncharted-cards { 8 | width: 100%; 9 | height: 100%; 10 | } -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/cards/cards.handlebars: -------------------------------------------------------------------------------- 1 | {{!-- 2 | /* 3 | * Copyright 2018 Uncharted Software Inc. 4 | */ 5 | --}} 6 | 7 |
8 |
-------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/cards/cards.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import cardsTemplate from './cards.handlebars'; 6 | import InlineCardsView from '../inlineCardsView/inlineCardsView'; 7 | import WrappedCardsView from '../wrappedCardsView/wrappedCardsView'; 8 | import Card from '../card/card'; 9 | import { IBindable } from '../../util'; 10 | import { DEFAULT_CONFIG } from '../constants'; 11 | 12 | export default class Cards extends IBindable { 13 | constructor(config = {}) { // eslint-disable-line 14 | super(); 15 | this._initInlineCardsView(); 16 | this._initWrappedCardsView(); 17 | this.reset(config); 18 | } 19 | 20 | _initInlineCardsView() { 21 | this.inlineCardsView = new InlineCardsView({ config: this._config }); 22 | this.inlineCardsView.postRender = () => { 23 | this._fadeOutOverflowingCardMetadataTexts(this.inlineCardsView.cardsInViewPort); 24 | }; 25 | this.forward(this.inlineCardsView); 26 | } 27 | 28 | _initWrappedCardsView() { 29 | this.wrappedCardsView = new WrappedCardsView({ config: this._config }); 30 | this.wrappedCardsView.postRender = () => { 31 | this._fadeOutOverflowingCardMetadataTexts(this.wrappedCardsView.cardInstances); 32 | }; 33 | this.forward(this.wrappedCardsView); 34 | } 35 | 36 | _initMoreCards(cardsData) { 37 | const cardsInstances = cardsData.map(data => { 38 | const card = new Card({ data, config: Object.assign({}, this._config, { 39 | 'card.height': this.inlineMode ? null : this._config['card.height'], 40 | })}); 41 | this.forward(card); 42 | return card; 43 | }); 44 | this.cardInstances.push(...cardsInstances); 45 | } 46 | 47 | _fadeOutOverflowingCardMetadataTexts(cardInstances) { 48 | const overflowBoxElements = []; 49 | cardInstances.forEach(card => { 50 | const cardEle = card.$element && card.$element[0]; 51 | const textBoxElements = cardEle && cardEle.querySelectorAll('.meta-data-content .overflow-box'); 52 | [].forEach.call(textBoxElements || [], element => { 53 | const textValueElement = element.children && element.children[0]; 54 | if (textValueElement) { 55 | const boxWidth = element.offsetWidth; 56 | const boxHeight = element.offsetHeight; 57 | const valueWidth = textValueElement.offsetWidth; 58 | const valueHeight = textValueElement.offsetHeight; 59 | const isOverflowing = valueWidth > boxWidth || valueHeight > boxHeight; 60 | isOverflowing && overflowBoxElements.push(element); 61 | } 62 | }); 63 | }); 64 | overflowBoxElements.forEach(element => element.classList.add('overflow')); 65 | } 66 | 67 | reset(config) { 68 | this._config = Object.assign({}, DEFAULT_CONFIG, config); 69 | this.cardInstances = []; 70 | this.inlineMode = Boolean(this._config.inlineMode); 71 | this.inlineCardsView.reset({ config: this._config }); 72 | this.wrappedCardsView.reset({ config: this._config }); 73 | return this; 74 | } 75 | 76 | render() { 77 | this.$element = $(cardsTemplate({ 78 | inlineMode: this.inlineMode, 79 | })); 80 | this.inlineMode 81 | ? this.$element.html(this.inlineCardsView.render()) 82 | : this.$element.html(this.wrappedCardsView.render()); 83 | return this.$element; 84 | } 85 | 86 | resize() { 87 | if (this.$element) { 88 | this.inlineMode 89 | ? this.inlineCardsView.resize() 90 | : this.wrappedCardsView.resize(); 91 | } 92 | } 93 | 94 | clearCards() { 95 | this.cardInstances = []; 96 | this.inlineCardsView.cardInstances = []; 97 | this.wrappedCardsView.cardInstances = []; 98 | this.inlineMode 99 | ? this.$element.html(this.inlineCardsView.render()) 100 | : this.$element.html(this.wrappedCardsView.clearCards()); 101 | } 102 | 103 | loadData(cardsData) { 104 | this.clearCards(); 105 | this._initMoreCards(cardsData); 106 | 107 | this.inlineMode 108 | ? this.inlineCardsView.updateCardInstances(this.cardInstances) 109 | : this.wrappedCardsView.renderMoreCards(this.cardInstances); 110 | } 111 | 112 | loadMoreData(cardsData) { 113 | const numberOfCurrentCards = this.cardInstances.length; 114 | this._initMoreCards(cardsData); 115 | this.inlineMode 116 | ? this.inlineCardsView.updateCardInstances(this.cardInstances) 117 | : this.wrappedCardsView.renderMoreCards(this.cardInstances.slice(numberOfCurrentCards)); 118 | } 119 | 120 | findCardById(cardId) { 121 | return this.cardInstances.find(card => card.data.id === cardId); 122 | } 123 | 124 | /** 125 | * Open a reader for the provided card 126 | * @param {Object} card - card instance whose reader will be opened. 127 | */ 128 | openReader(card) { 129 | this.inlineMode 130 | ? this.inlineCardsView.openReader(card) 131 | : this.wrappedCardsView.openReader(card); 132 | } 133 | 134 | closeReader() { 135 | this.inlineMode 136 | ? this.inlineCardsView.closeReader() 137 | : this.wrappedCardsView.closeReader(); 138 | } 139 | 140 | toggleInlineDisplayMode(state) { 141 | this.closeReader(); 142 | this.inlineMode = state === undefined ? !this.inlineMode : state; 143 | this.cardInstances.forEach(card => { 144 | card.$element && card.$element.remove() && (card.$element = undefined); 145 | card._config['card.height'] = this.inlineMode ? null : this._config['card.height']; 146 | }); 147 | this.inlineMode 148 | ? this.$element.html(this.inlineCardsView.render()) && this.inlineCardsView.updateCardInstances(this.cardInstances) 149 | : this.$element.html(this.wrappedCardsView.clearCards()) && this.wrappedCardsView.renderMoreCards(this.cardInstances); 150 | } 151 | 152 | updateReaderContent(card, readerContentData) { 153 | this.inlineMode 154 | ? card.updateReaderContent(readerContentData) 155 | : this.wrappedCardsView.verticalReader.updateReaderContent(card, readerContentData); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/constants.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | export const DEFAULT_CONFIG = { 6 | 'inlineMode': true, 7 | 'subtitleDelimiter': ' \u2022 ', 8 | 'scrollToVerticalReaderDuration': 300, 9 | 'inlineCardCenteringDuration': 300, 10 | 'card.width': 200, 11 | 'card.height': 300, 12 | 'card.enableBoxShadow': false, 13 | 'card.expandedWidth': 520, 14 | 'card.disableFlipping': false, 15 | 'card.displayBackCardByDefault': false, 16 | 'card.disableLinkNavigation': false, // set to true if you plan to handle the CARD_CLICK_LINK event 17 | 'card.displayLargeImage': false, // false is actually more like 'auto' 18 | 'card.metadata.fontSize': 10, // pt, to match the PBI fontSize control 19 | 'card.metadata.title.color': '#bbb', 20 | 'card.metadata.title.fontFamily': 'inherit', 21 | 'card.metadata.value.color': '#000', 22 | 'card.metadata.value.fontFamily': 'inherit', 23 | 'verticalReader.height': 500, 24 | 'readerContent.headerBackgroundColor': '#555', 25 | 'readerContent.headerSourceLinkColor': '#fff', 26 | 'readerContent.headerImageMaxWidth': 190, 27 | 'readerContent.disableLinkNavigation': false, // set to true if you plan to handle the READER_CONTENT_CLICK_LINK event 28 | 'readerContent.cropImages': true, // set to false to show entire image, even if portrait mode and tiny 29 | }; 30 | 31 | export const EVENTS = { 32 | CARDS_CLICK_BACKGROUND: 'cards:clickBackground', 33 | CARD_CLICK: 'card:click', 34 | CARD_CLICK_LINK: 'card:clickLink', 35 | CARD_EXPAND: 'card:expand', 36 | CARD_SHRINK: 'card:shrink', 37 | INLINE_CARDS_VIEW_SCROLL_END: 'inlineCardsView:scrollEnd', 38 | WRAPPED_CARDS_VIEW_SCROLL_END: 'wrappedCardsView:scrollEnd', 39 | VERTICAL_READER_NAVIGATE_CARD: 'verticalReader:navigateToCard', 40 | VERTICAL_READER_CLICK_BACKGROUND: 'verticalReader:clickBackground', 41 | READER_CONTENT_CLICK_CLOSE: 'readerContent:clickCloseButton', 42 | READER_CONTENT_CLICK_LINK: 'readerContent:clickLink', 43 | }; 44 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/headerImage/_headerImage.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | .uncharted-cards-header-image { 6 | height: 100px; 7 | display: flex; 8 | position: relative; 9 | overflow: hidden; 10 | .image { 11 | height: 100%; 12 | position: relative; 13 | background-position: 50% 30%; 14 | background-repeat: no-repeat; 15 | background-color: #fff; 16 | flex-grow: 1; 17 | background-size: cover; 18 | } 19 | .mirror-image-box { 20 | flex-grow: 1; 21 | position: relative; 22 | display: flex; 23 | 24 | .gradient { 25 | position: absolute; 26 | height: 100%; 27 | width: 100%; 28 | z-index: 1; 29 | left: 1px; 30 | } 31 | 32 | .image { 33 | right: 1px; 34 | width: 100%; 35 | transform: scaleX(-1) translate3d(0, 0, 0); 36 | outline: 3.5px solid transparent; 37 | } 38 | 39 | .padding { 40 | border-left: 1px solid; 41 | flex-grow: 1; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/headerImage/headerImage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import { IBindable, loadImage } from '../../util'; 6 | import { DEFAULT_CONFIG } from '../constants'; 7 | 8 | export default class HeaderImage extends IBindable { 9 | constructor(spec = {}) { 10 | super(); 11 | this.reset(spec); 12 | } 13 | 14 | reset(spec) { 15 | this.MAX_NUM_IMAGES = 4; 16 | this.DEFAULT_IMAGE_HEIGHT = 100; 17 | this.DEFAULT_IMAGE_BG_COLOR = '#FFF'; 18 | 19 | this._config = Object.assign({}, DEFAULT_CONFIG, spec.config); 20 | this.imageUrls = [].concat(spec.imageUrls || []).slice(0, this.MAX_NUM_IMAGES); 21 | this.loadedImagePromises = this.imageUrls.map(imageUrl => loadImage(imageUrl)); 22 | this._mirrorLastImage = Boolean(spec.mirrorLastImage); 23 | this._mirrorImageGradientColor = spec.mirrorImageGradientColor || '#555'; 24 | this.imageHeight = spec.imageHeight || this.DEFAULT_IMAGE_HEIGHT; 25 | this.imgMaxWidth = `${spec.imageMaxWidth}px` || 'none'; 26 | 27 | this.portraitImageMaxWidth = '100px'; 28 | return this; 29 | } 30 | 31 | render() { 32 | const lastImageUrl = this.imageUrls[this.imageUrls.length - 1]; 33 | const wrapImages = this.imageHeight > this.DEFAULT_IMAGE_HEIGHT && this.imageUrls.length > 2; 34 | this.$element = $('
') 35 | .css({ 36 | 'background-color': this._mirrorLastImage ? this._mirrorImageGradientColor : this.DEFAULT_IMAGE_BG_COLOR, 37 | 'height': `${this.imageHeight}px`, 38 | 'flex-wrap': wrapImages ? 'wrap' : 'nowrap', 39 | }); 40 | this._$partialImages = this.imageUrls.map(imageUrl => { 41 | const $image = $('
') 42 | .css({ 43 | 'background-image': `url(${imageUrl})`, 44 | 'max-width': this.imgMaxWidth, 45 | }); 46 | return wrapImages ? $image.css({ height: '50%', width: '50%', 'flex-grow': 0 }) : $image; 47 | }); 48 | if (this._mirrorLastImage && lastImageUrl) { 49 | this._$partialImages.push($(` 50 |
51 |
52 |
53 |
54 | `)); 55 | } 56 | return this.$element.append(this._$partialImages); 57 | } 58 | 59 | hasImages() { 60 | return this.imageUrls.length > 0; 61 | } 62 | 63 | scaleImages(containerWidth, containerHeight) { 64 | const $partialImages = []; 65 | const numberOfImages = this.imageUrls.length; 66 | const partialImageWidth = containerWidth / numberOfImages; 67 | const partialImageHeight = containerHeight / numberOfImages; 68 | const subdivided = numberOfImages > 1; 69 | this.loadedImagePromises.forEach((imagePromise, index) => { 70 | imagePromise.then(img => { 71 | const scale = Math.max(partialImageWidth / img.width, partialImageHeight / img.height); 72 | const scaledWidth = Math.round(img.width * scale); 73 | let sizeType = 'cover'; 74 | if ((subdivided && scaledWidth < partialImageWidth) || (!subdivided && scaledWidth > partialImageWidth)) { 75 | sizeType = 'contain'; 76 | } else if (scale > 1) { 77 | sizeType = 'auto'; 78 | } 79 | this._$partialImages[index].css({ 80 | 'background-size': sizeType, 81 | 'background-image': `url(${this.imageUrls[index]})`, 82 | }); 83 | }); 84 | }); 85 | return $partialImages; 86 | } 87 | 88 | fitImages() { 89 | this.loadedImagePromises.forEach((imagePromise, index, array) => { 90 | imagePromise.then(img => { 91 | const aspectRatio = img.width / img.height; 92 | const imageProperty = { 93 | 'max-width': Math.round(this.imageHeight * aspectRatio) + 'px', 94 | 'background-size': 'cover', 95 | }; 96 | this._$partialImages[index].css(imageProperty); 97 | if (index === array.length - 1) { 98 | this._$partialImages[array.length].find('.image, .gradient').css(imageProperty); 99 | } 100 | }); 101 | }); 102 | } 103 | } 104 | 105 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/inlineCardsView/_inlineCardsView.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | .uncharted-inline-cards-view { 6 | height: 100%; 7 | width: 100%; 8 | overflow: auto; 9 | .cards-container { 10 | height: 100%; 11 | display: flex; 12 | flex-wrap: nowrap; 13 | flex-direction: row; 14 | justify-content: flex-start; 15 | } 16 | 17 | .uncharted-cards-card { 18 | height: $inline-card-height; 19 | max-height: $inline-card-max-height; 20 | min-height: $inline-card-min-height; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/inlineCardsView/inlineCardsView.handlebars: -------------------------------------------------------------------------------- 1 | {{!-- 2 | /* 3 | * Copyright 2018 Uncharted Software Inc. 4 | */ 5 | --}} 6 | 7 |
8 |
9 |
10 |
11 |
-------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/inlineCardsView/inlineCardsView.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import inlineCardsListTemplate from './inlineCardsView.handlebars'; 6 | import { IBindable, mapVerticalToHorizontalScroll, delayOnHorizontalToVerticalScrollingTransition } from '../../util'; 7 | import { DEFAULT_CONFIG, EVENTS } from '../constants'; 8 | 9 | export default class InlineCardsView extends IBindable { 10 | constructor(spec = {}) { // eslint-disable-line 11 | super(); 12 | this.postRender = () => { /* this function called after render */ }; 13 | this.reset(spec); 14 | } 15 | 16 | reset(spec) { 17 | this._config = Object.assign({}, DEFAULT_CONFIG, spec.config); 18 | this.cardInstances = []; 19 | this.cardsInViewPort = []; 20 | this.cardExpansionState = {}; 21 | this.CARD_WIDTH = this._config['card.width']; 22 | this.CARD_EXPANDED_WIDTH = this._config['card.expandedWidth']; 23 | this.cardAnimationDuration = this._config.inlineCardCenteringDuration; 24 | return this; 25 | } 26 | 27 | render() { 28 | this.$element = $(inlineCardsListTemplate()); 29 | this._$cardsContainer = this.$element.find('.cards-container'); 30 | this._$responsivePadding = this.$element.find('.responsive-padding'); 31 | this._registerDomEvents(); 32 | this.renderCardsInViewPort(); 33 | return this.$element; 34 | } 35 | 36 | resize() { 37 | this.$element && this.renderCardsInViewPort(); 38 | } 39 | 40 | _registerDomEvents() { 41 | let ticking = false; 42 | 43 | this.$element.on('click', event => this.emit(EVENTS.CARDS_CLICK_BACKGROUND, this, event.originalEvent)); 44 | this.$element.on('scroll', event => { 45 | if (!ticking) { 46 | requestAnimationFrame(() => { 47 | this.$element[0].scrollLeft + this.$element[0].offsetWidth >= this.$element[0].scrollWidth - 1 && this.emit(EVENTS.INLINE_CARDS_VIEW_SCROLL_END, this, event.originalEvent); 48 | this.renderCardsInViewPort(); 49 | ticking = false; 50 | }); 51 | ticking = true; 52 | } 53 | }); 54 | mapVerticalToHorizontalScroll(this.$element); 55 | delayOnHorizontalToVerticalScrollingTransition(this.$element, '.uncharted-cards-reader-content'); 56 | } 57 | 58 | updateCardInstances(cardInstances) { 59 | this.cardsInViewPort = []; 60 | this.cardInstances = cardInstances; 61 | this._$cardsContainer.css({ 'min-width': this.CARD_WIDTH * this.cardInstances.length }); 62 | this.renderCardsInViewPort(); 63 | } 64 | 65 | _getIndexOfFirstCardInViewPort(scrollLeft) { 66 | const { 67 | closingCard, 68 | closingCardIndex, 69 | expandingCard, 70 | expandingCardIndex, 71 | expandedCard, 72 | expandedCardIndex, 73 | } = this.cardExpansionState; 74 | const CARD_WIDTH = this.CARD_WIDTH; 75 | const responsivePaddingWidth = this._$responsivePadding[0].getBoundingClientRect().width; 76 | let index; 77 | if (closingCard && expandingCard && closingCardIndex < expandingCardIndex) { 78 | const closingCardOffSetLeft = closingCardIndex * CARD_WIDTH; 79 | if (closingCardOffSetLeft >= scrollLeft) { 80 | // [|][][][ c ][][|] 81 | index = Math.floor(scrollLeft / CARD_WIDTH); 82 | } else if (closingCardOffSetLeft < scrollLeft && closingCardOffSetLeft + this.CARD_EXPANDED_WIDTH >= scrollLeft) { 83 | // [ c | ][][][]| 84 | index = closingCardIndex; 85 | } else { 86 | // [ c ][]|[][][]| 87 | index = Math.floor((Math.max(scrollLeft - responsivePaddingWidth, 0)) / CARD_WIDTH); 88 | } 89 | } else if (expandedCard && expandedCardIndex * CARD_WIDTH < scrollLeft) { 90 | const expandedReaderLeftPosition = expandedCardIndex * CARD_WIDTH; 91 | const expandedReaderWidth = this.CARD_EXPANDED_WIDTH; 92 | const widthBetweenExpandedCardToScrollLeftPosition = scrollLeft - (expandedReaderLeftPosition + expandedReaderWidth); 93 | if (widthBetweenExpandedCardToScrollLeftPosition > 0) { 94 | const paddingWidth = expandedReaderWidth - CARD_WIDTH; 95 | index = Math.floor(widthBetweenExpandedCardToScrollLeftPosition / CARD_WIDTH) + expandedCardIndex + 1; 96 | this._$cardsContainer.css({ 'min-width': this.CARD_WIDTH * this.cardInstances.length + paddingWidth}); 97 | this._$responsivePadding.css({ width: paddingWidth }); 98 | // console.log('in left offscreen'); 99 | } else { 100 | index = expandedCardIndex; 101 | this._$responsivePadding.css({ width: 0 }); 102 | // console.log('in viewport'); 103 | } 104 | } else { 105 | // console.log('other cases'); 106 | index = Math.floor(scrollLeft / CARD_WIDTH); 107 | this._$responsivePadding.css({ width: 0 }); 108 | } 109 | return index; 110 | } 111 | 112 | renderCardsInViewPort() { 113 | const scrollLeft = this.$element[0].scrollLeft; 114 | const viewportWidth = this.$element[0].offsetWidth; 115 | const CARD_WIDTH = this.CARD_WIDTH; 116 | const indexOfFirstCardInViewPort = this._getIndexOfFirstCardInViewPort(scrollLeft); 117 | const paddingLeft = indexOfFirstCardInViewPort * CARD_WIDTH; 118 | 119 | const newItemsInViewPort = []; 120 | 121 | // get cards to render 122 | for (let i = indexOfFirstCardInViewPort, remainingSpaceInViewPort = viewportWidth + (this.CARD_EXPANDED_WIDTH - CARD_WIDTH); remainingSpaceInViewPort > 0; i++) { 123 | const card = this.cardInstances[i]; 124 | card && newItemsInViewPort.push(card); 125 | i !== indexOfFirstCardInViewPort && (remainingSpaceInViewPort -= CARD_WIDTH); 126 | } 127 | 128 | if (this.cardsInViewPort[0] !== newItemsInViewPort[0] || this.cardsInViewPort[this.cardsInViewPort.length - 1] !== newItemsInViewPort[newItemsInViewPort.length - 1]) { 129 | this.cardsInViewPort.forEach(card => { 130 | newItemsInViewPort.indexOf(card) < 0 && card.$element && card.$element.remove() && (card.$element = undefined); 131 | }); 132 | const fragments = document.createDocumentFragment(); 133 | newItemsInViewPort.forEach((card, index) => { 134 | const isCardAlreadyRendered = Boolean(card.$element); 135 | const $cardElement = isCardAlreadyRendered ? card.$element : card.render(); 136 | $cardElement.css({ 137 | 'order': index + 1, 138 | }); 139 | !isCardAlreadyRendered && fragments.appendChild($cardElement[0]); 140 | }); 141 | this._$cardsContainer.css({ 142 | 'padding-left': paddingLeft, 143 | }); 144 | this._$cardsContainer.append(fragments); 145 | this.cardsInViewPort = newItemsInViewPort; 146 | } 147 | this.postRender(); 148 | } 149 | 150 | _expandAndCenterCard(card, scrollDuration) { 151 | const cardIndex = this.cardInstances.indexOf(card); 152 | const previouslyExpandedCard = this.cardInstances.filter(cardItem => cardItem.isExpanded)[0]; 153 | const previouslyExpandedCardIndex = this.cardInstances.indexOf(previouslyExpandedCard); 154 | const expandedToNormalLeftOffsetDifference = previouslyExpandedCard && previouslyExpandedCardIndex < cardIndex 155 | ? this.CARD_EXPANDED_WIDTH - this.CARD_WIDTH 156 | : 0; 157 | previouslyExpandedCard && previouslyExpandedCard.shrink(); 158 | card.expand(); 159 | this.centerCard(card, scrollDuration, -expandedToNormalLeftOffsetDifference); 160 | 161 | this.cardExpansionState = { 162 | expandingCard: card, 163 | expandingCardIndex: cardIndex, 164 | closingCard: previouslyExpandedCard, 165 | closingCardIndex: previouslyExpandedCardIndex, 166 | }; 167 | 168 | this._$responsivePadding.animate({ width: 0 }, { duration: scrollDuration, complete: () => { 169 | this.cardExpansionState = { 170 | expandedCard: card, 171 | expandedCardIndex: cardIndex, 172 | }; 173 | }}); 174 | } 175 | 176 | /** 177 | * Open a reader for the provided card 178 | * @param {Object} card - card instance whose reader will be opened. 179 | */ 180 | openReader(card) { 181 | if (!card.isExpanded) { 182 | this._expandAndCenterCard(card, this.cardAnimationDuration); 183 | } 184 | } 185 | 186 | closeReader() { 187 | this.cardInstances.forEach(card => card.shrink()); 188 | this._$responsivePadding.animate({ width: 0 }, { duration: this.cardAnimationDuration, complete: () => { 189 | this.cardExpansionState = {}; 190 | this._$cardsContainer.css({ 'min-width': this.CARD_WIDTH * this.cardInstances.length }); 191 | }}); 192 | } 193 | 194 | /** 195 | * Scroll to the provided card and centers it. 196 | * @param {Object} card - A card to be centered. 197 | * @param {Number} duration - A number representing the duration of the centering animation in ms. 198 | * @param {Number} offset - Given card will be offset from the center by provided number. 199 | */ 200 | centerCard(card, duration, offset) { 201 | const viewportWidth = this.$element[0].offsetWidth; 202 | const targetCardIndex = this.cardInstances.indexOf(card); 203 | const expandedCardIndex = this.cardExpansionState.expandedCardIndex; 204 | const extraLeftOffset = expandedCardIndex !== undefined && expandedCardIndex < targetCardIndex 205 | ? this.CARD_EXPANDED_WIDTH - this.CARD_WIDTH 206 | : 0; 207 | const cardOffsetLeft = Math.max(targetCardIndex, 0) * this.CARD_WIDTH + extraLeftOffset; 208 | const leftMargin = Math.max(viewportWidth - card.expandedWidth, 0) / 2; 209 | const scrollLeft = cardOffsetLeft - leftMargin + (offset || 0); 210 | duration 211 | ? this.$element.animate({ scrollLeft: scrollLeft }, duration) 212 | : this.$element.scrollLeft(scrollLeft); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/readerContent/_readerContent.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | .uncharted-cards-reader-content { 6 | $header-height: 33px; 7 | width: 100%; 8 | height: 100%; 9 | background: #fff; 10 | position: relative; 11 | 12 | hr { 13 | display: block; 14 | height: 1px; 15 | border: 0; 16 | border-top: 1px solid #ccc; 17 | margin: 1em 0; 18 | padding: 0; 19 | } 20 | 21 | .reader-content-header { 22 | $header-background-color: #f6f6f6; 23 | position: absolute; 24 | z-index: 999; 25 | width: 100%; 26 | display: flex; 27 | 28 | .header-source-link-box { 29 | position: relative; 30 | min-width: 170px; 31 | max-width: 50%; 32 | height: 27px; 33 | padding: 0 15px; 34 | top: 0; 35 | display: flex; 36 | align-items: center; 37 | } 38 | 39 | .header-source-link-background { 40 | position: absolute; 41 | left: 0; 42 | top: 0; 43 | width: 100%; 44 | height: 100%; 45 | background: #555; 46 | opacity: 0.7; 47 | } 48 | 49 | .source-link { 50 | z-index: 1; 51 | flex-grow: 1; 52 | text-decoration: none; 53 | color: #fff; 54 | white-space: nowrap; 55 | text-overflow: ellipsis; 56 | overflow: hidden; 57 | } 58 | 59 | .close-button { 60 | flex-grow: 1; 61 | text-align: right; 62 | height: 30px; 63 | color: rgba(0, 0, 0, 0.4); 64 | align-items: center; 65 | display: flex; 66 | flex-direction: row-reverse; 67 | cursor: pointer; 68 | .icon-box { 69 | height: calc(100% - 2px); 70 | margin-top: 2px; 71 | display: flex; 72 | align-items: center; 73 | } 74 | i { 75 | padding-right: 6px; 76 | } 77 | &:hover { 78 | i { 79 | color: rgba(0, 0, 0, 1); 80 | } 81 | } 82 | } 83 | 84 | .source-icon { 85 | height: 28px; 86 | width: 28px; 87 | padding: 6px; 88 | background: $header-background-color no-repeat 50% 50% / 16px 16px; 89 | border-radius: 14px; 90 | margin-top: 2px; 91 | margin-right: 3px; 92 | } 93 | } 94 | 95 | .reader-content-viewport { 96 | height: 100%; 97 | overflow: auto; 98 | overflow: overlay; // for webkit browser 99 | } 100 | 101 | .reader-content-body { 102 | position: relative; 103 | padding: 8px 32px 22px; 104 | font-size: 12px; 105 | } 106 | 107 | &.no-header-image { 108 | .reader-content-body { 109 | padding-top: 40px; 110 | } 111 | } 112 | 113 | h1.title { 114 | font-size: 1.3em; 115 | } 116 | 117 | .content { 118 | margin-top: 10px; 119 | } 120 | 121 | .reader-content-meta-data-table { 122 | font-size: 12px; 123 | 124 | td { 125 | border: 5px solid transparent; 126 | &.key-column { 127 | border-left: 0; 128 | } 129 | &.value-column { 130 | border-right: 0; 131 | } 132 | &:first-of-type { 133 | border-top: 0; 134 | } 135 | &:last-of-type { 136 | border-bottom: 0; 137 | } 138 | } 139 | 140 | .key-column { 141 | &.value-box { 142 | max-width: 170px; 143 | white-space: nowrap; 144 | overflow: hidden; 145 | text-overflow: ellipsis; 146 | text-align: right; 147 | padding-right: 2px; 148 | color: #bbb; 149 | } 150 | } 151 | 152 | .value-column { 153 | &.value-box { 154 | padding-left: 2px; 155 | } 156 | .value img { 157 | max-height: 1em; 158 | max-width: 100%; 159 | } 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/readerContent/readerContent.handlebars: -------------------------------------------------------------------------------- 1 | {{!-- 2 | /* 3 | * Copyright 2018 Uncharted Software Inc. 4 | */ 5 | --}} 6 | 7 |
8 |
9 | {{#if source}} 10 | 14 | {{/if}} 15 |
16 |
17 | 18 |
19 |
20 | {{#if iconUrl}} 21 |
22 | {{/if}} 23 |
24 |
25 |
26 |
27 |
28 |

{{title}}

29 | {{#if subtitle}} 30 |
31 | {{#each subtitle}}{{#unless @first}}{{../subtitleDelimiter}}{{/unless}}{{this}}{{/each}} 32 |
33 | {{/if}} 34 | 35 |
36 | {{{content}}} 37 |
38 | 39 | {{#if metadata}} 40 |
41 | 42 | {{#each metadata}} 43 | 44 | 47 | 58 | 59 | {{/each}} 60 | 61 | {{/if}} 62 |
63 |
64 |
65 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/readerContent/readerContent.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import readerContentTemplate from './readerContent.handlebars'; 6 | import { IBindable, createFallbackIconURL } from '../../util'; 7 | import HeaderImage from '../headerImage/headerImage'; 8 | import { DEFAULT_CONFIG, EVENTS } from '../constants'; 9 | 10 | export default class ReaderContent extends IBindable { 11 | constructor(spec = {}) { 12 | super(); 13 | this.headerImage = new HeaderImage(); 14 | this.reset(spec); 15 | } 16 | 17 | reset(spec = {}) { 18 | this._config = Object.assign({}, DEFAULT_CONFIG, spec.config); 19 | this.updateData(spec.data); 20 | this.headerImage.reset({ 21 | imageUrls: this.data.imageUrl, 22 | mirrorLastImage: true, 23 | mirrorImageGradientColor: this._config['readerContent.headerBackgroundColor'], 24 | imageMaxWidth: this._config['readerContent.headerImageMaxWidth'], 25 | config: this._config, 26 | }); 27 | return this; 28 | } 29 | 30 | render() { 31 | const metaDataFontSize = this._config['card.metadata.fontSize']; 32 | const metaDataTitleColor = this._config['card.metadata.title.color']; 33 | const metaDataTitleFontFamily = this._config['card.metadata.title.fontFamily']; 34 | const metaDataValueColor = this._config['card.metadata.value.color']; 35 | const metaDataValueFontFamily = this._config['card.metadata.value.fontFamily']; 36 | 37 | this.$element = $(readerContentTemplate(Object.assign({ 38 | iconUrl: this._getIconUrl(), 39 | subtitleDelimiter: this._config.subtitleDelimiter, 40 | headerBackgroundColor: this._config['readerContent.headerBackgroundColor'], 41 | headerSourceLinkColor: this._config['readerContent.headerSourceLinkColor'], 42 | metaDataFontSize: metaDataFontSize, 43 | metaDataTitleColor: metaDataTitleColor, 44 | metaDataTitleFontFamily: metaDataTitleFontFamily, 45 | metaDataValueColor: metaDataValueColor, 46 | metaDataValueFontFamily: metaDataValueFontFamily, 47 | }, this.data))); 48 | this.$headerImageContainer = this.$element.find('.reader-content-header-image'); 49 | if (this.headerImage.hasImages()) { 50 | this.$headerImageContainer.append(this.headerImage.render()); 51 | if (!this._config['readerContent.cropImages']) { 52 | this.headerImage.fitImages(); 53 | } 54 | } else { 55 | this.$element.addClass('no-header-image'); 56 | } 57 | this._registerDomEvents(); 58 | return this.$element; 59 | } 60 | 61 | updateData(data) { 62 | this.data = data || {}; 63 | } 64 | 65 | _registerDomEvents() { 66 | this.$element.on('click', '.close-button', event => { 67 | event.stopPropagation(); 68 | this.emit(EVENTS.READER_CONTENT_CLICK_CLOSE, this); 69 | }); 70 | 71 | this.$element.on('click', 'a', event => { 72 | var $anchor = $(event.target).closest('a'); 73 | var href = $anchor.attr('href'); 74 | if (href) { 75 | this.emit(EVENTS.READER_CONTENT_CLICK_LINK, event); 76 | if (!this._config['readerContent.disableLinkNavigation']) { 77 | window.open(href, '_blank'); 78 | } 79 | } 80 | return false; 81 | }); 82 | } 83 | 84 | _getIconUrl() { 85 | const source = this.data.sourceIconName || this.data.source; 86 | return this.data.sourceImage || (this.data.source && createFallbackIconURL(50, 50, source)); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/verticalReader/_verticalReader.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | $reader-animation-duration: 0.3s; 6 | .uncharted-cards-reader-holder { 7 | width: 100%; 8 | height: 0; 9 | transition: height $reader-animation-duration ease-out; 10 | } 11 | 12 | .uncharted-cards-vertical-reader { 13 | height: 100%; 14 | width: 100%; 15 | position: relative; 16 | padding: 5px 0; 17 | background: #555; 18 | outline: none; 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | 23 | .marker { 24 | position: absolute; 25 | top: -10px; 26 | left: 50%; 27 | transform: translateX(-50%); 28 | border-bottom: 10px solid #555; 29 | border-left: 10px solid transparent; 30 | border-right: 10px solid transparent; 31 | height: 0; 32 | width: 0; 33 | transition: left $reader-animation-duration ease-out; 34 | } 35 | 36 | .reader-content-container { 37 | height: 100%; 38 | width: 100%; 39 | max-width: 1200px; 40 | } 41 | 42 | .reader-page-button { 43 | margin: 0; 44 | color: #ccc; 45 | font-size: 36px; 46 | i { 47 | padding: 25px; 48 | } 49 | &:hover { 50 | color: white; 51 | cursor: pointer; 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/verticalReader/verticalReader.handlebars: -------------------------------------------------------------------------------- 1 | {{!-- 2 | /* 3 | * Copyright 2018 Uncharted Software Inc. 4 | */ 5 | --}} 6 | 7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/verticalReader/verticalReader.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import verticalReaderTemplate from './verticalReader.handlebars'; 6 | import { IBindable } from '../../util'; 7 | import { DEFAULT_CONFIG, EVENTS } from '../constants'; 8 | 9 | export default class VerticalReader extends IBindable { 10 | constructor(spec = {}) { 11 | super(); 12 | this.reset(spec); 13 | } 14 | 15 | reset(spec = {}) { 16 | this._config = Object.assign({}, DEFAULT_CONFIG, spec.config); 17 | this._cardInstances = []; 18 | return this; 19 | } 20 | 21 | get $readerHolder() { 22 | return this.$element.closest('.uncharted-cards-reader-holder'); 23 | } 24 | 25 | render() { 26 | this.$element = $(verticalReaderTemplate()); 27 | this._registerDOMEvents(); 28 | return this.$element; 29 | } 30 | 31 | _registerDOMEvents() { 32 | this.$element.on('transitionend', event => { 33 | const originalEvent = event.originalEvent; 34 | if (event.target !== this.$element[0]) { 35 | return; 36 | } 37 | if (event.target === this.$element[0] && originalEvent.propertyName === 'width' && !this.isExpanded) { 38 | this._clearReaderContainer(); 39 | this.emit(EVENTS.CARD_SHRINK, this); 40 | } else if (event.target === this.$element[0] && originalEvent.propertyName === 'width' && this.isExpanded) { 41 | this.emit(EVENTS.CARD_EXPAND, this); 42 | } 43 | }); 44 | this.$element.on('click', event => { 45 | event.stopImmediatePropagation(); 46 | event.target === this.$element[0] && this.emit(EVENTS.VERTICAL_READER_CLICK_BACKGROUND); 47 | }); 48 | this.$element.on('click', '.reader-prev-button', () => this._navigate(-1)); 49 | this.$element.on('click', '.reader-next-button', () => this._navigate(1)); 50 | } 51 | 52 | /** 53 | * Move to a neighbouring card 54 | * @param {Number} offset - +1 to move to the next card; -1 to move to the previous card 55 | * @private 56 | */ 57 | _navigate(offset) { 58 | const currentCardIndex = this._cardInstances.findIndex(card => card.data.id === this._markedCard.data.id); 59 | const toIndex = (currentCardIndex + offset) > 0 ? currentCardIndex + offset : 0; 60 | 61 | if (toIndex >= 0 && toIndex < this._cardInstances.length && currentCardIndex !== toIndex) { 62 | const targetCard = this._cardInstances[toIndex]; 63 | this.placeUnder(targetCard, true); 64 | this.emit(EVENTS.VERTICAL_READER_NAVIGATE_CARD, targetCard); 65 | } 66 | } 67 | 68 | _placeMarker(card) { 69 | const cardCenterOffSetLeft = card.$element[0].offsetWidth / 2 + (card.$element[0].offsetLeft - this.$element[0].offsetLeft); 70 | const $marker = this.$element.find('.marker'); 71 | $marker.css({ 72 | left: cardCenterOffSetLeft, 73 | }); 74 | this._markedCard = card; 75 | } 76 | 77 | _createNewReaderHolder() { 78 | // The reader holder closes and removes itself when its child reader is removed. 79 | const $readerHolder = $('
'); 80 | 81 | const observer = new MutationObserver(mutations => { 82 | if (mutations[0].removedNodes.length > 0) { 83 | $readerHolder.css({ height: '0' }); 84 | } 85 | }); 86 | observer.observe($readerHolder[0], { childList: true }); 87 | 88 | $readerHolder[0].addEventListener('transitionend', event => { 89 | if (event.propertyName === 'height' && $readerHolder.height() === 0) { 90 | $readerHolder.remove(); 91 | } 92 | }); 93 | 94 | return $readerHolder; 95 | } 96 | 97 | resize() { 98 | this._markedCard && this.$readerHolder.height() > 0 && this.placeUnder(this._markedCard, true); 99 | } 100 | 101 | _expandReaderHolder() { 102 | this.$readerHolder.css({ height: `${this._config['verticalReader.height']}px`}); 103 | } 104 | 105 | updateCardInstances(cardInstances = []) { 106 | this._cardInstances = cardInstances; 107 | } 108 | 109 | open(card) { 110 | this.placeUnder(card); 111 | requestAnimationFrame(() => { 112 | this._expandReaderHolder(); 113 | }); 114 | } 115 | 116 | close() { 117 | this.$element && this.$element.detach(); 118 | } 119 | 120 | updateReaderContent(card, readerContentData) { 121 | card.readerContent.updateData(readerContentData); 122 | this.$element.find('.reader-content-container').html(card.readerContent.render()); 123 | } 124 | 125 | placeUnder(card, stayOpened) { 126 | const targetCardPosition = card.$element.position(); 127 | const elementsInSameRow = card.$element.nextAll() 128 | .filter((index, ele) => $(ele).position().top === targetCardPosition.top); 129 | const lastElementInRow = elementsInSameRow[elementsInSameRow.length - 1] || card.$element; 130 | 131 | const $nextElement = $(lastElementInRow).next(); 132 | const $prevReaderHolder = this.$readerHolder; 133 | if (!$nextElement.is($prevReaderHolder)) { 134 | const $newReaderHolder = this._createNewReaderHolder(); 135 | $newReaderHolder.append(this.$element); 136 | if (stayOpened) { 137 | $prevReaderHolder.remove(); 138 | this._expandReaderHolder(); 139 | } 140 | $(lastElementInRow).after($newReaderHolder); 141 | } 142 | this._placeMarker(card); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/wrappedCardsView/_wrappedCardsView.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | @import '../variables'; 6 | 7 | .uncharted-wrapped-cards-view { 8 | height: 100%; 9 | width: 100%; 10 | overflow: auto; 11 | .cards-container { 12 | display: flex; 13 | flex-wrap: wrap; 14 | justify-content: center; 15 | align-content: flex-start; 16 | 17 | .uncharted-cards-card { 18 | order: unset !important; 19 | } 20 | 21 | .dummy-card { 22 | height: 0; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/wrappedCardsView/wrappedCardsView.handlebars: -------------------------------------------------------------------------------- 1 | {{!-- 2 | /* 3 | * Copyright 2018 Uncharted Software Inc. 4 | */ 5 | --}} 6 | 7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
-------------------------------------------------------------------------------- /lib/@uncharted/cards/src/components/wrappedCardsView/wrappedCardsView.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import wrappedCardsViewTemplate from './wrappedCardsView.handlebars'; 6 | import { IBindable } from '../../util'; 7 | import { DEFAULT_CONFIG, EVENTS } from '../constants'; 8 | import VerticalReader from '../verticalReader/verticalReader'; 9 | 10 | export default class WrappedCardsView extends IBindable { 11 | constructor(spec = {}) { // eslint-disable-line 12 | super(); 13 | this.reset(spec); 14 | this._initVerticalReader(); 15 | this.postRender = () => {}; 16 | } 17 | 18 | reset(spec = {}) { 19 | this._config = Object.assign({}, DEFAULT_CONFIG, spec.config); 20 | this.cardInstances = []; 21 | this.verticalReader && this.verticalReader.reset(spec); 22 | return this; 23 | } 24 | 25 | _initVerticalReader() { 26 | this.verticalReader = new VerticalReader({ config: this._config }); 27 | this.verticalReader.on(EVENTS.VERTICAL_READER_NAVIGATE_CARD, card => this.scrollToVerticalReader(card.$element[0].offsetHeight, 0)); 28 | this.forward(this.verticalReader); 29 | } 30 | 31 | _registerDomEvents() { 32 | this.$element.on('click', event => this.emit(EVENTS.CARDS_CLICK_BACKGROUND, this, event)); 33 | this.$element.on('scroll', event => { 34 | let ticking = false; 35 | if (!ticking) { 36 | requestAnimationFrame(() => { 37 | this.$element[0].scrollTop + this.$element[0].offsetHeight >= this.$element[0].scrollHeight - 1 && this.emit(EVENTS.WRAPPED_CARDS_VIEW_SCROLL_END, this, event.originalEvent); 38 | ticking = false; 39 | }); 40 | ticking = true; 41 | } 42 | }); 43 | } 44 | 45 | render() { 46 | this.$element = $(wrappedCardsViewTemplate({ 47 | cardWidth: this._config['card.width'], 48 | })); 49 | this._$cardsContainer = this.$element.find('.cards-container'); 50 | this._$dummyCards = this.$element.find('.dummy-card'); 51 | this.verticalReader.render(); 52 | this._registerDomEvents(); 53 | return this.$element; 54 | } 55 | 56 | clearCards() { 57 | this.cardInstances = []; 58 | return this.render(); 59 | } 60 | 61 | resize() { 62 | this.verticalReader.resize(); 63 | } 64 | 65 | renderMoreCards(cardInstances) { 66 | const cardFragments = document.createDocumentFragment(); 67 | cardInstances.forEach(card => cardFragments.appendChild(card.render()[0])); 68 | this._$cardsContainer.append(cardFragments).append(this._$dummyCards); 69 | this.cardInstances.push(...cardInstances); 70 | this.verticalReader.updateCardInstances(this.cardInstances); 71 | this.postRender(); 72 | } 73 | 74 | scrollToVerticalReader(offset = 0, duration = 0) { 75 | const $vReaderHolders = this.$element.find('.uncharted-cards-reader-holder'); 76 | const currentReaderHolderOffsetTop = this.verticalReader.$readerHolder[0].offsetTop; 77 | let scrollTop = currentReaderHolderOffsetTop - offset; 78 | if ($vReaderHolders[0].offsetTop < currentReaderHolderOffsetTop) { 79 | scrollTop -= this._config['verticalReader.height']; 80 | } 81 | this.$element.animate({scrollTop: scrollTop}, duration); 82 | } 83 | 84 | openReader(card) { 85 | this.verticalReader.open(card); 86 | this.scrollToVerticalReader(card.$element[0].offsetHeight, this._config.scrollToVerticalReaderDuration); 87 | } 88 | 89 | closeReader() { 90 | this.verticalReader.close(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/handlebarHelper/cardImages.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | export default function(imageUrl, block) { 6 | const imagesUrls = imageUrl ? [].concat(imageUrl) : []; 7 | let output = ''; 8 | imagesUrls.forEach(url => { 9 | output += block.fn({ url }); 10 | }); 11 | return output; 12 | } 13 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/handlebarHelper/isImageDataUrl.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | export default function(value, options) { 6 | const fnTrue = options.fn; 7 | const fnFalse = options.inverse; 8 | const supportedImageTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif']; 9 | const isImageDataUrl = supportedImageTypes.some(imageType => value.indexOf && value.indexOf(`data:${imageType};base64`) === 0); 10 | return isImageDataUrl ? fnTrue(this) : fnFalse(this); 11 | } 12 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/handlebarHelper/linkify.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | // import { Utils, SafeString } from 'handlebars/runtime'; 6 | import { Utils, SafeString } from 'handlebars'; 7 | 8 | function isUrl(url) { 9 | const regexp = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/; 10 | return regexp.test(url); 11 | } 12 | 13 | function getHostName(url) { 14 | var link = document.createElement('a'); 15 | link.href = url; 16 | return link.hostname; 17 | } 18 | 19 | function getLink(href, text) { 20 | return `${text}`; 21 | } 22 | 23 | function isMailTo(text) { 24 | const regexp = /mailto:*/; 25 | return regexp.test(text); 26 | } 27 | 28 | function linkify(text) { 29 | if (isUrl(text)) { 30 | return getLink(text, getHostName(text).replace('www.', '')); 31 | } 32 | if (isMailTo(text)) { 33 | return getLink(text, text.replace('mailto:', '')); 34 | } 35 | return text; 36 | } 37 | 38 | export default function(text) { 39 | const escapedText = Utils.escapeExpression(text); 40 | return new SafeString(linkify(escapedText)); 41 | } 42 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import Cards from './components/cards/cards'; 6 | 7 | export default Cards; 8 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/main.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | @import 'style/base'; 6 | @import 'components/cards/cards'; 7 | .uncharted-cards-style { 8 | @import 'components/inlineCardsView/inlineCardsView'; 9 | @import 'components/wrappedCardsView/wrappedCardsView'; 10 | @import 'components/card/card'; 11 | @import 'components/readerContent/readerContent'; 12 | @import 'components/verticalReader/verticalReader'; 13 | @import 'components/headerImage/headerImage'; 14 | } -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/style/_base.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | .uncharted-cards-style { 6 | 7 | -ms-overflow-style: -ms-autohiding-scrollbar; 8 | 9 | ::-webkit-scrollbar { 10 | width: 8px; 11 | height: 8px; 12 | border-radius: 100px; 13 | background-color: rgba(0, 0, 0, 0); 14 | } 15 | 16 | ::-webkit-scrollbar:hover { 17 | background-color: rgba(0, 0, 0, 0.09); 18 | } 19 | 20 | ::-webkit-scrollbar-thumb { 21 | background: rgba(0, 0, 0, 0.2); 22 | border-radius: 100px; 23 | } 24 | 25 | ::-webkit-scrollbar-thumb:hover, 26 | ::-webkit-scrollbar-thumb:active 27 | { 28 | background: rgba(0, 0, 0, 0.5); 29 | } 30 | 31 | box-sizing: border-box; 32 | 33 | *, 34 | *:before, 35 | *:after { 36 | box-sizing: inherit; 37 | } 38 | /* css reset */ 39 | div, span, applet, object, iframe, 40 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 41 | a, abbr, acronym, address, big, cite, code, 42 | del, dfn, em, img, ins, kbd, q, s, samp, 43 | small, strike, strong, sub, sup, tt, var, 44 | b, u, i, center, 45 | dl, dt, dd, ol, ul, li, 46 | fieldset, form, label, legend, 47 | table, caption, tbody, tfoot, thead, tr, th, td, 48 | article, aside, canvas, details, embed, 49 | figure, figcaption, footer, header, hgroup, 50 | menu, nav, output, ruby, section, summary, 51 | time, mark, audio, video { 52 | margin: 0; 53 | padding: 0; 54 | border: 0; 55 | font-size: 100%; 56 | vertical-align: baseline; 57 | } 58 | 59 | // HTML5 display-role reset for older browsers 60 | article, aside, details, figcaption, figure, 61 | footer, header, hgroup, menu, nav, section { 62 | display: block; 63 | } 64 | ol, ul { 65 | list-style: none; 66 | } 67 | blockquote, q { 68 | quotes: none; 69 | } 70 | blockquote:before, blockquote:after, 71 | q:before, q:after { 72 | content: ''; 73 | content: none; 74 | } 75 | table { 76 | border-collapse: collapse; 77 | border-spacing: 0; 78 | } 79 | 80 | h1, 81 | h2, 82 | h3, 83 | h4, 84 | h5, 85 | h6 { 86 | font-family: inherit; 87 | font-weight: 500; 88 | line-height: 1.25; 89 | color: inherit; 90 | } 91 | img { 92 | border: 0; 93 | vertical-align: middle; 94 | } 95 | ol, 96 | ul { 97 | margin-top: 0; 98 | margin-bottom: 10px; 99 | } 100 | a { 101 | text-decoration: none; 102 | color: #2C83C0; 103 | } 104 | 105 | .meta-line { 106 | list-style: none; 107 | padding: 0; 108 | overflow: hidden; 109 | font-size: 11px; 110 | color: #777; 111 | margin-top: 2px; 112 | } 113 | } -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/util/IBindable.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | const _sourceFunctionKey = Symbol('SourceFunctionKey'); 6 | const _sourceOwnerKey = Symbol('SourceOwnerKey'); 7 | 8 | /** 9 | * Base interface class for objects that wish to emit events. 10 | * 11 | * @class IBindable 12 | */ 13 | export class IBindable { 14 | /** 15 | * @constructor IBindable 16 | */ 17 | constructor() { 18 | this.mHandlers = {}; 19 | this.mOmniHandlers = []; 20 | this.mBoundForwardEvent = this._forwardEvent.bind(this); 21 | } 22 | 23 | /** 24 | * Unbinds all events bound to this IBindable instance. 25 | * 26 | * @method destroy 27 | */ 28 | destroy() { 29 | Object.keys(this.mHandlers).forEach(key => { 30 | delete this.mHandlers[key]; 31 | }); 32 | this.mOmniHandlers.length = 0; 33 | 34 | delete this.mHandlers; 35 | delete this.mOmniHandlers; 36 | delete this.mBoundForwardEvent; 37 | } 38 | 39 | /** 40 | * Binds a list of events to the specified callback. 41 | * 42 | * @method on 43 | * @param {String|Array|null} events - A space-separated list or an Array of events to listen for. If null is passed, the callback will be invoked for all events. 44 | * @param {Function} callback - The callback to invoke when the event is triggered. If this callback returns true, event bubbling stops. 45 | */ 46 | on(events, callback) { 47 | if (events === null) { 48 | if (this.mOmniHandlers.indexOf(callback) < 0) { 49 | this.mOmniHandlers.push(callback); 50 | } 51 | } else { 52 | const eventArray = events instanceof Array ? events : events.split(' '); 53 | eventArray.forEach(event => { 54 | let handlers = this.mHandlers[event]; 55 | if (!handlers) { 56 | handlers = []; 57 | this.mHandlers[event] = handlers; 58 | } 59 | if (handlers.indexOf(callback) < 0) { 60 | handlers.push(callback); 61 | } 62 | }); 63 | } 64 | } 65 | 66 | /** 67 | * Unbinds the specified callback from the specified event. If no callback is specified, all callbacks for the specified event are removed. 68 | * 69 | * @method off 70 | * @param {String|Array|null=} events - A space-separated list or an Array of events to listen for. If null is passed the callback will be removed from the all-event handler list. 71 | * @param {Function=} callback - The callback to remove from the event or nothing to completely clear the event callbacks. 72 | * @param {*} owner - The owner of the callback, needed when unregistering callbacks created with `safeBind`. 73 | */ 74 | off(events = null, callback = null, owner = null) { 75 | if (events === null) { 76 | if (!callback) { 77 | this.mOmniHandlers.length = 0; 78 | } else { 79 | const index = this.mOmniHandlers.indexOf(callback); 80 | if (index >= 0) { 81 | this.mOmniHandlers.splice(index, 1); 82 | } 83 | } 84 | } else { 85 | const eventArray = events instanceof Array ? events : events.split(' '); 86 | eventArray.forEach(event => { 87 | const handlers = this.mHandlers[event]; 88 | if (handlers) { 89 | if (!callback) { 90 | delete this.mHandlers[event]; 91 | } else { 92 | for (let i = 0, n = handlers.length; i < n; ++i) { 93 | if (callback === handlers[i] || (callback === handlers[i][_sourceFunctionKey] && owner === handlers[i][_sourceOwnerKey])) { 94 | handlers.splice(i, 1); 95 | break; 96 | } 97 | } 98 | } 99 | } 100 | }); 101 | } 102 | } 103 | 104 | /** 105 | * Emits the specified event and forwards all passed parameters. 106 | * 107 | * @method emit 108 | * @param {String} event - The name of the event to emit. 109 | * @param {...*} varArgs - Arguments to forward to the event listener callbacks. 110 | */ 111 | emit(event, ...varArgs) { 112 | let i; 113 | let n; 114 | 115 | if (this.mHandlers[event]) { 116 | for (i = 0, n = this.mHandlers[event].length; i < n; ++i) { 117 | if (this.mHandlers[event][i](...varArgs) === true) { 118 | break; 119 | } 120 | } 121 | } 122 | 123 | if (this.mOmniHandlers.length > 0) { 124 | for (i = 0, n = this.mOmniHandlers.length; i < n; ++i) { 125 | if (this.mOmniHandlers[i](...arguments) === true) { 126 | break; 127 | } 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Forwards the specified events triggered by the given `bindable` as if this object was emitting them. If no events 134 | * are passed, all events are forwarded. 135 | * 136 | * @method forward 137 | * @param {IBindable} bindable - The `IBindable` instance for which all events will be forwarded through this instance. 138 | * @param {String|Array|null=} events - A space-separated list of events to forward or null to forward all the events. 139 | */ 140 | forward(bindable, events = null) { 141 | if (events === null) { 142 | bindable.on(null, this.mBoundForwardEvent); 143 | } else { 144 | const eventArray = events instanceof Array ? events : events.split(' '); 145 | eventArray.forEach(event => { 146 | bindable.on(event, this.safeBind(this._forwardEvent, this, event)); 147 | }); 148 | } 149 | } 150 | 151 | /** 152 | * Stops forwarding the events of the specified `bindable` 153 | * 154 | * @method unforward 155 | * @param {IBindable} bindable - The `IBindable` instance to stop forwarding. 156 | * @param {String|Array|null=} events - A space-separated list of events to stop forwarding or null to stop forwarding all the events. 157 | */ 158 | unforward(bindable, events = null) { 159 | if (events === null) { 160 | bindable.off(null, this.mBoundForwardEvent); 161 | } else { 162 | const eventArray = events instanceof Array ? events : events.split(' '); 163 | eventArray.forEach(event => { 164 | bindable.off(event, this._forwardEvent, this); 165 | }); 166 | } 167 | } 168 | 169 | /** 170 | * Binds a function so it is safe to unregister it using the `off` method with only the original function as the second argument. 171 | * 172 | * @method safeBind 173 | * @param {Function} func - The function to bind. 174 | * @param {*} owner - The owner of this function. 175 | * @param {...*} varArgs - The arguments used to bind this function. 176 | * @returns {Function} 177 | */ 178 | safeBind(func, owner, ...varArgs) { 179 | const boundFunction = func.bind(owner, ...varArgs); 180 | boundFunction[_sourceFunctionKey] = func; 181 | boundFunction[_sourceOwnerKey] = owner; 182 | return boundFunction; 183 | } 184 | 185 | /** 186 | * Internal method used to forward the events from other `IBindable` instances. 187 | * 188 | * @method _forwardEvent 189 | * @param {String} event - The name of the event to emit. 190 | * @param {...*} varArgs - Arguments to forward to the event listener callbacks. 191 | * @private 192 | */ 193 | _forwardEvent(/* event */ /* varArgs */) { 194 | this.emit.apply(this, arguments); 195 | } 196 | } 197 | 198 | export default IBindable; 199 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/util/images.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | /** 6 | * Hash a string, such as a domain, into one of 256 shades of gray. 7 | * @param {String} str - arbitrary string to hash into a grey shade 8 | * @param {Number=} min - optional lower bound for the grey value 9 | * @param {Number=} max - optional upper bound for the grey value 10 | * @returns {number|*} A shade of grey in the range [min|0, max|255] 11 | * @static 12 | */ 13 | export function grayShadeFromString(str, min, max) { 14 | let hash = 0; 15 | for (let i = 0; i < str.length; i++) { 16 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 17 | } 18 | 19 | const color32bit = (hash & 0xFFFFFF); 20 | let r = (color32bit >> 16) & 255; 21 | let g = (color32bit >> 8) & 255; 22 | let b = color32bit & 255; 23 | 24 | /* clamp the colors */ 25 | if (min !== undefined) { 26 | r = Math.max(r, min); 27 | g = Math.max(g, min); 28 | b = Math.max(b, min); 29 | } 30 | 31 | if (max !== undefined) { 32 | r = Math.min(r, max); 33 | g = Math.min(g, max); 34 | b = Math.min(b, max); 35 | } 36 | 37 | return Math.floor((r + g + b) / 3); 38 | } 39 | 40 | /** 41 | * Generate a Data URL encoding a grey single-letter icon. 42 | * @param {Number} width - width of the icon in pixels 43 | * @param {Number} height - height of the icon in pixels 44 | * @param {String} sourceName - string to create an icon for; 45 | * the first character becomes the icon's letter and the string as a whole gets hashed into a grey shade 46 | * @returns {string} Data URL encoding an icon image 47 | * @static 48 | */ 49 | export function createFallbackIconURL(width, height, sourceName) { 50 | /* get the gray shade for the background */ 51 | let channel = grayShadeFromString(sourceName, 0, 102); 52 | 53 | /* initialize an offscreen canvas */ 54 | const canvas = document.createElement('canvas'); 55 | canvas.width = width; 56 | canvas.height = height; 57 | const context = canvas.getContext('2d'); 58 | 59 | /* draw the background */ 60 | context.fillStyle = 'rgb(' + channel + ',' + channel + ',' + channel + ')'; 61 | context.fillRect(0, 0, width, height); 62 | 63 | /* make the channel brighter for the text */ 64 | channel = Math.floor(channel * 2.5); 65 | context.fillStyle = 'rgb(' + channel + ',' + channel + ',' + channel + ')'; 66 | 67 | /* draw the text */ 68 | const letter = sourceName[0].toUpperCase(); 69 | context.font = Math.round(height * 0.7) + 'px helvetica'; 70 | context.fontWeight = 'bolder'; 71 | context.textAlign = 'center'; 72 | context.textBaseline = 'middle'; 73 | context.fillText(letter, width * 0.5, height * 0.5); 74 | 75 | return canvas.toDataURL(); 76 | } 77 | 78 | /** 79 | * Load one image from the given URL. 80 | * @param {String} url - Address of the image 81 | * @returns {Promise} 82 | */ 83 | export function loadImage(url) { 84 | return new Promise(resolve => { 85 | const img = new Image(); 86 | img.onload = () => { 87 | resolve(img); 88 | }; 89 | img.src = url; 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/util/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | export { default as IBindable } from './IBindable'; 6 | export * from './scroll'; 7 | export * from './images'; 8 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/src/util/scroll.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import debounce from 'lodash/debounce'; 6 | 7 | const V_TO_H_MOUSE_WHEEL_EVENT = 'wheel.uncharted.verticalToHorizontal'; 8 | 9 | export function mapVerticalToHorizontalScroll($element) { 10 | $element.on(V_TO_H_MOUSE_WHEEL_EVENT, event => { 11 | const { deltaX, deltaY } = event.originalEvent; 12 | const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY; 13 | $element.scrollLeft($element.scrollLeft() + delta); 14 | event.preventDefault(); 15 | }); 16 | } 17 | 18 | export function delayOnHorizontalToVerticalScrollingTransition($element, verticalScrollContainerSelector, delay = 1000) { 19 | let canIScrollVertically = true; 20 | const preventFollowingVerticalScrolling = debounce(function () { 21 | canIScrollVertically = true; 22 | }, delay); 23 | 24 | $element.on(V_TO_H_MOUSE_WHEEL_EVENT, () => { 25 | canIScrollVertically = false; 26 | preventFollowingVerticalScrolling(); 27 | }); 28 | $element.on(V_TO_H_MOUSE_WHEEL_EVENT, verticalScrollContainerSelector, event => { 29 | event.stopPropagation(); 30 | !canIScrollVertically && event.preventDefault(); 31 | }); 32 | } 33 | 34 | export function unMapVerticalToHorizontalScroll($element) { 35 | $element.off(V_TO_H_MOUSE_WHEEL_EVENT); 36 | } 37 | -------------------------------------------------------------------------------- /lib/@uncharted/cards/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 4 | 5 | const OUTPUT_FILE_NAME = 'uncharted.cards'; 6 | 7 | const isProduction = (process.env.NODE_ENV === 'production'); // eslint-disable-line 8 | const isServing = (process.env.WEBPACK_ENV === 'serve'); // eslint-disable-line 9 | const outputFileName = isProduction ? `${OUTPUT_FILE_NAME}.min` : OUTPUT_FILE_NAME; 10 | 11 | const HANDLEBAR_RUNTIME_PATH = isProduction ? 'handlebars/dist/handlebars.runtime.min' : 'handlebars/runtime'; 12 | 13 | /** Base config */ 14 | const config = { 15 | entry: ['./src/main.scss', 'babel-polyfill', './src/index.js'], 16 | output: { 17 | filename: `${outputFileName}.js`, 18 | path: path.resolve(__dirname, 'dist'), 19 | }, 20 | resolve: { 21 | alias: { 22 | handlebars: HANDLEBAR_RUNTIME_PATH, 23 | }, 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | enforce: 'pre', 29 | test: /\.js$/, 30 | exclude: /node_modules/, 31 | loader: 'eslint-loader', 32 | }, 33 | { 34 | test: /\.js$/, 35 | loader: 'babel-loader', 36 | options: { 37 | presets: [ 38 | ['latest', { es2015: { modules: false } }], 39 | ], 40 | }, 41 | exclude: /node_modules/, 42 | }, 43 | { 44 | test: /\.scss$|\.css$/, 45 | use: [ 46 | { 47 | loader: "style-loader" // creates style nodes from JS strings 48 | }, 49 | { 50 | loader: "css-loader" // translates CSS into CommonJS 51 | }, 52 | { 53 | loader: "sass-loader" // compiles Sass to CSS 54 | } 55 | ] 56 | }, 57 | { 58 | test: /\.handlebars$/, 59 | loader: 'handlebars-loader', 60 | 61 | query: { 62 | helperDirs: [ 63 | path.resolve(__dirname, 'src/handlebarHelper'), 64 | ], 65 | runtime: HANDLEBAR_RUNTIME_PATH, 66 | }, 67 | }, 68 | ], 69 | }, 70 | 71 | externals: { 72 | jquery: 'jQuery', 73 | }, 74 | 75 | plugins: [ 76 | new webpack.optimize.ModuleConcatenationPlugin(), 77 | ], 78 | devServer: { 79 | disableHostCheck: true, 80 | }, 81 | }; 82 | 83 | if (!isServing) { 84 | config.plugins.push(...[ 85 | new CleanWebpackPlugin(['dist']), 86 | ]); 87 | } 88 | 89 | // Environment specific configs 90 | if (isProduction) { 91 | config.devtool = 'source-map'; 92 | config.plugins.push(...[ 93 | new webpack.DefinePlugin({ 94 | 'process.env': { 95 | 'NODE_ENV': JSON.stringify('production'), 96 | }, 97 | }), 98 | ]); 99 | 100 | config.mode = 'production'; 101 | } else { 102 | config.output.libraryTarget = 'window'; 103 | config.output.library = ['Uncharted', 'Cards']; 104 | config.devtool = 'inline-source-map'; 105 | config.plugins.push(...[ 106 | new webpack.DefinePlugin({ 107 | 'process.env': { 108 | 'NODE_ENV': JSON.stringify('dev'), 109 | }, 110 | }), 111 | ]); 112 | 113 | config.mode = 'development'; 114 | } 115 | 116 | module.exports = config; 117 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "powerbi-visuals-cardbrowser", 3 | "version": "2.0.1", 4 | "description": "Card Browser, a custom visual for PowerBI", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "install-certificate": "node bin/openCert", 8 | "install-private-submodule": "node bin/installPrivateSubmodules", 9 | "start": "yarn run dev", 10 | "tdd": "npm-run-all --parallel dev test:watch", 11 | "dev": "node bin/startDev", 12 | "package": "node bin/packageVisual", 13 | "test": "jest --coverage", 14 | "test:watch": "jest --watch", 15 | "lint": "tslint --project ./tsconfig.json src/**/*.ts", 16 | "clean": "rm -rf node_modules .tmp" 17 | }, 18 | "config": {}, 19 | "keywords": [], 20 | "author": "Microsoft", 21 | "license": "MIT", 22 | "privacyTerms": "https://privacy.microsoft.com/en-US/privacystatement/", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/Microsoft/PowerBI-visuals-CardBrowser" 26 | }, 27 | "privateSubmodules": { 28 | "@uncharted/cards": "0.13.9" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^24.0.13", 32 | "@types/jquery": "^3.3.29", 33 | "@types/lodash-es": "^4.17.0", 34 | "@types/node": "^9.4.1", 35 | "babel-core": "^6.26.0", 36 | "babel-loader": "^7.1.2", 37 | "babel-polyfill": "^6.26.0", 38 | "babel-preset-env": "^1.6.1", 39 | "chai": "^4.1.2", 40 | "chokidar": "^2.1.2", 41 | "clean-css": "^4.1.9", 42 | "connect": "^3.6.5", 43 | "eslint": "^5.15.1", 44 | "eslint-loader": "^2.1.2", 45 | "fs-extra": "^7.0.0", 46 | "handlebars": "^4.3.0", 47 | "handlebars-loader": "^1.7.0", 48 | "jest": "^24.8.0", 49 | "jquery": "^3.5.0", 50 | "json": "^9.0.6", 51 | "memory-fs": "^0.4.1", 52 | "mkdirp": "^0.5.1", 53 | "mv": "^2.1.1", 54 | "node-sass": "^4.7.2", 55 | "node-zip": "^1.1.1", 56 | "npm-run-all": "^4.1.5", 57 | "onchange": "^6.0.0", 58 | "serve-static": "^1.13.2", 59 | "sinon": "^7.2.7", 60 | "sinon-chai": "^3.3.0", 61 | "targz": "^1.0.1", 62 | "ts-jest": "^24.0.2", 63 | "ts-loader": "^3.5.0", 64 | "tslint": "^5.9.1", 65 | "tslint-loader": "^3.5.3", 66 | "tslint-microsoft-contrib": "^5.0.3", 67 | "typescript": "^2.7.2", 68 | "upath": "^1.1.1", 69 | "webpack": "^3.12.0" 70 | }, 71 | "dependencies": { 72 | "@fortawesome/fontawesome-free": "^5.8.2", 73 | "@uncharted/cards": "./lib/@uncharted/cards", 74 | "lodash": "^4.17.19", 75 | "lodash-es": "^4.17.14", 76 | "moment": "^2.24.0" 77 | }, 78 | "engines": { 79 | "node": ">=8" 80 | }, 81 | "jest": { 82 | "transform": { 83 | "^.+\\.(j|t)sx?$": "ts-jest" 84 | }, 85 | "transformIgnorePatterns": [ 86 | "/node_modules/(?!lodash-es/.*)" 87 | ], 88 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 89 | "moduleFileExtensions": [ 90 | "ts", 91 | "tsx", 92 | "js", 93 | "jsx", 94 | "json" 95 | ], 96 | "testURL": "http://localhost/" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pbiviz.json: -------------------------------------------------------------------------------- 1 | { 2 | "visual": { 3 | "name": "CardBrowser", 4 | "displayName": "Card Browser", 5 | "guid": "CardBrowser8D7CFFDA2E7E400C9474F41B9EDBBA58", 6 | "visualClassName": "CardBrowserVisual", 7 | "description": "ESSEX Card Browser", 8 | "supportUrl": "http://community.powerbi.com", 9 | "gitHubUrl": "https://github.com/Microsoft/PowerBI-visuals-CardBrowser" 10 | }, 11 | "author": { 12 | "name": "Microsoft", 13 | "email": "msrvizsupport@microsoft.com" 14 | }, 15 | "apiVersion": "2.6.0", 16 | "assets": { 17 | "icon": "assets/icon.svg", 18 | "thumbnail": "assets/thumbnail.png", 19 | "screenshot": "assets/1-preview.png" 20 | }, 21 | "style": "style/visual.scss", 22 | "capabilities": "capabilities.json", 23 | "output": "dist/essex.cardbrowser.pbiviz" 24 | } 25 | -------------------------------------------------------------------------------- /src/VisualMain.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | window['powerbi'] = { 6 | visuals: { 7 | } 8 | }; 9 | 10 | import * as $ from 'jquery'; 11 | import populateData from './test_data/testDataUtils'; 12 | import colors from './test_data/colors'; 13 | import CardBrowser from './VisualMain'; 14 | 15 | jest.mock('../lib/@uncharted/cards/src/index', () => { 16 | // typescript creates a named export called "default". So your mock needs to be an object with a default key 17 | // reference https://github.com/kulshekhar/ts-jest/issues/120 18 | return { 19 | 'default': jest.fn().mockImplementation(() => { 20 | return { 21 | on: jest.fn(), 22 | render: jest.fn(), 23 | }; 24 | }) 25 | }; 26 | }); 27 | jest.mock('../lib/@uncharted/cards/src/components/constants', () => { 28 | return { 29 | EVENTS: {} 30 | }; 31 | }); 32 | jest.mock('./visual.handlebars', () => () => '
visualTemplate
'); 33 | jest.mock('./loader.handlebars', () => () => '
loaderTamplate
'); 34 | 35 | describe('Card Browser Visual', () => { 36 | let visual; 37 | 38 | beforeAll(function () { 39 | const element = $('
'); 40 | const dummyHost = { 41 | createSelectionManager: () => ({ 42 | hostServices: 'hostService', 43 | registerOnSelectCallback: () => {} 44 | }), 45 | colors: colors, 46 | }; 47 | visual = new CardBrowser({ 48 | element: element[0], 49 | host: dummyHost, 50 | }); 51 | }); 52 | 53 | it('exists', () => { 54 | expect(CardBrowser).toBeTruthy(); 55 | expect(visual).toBeTruthy(); 56 | }); 57 | 58 | it('update', () => { 59 | const options = populateData([]); 60 | visual.update(options); 61 | }); 62 | 63 | it('enumerateObjectInstances', () => { 64 | const options = { 65 | objectName: 'presentation', 66 | }; 67 | const instances = visual.enumerateObjectInstances(options); 68 | expect(instances).toBeTruthy(); 69 | expect(instances.length).toBe(1); 70 | const instanceProperties = instances[0].properties; 71 | expect(instanceProperties.shadow).toBe(true); 72 | expect(instanceProperties.fade).toBe(true); 73 | expect(instanceProperties.dateFormat).toBe('MMM D, YYYY'); 74 | expect(instanceProperties.separator).toBe(' \u2022 '); 75 | expect(instanceProperties.separator).toBe(' \u2022 '); 76 | expect(instanceProperties.cardWidth).toBe(200); 77 | }); 78 | 79 | it('destroy', () => { 80 | visual.destroy(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | const packageJSON = require("../package.json"); 5 | const isDev = process.env.NODE_ENV !== 'production'; 6 | 7 | export const METADATA_FIELDS = ['metadata']; 8 | export const REQUIRED_FIELDS = ['id']; 9 | export const SUMMARY_FIELD = 'summary'; 10 | export const CONTENT_FIELD = 'content'; 11 | export const IMAGE_FIELD = 'imageUrl'; 12 | export const CARD_FACE_METADATA = 'metadata'; 13 | export const CARD_FACE_PREVIEW = 'preview'; 14 | 15 | /** 16 | * White list of HTML tags allowed in either the content or summary 17 | * @type {string[]} 18 | */ 19 | export const HTML_WHITELIST_STANDARD = [ 20 | 'A', 'ABBR', 'ACRONYM', 'ADDRESS', 'AREA', 'ARTICLE', 'ASIDE', 21 | 'B', 'BDI', 'BDO', 'BLOCKQUOTE', 'BR', 22 | 'CAPTION', 'CITE', 'CODE', 'COL', 'COLGROUP', 23 | 'DD', 'DEL', 'DETAILS', 'DFN', 'DIV', 'DL', 'DT', 24 | 'EM', 25 | 'FIGCAPTION', 'FIGURE', 'FONT', 'FOOTER', 26 | 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'HTML', 27 | 'I', 'INS', 28 | 'LEGEND', 'LI', 'LINK', 29 | 'MAIN', 'MAP', 30 | // We probably don't want navigation, but it's also probably mostly harmless 31 | // 'NAV', 32 | 'OL', 33 | 'P', 'PRE', 34 | 'SECTION', 'SMALL', 'SOURCE', 'SPAN', 'STRONG', 'STYLE', 'SUB', 'SUMMARY', 'SUP', 35 | 'TABLE', 'TBODY', 'TD', 'TEXTAREA', 'TFOOT', 'TH', 'THEAD', 'TIME', 'TR', 36 | 'U', 'UL', 37 | 'VAR', 38 | ]; 39 | 40 | /** 41 | * White list of HTML tags, for media, which are allowed only in the content 42 | * @type {string[]} 43 | */ 44 | export const HTML_WHITELIST_MEDIA = [ 45 | 'IMG', 46 | 'PICTURE', 47 | 'SVG', 48 | 'VIDEO' 49 | ]; 50 | 51 | export const HTML_WHITELIST_SUMMARY = HTML_WHITELIST_STANDARD; 52 | export const HTML_WHITELIST_CONTENT = HTML_WHITELIST_STANDARD.concat(HTML_WHITELIST_MEDIA); 53 | export const WRAP_HEIGHT_FACTOR = 1.25; 54 | export const FLIP_ANIMATION_DURATION = 317; // 300 ms from CSS plus one frame 55 | export const INFINITE_SCROLL_DELAY = 50; 56 | export const MIN_CARD_WIDTH = 11; 57 | 58 | /** 59 | * Default visual settings 60 | */ 61 | export const DEFAULT_VISUAL_SETTINGS = { 62 | presentation: { 63 | shadow: true, 64 | fade: true, 65 | dateFormat: 'MMM D, YYYY', 66 | separator: ' \u2022 ', 67 | showImageOnBack: true, 68 | cardWidth: 200, 69 | cardHeight: 300, 70 | filter: true, 71 | cropImages: true, 72 | }, 73 | reader: { 74 | headerBackgroundColor: { 75 | solid: { 76 | color: '#373737', 77 | } 78 | }, 79 | headerTextColor: { 80 | solid: { 81 | color: '#fff', 82 | } 83 | }, 84 | width: 520, 85 | height: 500, 86 | }, 87 | metadata: { 88 | fontSize: 10, 89 | titleColor: { 90 | solid: { 91 | color: '#bbb', 92 | } 93 | }, 94 | valueColor: { 95 | solid: { 96 | color: '#000', 97 | } 98 | }, 99 | titleFontFamily: "Default", 100 | valueFontFamily: "Default" 101 | }, 102 | flipState: { 103 | show: true, 104 | cardFaceDefault: CARD_FACE_PREVIEW, 105 | }, 106 | loadMoreData: { 107 | show: false, 108 | limit: 500 109 | }, 110 | general: { 111 | version: `${packageJSON.version}` 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /src/dataConversion.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | /// 5 | 6 | import DataView = powerbi.DataView; 7 | import IVisualHost = powerbi.extensibility.IVisualHost; 8 | 9 | import * as utils from './utils'; 10 | import * as moment from 'moment'; 11 | 12 | import { HTML_WHITELIST_SUMMARY, HTML_WHITELIST_CONTENT } from './constants'; 13 | import { HashMap, CardBrowserDocument, CardBrowserDocumentData } from './types'; 14 | 15 | function flattenMetaData(metaData) { 16 | const metaDataArray = Array.isArray(metaData) ? metaData : [metaData]; 17 | const metaDataObject = {}; 18 | for (let i = 0; i < metaDataArray.length; i++) { 19 | metaDataObject[metaDataArray[i].key] = metaDataArray[i].value; 20 | } 21 | return metaDataObject; 22 | } 23 | 24 | function createSelectionId(i, dataView: DataView, host) { 25 | const category = dataView.categorical.categories.find(n => n.source.roles.id); 26 | return host.createSelectionIdBuilder() 27 | .withCategory(category, i) 28 | .withMeasure(dataView.metadata.columns.find((col) => col.roles.id).queryName) 29 | .createSelectionId(); 30 | } 31 | 32 | function convertToDocuments(rowObjs: CardBrowserDocument[], dataView, host): CardBrowserDocumentData { 33 | const documents: HashMap = {}; 34 | const documentList: CardBrowserDocument[] = []; 35 | let obj: CardBrowserDocument; 36 | let docId: string | number; 37 | let i: number; 38 | const rowCount = rowObjs.length; 39 | 40 | for (i = 0; i < rowCount; i++) { 41 | obj = rowObjs[i]; 42 | docId = obj.id; 43 | if (docId !== null && !documents[docId]) { 44 | obj.selectionId = createSelectionId(i, dataView, host); 45 | documents[docId] = obj; 46 | documentList.push(documents[docId]); 47 | } 48 | } 49 | 50 | return { documents, documentList }; 51 | } 52 | 53 | function assignRole(rowObj, role, columnValue, roles, idx) { 54 | if (roles && roles.ordering) { 55 | const roleOrdering = roles.ordering[role]; 56 | const index = roleOrdering.indexOf(idx); 57 | if (index < 0 || rowObj[role][index] !== undefined) { 58 | // TODO: fix the bug that causes this to happen 59 | rowObj[role].push(columnValue); 60 | } 61 | else { 62 | rowObj[role][index] = columnValue; 63 | } 64 | } 65 | else { 66 | rowObj[role].push(columnValue); 67 | } 68 | } 69 | 70 | function assignValue(role, displayName, columnValue) { 71 | switch (role) { 72 | case 'metadata': 73 | return { 74 | key: displayName, 75 | value: columnValue, 76 | }; 77 | case 'summary': 78 | return utils.sanitizeHTML(columnValue, HTML_WHITELIST_SUMMARY); 79 | default: 80 | return columnValue; 81 | } 82 | } 83 | 84 | function convertToRowObjs(dataView: DataView, settings, roles = null): CardBrowserDocument[] { 85 | const result = []; 86 | // const columns = dataView.metadata.columns; 87 | let rowObj: any; 88 | let firstRoleIndexMap = []; 89 | const categorical = 90 | dataView && 91 | dataView.categorical; 92 | const categories = 93 | categorical && 94 | dataView.categorical.categories; 95 | const columnValues = 96 | categorical && 97 | dataView.categorical.values; 98 | function parseColumn(column: powerbi.DataViewMetadataColumn, colValue: any, colIdx: number) { 99 | const colRoles = Object.keys(column.roles); 100 | const columnValue = colValue && (column.type.dateTime ? 101 | moment(colValue as any).format(settings.presentation.dateFormat) : colValue); 102 | colRoles.forEach((role) => { 103 | if (rowObj[role] === undefined) { 104 | rowObj[role] = assignValue(role, column.displayName, columnValue); 105 | firstRoleIndexMap[role] = colIdx; 106 | return; 107 | } 108 | if (!Array.isArray(rowObj[role])) { 109 | const firstRoleValue = rowObj[role]; 110 | rowObj[role] = []; 111 | assignRole(rowObj, role, firstRoleValue, roles, firstRoleIndexMap[role]); 112 | } 113 | assignRole(rowObj, role, assignValue(role, column.displayName, columnValue), roles, colIdx); 114 | }); 115 | } 116 | if (categories && categories.length > 0 && categories[0].values && categories[0].values.length > 0) { 117 | 118 | // We can get duplicated categories if the user passes in the same column for multiple fields 119 | const uniqueCategories = dedupeCategories(categories); 120 | const idValues = categories[0].values; 121 | for (let rowIdx = 0; rowIdx < idValues.length; rowIdx++) { 122 | rowObj = { 123 | index: rowIdx, 124 | }; 125 | 126 | for (const cat of uniqueCategories) { 127 | parseColumn(cat.source, cat.values[rowIdx], 0); 128 | } 129 | 130 | if (columnValues && columnValues.length > 0) { 131 | columnValues.forEach((valueCol, colIdx) => { 132 | const column = valueCol.source; 133 | const colValue = valueCol.values[rowIdx]; 134 | parseColumn(column, colValue, colIdx + 1); 135 | }); 136 | } 137 | 138 | if (rowObj.metadata) { 139 | rowObj.metadata = flattenMetaData(rowObj.metadata); 140 | } 141 | 142 | if (rowObj.subtitle) { 143 | if (Array.isArray(rowObj.subtitle)) { 144 | rowObj.subtitle = rowObj.subtitle.filter(item => item); 145 | } 146 | else { 147 | rowObj.subtitle = [rowObj.subtitle]; 148 | } 149 | } 150 | 151 | if (rowObj.imageUrl && Array.isArray(rowObj.imageUrl)) { 152 | const cleanArray = []; 153 | for (let i = 0; i < rowObj.imageUrl.length; i++) { 154 | if (rowObj.imageUrl[i]) { 155 | cleanArray.push(rowObj.imageUrl[i]); 156 | } 157 | } 158 | rowObj.imageUrl = cleanArray; 159 | } 160 | 161 | if (rowObj.content) { 162 | rowObj.content = utils.sanitizeHTML(rowObj.content, HTML_WHITELIST_CONTENT); 163 | if (!rowObj.summary) { 164 | rowObj.summary = utils.sanitizeHTML(rowObj.content, HTML_WHITELIST_SUMMARY); 165 | } 166 | } 167 | 168 | if (rowObj.title && Array.isArray(rowObj.title)) { 169 | rowObj.title = rowObj.title.join(' '); 170 | } 171 | 172 | result.push(rowObj); 173 | } 174 | } 175 | return result; 176 | } 177 | 178 | function dedupeCategories(categories: powerbi.DataViewCategoryColumn[]) { 179 | const uniqueCategoryMap = categories.reduce((map, category, i) => { 180 | map[category.source.queryName || `category_${i}`] = category; 181 | return map; 182 | }, {}); 183 | return Object.keys(uniqueCategoryMap).map(n => uniqueCategoryMap[n]); 184 | } 185 | 186 | function convertToDocumentData(dataView: DataView, settings, roles, host: IVisualHost) { 187 | const rowObjs = convertToRowObjs(dataView, settings, roles); 188 | return convertToDocuments(rowObjs, dataView, host); 189 | } 190 | 191 | function countDocuments(dataView: DataView) { 192 | const categories = 193 | dataView && 194 | dataView.categorical && 195 | dataView.categorical.categories; 196 | if (!categories || categories.length === 0) { 197 | return 0; 198 | } 199 | 200 | const idCat = categories.find(n => n.source.roles.id); 201 | if (idCat && idCat.values) { 202 | return Object.keys(idCat.values.reduce((map, item) => { 203 | map[`${item}`] = true; 204 | return map; 205 | }, {})).length; 206 | } else { 207 | return 1; 208 | } 209 | } 210 | 211 | export { 212 | convertToDocumentData, 213 | countDocuments, 214 | }; 215 | -------------------------------------------------------------------------------- /src/loader.handlebars: -------------------------------------------------------------------------------- 1 | {{!-- 2 | /* 3 | * Copyright 2018 Uncharted Software Inc. 4 | */ 5 | 6 | --}} 7 |
Loading ...
-------------------------------------------------------------------------------- /src/sandboxPolyfill.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | // If, and only if, we are sandboxed, load babel-polyfill 6 | if (window.parent !== window) { 7 | "use strict"; 8 | 9 | require("core-js/shim"); 10 | 11 | require("regenerator-runtime/runtime"); 12 | 13 | require("core-js/fn/regexp/escape"); 14 | 15 | if (global._babelPolyfill) { 16 | throw new Error("only one instance of babel-polyfill is allowed"); 17 | } 18 | global._babelPolyfill = true; 19 | 20 | var DEFINE_PROPERTY = "defineProperty"; 21 | function define(O, key, value) { 22 | O[key] || Object[DEFINE_PROPERTY](O, key, { 23 | writable: true, 24 | configurable: true, 25 | value: value 26 | }); 27 | } 28 | 29 | define(String.prototype, "padLeft", "".padStart); 30 | define(String.prototype, "padRight", "".padEnd); 31 | 32 | "pop,reverse,shift,keys,values,entries,indexOf,every,some,forEach,map,filter,find,findIndex,includes,join,slice,concat,push,splice,unshift,sort,lastIndexOf,reduce,reduceRight,copyWithin,fill".split(",").forEach(function (key) { 33 | [][key] && define(Array, key, Function.call.bind([][key])); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/test_data/table.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | export default { 6 | "rows": [ 7 | [ 8 | 4423, 9 | "Comments", 10 | 0, 11 | "Now is the time", 12 | 2935, 13 | 5, 14 | "2017-01-01 20:43:39" 15 | ], 16 | [ 17 | 4423, 18 | "Comments", 19 | 2, 20 | "From who manufactured the narrative.", 21 | 10019, 22 | 4, 23 | "2017-01-01 20:43:39" 24 | ], 25 | [ 26 | 4423, 27 | "Comments", 28 | 4, 29 | "demonstrates that students", 30 | 9415, 31 | 2, 32 | "2017-01-01 20:43:39" 33 | ], 34 | [ 35 | 15223, 36 | "Free Market", 37 | 0, 38 | "Free Market for Education", 39 | 8883, 40 | 2, 41 | "2017-01-02 01:57:41" 42 | ], 43 | [ 44 | 211289, 45 | "Democrats", 46 | 0, 47 | "Washington", 48 | 1272, 49 | 64, 50 | "2017-01-02 05:45:33" 51 | ], 52 | [ 53 | 211289, 54 | "Democrats", 55 | 2, 56 | "WASHINGTON", 57 | 5787, 58 | 50, 59 | "2017-01-02 05:45:33" 60 | ], 61 | [ 62 | 211289, 63 | "Democrats", 64 | 4, 65 | "Such delays", 66 | 4476, 67 | 41, 68 | "2017-01-02 05:45:33" 69 | ], 70 | [ 71 | 211289, 72 | "Democrats", 73 | 6, 74 | "Incoming", 75 | 4475, 76 | 43, 77 | "2017-01-02 05:45:33" 78 | ], 79 | [ 80 | 211289, 81 | "Democrats", 82 | 8, 83 | "the focus of Democratic attacks", 84 | 4479, 85 | 43, 86 | "2017-01-02 05:45:33" 87 | ], 88 | [ 89 | 211289, 90 | "Democrats", 91 | 10, 92 | "President-elect nominees have made billions off the industries they'd be tasked with regulating", 93 | 7778, 94 | 46, 95 | "2017-01-02 05:45:33" 96 | ], 97 | [ 98 | 211696, 99 | "Sheltering", 100 | 0, 101 | "Sheltering in place", 102 | 13478, 103 | 2, 104 | "2017-01-01 23:49:00" 105 | ] 106 | ] 107 | }; -------------------------------------------------------------------------------- /src/test_data/testDataUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | import cloneDeep from 'lodash-es/cloneDeep'; 6 | import mockDataView from './mockdataview'; 7 | import table from './table'; 8 | 9 | // pbi wraps the categories with a "wrapCtor" that has the actual data accessors 10 | function wrapCtor(category, values) { 11 | 12 | this.source = category.source; 13 | this.identity = category.values.map(v => 'fakeId' + v); 14 | this.identityFields = []; 15 | this.values = values || []; 16 | } 17 | 18 | export default function populateData(data, highlights = null) { 19 | const options = cloneDeep(mockDataView); 20 | 21 | let dataView = options.dataViews[0]; 22 | 23 | dataView.categorical.categories = dataView.categorical.categories.map(function (category, index) { 24 | return new wrapCtor(category, data && data[index]); 25 | }); 26 | 27 | if (highlights) { 28 | dataView.categorical.values[0]['highlights'] = highlights; 29 | } 30 | 31 | dataView.table = table; 32 | return options; 33 | } -------------------------------------------------------------------------------- /src/test_data/testHtmlStrings.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | */ 4 | 5 | export default { 6 | testArticle: ' Lorem ipsumLorem ipsum dolor sit amet, consectetur adipiscing elit. Nam congue erat nulla, at lobortis velit efficitur eget. Pellentesque sit amet ante mattis, dignissim nisi et, efficitur nisi. Nunc vitae sapien eget arcu egestas viverra eu vitae metus. Cras et tincidunt nunc. Suspendisse vitae feugiat justo, sed malesuada est. Morbi enim leo, euismod porttitor risus nec, auctor pellentesque leo. Mauris volutpat commodo nisi eu rutrum. Etiam molestie congue nibh id rhoncus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Maecenas ut dolor posuere, tempor dolor nec, mattis ex. Pellentesque lobortis leo ac eros sagittis, ac commodo velit feugiat. Sed lectus nulla, suscipit et faucibus et, congue a tellus. Fusce suscipit odio dui. Maecenas blandit est a mauris interdum, id pellentesque velit feugiat. Morbi non volutpat magna. In vitae ipsum eget nibh fringilla auctor vitae rutrum lorem. <\/html>\n' 7 | }; -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export interface CardBrowserDocument { 4 | id: string | number; 5 | selectionId: powerbi.visuals.ISelectionId; 6 | } 7 | 8 | export interface HashMap { 9 | [key: string]: T; 10 | } 11 | 12 | export interface CardBrowserDocumentData { 13 | documents: HashMap; 14 | documentList: CardBrowserDocument[]; 15 | } -------------------------------------------------------------------------------- /src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Uncharted Software Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import DataViewMetadataColumn = powerbi.DataViewMetadataColumn; 18 | 19 | import * as $ from 'jquery'; 20 | import * as utils from './utils'; 21 | import mockDataView from './test_data/mockdataview'; 22 | import { HTML_WHITELIST_CONTENT } from './constants'; 23 | import testHtmlStrings from './test_data/testHtmlStrings.js'; 24 | import cloneDeep from 'lodash-es/cloneDeep'; 25 | 26 | describe('utils', () => { 27 | it('findColumn', () => { 28 | let options = cloneDeep(mockDataView); 29 | let dataView = options.dataViews[0]; 30 | const result = utils.findColumn(dataView, 'document', false); 31 | expect(result).toEqual({ 32 | "roles": { 33 | "id": true, 34 | "document": true 35 | }, 36 | "type": { 37 | "underlyingType": 260, 38 | "category": null 39 | }, 40 | "displayName": "documentID", 41 | "queryName": "betsydevos_lsh_strippets_browser.documentID", 42 | "expr": { 43 | "_kind": 2, 44 | "source": { 45 | "_kind": 0, 46 | "entity": "betsydevos_lsh_strippets_browser" 47 | }, 48 | "ref": "documentID" 49 | } 50 | }); 51 | }); 52 | 53 | it('hasColumns', () => { 54 | let options = cloneDeep(mockDataView); 55 | let dataView = options.dataViews[0]; 56 | expect(utils.hasColumns(dataView, ['document'])).toBe(true); 57 | }); 58 | 59 | it('hasRole', () => { 60 | const column = mockDataView.dataViews[0].metadata.columns[0]; 61 | expect(utils.hasRole(column, 'title')).toBe(false); 62 | expect(utils.hasRole(column, 'id')).toBe(true); 63 | expect(utils.hasRole(column, 'document')).toBe(true); 64 | }); 65 | 66 | it('removeScriptAttributes', () => { 67 | const element = $('

')[0]; 68 | expect([].find.call(element.attributes, (element, index, array) => element.nodeName === 'onerror')).toBeTruthy(); 69 | utils.removeScriptAttributes(element); 70 | expect([].find.call(element.attributes, (element, index, array) => element.nodeName === 'onerror')).toBeUndefined(); 71 | }); 72 | 73 | it('sanitizes HTML', function () { 74 | const sanitized = utils.sanitizeHTML(testHtmlStrings.testArticle, HTML_WHITELIST_CONTENT); 75 | expect(sanitized).toBeTruthy(); 76 | expect(sanitized.indexOf('