Merge remote-tracking branch 'powerbi/master' into main

This commit is contained in:
Ali Hamud 2020-09-21 16:16:14 +03:00
Родитель 30718e6f6b 35666a1a97
Коммит 6646540c7f
101 изменённых файлов: 28808 добавлений и 350 удалений

369
.gitignore поставляемый
Просмотреть файл

@ -1,350 +1,47 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.DS_Store
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
# dotnet core
bin/
obj/
project.lock.json
project.fragment.lock.json
artifacts/
NuGetScratch/
# StyleCop
StyleCopReport.xml
api/wwwroot/**
!api/wwwroot/scratch.html
!api/wwwroot/favicon.ico
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# node
node_modules/
typings/
build/
npm-debug.log
# Chutzpah Test files
_Chutzpah*
# client-react
client-react/components/*.js
client-react.test/build
!client-react.test/build/client-react/styles/
**/react-app-env.d.ts
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# testing
compiledTests/
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# ops
ops/hosts
ops/config.yml
ops/*.retry
# Visual Studio Trace Files
*.e2e
# other
*.js.map
# TFS 2012 Local Workspace
$tf/
# IDE
.idea/
.vs/
.vscode/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
# deployment
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
*.user
dotnet-tools.json

37
ContosoSalesDemo.sln Normal file
Просмотреть файл

@ -0,0 +1,37 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.28307.1145
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ContosoSalesDemo", "ContosoSalesDemo\ContosoSalesDemo.csproj", "{07B7833D-090C-4F50-B9F6-07C8D1709338}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Debug|Any CPU.Build.0 = Debug|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Debug|x64.ActiveCfg = Debug|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Debug|x64.Build.0 = Debug|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Debug|x86.ActiveCfg = Debug|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Debug|x86.Build.0 = Debug|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Release|Any CPU.ActiveCfg = Release|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Release|Any CPU.Build.0 = Release|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Release|x64.ActiveCfg = Release|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Release|x64.Build.0 = Release|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Release|x86.ActiveCfg = Release|Any CPU
{07B7833D-090C-4F50-B9F6-07C8D1709338}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4CCA31EC-31A5-4FA1-B413-BA8B8E5A66DE}
EndGlobalSection
EndGlobal

Просмотреть файл

@ -0,0 +1,43 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
module.exports = {
parser: "@typescript-eslint/parser", // Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: "module", // Allows for the use of imports
ecmaFeatures: {
jsx: true // Allows for the parsing of JSX
},
project: './tsconfig.json',
},
plugins: ["@typescript-eslint", "prettier"],
settings: {
react: {
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
}
},
extends: [
"eslint:recommended",
"plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from @typescript-eslint/eslint-plugin
"plugin:react-hooks/recommended",
"plugin:prettier/recommended"
],
rules: {
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"prefer-const": "warn",
"no-var": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/no-extra-semi": "off",
"prettier/prettier": "error",
"no-duplicate-imports": ["error", { includeExports: true }],
},
};

Просмотреть файл

@ -0,0 +1,15 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
module.exports = {
tabWidth: 4,
useTabs: true,
singleQuote: true,
jsxSingleQuote: true,
trailingComma: "es5",
bracketSpacing: true,
jsxBracketSameLine: true,
printWidth: 110,
};

Просмотреть файл

@ -0,0 +1,17 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
files:
include: 'src/**/*.s+(a|c)ss'
rules:
indentation: 0
hex-notation:
- 2
-
style: uppercase
no-invalid-hex: 0
no-color-literals: 0
hex-length: 0
single-line-per-selector: 0
border-zero: 0
leading-zero: 0

Просмотреть файл

@ -0,0 +1,52 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
// Karma configuration
module.exports = function (config) {
config.set({
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '',
// frameworks to use
frameworks: ['jasmine'],
// list of files / patterns to load in the browser
files: [
'compiledTests/**/*.js'
],
// list of files / patterns to exclude
exclude: [
'*.map',
'compiledTests/**/*.map'
],
// preprocess matching files before serving them to the browser
preprocessors: {
},
// test results reporter to use
reporters: ['progress'],
// enable / disable colors in the output (reporters and logs)
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
// enable / disable watching file and executing tests whenever any file changes
autoWatch: false,
// start these browsers
browsers: ['ChromeHeadless'],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: true,
})
}

18383
ContosoSalesDemo/ClientApp/package-lock.json сгенерированный Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -0,0 +1,73 @@
{
"name": "ContosoSalesDemo",
"version": "0.1.0",
"private": true,
"dependencies": {
"powerbi-client-react": "^1.1.0",
"react": "16.13.1",
"react-datepicker": "^3.1.3",
"react-dom": "^16.13.1",
"react-hook-form": "^6.0.4"
},
"devDependencies": {
"@popperjs/core": "^2.4.2",
"@types/bootstrap": "^4.5.0",
"@types/jasmine": "^3.5.10",
"@types/jquery": "^3.3.38",
"@types/jsonwebtoken": "^8.5.0",
"@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"@typescript-eslint/eslint-plugin": "^3.3.0",
"@typescript-eslint/parser": "^3.3.0",
"ajv": "^6.9.1",
"bootstrap": "^4.5.0",
"cross-env": "^5.2.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-config-react-app": "^5.2.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-react-hooks": "^4.0.4",
"jasmine-core": "^3.5.0",
"jquery": "^3.5.1",
"jsonwebtoken": "^8.5.1",
"karma": "^5.0.9",
"karma-chrome-launcher": "^3.1.0",
"karma-jasmine": "^3.3.1",
"node-sass": "^4.14.1",
"prettier": "^2.0.5",
"react-scripts": "^3.4.1",
"rimraf": "^2.6.2",
"sass-lint": "^1.13.1",
"svg-url-loader": "^6.0.0",
"ts-loader": "^7.0.5",
"typescript": "^3.9.3",
"webpack-cli": "^3.3.12"
},
"eslintConfig": {
"extends": "react-app"
},
"scripts": {
"start": "rimraf ./build && react-scripts start",
"build": "react-scripts build",
"pretest": "webpack --config webpack.test.config.js",
"test": "karma start karma.conf.js",
"eject": "react-scripts eject",
"lint": "eslint --fix src/**/*.{ts,tsx} && echo Linting complete.",
"scss-lint": "sass-lint -c .sass-lint.yml -v -q"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Просмотреть файл

@ -0,0 +1,22 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the MIT license.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="This website is owned by Microsoft"
/>
<title>Contoso Sales Demo</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<main id="root" class="h-100 w-100"></main>
</body>
</html>

Просмотреть файл

@ -0,0 +1,90 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.app-name {
margin-left: 18px;
}
.cursor-default {
cursor: default;
}
.form-control {
box-shadow: none !important;
background-color: transparent;
&.light {
border: 1px solid #C1C1C1;
}
&.dark {
border: 1px solid #747D94;
}
}
.gradient-bg {
background: linear-gradient(135deg, #D1A2B8, #9585BE, #2F3C6B);
height: 100%;
margin: 0;
padding: 0;
}
.input-icon {
height: 16px;
width: 16px;
}
.input-text-icon {
padding-left: 3rem;
}
input[type='text']:focus,
input[type='password']:focus {
border-color: #A7AEFF;
}
input[type='radio'] {
cursor: pointer;
}
input[type='radio']:focus {
outline: none;
}
.invalid-feedback {
position: absolute;
}
.non-selectable {
user-select: none;
}
.rounded-img {
border-radius: 50%;
}
.report-container {
display: flex;
flex: 1;
margin: 34px 35px;
min-height: 80vh;
height: 940px;
position: sticky;
}
.report-container > iframe {
border: 0;
}
.set-icon {
color: #808080;
opacity: 0.7;
z-index: 1;
}
.vertical-center {
top: 50%;
transform: translateY(-50%);
}

Просмотреть файл

@ -0,0 +1,68 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.min.js';
import './App.scss';
import React, { useState } from 'react';
import { Card } from './components/Card/Card';
import { EmbedPage } from './components/EmbedPage/EmbedPage';
import { AnonymousUser, SalesManager, SalesPerson } from './constants';
import { getStoredToken, checkTokenValidity, getTokenPayload } from './components/utils';
import { Profile } from './models';
export default function App(): React.FunctionComponentElement<null> {
// This state is used to re-render the app to switch between pages, state value is never used
const [state, setState] = useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
// Get token from storage
const storedToken = getStoredToken();
// Invalid token and render login page to get a new valid token
if (!checkTokenValidity(storedToken)) {
return <Card updateApp={setState} />;
}
// Get user data
const tokenPayload = getTokenPayload(storedToken);
const username: string = tokenPayload.username;
const role: string = tokenPayload.role;
const name: string = tokenPayload.name;
let profileType: Profile;
let profileImageName: string;
// For production application, get these values from identity provider
switch (role) {
case Profile.SalesManager:
profileType = Profile.SalesManager;
profileImageName = SalesManager.profileImageName;
break;
case Profile.SalesPerson:
profileType = Profile.SalesPerson;
if (username) {
profileImageName = SalesPerson.profileImageName;
} else {
profileImageName = AnonymousUser.profileImageName;
}
break;
default:
console.error('Profile type is not valid');
// Redirect to login again
return <Card updateApp={setState} />;
}
return (
<EmbedPage
profile={profileType}
name={name}
profileImageName={profileImageName}
updateApp={setState}
/>
);
}

Просмотреть файл

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10" viewBox="0 0 20 10">
<path id="Icon_feather-chevron-down" data-name="Icon feather-chevron-down" d="M9,13.5l9,9,9-9" transform="translate(-7.939 -12.439)" fill="none" stroke="#d8d8d8" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
</svg>

После

Ширина:  |  Высота:  |  Размер: 323 B

Просмотреть файл

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10" viewBox="0 0 20 10"><defs><style>.a{fill:none;stroke:#656565;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs><path class="a" d="M9,13.5l9,9,9-9" transform="translate(-7.939 -12.439)"/></svg>

После

Ширина:  |  Высота:  |  Размер: 283 B

Просмотреть файл

@ -0,0 +1,174 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="analytics-captureview-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M20.765,14.473H16.04V9.748a.783.783,0,1,0-1.567,0v4.725H9.748a.783.783,0,0,0,0,1.567h4.725v4.725a.783.783,0,0,0,1.567,0V16.04h4.725a.783.783,0,0,0,0-1.567Z" transform="translate(-8.965 -8.965)"/>
</g>
<g id="analytics-captureview-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M20.765,14.473H16.04V9.748a.783.783,0,1,0-1.567,0v4.725H9.748a.783.783,0,0,0,0,1.567h4.725v4.725a.783.783,0,0,0,1.567,0V16.04h4.725a.783.783,0,0,0,0-1.567Z" transform="translate(-8.965 -8.965)"/>
</g>
<g id="analytics-myviews-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M4.989,11.707a1.239,1.239,0,1,0,1.239,1.239A1.238,1.238,0,0,0,4.989,11.707Zm0-4.957A1.239,1.239,0,1,0,6.229,7.989,1.238,1.238,0,0,0,4.989,6.75Zm0,9.914A1.239,1.239,0,1,0,6.229,17.9,1.243,1.243,0,0,0,4.989,16.664Zm2.479,2.065H19.034V17.077H7.468Zm0-4.957H19.034V12.12H7.468Zm0-6.609V8.815H19.034V7.163Z" transform="translate(-3.75 -6.75)"/>
</g>
<g id="analytics-myviews-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M4.989,11.707a1.239,1.239,0,1,0,1.239,1.239A1.238,1.238,0,0,0,4.989,11.707Zm0-4.957A1.239,1.239,0,1,0,6.229,7.989,1.238,1.238,0,0,0,4.989,6.75Zm0,9.914A1.239,1.239,0,1,0,6.229,17.9,1.243,1.243,0,0,0,4.989,16.664Zm2.479,2.065H19.034V17.077H7.468Zm0-4.957H19.034V12.12H7.468Zm0-6.609V8.815H19.034V7.163Z" transform="translate(-3.75 -6.75)"/>
</g>
<g id="anonymous-profile">
<path xmlns="http://www.w3.org/2000/svg" fill="#ebeff3" d="M16.016,32.056l-5.669,3.092a5.1,5.1,0,0,0-.91.647,18.988,18.988,0,0,0,24.4.063,5.029,5.029,0,0,0-1-.67l-6.071-3.035a2.315,2.315,0,0,1-1.28-2.071V27.7a9.169,9.169,0,0,0,.574-.738,13.969,13.969,0,0,0,1.887-3.8A1.907,1.907,0,0,0,29.3,21.347V18.8a1.9,1.9,0,0,0-.635-1.409V13.72S29.417,8,21.672,8s-6.991,5.719-6.991,5.719V17.4a1.9,1.9,0,0,0-.635,1.409v2.542a1.906,1.906,0,0,0,.879,1.6,12.617,12.617,0,0,0,2.3,4.756v2.323A2.317,2.317,0,0,1,16.016,32.056Z" transform="translate(-2.671 -2.264)"/><path xmlns="http://www.w3.org/2000/svg" fill="#c2c4d4" d="M19.325,0A18.988,18.988,0,0,0,6.774,33.526a5.049,5.049,0,0,1,.9-.641l5.669-3.092a2.316,2.316,0,0,0,1.207-2.033V25.437a12.6,12.6,0,0,1-2.3-4.756,1.907,1.907,0,0,1-.879-1.6V16.539a1.9,1.9,0,0,1,.635-1.409V11.455S11.254,5.736,19,5.736s6.991,5.719,6.991,5.719V15.13a1.9,1.9,0,0,1,.635,1.409v2.542A1.907,1.907,0,0,1,25.273,20.9a13.969,13.969,0,0,1-1.887,3.8,9.169,9.169,0,0,1-.574.738v2.382a2.314,2.314,0,0,0,1.28,2.071l6.071,3.035a5.055,5.055,0,0,1,1,.668A19,19,0,0,0,19.325,0Z"/>
</g>
<g id="app-name-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M33.842,5.8,31.849,7.739C27.576,3.932,22.691,2.212,17.065,3.17A16.747,16.747,0,0,0,6.431,9.647,17.39,17.39,0,0,0,7,31.022c5.463,6.347,15.508,8.444,23.86,2.125.571.68,1.164,1.39,1.759,2.1C26.079,41.368,13.68,42.273,5.526,33.82A20.141,20.141,0,0,1,6.533,5.12C14.044-1.93,27.1-1.7,33.842,5.8Z"/><path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M31.558,42.443v2.829A14.229,14.229,0,0,1,19.967,39c-3.737-5.395-3.987-11.133-.625-16.8,2.691-4.541,6.8-6.9,12.186-7.26v2.838c-4.089.576-7.534,2.355-9.759,6.084a12.536,12.536,0,0,0-1.6,7.875C20.7,37.186,25.151,41.454,31.558,42.443Z" transform="translate(-11.907 -10.472)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M88.618,49.681c-.042,4.895-3.019,7.892-7.781,7.841-4.66-.051-7.441-3-7.387-7.835s3.052-7.667,8.121-7.634C85.963,42.078,88.657,45,88.618,49.681Zm-11.87.072c0,.3-.015.6,0,.9.138,2.447,1.621,4.116,3.764,4.254,2.8.177,4.3-.866,4.717-3.6a10.333,10.333,0,0,0-.213-3.812A3.578,3.578,0,0,0,81.188,44.6C78.353,44.546,76.751,46.407,76.748,49.754Z" transform="translate(-51.142 -29.197)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M332.272,49.69c-.015,4.841-3.043,7.871-7.814,7.823-4.537-.045-7.417-2.976-7.417-7.543,0-5,2.979-7.94,8.021-7.925C329.584,42.056,332.287,44.925,332.272,49.69Zm-3.226.409c-.03-.688-.015-1.088-.072-1.479-.4-2.775-2.05-4.2-4.612-4.023-2.4.168-3.881,1.879-3.939,4.564a15.975,15.975,0,0,0,.021,1.936,4.025,4.025,0,0,0,3.725,3.8c2.7.162,4.026-.743,4.609-3.175C328.929,51.1,328.989,50.454,329.046,50.1Z" transform="translate(-220.753 -29.19)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M236.265,49.693c.009,4.81-2.926,7.82-7.619,7.814s-7.483-2.823-7.5-7.54c-.015-4.937,2.862-7.91,7.667-7.922S236.256,44.741,236.265,49.693Zm-11.834.078c-.072,1.888.3,3.662,2.086,4.624a4.32,4.32,0,0,0,4.817-.189c1.969-1.494,1.879-3.746,1.606-5.887a3.793,3.793,0,0,0-2.769-3.533C226.794,43.863,224.437,45.911,224.431,49.771Z" transform="translate(-153.985 -29.19)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M134.553,56.971v-4.36q0-4.1,0-8.2c0-2.168.009-2.18,2.147-2.056a7.908,7.908,0,0,1,.9.159c.063.577.123,1.109.219,1.966.46-.409.758-.667,1.046-.932a5.514,5.514,0,0,1,5.385-1.4,4.1,4.1,0,0,1,3.253,4c.162,3.551.045,7.117.045,10.791h-2.925c0-1.6.024-3.22-.006-4.838a41.352,41.352,0,0,0-.222-4.895,2.713,2.713,0,0,0-2.992-2.682,3.3,3.3,0,0,0-3.421,2.751c-.241,2.011-.222,4.053-.282,6.082-.036,1.179-.006,2.357-.006,3.614C136.585,56.971,135.665,56.971,134.553,56.971Z" transform="translate(-93.701 -29.133)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M279.006,54.125c1.1.313,2.09.652,3.1.851a2.978,2.978,0,0,0,1.738-.087c.6-.292,1.377-.854,1.449-1.383a2.479,2.479,0,0,0-.986-1.768,9.982,9.982,0,0,0-2.258-1.13c-2.634-1.209-3.566-2.733-3.085-5.087.379-1.867,2.507-3.4,4.874-3.494a14.636,14.636,0,0,1,1.5.006c2.348.147,2.7.631,2.234,2.88a21.708,21.708,0,0,0-3.68-.328,2.1,2.1,0,0,0-1.587,1.173,2.522,2.522,0,0,0,.764,2,9.373,9.373,0,0,0,2.405,1.154c2.291,1.043,3.373,2.646,3.091,4.573a4.772,4.772,0,0,1-4.344,3.878,11.745,11.745,0,0,1-2.832.015C278.844,57.083,278.694,56.843,279.006,54.125Z" transform="translate(-194.229 -29.167)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M194.953,33.476V35.92c-.836,0-1.617.03-2.4-.009-.857-.042-1.326.265-1.323,1.173.012,2.135-.063,4.272.033,6.4.093,2.047.692,2.405,3.617,2.51.292,2.243.168,2.4-1.993,2.553-2.916.2-4.54-1.155-4.678-4.089-.111-2.378-.081-4.762-.06-7.147.009-.968-.253-1.542-1.35-1.4-.9.117-1.233-.274-1.224-1.188.006-.857.223-1.362,1.17-1.254,1.04.117,1.389-.34,1.41-1.359.051-2.45.168-2.522,3.064-2.811,0,.9.045,1.78-.012,2.655-.075,1.158.394,1.645,1.569,1.53C193.459,33.422,194.15,33.476,194.953,33.476Z" transform="translate(-129.269 -20.305)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a6abe6" d="M41.792,53.306c-.619.64-1.2,1.242-1.732,1.795-3.6-2.078-4.414-9.687.2-13.208L42.1,43.8C39.317,47.828,39.251,49.515,41.792,53.306Z" transform="translate(-25.995 -29.11)"/>
</g>
<g id="app-name-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M33.842,5.8,31.849,7.739C27.576,3.932,22.691,2.212,17.065,3.17A16.747,16.747,0,0,0,6.431,9.647,17.39,17.39,0,0,0,7,31.022c5.463,6.347,15.508,8.444,23.86,2.125.571.68,1.164,1.39,1.759,2.1C26.079,41.368,13.68,42.273,5.526,33.82A20.141,20.141,0,0,1,6.533,5.12C14.044-1.93,27.1-1.7,33.842,5.8Z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M31.558,42.443v2.829A14.229,14.229,0,0,1,19.967,39c-3.737-5.395-3.987-11.133-.625-16.8,2.691-4.541,6.8-6.9,12.186-7.26v2.838c-4.089.576-7.534,2.355-9.759,6.084a12.536,12.536,0,0,0-1.6,7.875C20.7,37.186,25.151,41.454,31.558,42.443Z" transform="translate(-11.907 -10.471)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M88.618,49.681c-.042,4.895-3.019,7.892-7.781,7.841-4.66-.051-7.441-3-7.387-7.835s3.052-7.667,8.121-7.634C85.963,42.078,88.657,45,88.618,49.681Zm-11.87.072c0,.3-.015.6,0,.9.138,2.447,1.621,4.116,3.764,4.254,2.8.177,4.3-.866,4.717-3.6a10.333,10.333,0,0,0-.213-3.812A3.578,3.578,0,0,0,81.188,44.6C78.353,44.546,76.751,46.407,76.748,49.754Z" transform="translate(-51.142 -29.197)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M332.272,49.69c-.015,4.841-3.043,7.871-7.814,7.823-4.537-.045-7.417-2.976-7.417-7.543,0-5,2.979-7.94,8.021-7.925C329.584,42.056,332.287,44.925,332.272,49.69Zm-3.226.409c-.03-.688-.015-1.088-.072-1.479-.4-2.775-2.05-4.2-4.612-4.023-2.4.168-3.881,1.879-3.939,4.564a15.975,15.975,0,0,0,.021,1.936,4.025,4.025,0,0,0,3.725,3.8c2.7.162,4.026-.743,4.609-3.175C328.929,51.1,328.989,50.454,329.046,50.1Z" transform="translate(-220.753 -29.191)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M236.265,49.693c.009,4.81-2.926,7.82-7.619,7.814s-7.483-2.823-7.5-7.54c-.015-4.937,2.862-7.91,7.667-7.922S236.256,44.741,236.265,49.693Zm-11.834.078c-.072,1.888.3,3.662,2.086,4.624a4.32,4.32,0,0,0,4.817-.189c1.969-1.494,1.879-3.746,1.606-5.887a3.793,3.793,0,0,0-2.769-3.533C226.794,43.863,224.437,45.911,224.431,49.771Z" transform="translate(-153.985 -29.191)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M134.553,56.971v-4.36q0-4.1,0-8.2c0-2.168.009-2.18,2.147-2.056a7.908,7.908,0,0,1,.9.159c.063.577.123,1.109.219,1.966.46-.409.758-.667,1.046-.932a5.514,5.514,0,0,1,5.385-1.4,4.1,4.1,0,0,1,3.253,4c.162,3.551.045,7.117.045,10.791h-2.925c0-1.6.024-3.22-.006-4.838a41.352,41.352,0,0,0-.222-4.895,2.713,2.713,0,0,0-2.992-2.682,3.3,3.3,0,0,0-3.421,2.751c-.241,2.011-.222,4.053-.282,6.082-.036,1.179-.006,2.357-.006,3.614C136.585,56.971,135.665,56.971,134.553,56.971Z" transform="translate(-93.701 -29.133)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M279.006,54.125c1.1.313,2.09.652,3.1.851a2.978,2.978,0,0,0,1.738-.087c.6-.292,1.377-.854,1.449-1.383a2.479,2.479,0,0,0-.986-1.768,9.982,9.982,0,0,0-2.258-1.13c-2.634-1.209-3.566-2.733-3.085-5.087.379-1.867,2.507-3.4,4.874-3.494a14.636,14.636,0,0,1,1.5.006c2.348.147,2.7.631,2.234,2.88a21.708,21.708,0,0,0-3.68-.328,2.1,2.1,0,0,0-1.587,1.173,2.522,2.522,0,0,0,.764,2,9.373,9.373,0,0,0,2.405,1.154c2.291,1.043,3.373,2.646,3.091,4.573a4.772,4.772,0,0,1-4.344,3.878,11.745,11.745,0,0,1-2.832.015C278.844,57.083,278.694,56.843,279.006,54.125Z" transform="translate(-194.229 -29.167)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M194.953,33.476V35.92c-.836,0-1.617.03-2.4-.009-.857-.042-1.326.265-1.323,1.173.012,2.135-.063,4.272.033,6.4.093,2.047.692,2.405,3.617,2.51.292,2.243.168,2.4-1.993,2.553-2.916.2-4.54-1.155-4.678-4.089-.111-2.378-.081-4.762-.06-7.147.009-.968-.253-1.542-1.35-1.4-.9.117-1.233-.274-1.224-1.188.006-.857.223-1.362,1.17-1.254,1.04.117,1.389-.34,1.41-1.359.051-2.45.168-2.522,3.064-2.811,0,.9.045,1.78-.012,2.655-.075,1.158.394,1.645,1.569,1.53C193.459,33.422,194.15,33.476,194.953,33.476Z" transform="translate(-129.268 -20.305)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M41.792,53.306c-.619.64-1.2,1.242-1.732,1.795-3.6-2.078-4.414-9.687.2-13.208L42.1,43.8C39.317,47.828,39.251,49.515,41.792,53.306Z" transform="translate(-25.995 -29.111)"/>
</g>
<g id="auth-error">
<path xmlns="http://www.w3.org/2000/svg" fill="#c74990" width="16" height="16" viewBox="0 0 15.607 15.607" d="M10.8,3a7.8,7.8,0,1,0,7.8,7.8A7.807,7.807,0,0,0,10.8,3Zm.78,11.706H10.023V13.145h1.561Zm0-3.121H10.023V6.9h1.561Z" transform="translate(-3 -3)"/>
</g>
<g id="colspan">
<g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H12V16H0z" transform="rotate(-90 10.5 10.5)"/><path d="M0.5 0.5H11.5V15.5H0.5z" transform="rotate(-90 10.5 10.5)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="rotate(-90 3.5 3.5)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="rotate(-90 3.5 3.5)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="rotate(-90 8 -1)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="rotate(-90 8 -1)"/></g>
</g>
<g id="colspan-selected">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H12V16H0z" transform="rotate(-90 10.5 10.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="rotate(-90 3.5 3.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="rotate(-90 8 -1)"/>
</g>
<g id="colspan-selected-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H12V16H0z" transform="rotate(-90 10.5 10.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="rotate(-90 3.5 3.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="rotate(-90 8 -1)"/>
</g>
<g id="colspan-selected-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H12V16H0z" transform="rotate(-90 10.5 10.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="rotate(-90 3.5 3.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="rotate(-90 8 -1)"/>
</g>
<g id="colspan-selected-personalise-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H12V16H0z" transform="rotate(-90 10.5 10.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="rotate(-90 3.5 3.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="rotate(-90 8 -1)"/>
</g>
<g id="colspan-selected-personalise-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H12V16H0z" transform="rotate(-90 10.5 10.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="rotate(-90 3.5 3.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="rotate(-90 8 -1)"/>
</g>
<g id="error-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#e37fb7" d="M18,3A15,15,0,1,0,33,18,15.005,15.005,0,0,0,18,3Zm1.5,22.5h-3v-3h3Zm0-6h-3v-9h3Z" transform="translate(-3 -3)"/>
</g>
<g id="error-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#e37fb7" d="M18,3A15,15,0,1,0,33,18,15.005,15.005,0,0,0,18,3Zm1.5,22.5h-3v-3h3Zm0-6h-3v-9h3Z" transform="translate(-3 -3)"/>
</g>
<g id="icon-feather-arrow-right-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#d8d8d8" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.5,18H22.509" transform="translate(-6.75 -9.434)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#d8d8d8" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18,7.5,25.5,15l-7.5,7.5" transform="translate(-9.745 -6.439)"/>
</g>
<g id="icon-feather-arrow-right-light">
<path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.5,18H22.509" transform="translate(-6.75 -9.434)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18,7.5,25.5,15l-7.5,7.5" transform="translate(-9.745 -6.439)"/>
</g>
<g id="lock">
<path xmlns="http://www.w3.org/2000/svg" fill="#707487" d="M87.39,163.318H86.3v-1.525a4.793,4.793,0,1,0-9.586,0v1.525H75.625a1.489,1.489,0,0,0-1.525,1.525v9.368a1.684,1.684,0,0,0,1.525,1.743H87.39a1.718,1.718,0,0,0,1.743-1.743v-9.368A1.849,1.849,0,0,0,87.39,163.318Zm-8.5-1.525a2.614,2.614,0,1,1,5.229,0v1.525H78.893v-1.525Zm3.486,9.586v2.4a.213.213,0,0,1-.218.218H81.072a.213.213,0,0,1-.218-.218v-2.4a1.905,1.905,0,0,1-1.307-1.961,2.179,2.179,0,1,1,4.357,0A2.472,2.472,0,0,1,82.379,171.379Z" opacity=".7" transform="translate(-74.1 -157)"/>
</g>
<g id="one-column">
<g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z"/><path d="M0.5 0.5H6.5V6.5H0.5z"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(0 9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(0 9)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(0 18)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(0 18)"/></g>
</g>
<g id="one-column-selected">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/>
</g>
<g id="one-column-selected-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(0 18)"/>
</g>
<g id="one-column-selected-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/>
</g>
<g id="one-column-selected-personalise-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(0 18)"/>
</g>
<g id="one-column-selected-personalise-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/>
</g>
<g id="personalise-close-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#d8d8d8" d="M24.852,9.248,23.1,7.5l-6.928,6.928L9.248,7.5,7.5,9.248l6.928,6.928L7.5,23.1l1.748,1.748,6.928-6.928L23.1,24.852,24.852,23.1l-6.928-6.928Z" transform="translate(-7.5 -7.5)"/>
</g>
<g id="personalise-close-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#6e6e6e" d="M24.852,9.248,23.1,7.5l-6.928,6.928L9.248,7.5,7.5,9.248l6.928,6.928L7.5,23.1l1.748,1.748,6.928-6.928L23.1,24.852,24.852,23.1l-6.928-6.928Z" transform="translate(-7.5 -7.5)"/>
</g>
<g id="personalise-include-visuals-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M16.676,7.875c-5.182,0-9.269,3.262-14.155,8.592a1.019,1.019,0,0,0-.006,1.373c4.183,4.622,7.87,8.6,14.161,8.6,6.214,0,10.874-5.008,14.187-8.637a1.011,1.011,0,0,0,.032-1.334C27.517,12.374,22.844,7.875,16.676,7.875Zm.284,15.076a5.8,5.8,0,1,1,5.524-5.524A5.8,5.8,0,0,1,16.959,22.951Z" transform="translate(-2.252 -7.875)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M17.625,14.6a3.029,3.029,0,0,1,.2-1.089c-.064,0-.129-.006-.2-.006a4.125,4.125,0,1,0,4.125,4.125c0-.084-.006-.168-.006-.251a2.858,2.858,0,0,1-1.173.251A2.992,2.992,0,0,1,17.625,14.6Z" transform="translate(-3.189 -8.343)"/>
</g>
<g id="personalise-include-visuals-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M16.676,7.875c-5.182,0-9.269,3.262-14.155,8.592a1.019,1.019,0,0,0-.006,1.373c4.183,4.622,7.87,8.6,14.161,8.6,6.214,0,10.874-5.008,14.187-8.637a1.011,1.011,0,0,0,.032-1.334C27.517,12.374,22.844,7.875,16.676,7.875Zm.284,15.076a5.8,5.8,0,1,1,5.524-5.524A5.8,5.8,0,0,1,16.959,22.951Z" transform="translate(-2.252 -7.875)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M17.625,14.6a3.029,3.029,0,0,1,.2-1.089c-.064,0-.129-.006-.2-.006a4.125,4.125,0,1,0,4.125,4.125c0-.084-.006-.168-.006-.251a2.858,2.858,0,0,1-1.173.251A2.992,2.992,0,0,1,17.625,14.6Z" transform="translate(-3.189 -8.343)"/>
</g>
<g id="personalise-layout-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z" transform="translate(18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z" transform="translate(18 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z" transform="translate(9 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#A7AEFF" d="M0 0H7V7H0z" transform="translate(18 18)"/>
</g>
<g id="personalise-layout-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18 18)"/>
</g>
<g id="personalise-question-answer-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M26.75,8h-2.5V19.25H8v2.5A1.254,1.254,0,0,0,9.25,23H23l5,5V9.25A1.254,1.254,0,0,0,26.75,8Zm-5,7.5V4.25A1.254,1.254,0,0,0,20.5,3H4.25A1.254,1.254,0,0,0,3,4.25v17.5l5-5H20.5A1.254,1.254,0,0,0,21.75,15.5Z" transform="translate(-3 -3)"/>
</g>
<g id="personalise-question-answer-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M26.75,8h-2.5V19.25H8v2.5A1.254,1.254,0,0,0,9.25,23H23l5,5V9.25A1.254,1.254,0,0,0,26.75,8Zm-5,7.5V4.25A1.254,1.254,0,0,0,20.5,3H4.25A1.254,1.254,0,0,0,3,4.25v17.5l5-5H20.5A1.254,1.254,0,0,0,21.75,15.5Z" transform="translate(-3 -3)"/>
</g>
<g id="rowspan">
<g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H12V16H0z"/><path d="M0.5 0.5H11.5V15.5H0.5z"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(14)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(14)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(14 9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(14 9)"/></g>
</g>
<g id="rowspan-selected">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H12V16H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(14)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(14 9)"/>
</g>
<g id="rowspan-selected-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H12V16H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(14)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(14 9)"/>
</g>
<g id="rowspan-selected-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H12V16H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(14)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(14 9)"/>
</g>
<g id="rowspan-selected-personalise-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H12V16H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(14)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(14 9)"/>
</g>
<g id="rowspan-selected-personalise-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H12V16H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(14)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(14 9)"/>
</g>
<defs xmlns="http://www.w3.org/2000/svg"><pattern id="a" width="100%" height="100%" preserveAspectRatio="xMidYMid slice" viewBox="0 0 38 38"><image id="salesmanager-profile" width="38" height="38" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="data:image/jpeg;base64,/9j/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCABMAEwDASIAAhEBAxEB/8QAGwAAAgMBAQEAAAAAAAAAAAAABgcEBQgCAwD/xAA3EAABAwMDAQUGBQIHAAAAAAABAgMEAAURBhIhMQcTQVFxFCIjYYGRFTJCocEIsRYkUmJy0fD/xAAYAQADAQEAAAAAAAAAAAAAAAACAwUEAf/EACURAAEEAgIBBAMBAAAAAAAAAAEAAgMRBBIhMSITMlFxQWGh8P/aAAwDAQACEQMRAD8A07XhPlMQobsqSvY02kqUa96W/bNeXozTFtZVtSR3r2TgEZwOfWkTSemwuTIY/UeGqi1bqi63FxS2Phx8fDaKsJA81eZoMehy5ClPS2UEJIUts5ytPmk/wc1caOP44+53ZWUIIQnJIBPiSPGmpZtM28NI7yOlah4mpXqOceVabCGt44Sbc04USUvxjtZcUFDnkYHQ/tVjabNJWVyJUxCQckNkgY8hz5DFO0afgBoI9nQE9elRJ+nbc+0ULhtcdFbeRREuXAxp/KTNytklCAtDjilJ5SccH+DUjQmpn9PXBxUokR1kBxrpkeYHnRJqnT0mIVrjyny0oYxnp9qUV+cei3Lu5LhKSeFuK3JI+R6/9VxjzdhDJFQo9LWMV9qVFaksLC2nUBaFDxBGRXeT50Fdj12/ENKojLUguRTtTsVlJbPKSD8uR9KNarRv3aCo8jdHEL6kX2+3dSbqliKErVhDYOM5X4D7mnPfH1RbTJfScFDZIPlWa9YXxiYoy5LISlhwlKiehJ4P/vOseZJQDFrwo7Jcjnsjbb9ic7tQWkOlsOY/MR1P3zTctXAxjpSV7I5pTpNiTFZMkqecCEIIG7CiDyeAKPImu0W2W3GvNllxQtWA+0pLzX1I6Vib7rKquHhQTBKTgZrykDDdcNXBl5lLjZBQRkH5UN6h1mzb3vZI9ukTnj17shKU+qjxTy5tLO1jyelMuqElo7gDmkZ2hQIoluu7MsA5cSONivBXy9fvTZeutykR+8k29ptpXRTMgOFP/Icftmkf20Xd2x31h9KO8YkN7HUE8HPHNJb7k6QHRG3YU25EvBQ24Cw8CQAMeBzx9BTtpC/09vp/GluOuq3OI90K+Y4x/bHnT4BOOlUMQ2w/ak5Y8x9KNeWQ/aZbJGQtlY/Y1k7tMcYjIRbS3lttaVPf7yTkgfvWunVJS2rfgJwc58qyx22QFM3Nb7SMsocUoKUOFJB3Ck5o5aU7APJCNuy+0OTOzJqLbFphOlLncnGdhJPh61Gs+h9SN3CMty8TVIS1/mEupCtzuD0x+nOPnirXsWv9rmWlBtstl9pKgVJQoZbKhnaoeB68U3famBEKyQOKzROq7VOVnVfxU2nIj0KyPMSHg4tCeCOgJHQUGa10rdpsV8w7g6wpQSWChPjn3tx8OOmKP4mV2qS4RjecjmpVrlMLa7tSsKT1FEygQUL7opXaD0ff7fLcdlXV1+CW0/De/MF/qIIwMH0qi7TbPCl6ktZkAkIc2JPUcnxHiOlOq9y22Yq1JUBxSxvPsCmH7reJAZhRFd8tROB7vPr9B1pcrqdabC22jbpVOjYr0W5qSiQl59pwBRVwoAk4yPPpj5CnghwKQFBR5Gazl2RvvXnVd6vpSphEtaNqVeCcjYPXaefU1oWOVoaCTzgY5rXh8WFLz6LrC6uqd8NSeduRu9M80le1iH7ZOMcN7g6goUcdOOtPNwBSCCAQfOlJqxxDdyXb17O9WVJQsAqz5/XFFmjxBScM+SUP9OkdVs1TfbXnYqSw3JbBPUoWUn9lCtFNXONFipVcpLTDYGdzigE/c1nW/s3HR+ubbfIUcuHvEtKaB/O2shJTx8jn1FP6Mli6QHbfIbbdQQU4WM5FT3E7AlWoXAtpVxk6Rltu9xrNtMMrPfM+08e95HqPpV5An6fERqPabvFfWnhGHwpSvXxobT2ePpd3R7faXW85Cnoqd4HqMZohsGmotiQt9caOHyMKcDSQQPIYHFGaA6WqUQBvg8k/79Lm8KkvpCFnanHNZevUpeotfakYcdcVGU8swffO34KQgjHTnbkeh86dnbXqqVY9E3Sfb1JEzYGmCeiFLUEhXqM5HpWe9Dx5Mm5xEs95vRhfT8+epJ+dAOGkrJIbICffZBY5driIVJAW1JSkjH6Ofdz607kIynJFCWg4Kzp9gyAEbl7lDxODhI+woyT0qliR6sv5UXKk2fXwvK4KWiE8tH5ggkUuL5E72ZFCU96o4Ug56nxOaZUs4jrI/wBNK+TMkf4hUhpwsp98YQOME4xznw/vR5EW5AQQSaAqdcbbYWWAq7SI7c59BVCQVDedvOUjqfXpj1qKj2iMhuZGGFgDcnwUKg+wRm9VTHQglxxDZUsnKiNvTJ8OelEZQn2QpxxtqZkuG2oHSsYjCGbE3a8ka3cjICHYTmfMDJqDc79c7oAhmOpho9VK61ZwozLkNorbBPniu5jTae7SlIAKucUrYkLTTQekqO22OU6AkJUCtXfMkk+e8DP3IoY7M4ZSuC4G0/AA3KPgAST9MU4NX2+JcLVLiS2UusONFK0HoRS47K4DMe9zbEVOvw2XCUB5W5QBRnaT4gEcZomAuFBJm48k7tG3RuS0000CWnAdoI6Y/ii8YxQxoSFHZtyFoTykBKfkOtE9V8UERi1DyCDIaX//2Q=="/></pattern></defs><rect xmlns="http://www.w3.org/2000/svg" width="38" height="38" fill="url(#a)" rx="19"/>
<defs xmlns="http://www.w3.org/2000/svg"><pattern id="a" width="100%" height="100%" preserveAspectRatio="xMidYMid slice" viewBox="0 0 38 38"><image id="salesperson-profile" width="38" height="38" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="data:image/jpeg;base64,/9j/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCABEAEQDASIAAhEBAxEB/8QAHAAAAwEAAwEBAAAAAAAAAAAAAAUGBwIDBAgB/8QANxAAAQMDAgQDBgQFBQAAAAAAAQIDBAAFEQYSEyExQQdRYRQiMnGBkRViocEWIzOx8EJDUoLR/8QAGgEAAgMBAQAAAAAAAAAAAAAABAUBAgMABv/EACMRAAIBAwQDAAMAAAAAAAAAAAABAgMREgQhMTITIkFCUvD/2gAMAwEAAhEDEQA/AMXdu7d+nNu3BKmmWgAU+Zqu0lpi5amWiLbm1Nwu6ljokdye1Xp8FLVeLmiW5dnWkskJ2ISkJV559apYLlrsWbDaSkRIKR7Q93cX2HqB+ppR43DeRpT06k+dig0PoqyWC2ssSXnHwk5TlW1JUfIDmarhbLMHA4nTEGQoDAKmhvx81CoSbqyJZ+E2+pUi5vN7m4jZPE2noVKHwA+fWuCHdR3lkKOmY8EkZQ83OcLiT61DryGVPSprZFTPs+i7vIdg/h4gTgN6koRsUPp0I+VZnrTSTunJXtcZ1x5lagWdp64OenYitFtthvUlpEqdN3S2kbULJ548ledeGYq6MXL8J1E2w5DlqAiyUDAS4OgV/nnUznlH2Mq2iutidsfidOkcGJOYRw0e6rlg09vl6/ELa4iC6iOFIxlI5fao2TajM1UmI2ylp1BKXcDGCK7dQRp1qPCx8XIHzFYQnUW6ewAlZO5Hyly4z6mVz1KKTjNFdM1ZEg5Z3k9SR3orvIzFjvw/m3FDc+6TZT6m204QlajgKUOvzxk0p07c+O82/KWoplSlPKGB8CSVf2TTnVKkWLw/MdBIWpsqVj4itfIfpUvBirbMNhogBFvcUceqCP3Fc5O244oU7RRV+HyZN3lzNQy9qn5j3EA/4J/0p+gwK2KyvvFlIWUpIHSsD07qxrStjQzxY7Ky2P5j55Dl2SK9kTxTuvEC1SbXLjn/AHI4II/WrRi+Rkml6n0Uy8VAjI9edLdXRPxCwyWyAVIQXGyOoUnmCPtUBdNSXGDpxu9KUna4MIBOAT60o0l4py57wYlXWyniHallKVblZ7ZzVm8kRKKTHd0lJbvES+pa3F6MhaynlvBGCfnypJqC9puF3L6MCK0kAbu5716FPmR4XQH2yeI2p1pKvy5OM/aoa1ykSHExXUla18sE9apH4hJXhjNoaKvdqCiBHSrB67RRXB/Tq0r91CMKGaKOSnbgFuz88SnPbnIsQH3VO7to9ByH+eVcWeDFF0fVhKIkRDQPrgJ/eut2M9cdXx939NlBUB5nrml12e26cuqlLGHn0pKu3xClV77D+MUi2/gTTOsLXHU42w2+lI2rKM16GfDKz6egbnXWC2nISgNJSOf69hUVpy/3C3xkMtpWoJHIppkxqC4Xha5AusePIj846HHAdqvNSSaJjJpYheMG8vprqbJDuuhosZ9tAYaWSCUggDz58qnYPhPp6HLFycbiSUtHitpDASArz5cs+tKdAal1cpLlruL1v9lJVl5JGDnqAM0Qb5PtkyVanHHHo7OSlzO5O0ds+YqXOxHjTd2el6KuPoNyOwgBDcxRAHYKKqktJxbdHDsuftS+MkE9Uj0rQ/dGmXmXCBxUpWD+bdyrGdSy3ImoltozwXBnPz7V1CKSUmI9W7VGPrjf5b8pS4zYQ0OSc9x50Ul9uSpKdiUkAY50US6kb8gOTLKA023LYlIHvpgqUo+uQM1DeIURbHhfMS2SlwvApI6/1E/+VftuOTRLXgJW+tEdOBjAHM48uwqF8b3PZNOQIrKuTs1KjtPUJVjr8zS6DyqIfy9YMSeH2om5kZkuKSl9vAWk9a0OZpO3XtpudGtkJ14dVFtJJ+dYrabS+llL7BUh1Pccqs9K6svFpVwviT3SSRmtk7O8TeEmuTS7FoNaZYMnTEGO3y3LbSr+27A+1dus34cO4Q9OwUtNuS1jchAA2Njms/YY+ZqUvXi/d9P2wOiEHnnztbbW5jHLOTjsPKlmi5Eyff4dwuTheuUtvjSF9kI3kpQPLnzPyFVrP1yZaVZuWNzaL4yn+FJDgIBDGUnyIVWJ6qgSLjc0vxyAABuHl51udwbW7pp9sY3cB0gY+oH6Vk0WUmNxCpKVlY93J6URpknHcQ6x2mKo9icLSS5JCFY5jOKK65TU1b6lcbOefyoovBfqB7Fw2osxErRyIQ45n83MVHa8gsXHw4edkhSnIslHCUDzG8ZUPulJ+lFFJYd0ein0Yn0wEuW9l1SRuXkK9cGmqYjAkghAzmiiiJfSYcIjdfpDupoUdfNpMZ5wJ9QUj96vvD9lCr9HQc44CE/SiisqvWJ35v8AvhvkhtsQlo2DbsAx/wBTXzzcDkqHTBUBj0oophpeol13ZC0gr5lSvoaKKKKSBEf/2Q=="/></pattern></defs><rect xmlns="http://www.w3.org/2000/svg" width="38" height="38" fill="url(#a)" rx="19"/>
<g id="settings-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M29.271,18A3.474,3.474,0,0,1,31.5,14.759a13.772,13.772,0,0,0-1.666-4.015,3.521,3.521,0,0,1-1.413.3,3.467,3.467,0,0,1-3.171-4.88A13.73,13.73,0,0,0,21.241,4.5a3.471,3.471,0,0,1-6.483,0,13.772,13.772,0,0,0-4.015,1.666,3.467,3.467,0,0,1-3.171,4.88,3.406,3.406,0,0,1-1.413-.3A14.076,14.076,0,0,0,4.5,14.766a3.473,3.473,0,0,1,.007,6.483,13.772,13.772,0,0,0,1.666,4.015,3.468,3.468,0,0,1,4.577,4.577,13.852,13.852,0,0,0,4.015,1.666,3.465,3.465,0,0,1,6.469,0,13.772,13.772,0,0,0,4.015-1.666,3.472,3.472,0,0,1,4.577-4.577,13.852,13.852,0,0,0,1.666-4.015A3.491,3.491,0,0,1,29.271,18ZM18.063,23.618a5.625,5.625,0,1,1,5.625-5.625A5.623,5.623,0,0,1,18.063,23.618Z" transform="translate(-4.5 -4.5)"/>
</g>
<g id="settings-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M29.271,18A3.474,3.474,0,0,1,31.5,14.759a13.772,13.772,0,0,0-1.666-4.015,3.521,3.521,0,0,1-1.413.3,3.467,3.467,0,0,1-3.171-4.88A13.73,13.73,0,0,0,21.241,4.5a3.471,3.471,0,0,1-6.483,0,13.772,13.772,0,0,0-4.015,1.666,3.467,3.467,0,0,1-3.171,4.88,3.406,3.406,0,0,1-1.413-.3A14.076,14.076,0,0,0,4.5,14.766a3.473,3.473,0,0,1,.007,6.483,13.772,13.772,0,0,0,1.666,4.015,3.468,3.468,0,0,1,4.577,4.577,13.852,13.852,0,0,0,4.015,1.666,3.465,3.465,0,0,1,6.469,0,13.772,13.772,0,0,0,4.015-1.666,3.472,3.472,0,0,1,4.577-4.577,13.852,13.852,0,0,0,1.666-4.015A3.491,3.491,0,0,1,29.271,18ZM18.063,23.618a5.625,5.625,0,1,1,5.625-5.625A5.623,5.623,0,0,1,18.063,23.618Z" transform="translate(-4.5 -4.5)"/>
</g>
<g id="spinner">
<path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#303778" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M18,3v7.14" transform="translate(1.35 -1.5)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="rgba(48,55,120,0.6)" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M18,27v7.14" transform="translate(1.35 3.06)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="rgba(48,55,120,0.3)" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M7.395,7.395l5.052,5.052" transform="translate(-.665 -.665)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="rgba(48,55,120,0.7)" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M24.36,24.36l5.052,5.052" transform="translate(2.558 2.558)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="rgba(48,55,120,0.4)" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M3,18h7.14" transform="translate(-1.5 1.35)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="rgba(48,55,120,0.8)" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M27,18h7.14" transform="translate(3.06 1.35)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="rgba(48,55,120,0.5)" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M7.395,29.412l5.052-5.052" transform="translate(-.665 2.558)"/><path xmlns="http://www.w3.org/2000/svg" fill="none" stroke="rgba(48,55,120,0.9)" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M24.36,12.447l5.052-5.052" transform="translate(2.558 -.665)"/>
</g>
<g id="three-column">
<g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z"/><path d="M0.5 0.5H6.5V6.5H0.5z"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(9)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(18)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(18)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(0 9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(0 9)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(9 9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(9 9)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(18 9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(18 9)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(0 18)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(0 18)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(9 18)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(9 18)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(18 18)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(18 18)"/></g>
</g>
<g id="three-column-selected">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18 18)"/>
</g>
<g id="three-column-selected-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(18 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(9 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(18 18)"/>
</g>
<g id="three-column-selected-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18 18)"/>
</g>
<g id="three-column-selected-personalise-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(18 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(9 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(18 18)"/>
</g>
<g id="three-column-selected-personalise-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(18 18)"/>
</g>
<g id="two-column">
<g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z"/><path d="M0.5 0.5H6.5V6.5H0.5z"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(9)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(0 9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(0 9)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(9 9)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(9 9)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(0 18)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(0 18)"/></g><g xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#8d92c4"><path stroke="none" d="M0 0H7V7H0z" transform="translate(9 18)"/><path d="M0.5 0.5H6.5V6.5H0.5z" transform="translate(9 18)"/></g>
</g>
<g id="two-column-selected">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 18)"/>
</g>
<g id="two-column-selected-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#d7daf9" d="M0 0H7V7H0z" transform="translate(9 18)"/>
</g>
<g id="two-column-selected-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 18)"/>
</g>
<g id="two-column-selected-personalise-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#a7aeff" d="M0 0H7V7H0z" transform="translate(9 18)"/>
</g>
<g id="two-column-selected-personalise-light">
<path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 9)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(0 18)"/><path xmlns="http://www.w3.org/2000/svg" fill="#303778" d="M0 0H7V7H0z" transform="translate(9 18)"/>
</g>
<g id="user">
<path xmlns="http://www.w3.org/2000/svg" fill="#707487" d="M82.543,135.407a3.922,3.922,0,1,0-3.486,0,6.279,6.279,0,0,0-4.357,5.882v3.05H87.118v-3.05A6.8,6.8,0,0,0,82.543,135.407Z" opacity=".7" transform="translate(-74.7 -128)"/>
</g>
<g id="export-notice-dark">
<path xmlns="http://www.w3.org/2000/svg" fill="#fff" width="13.349" height="13.349" viewBox="0 0 13.349 13.349" d="M6.675,0a6.675,6.675,0,1,0,6.675,6.675A6.675,6.675,0,0,0,6.675,0ZM7.54,10.212H5.8V5.968H7.54Zm0-5.465H5.8V3.137H7.54Z"/>
</g>
</defs>
</svg>

После

Ширина:  |  Высота:  |  Размер: 44 KiB

Двоичные данные
ContosoSalesDemo/ClientApp/src/assets/Images/architecture.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 43 KiB

Просмотреть файл

@ -0,0 +1,227 @@
{
"name": "Dark Theme",
"dataColors": [
"#5463CE",
"#7ED7E9",
"#FE9AD2",
"#AA86F1",
"#ABDAFC",
"#FFBAF7",
"#EBEF95",
"#FFA88D",
"#91EFC6",
"#E37FA4",
"#F8D871",
"#75D9C9"
],
"maximum": "#746397",
"center": "#79A6AF",
"minimum": "#565D8F",
"textClasses": {
"title": {
"color": "#D8D8D8",
"fontSize": 10,
"fontFace": "Segoe UI Bold"
},
"callout": {
"color": "#D8D8D8",
"fontSize": 40,
"fontFace": "Segoe UI"
},
"largeTitle": {
"color": "#D8D8D8",
"fontSize": 10,
"fontFace": "Segoe UI Bold"
},
"smallLightLabel": {
"color": "#D8D8D8",
"fontSize": 9,
"fontFace": "Segoe UI"
},
"lightLabel": {
"color": "#D8D8D8",
"fontSize": 9,
"fontFace": "Segoe UI"
}
},
"visualStyles": {
"*": {
"*": {
"background": [
{
"color": {
"solid": {
"color": "#343741"
}
},
"transparency": 0
}
],
"labels": [
{
"color": {
"solid": {
"color": "#D8D8D8"
}
}
}
],
"legend": [
{
"labelColor": {
"solid": {
"color": "#D8D8D8"
}
}
}
],
"calloutValue": [
{
"color": {
"solid": {
"color": "#D8D8D8"
}
}
}
],
"categoryLabels": [
{
"color": {
"solid": {
"color": "#D8D8D8"
}
}
}
],
"outspacePane": [
{
"backgroundColor": {
"solid": {
"color": "#1F2128"
}
},
"foregroundColor": {
"solid": {
"color": "#D8D8D8"
}
}
}
],
"filterCard": [
{
"$id": "Available",
"foregroundColor": {
"solid": {
"color": "#D8D8D8"
}
},
"backgroundColor": {
"solid": {
"color": "#1F2128"
}
},
"transparency": 0
},
{
"$id": "Applied",
"backgroundColor": {
"solid": {
"color": "#343741"
}
},
"foregroundColor": {
"solid": {
"color": "#D8D8D8"
}
},
"transparency": 0
}
]
}
},
"page": {
"*": {
"background": [
{
"color": {
"solid": {
"color": "#1F2128"
}
},
"transparency": 0
}
],
"outspace": [
{
"color": {
"solid": {
"color": "#1F2128"
}
},
"transparency": 0
}
]
}
},
"tableEx": {
"*": {
"columnHeaders": [
{
"fontColor": {
"solid": {
"color": "#FFFFFF"
}
},
"backColor": {
"solid": {
"color": "#747D94"
}
}
}
],
"values": [
{
"fontColorPrimary": {
"solid": {
"color": "#D8D8D8"
}
},
"fontColorSecondary": {
"solid": {
"color": "#D8D8D8"
}
}
}
]
}
},
"treemap": {
"*": {
"categoryLabels": [
{
"show": true,
"color": {
"solid": {
"color": "#E1E1E1"
}
}
}
]
}
},
"basicShape": {
"*": {
"fill": [
{
"show": true,
"fillColor": {
"solid": {
"color": "#1F2128"
}
}
}
]
}
}
}
}

Просмотреть файл

@ -0,0 +1,149 @@
{
"name": "Light Theme",
"dataColors": [
"#515889",
"#7ED7E9",
"#FFA1D6",
"#B89FE9",
"#ABDAFC",
"#FFAEF6",
"#EBEF95",
"#FFA88D",
"#91EFC6",
"#E37FA4",
"#F8D871",
"#75D9C9"
],
"maximum": "#EBEDFF",
"center": "#E4FAFF",
"minimum": "#FFD9EF",
"textClasses": {
"title": {
"color": "#3A3A3A",
"fontSize": 10,
"fontFace": "Segoe UI Bold"
},
"callout": {
"color": "#4B4B4B",
"fontSize": 40,
"fontFace": "Segoe UI"
},
"largeTitle": {
"color": "#3A3A3A",
"fontSize": 10,
"fontFace": "Segoe UI Bold"
},
"smallLightLabel": {
"color": "#3A3A3A",
"fontSize": 9,
"fontFace": "Segoe UI"
},
"lightLabel": {
"color": "#303778"
}
},
"visualStyles": {
"*": {
"*": {
"background": [
{
"color": {
"solid": {
"color": "#FFFFFF"
}
},
"transparency": 0
}
],
"labels": [
{
"color": {
"solid": {
"color": "#3B3C3D"
}
}
}
],
"legend": [
{
"labelColor": {
"solid": {
"color": "#3B3C3D"
}
}
}
]
}
},
"page": {
"*": {
"background": [
{
"color": {
"solid": {
"color": "#EBEFF3"
}
},
"transparency": 0
}
],
"outspace": [
{
"color": {
"solid": {
"color": "#EBEFF3"
}
},
"transparency": 0
}
]
}
},
"tableEx": {
"*": {
"columnHeaders": [
{
"fontColor": {
"solid": {
"color": "#4B4F6D"
}
},
"backColor": {
"solid": {
"color": "#F5F5F5"
}
}
}
],
"values": [
{
"fontColorPrimary": {
"solid": {
"color": "#252423"
}
},
"fontColorSecondary": {
"solid": {
"color": "#252423"
}
}
}
]
}
},
"treemap": {
"*": {
"categoryLabels": [
{
"show": true,
"color": {
"solid": {
"color": "#F2F2F2"
}
}
}
]
}
}
}
}

Просмотреть файл

@ -0,0 +1,93 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
$btn-bg-color-light: #D7DAF9;
$btn-bg-color-dark: #343741;
.btn-analytics {
background: #FFFFFF 0% 0% no-repeat padding-box;
border: 1px solid #BCBFDE;
cursor: pointer;
display: inline-block;
height: 40px;
margin-right: 20px;
opacity: 1;
padding: 6px 0;
text-align: center;
width: 160px;
&.light {
background-color: #FFFFFF;
}
&.dark {
background-color: $btn-bg-color-dark;
}
}
.btn-analytics[aria-expanded='true'] {
&.light {
background-color: $btn-bg-color-light;
}
&.dark {
background-color: #252D4E;
}
}
.btn-analytics-active {
&.light {
background-color: $btn-bg-color-light;
}
&.dark {
background-color: $btn-bg-color-dark;
}
}
.btn-analytics-icon {
background: transparent;
display: inline-block;
margin-top: -2px;
}
.btn-analytics-text {
display: inline-block;
font: 600 17px/9px 'Segoe UI';
letter-spacing: 0;
margin-left: 8px;
opacity: 1;
text-align: left;
&.light {
color: #303778;
}
&.dark {
color: #A7AEFF;
}
}
.horizontal-rule {
background-color: #D0D1E1;
border-top: 1px solid #D0D1E1;
opacity: 1;
width: 100%;
margin: 26px 0 -8px 0;
}
.btn-analytics-container {
margin-top: 26px;
margin-left: 35px;
margin-right: 35px;
&.light {
background-color: #EBEFF3;
}
&.dark {
background-color: #1F2128;
}
}

Просмотреть файл

@ -0,0 +1,42 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './AnalyticsButton.scss';
import React, { useContext } from 'react';
import { Icon } from '../Icon/Icon';
import ThemeContext from '../../themeContext';
import { Theme } from '../../models';
export interface AnalyticsButtonProps extends React.HTMLAttributes<HTMLDivElement> {
icon: string; // svg image
children?: string;
dataToggle?: string;
dataTarget?: string;
}
/**
* Render buttons for Analytics tab
* @param props AnalyticsButtonProps
*/
export function AnalyticsButton(props: AnalyticsButtonProps): JSX.Element {
const theme: Theme = useContext(ThemeContext);
const buttonDimension = 15;
return (
<div
id={props.id}
className={`btn-analytics ${props.className}`}
data-toggle={props.dataToggle || null}
data-target={props.dataTarget || null}
onClick={props.onClick}>
<Icon
className='btn-analytics-icon'
iconId={props.icon}
height={buttonDimension}
width={buttonDimension}
/>
<div className={`btn-analytics-text non-selectable ${theme}`}>{props.children}</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,19 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.bookmark-form {
&.dark {
.input-bookmark {
color: #D8D8D8;
&:focus {
background-color: #343741 !important;
}
}
}
}
.input-bookmark::placeholder {
color: #777777;
}

Просмотреть файл

@ -0,0 +1,93 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Bookmark.scss';
import React, { useState, useContext, useRef, useEffect } from 'react';
import ThemeContext from '../../../themeContext';
import $ from 'jquery';
export interface BookmarkProp {
captureBookmarkWithName: { (bookmarkName: string): void };
onClick?: { (): void };
}
/**
* Render Bookmark options in the popup
*/
export function Bookmark(props: BookmarkProp): JSX.Element {
const theme = useContext(ThemeContext);
const [inputText, setInputText] = useState<string>('');
const invalidClass = 'is-invalid';
// Cache DOM element
const captureViewModal = $('#modal-capture-view');
const bookmarkName = useRef<HTMLInputElement>();
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setInputText(event.target.value);
}
function captureBookmarkOnClick(): void {
// Checks whether the bookmark name entered is empty or not
if (inputText.trim() === '') {
// Invalid bookmark-name
bookmarkName.current.classList.add(invalidClass);
return;
}
bookmarkName.current.classList.remove(invalidClass);
// Execute the function to save the bookmark
props.captureBookmarkWithName(inputText);
// Close the modal after saving the bookmark
props.onClick();
// Reset text box
setInputText('');
}
captureViewModal.on('hidden.bs.modal', function () {
// On modal close, remove the invalid class and reset the field
const bookmarkText = document.getElementById('bookmark-name');
if (bookmarkText) {
bookmarkText.classList.remove(invalidClass);
}
// Reset text box
setInputText('');
});
// On focus, remove the invalid class
useEffect(() => {
bookmarkName.current.addEventListener('focus', function () {
bookmarkName.current.classList.remove(invalidClass);
});
}, []);
return (
<React.Fragment>
<div className='modal-body'>
<form className={`bookmark-form ${theme}`} noValidate>
<p className={`input-title ${theme}`}>Enter a name for this view:</p>
<input
ref={bookmarkName}
id='bookmark-name'
type='text'
placeholder='Example: May 2020 Sales Analytics'
className={`form-control input-bookmark`}
value={inputText}
onChange={handleChange}
required
/>
<div className='invalid-feedback'>Please provide a valid bookmark name</div>
</form>
</div>
<div className='modal-footer text-center'>
<button type='button' className='btn btn-submit' onClick={captureBookmarkOnClick}>
Save
</button>
</div>
</React.Fragment>
);
}

Просмотреть файл

@ -0,0 +1,75 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.download-anchor {
display: none;
}
.export-info {
background: #4C5084 0 0 no-repeat padding-box;
height: 34px;
padding-left: 16px;
position: relative;
}
.icon-export-info {
margin-top: -3px;
}
.input-container {
margin-bottom: 10.5px;
}
.label-radio {
cursor: pointer;
font: 400 14px 'Segoe UI';
letter-spacing: 0;
margin-bottom: 1.5px;
opacity: 1;
text-align: left;
&.dark {
color: #FFFFFF;
}
}
.spinner-export {
height: 34px;
margin: 1px 19px 0 0;
width: 34px;
&.light {
border-color: #303778 #303778 #303778 transparent;
}
&.dark {
border-color: #A7AEFF #A7AEFF #A7AEFF transparent;
}
}
.text-export-info {
color: #FFFFFF;
font: 400 12px/36px 'Segoe UI';
letter-spacing: 0;
margin-left: 8px;
opacity: 1;
}
.wait-container {
height: 225.5px;
}
.wait-message {
font: 400 18px/22px 'Segoe UI';
margin-bottom: 2px;
&.light {
color: #000000;
}
&.dark {
color: #FFFFFF;
}
}

Просмотреть файл

@ -0,0 +1,160 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Export.scss';
import React, { useState, useContext } from 'react';
import { exportTypes, storageKeyJWT } from '../../../constants';
import { Bookmark, TabName, Theme, ServiceAPI, UpdateApp } from '../../../models';
import ThemeContext from '../../../themeContext';
import { getPageName, getStoredToken, checkTokenValidity, downloadFile } from '../../utils';
import { salesManagerTabs } from '../../../reportConfig';
import { Icon } from '../../Icon/Icon';
export interface ExportProp {
isExportInProgress: boolean;
setError: { (error: string): void };
toggleExportProgressState: { (): void };
selectedBookmark: Bookmark;
updateApp: UpdateApp;
}
/**
* Render Export options in the popup
*/
export function Export(props: ExportProp): JSX.Element {
// Set PDF as the default export selection
const [radioSelection, setRadioSelection] = useState<string>(
exportTypes[0] // Set the default radio selection to first option
);
const theme = useContext(ThemeContext);
const reportPageName = getPageName(TabName.Analytics, salesManagerTabs);
/**
* Fetches exported file from server and downloads it
*/
async function exportService(): Promise<void> {
// Get token from storage
const storedToken = getStoredToken();
// Check token expiry before making API request, redirect back to login page
if (!checkTokenValidity(storedToken)) {
alert('Session expired');
// Re-render App component
props.updateApp((prev: number) => prev + 1);
return;
}
// To show waiting experience
props.toggleExportProgressState();
try {
// Encoding bookmark state using base 64 to handle special characters during server side calls
const reqParamsBody: string = JSON.stringify({
pageName: reportPageName,
fileFormat: radioSelection,
pageState: props.selectedBookmark ? props.selectedBookmark.state : null,
});
const serverRes = await fetch(ServiceAPI.ExportReport, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${sessionStorage.getItem(storageKeyJWT)}`,
},
body: reqParamsBody,
});
const serverResString = await serverRes.text();
if (!serverRes.ok) {
// Show error popup if request fails
props.setError(serverResString);
console.error(`Failed to export report. Status: ${serverRes.status} ${serverRes.statusText}`);
}
downloadFile(await JSON.parse(serverResString));
} catch (error) {
console.error(`Error while exporting the report: ${error}`);
}
// Set export in progress state
props.toggleExportProgressState();
}
// Show loading icon if previous export is in progress
if (props.isExportInProgress) {
return <ExportInProgressView />;
}
let exportInfo: JSX.Element;
if (theme === Theme.Dark) {
exportInfo = (
<div className='export-info d-flex align-items-center'>
<div className='icon-export-info'>
<Icon iconId='export-notice-dark' height={14} width={14} />
</div>
<div className='text-export-info align-items-center'>
The color theme in light mode will be applied to the exported file.
</div>
</div>
);
}
return (
<React.Fragment>
<div className={`modal-body ${theme}`}>
<div className='input-container'>
<p className={`input-title ${theme}`}>Select format:</p>
{exportTypes.map((type: string, index: number) => (
<div className='form-check form-check-inline mr-3' key={index}>
<input
type='radio'
name='export-type'
id={`radio-${type}`}
className='form-check-input'
checked={type === radioSelection}
onClick={() => setRadioSelection(type)}
/>
<label
className={`form-check-label export-type-label label-radio text-uppercase ${theme}`}
htmlFor={`radio-${type}`}>
{type}
</label>
</div>
))}
</div>
{exportInfo}
</div>
<div className='modal-footer'>
<button type='button' className='btn btn-submit' onClick={exportService}>
Export and Download
</button>
</div>
</React.Fragment>
);
}
/**
* Component for export in progress view
*/
function ExportInProgressView(): JSX.Element {
const theme = useContext(ThemeContext);
return (
<div className='align-items-center d-flex modal-body justify-content-center wait-container'>
<div className={`spinner-border spinner-export ${theme}`} role='status'>
<span className='sr-only'>Loading...</span>
</div>
<div>
<p className={`wait-message ${theme}`}>The download will start automatically.</p>
<p className={`wait-message ${theme}`}>You may close this dialog box.</p>
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,135 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.btn-submit {
background: #D7DAF9;
border-radius: 4px;
color: #303778;
font: 700 18px/24px 'Segoe UI';
opacity: 1;
padding: 6px 24px;
}
.close {
color: #6E6E6E;
opacity: 1;
position: relative;
right: 0;
top: -15px;
&.light {
color: #6E6E6E;
font: 400 40px 'Segoe UI';
}
&.dark {
color: #D8D8D8;
font: 200 40px 'Segoe UI';
}
}
.input-title {
color: #3B3B3B;
font: 400 18px/24px 'Segoe UI';
letter-spacing: 0;
margin-bottom: 10px;
opacity: 1;
text-align: left;
&.light {
color: #3B3B3B;
}
&.dark {
color: #A7AEFF;
}
}
.modal {
background: rgba(32, 32, 32, 0.64);
}
.modal-backdrop {
display: none;
}
.modal-body {
padding: 40.5px 45px;
&.dark {
padding: 40px 45px 8px;
}
}
.modal-content {
border-color: transparent;
border-radius: 0;
height: 300px;
&.light {
background-color: #FFFFFF;
}
&.dark {
background-color: #343741;
}
}
.modal-dialog {
min-width: 600px;
}
.modal-footer {
border-top: 0;
justify-content: center;
padding-bottom: 31px;
padding-top: 0;
}
.modal-header {
padding: 35px 45px 0;
&.light {
border-bottom: 2px solid #E0E3FF;
}
&.dark {
border-bottom: 2px solid #424457;
}
}
.modal-tab {
cursor: pointer;
font: 400 18px 'Segoe UI';
letter-spacing: 0;
margin-bottom: -1.5px;
opacity: 1;
text-align: left;
&.light {
color: #707070;
}
&.dark {
color: #A7A7A7;
}
}
.modal-tab-active {
font: 700 18px 'Segoe UI';
letter-spacing: 0;
opacity: 1;
text-align: left;
&.light {
border-bottom: 2px solid #303778;
color: #303778;
}
&.dark {
border-bottom: 2px solid #A7AEFF;
color: #A7AEFF;
}
}

Просмотреть файл

@ -0,0 +1,98 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Modal.scss';
import React, { useState, useContext } from 'react';
import { Report } from 'powerbi-client';
import { Bookmark, BookmarkProp } from '../Bookmark/Bookmark';
import { Export, ExportProp } from '../Export/Export';
import { Tab, ModalTab } from '../../../models';
import ThemeContext from '../../../themeContext';
import $ from 'jquery';
export interface ModalProps extends BookmarkProp, ExportProp {
report: Report;
resetAnalyticsBtn: { (): void };
}
/**
* Render Capture View popup
* @param props ModalProps
*/
export function Modal(props: ModalProps): JSX.Element {
const theme = useContext(ThemeContext);
// State hook to set first tab as active
const [activeTab, setActiveTab] = useState<Tab['name']>(() => {
return ModalTab.Bookmark;
});
/**
* Close Capture view popup
*/
function closePopup() {
$('#modal-capture-view').modal('hide');
// Reset Capture view button background
props.resetAnalyticsBtn();
// Reset tab selection if export is not in progress
if (props.isExportInProgress) {
setActiveTab(ModalTab.Export);
} else {
setActiveTab(ModalTab.Bookmark);
}
}
let modalBody: JSX.Element;
if (activeTab === ModalTab.Bookmark) {
modalBody = <Bookmark captureBookmarkWithName={props.captureBookmarkWithName} onClick={closePopup} />;
} else if (activeTab === ModalTab.Export) {
modalBody = (
<Export
isExportInProgress={props.isExportInProgress}
setError={props.setError}
toggleExportProgressState={props.toggleExportProgressState}
selectedBookmark={props.selectedBookmark}
updateApp={props.updateApp}
/>
);
}
return (
<div id='modal-capture-view' className='modal fade' role='dialog' data-backdrop='static'>
<div className='modal-dialog modal-dialog-centered' role='document'>
<div className={`modal-content ${theme}`}>
<div className={`modal-header ${theme}`}>
<nav className='navbar p-0'>
<p
className={`pb-1 modal-tab mr-5 ${
activeTab === ModalTab.Bookmark ? 'modal-tab-active ' : ''
}${theme}`}
onClick={() => setActiveTab(ModalTab.Bookmark)}>
{`Save to 'My Views'`}
</p>
<p
className={`pb-1 modal-tab ${
activeTab === ModalTab.Export ? 'modal-tab-active ' : ''
}${theme}`}
onClick={() => setActiveTab(ModalTab.Export)}>
Export to File
</p>
</nav>
<button
type='button'
className={`close p-0 ${theme}`}
aria-label='Close'
onClick={closePopup}>
<span aria-hidden='true'>&times;</span>
</button>
</div>
{modalBody}
</div>
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,60 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.bookmark-list-item {
cursor: pointer;
font: 400 16px/21px 'Segoe UI';
height: 42px;
margin-bottom: 0;
padding: 5px 0;
&:active, &.selected {
background-color: transparent;
font-weight: 400;
&.light {
color: #707070;
}
&.dark {
color: #D8D8D8;
}
&:hover {
background-color: transparent;
}
}
// Override hover effect of bootstrap
&:hover {
background-color: transparent;
}
}
.bookmark-list-item>span {
margin: -0.5px 0 0 10px;
width: 250px;
}
.bookmarks-dropdown-container {
background: #FFFFFF 0 0 no-repeat padding-box;
border: 0;
border-radius: 0;
margin-top: 0;
max-height: 448px;
opacity: 1;
overflow-y: auto;
padding: 14px 0 14px 50px;
width: 340px;
z-index: 1;
&.light {
box-shadow: 0 11px 16px #94949430;
}
&.dark {
box-shadow: 0 11px 18px #00000030;
}
}

Просмотреть файл

@ -0,0 +1,67 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './BookmarksList.scss';
import '../SettingsDropdown/SettingsDropdown.scss';
import React, { useContext } from 'react';
import { Bookmark } from '../../models';
import ThemeContext from '../../themeContext';
/**
* Bookmarks list props
*/
export interface BookmarksListProps {
bookmarks: Bookmark[];
updateBookmarks: React.Dispatch<React.SetStateAction<Bookmark[]>>;
applyBookmarkOnClick?: {
(bookmarkName: Bookmark['name']): void;
};
}
/**
* Renders bookmark list in the dropdown form
*/
export function BookmarksList(props: BookmarksListProps): JSX.Element {
const theme = useContext(ThemeContext);
/**
*
* @param selectedBookmarkName name of the bookmark to be selected
*/
function handleChange(selectedBookmarkName: string) {
// Update checked for the selected bookmark
props.updateBookmarks(
props.bookmarks.map((bookmark) => {
bookmark.checked = bookmark.name === selectedBookmarkName;
return bookmark;
})
);
}
// List of bookmarks to be rendered
const bookmarksList = props.bookmarks.map((bookmark, idx) => {
return (
<div
className={`dropdown-item form-check bookmark-list-item ${theme}`}
key={idx}
onClick={() => handleChange(bookmark.name)}>
<input
type='radio'
id={bookmark.name}
name='bookmark-list-item'
className='form-check-input'
checked={bookmark.checked}
value={bookmark.name}
onChange={() => handleChange(bookmark.name)}
/>
<label className={`form-check-label label-radio ${theme}`} htmlFor={bookmark.name}></label>
<span className='d-inline-block text-truncate' title={bookmark.displayName}>
{bookmark.displayName}
</span>
</div>
);
});
return <div className={`dropdown-menu bookmarks-dropdown-container ${theme}`}>{bookmarksList}</div>;
}

Просмотреть файл

@ -0,0 +1,51 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
button {
background-color: #303778;
border: none;
color: #FFFFFF;
font: 400 14px 'Segoe UI';
margin: 0.5rem 66px;
opacity: 1;
text-align: center;
}
button:focus {
outline: none;
}
.card {
border-radius: 8px;
height: 427px;
width: 514px;
}
.card-img {
height: 40px;
margin-top: 60px;
width: 112px;
}
.card-title {
color: #303778;
font: 600 18px 'Segoe UI';
margin-bottom: 1rem;
text-align: center;
}
.gradient-bg {
background: linear-gradient(135deg, #D1A2B8, #9585BE, #2F3C6B);
height: 100%;
margin: 0;
padding: 0;
}
.horizontal-separator {
background-color: #CFCFCF;
height: 1px;
margin: 36px 87px 20px;
opacity: 1;
}

Просмотреть файл

@ -0,0 +1,50 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Card.scss';
import React, { useState } from 'react';
import { Home } from '../Home/Home';
import { Login, LoginProps } from '../Login/Login';
import { Icon } from '../Icon/Icon';
import { Profile } from '../../models';
interface CardsProps {
updateApp: LoginProps['updateApp'];
}
export function Card(props: CardsProps): JSX.Element {
const [selectedProfile, setSelectedProfile] = useState<Profile>(null);
// Get back to home
const homeOnClick = (): void => {
setSelectedProfile(null);
};
// Show profile selector page (<Home>) when no profile is selected, else show <Login> page
let cardBody: JSX.Element;
if (selectedProfile === null) {
cardBody = <Home setProfileType={setSelectedProfile} />;
} else {
cardBody = (
<Login
backToHomeOnClick={homeOnClick}
selectedProfile={selectedProfile}
updateApp={props.updateApp}
/>
);
}
return (
<div className='gradient-bg'>
<div className='card mx-auto vertical-center'>
<Icon className='card-img mx-auto' iconId='app-name-light' height={40} width={112} />
{!selectedProfile ? <div className='horizontal-separator'></div> : null}
{cardBody}
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,36 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import React, { useContext } from 'react';
import ThemeContext from '../../themeContext';
export interface CheckBoxProps {
title: string;
name: string;
checked: boolean;
handleCheckboxInput: { (event: React.ChangeEvent<HTMLInputElement>): void };
}
export const CheckBox = (props: CheckBoxProps): JSX.Element => {
const theme = useContext(ThemeContext);
return (
<li className={`visual-checkbox-li ${theme}`}>
<label>
<span className={`visual-title ${theme}`} title={props.title}>
{props.title}
</span>
<input
id={props.name}
onChange={props.handleCheckboxInput}
type='checkbox'
checked={props.checked}
value={props.title}
/>
<span className='visual-checkbox-checkmark'></span>
</label>
</li>
);
};

Просмотреть файл

@ -0,0 +1,44 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
body {
background: #EBEFF3 0% 0% no-repeat padding-box;
height: 100vh;
opacity: 1;
width: 100vw;
}
.embed-page-class {
height: auto;
min-width: 1150px;
width: 100%;
&.light {
background: #EBEFF3 0% 0% no-repeat padding-box;
}
&.dark {
background: #1F2128 0% 0% no-repeat padding-box;
}
}
.preview-tag {
border-radius: 5px;
font: 700 13px/17px 'Segoe UI';
height: 28px;
letter-spacing: 0;
margin: 0 0 0 32px;
padding: 5px 13px;
&.light {
background: #EAEBFC 0% 0% no-repeat padding-box;
color: #303778;
}
&.dark {
background: #1D2347 0% 0% no-repeat padding-box;
color: #A6ABE6;
}
}

Просмотреть файл

@ -0,0 +1,824 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './EmbedPage.scss';
import React, { useState, useEffect, useCallback } from 'react';
import { Report, Embed, models, service, IEmbedConfiguration } from 'powerbi-client';
import { PowerBIEmbed, EventHandler } from 'powerbi-client-react';
import { NavTabs } from '../NavTabs/NavTabs';
import { IconBar, IconBarProps } from '../IconBar/IconBar';
import { AnalyticsButton } from '../AnalyticsButton/AnalyticsButton';
import { PersonaliseBar } from '../PersonaliseBar/PersonaliseBar';
import ThemeContext from '../../themeContext';
import { Icon } from '../Icon/Icon';
import {
getActivePage,
getBookmarksFromReport,
getSelectedBookmark,
getStoredToken,
checkTokenValidity,
getPagesFromReport,
} from '../utils';
import { Footer } from '../Footer/Footer';
import { pairVisuals, getPageLayout, rearrangeVisualGroups } from '../VisualGroup';
import { Modal } from '../AnalyticsPopup/Modal/Modal';
import { Error } from '../ErrorPopup/Error';
import { BookmarksList } from '../BookmarksList/BookmarksList';
import { salesPersonTabs, salesManagerTabs, visualCommands, visualButtons } from '../../reportConfig';
import { AddLeadForm } from '../Forms/AddLeadForm';
import { EditLeadForm } from '../Forms/EditLeadForm';
import { UpdateOpportunityForm } from '../Forms/UpdateOpportunityForm';
import {
storageKeyJWT,
visualSelectorSchema,
minutesToRefreshBeforeExpiration,
FilterPaneWidth,
ExtraEmbeddingMargin,
AnonymousWritebackMessage,
WritebackRefreshFailMessage,
storageKeyTheme,
} from '../../constants';
import {
EmbedParamsResponse,
Bookmark,
Tab,
VisualGroup,
Layout,
TabName,
Profile,
Theme,
ServiceAPI,
} from '../../models';
import $ from 'jquery';
export interface EmbedPageProps {
profile: Profile;
name: string;
profileImageName: string;
updateApp: IconBarProps['updateApp'];
}
export function EmbedPage(props: EmbedPageProps): JSX.Element {
// State hook for error
const [error, setError] = useState<string>('');
const errorPopup = <Error error={error} setError={setError} />;
// State hook for PowerBI Report
const [powerbiReport, setReport] = useState<Report>(null);
// State hook to toggle personalise bar
const [theme, setTheme] = useState<Theme>(() => {
// Check theme state to persist across sessions
const storedThemeState = sessionStorage.getItem(storageKeyTheme);
// Return stored theme if any theme state is stored and value exists in Theme enum values
if (storedThemeState !== null && Object.values(Theme).includes(storedThemeState as Theme)) {
return storedThemeState as Theme;
}
return Theme.Light;
});
// Report config state hook
const [sampleReportConfig, setReportConfig] = useState<IEmbedConfiguration>({
type: 'report',
// Note: Empty string embedUrl is a temporary patch for powerbi-client bookmark bug
embedUrl: '',
tokenType: models.TokenType.Embed,
accessToken: undefined,
settings: {
layoutType: models.LayoutType.Custom,
customLayout: {
displayOption: models.DisplayOption.FitToWidth,
pagesLayout: {},
},
panes: {
filters: {
visible: false,
},
pageNavigation: {
visible: false,
},
},
extensions: [
{
command: {
name: visualCommands.editLeads.name,
title: visualCommands.editLeads.displayName,
selector: {
$schema: visualSelectorSchema,
visualName: visualCommands.editLeads.visualGuid,
},
extend: {
visualContextMenu: {
menuLocation: models.MenuLocation.Top,
},
},
},
},
{
command: {
name: visualCommands.editOpportunity.name,
title: visualCommands.editOpportunity.displayName,
selector: {
$schema: visualSelectorSchema,
visualName: visualCommands.editOpportunity.visualGuid,
},
extend: {
visualContextMenu: {
menuLocation: models.MenuLocation.Top,
},
},
},
},
],
},
theme: {
themeJson: require(`../../assets/ReportThemes/${theme}Theme.json`),
},
});
// State hook to toggle personalise bar
const [showPersonaliseBar, setShowPersonaliseBar] = useState<boolean>(false);
// State hook to toggle add activity form
const [editLeadFormPopup, setEditLeadFormPopup] = useState<boolean>(false);
// State hook to toggle edit add lead form
const [addLeadFormPopup, setAddLeadFormPopup] = useState<boolean>(false);
// State hook to toggle edit opportunity form
const [updateOpportunityFormPopup, setUpdateOpportunityFormPopup] = useState<boolean>(false);
// State hook to capture values from the visuals
const [visualAutofilledData, setVisualAutofilledData] = useState<object>(null);
// State hook to set qna visual index
const [qnaVisualIndex, setQnaVisualIndex] = useState<number>(null);
// List of tabs' name
let tabNames: Array<Tab['name']> = [];
if (props.profile === Profile.SalesPerson) {
tabNames = salesPersonTabs.map((tabConfig) => tabConfig.name);
} else if (props.profile === Profile.SalesManager) {
tabNames = salesManagerTabs.map((tabConfig) => tabConfig.name);
}
// State hook for active tab
const [activeTab, setActiveTab] = useState<Tab['name']>(() => {
if (tabNames?.length > 0) {
return tabNames[0];
} else {
return null;
}
});
// State hook for PowerBI Report
const [reportVisuals, setReportVisuals] = useState<VisualGroup[]>([]);
// State hook for the layout type to be rendered
// Three column layout is the default layout type selected
const [layoutType, setLayoutType] = useState<Layout>(Layout.threeColumnLayout);
// State hook for the list of bookmarks of the report
const [bookmarks, updateBookmarks] = useState<Bookmark[]>([]);
// State hook for the ratio of height/width of each respective page of the report
const [pagesAspectRatio] = useState(new Map<string, number>());
// State hook for report export progress
const [isExportInProgress, setIsExportInProgress] = useState<boolean>(false);
// State hook for write-back progress
const [isWritebackInProgress, setWritebackProgressState] = useState<boolean>(false);
// State hook for Analytics button background
const [analyticsBtnActive, setAnalyticsBtnActive] = useState<string>('');
/* End of state hooks declaration */
// Report embedding event handlers
const eventHandlersMap: Map<string, EventHandler> = new Map();
eventHandlersMap.set('loaded', async function (event, embeddedReport: Report) {
console.log('Report has loaded');
// Get the page for the Home tab
const pageName = getReportPageName(TabName.Home, props.profile);
const page = embeddedReport.page(pageName);
// Set the page active
page?.setActive().catch((reason) => console.error(reason));
// Get the report pages and update map of aspect ratios for all pages
const reportPages = await getPagesFromReport(embeddedReport);
reportPages.map((reportPage) => {
pagesAspectRatio.set(
reportPage.name,
reportPage.defaultSize.height / reportPage.defaultSize.width
);
});
const visuals = await page.getVisuals();
// Pair the configured visuals
const pairedVisual = pairVisuals(visuals);
// Build visual groups and update state
setReportVisuals(pairedVisual);
// Check if the report has a QnA visual from the list of visuals and get the index
let qnaVisualIndexSearch = pairedVisual.findIndex(
(visual) => visual.mainVisual?.type === 'qnaVisual' || visual.overlapVisual?.type === 'qnaVisual'
);
// No QnA visual in the report
if (qnaVisualIndexSearch === -1) {
qnaVisualIndexSearch = null;
}
setQnaVisualIndex(qnaVisualIndexSearch);
// Get bookmarks from the report and set the first bookmark as active
getBookmarksFromReport(embeddedReport, (reportBookmarks) => {
if (reportBookmarks.length > 0) {
reportBookmarks[0].checked = true;
}
updateBookmarks(reportBookmarks);
});
// Render report
embeddedReport.render();
});
eventHandlersMap.set('rendered', async function (event, embeddedReport: Report) {
console.log('Report has rendered');
// Add logic to trigger after report is rendered
const activePage = await getActivePage(embeddedReport);
const homePage = getReportPageName(TabName.Home, props.profile);
// Return if the Home tab is active
if (activePage.name === homePage) {
return;
}
setPageHeight(activePage.name);
});
eventHandlersMap.set('commandTriggered', function (event) {
console.log('Command triggered');
if (typeof event.detail.dataPoints[0] !== 'undefined') {
setVisualAutofilledData(event.detail.dataPoints[0].identity);
if (event.detail.command === visualCommands.editLeads.name) {
toggleEditLeadFormPopup();
} else if (event.detail.command === visualCommands.editOpportunity.name) {
toggleUpdateOpportunityFormPopup();
}
}
});
eventHandlersMap.set('buttonClicked', function (event): void {
if (event.detail.id === visualButtons.addLeadButtonGuid) {
// Restrict Anonymous User to toggle Add Activity form and Add New Lead form
if (props.name === 'Anonymous') {
setError(AnonymousWritebackMessage);
return;
}
// Open add lead form
toggleAddLeadFormPopup();
}
});
eventHandlersMap.set('error', function (event: service.ICustomEvent<string>) {
console.error(event.detail);
});
/**
* Refresh token when tokenExpiration has reached minutesToRefresh
* @param tokenExpiration time left to expire
* @param minutesBeforeExpiration time interval before expiration
*/
function setTokenExpirationListener(tokenExpiration: number, minutesBeforeExpiration: number): void {
// Time in ms before expiration
const msBeforeExpiration: number = minutesBeforeExpiration * 60 * 1000;
// Current UTC time in ms
const msCurrentTime: number = Date.now() + new Date().getTimezoneOffset() * 60 * 1000;
// Time until token refresh in milliseconds
const msToRefresh: number = tokenExpiration - msCurrentTime - msBeforeExpiration;
// If token already expired, generate new token and set the access token
if (msToRefresh <= 0) {
fetchReportConfig();
} else {
setTimeout(fetchReportConfig, msToRefresh);
}
}
// Fetch params for embed config for the report
async function fetchReportConfig(): Promise<void> {
// Get token from storage
const storedToken = getStoredToken();
// Check token expiry before making API request, redirect back to login page
if (!checkTokenValidity(storedToken)) {
alert('Session expired');
// Re-render App component
props.updateApp((prev: number) => prev + 1);
return;
}
try {
// Fetch report's embed params
const serverRes = await fetch(ServiceAPI.FetchEmbedParams, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${sessionStorage.getItem(storageKeyJWT)}`,
},
});
if (!serverRes.ok) {
// Show error popup if request fails
setError(await serverRes.text());
console.error(
`Failed to fetch params for report. Status: ${serverRes.status} ${serverRes.statusText}`
);
return;
}
const serverResString = await serverRes.text();
const embedParams: EmbedParamsResponse = await JSON.parse(serverResString);
// Update the state "sampleReportConfig" and re-render component to embed report
setReportConfig({
...sampleReportConfig,
embedUrl: embedParams.EmbedUrl,
accessToken: embedParams.EmbedToken.Token,
});
// Get ms to expiration
const msOfExpiration: number = Date.parse(embedParams.EmbedToken.Expiration);
// Starting the expiration listener
setTokenExpirationListener(msOfExpiration, minutesToRefreshBeforeExpiration);
} catch (error) {
setError(error.message);
console.error('Error in fetching embed configuration', error);
}
}
// Fetch config when sampleReportConfig state does not contain accessToken
// This effect runs only once at mount of this component
useEffect(
() => {
if (!sampleReportConfig.accessToken) {
fetchReportConfig();
}
},
// Eslint does not allow the effect to run exactly once at the mount of component irrespective of hook dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Update title of the report iframe
useEffect(() => {
if (powerbiReport) {
// Set iframe title
powerbiReport.setComponentTitle('Report');
}
}, [powerbiReport]);
/**
* Gets the embedded report and updates the report state
* @param embeddedReport Embedded report instance
*/
function getReport(embeddedReport: Embed): void {
const report = embeddedReport as Report;
setReport(report);
}
/**
* Change theme of the app
* @param theme Theme to be applied
*/
async function switchTheme(theme: Theme): Promise<void> {
if (!powerbiReport) {
console.debug('Report object is null');
return;
}
try {
await powerbiReport.applyTheme({
themeJson: require(`../../assets/ReportThemes/${theme}Theme.json`),
});
setTheme(theme);
// Store theme state to persist across sessions
sessionStorage.setItem(storageKeyTheme, theme);
} catch (error) {
console.error(error);
}
}
// Apply the currently selected bookmark
useEffect(() => {
if (powerbiReport && bookmarks.length > 0 && activeTab === TabName.Analytics) {
const selectedBookmark = getSelectedBookmark(bookmarks);
if (selectedBookmark) {
powerbiReport.bookmarksManager.applyState(selectedBookmark.state);
}
}
}, [bookmarks, powerbiReport, activeTab]);
function togglePersonaliseBar(): void {
setShowPersonaliseBar((prevState) => !prevState);
}
function toggleExportProgressState() {
setIsExportInProgress((prevState) => !prevState);
}
function toggleWritebackProgressState() {
setWritebackProgressState((prevState) => !prevState);
}
async function captureNewBookmark(capturedBookmarkName: string): Promise<void> {
// Capture current state of report in a bookmark
// Refer: https://github.com/microsoft/PowerBI-JavaScript/wiki/Bookmarks
const capturedBookmark: Bookmark = await powerbiReport.bookmarksManager.capture();
// default
capturedBookmark.checked = true;
// Update name of bookmark displayed in the list
capturedBookmark.displayName = capturedBookmarkName;
// Uncheck all other bookmarks
const bookmarkList = bookmarks.map((bookmark) => {
bookmark.checked = false;
return bookmark;
});
// Update the bookmarks' list with current bookmarks as selected
updateBookmarks([...bookmarkList, capturedBookmark]);
}
function tabOnClick(selectedTab: Tab['name']): void {
// Close personalise bar when other tab is clicked
if (selectedTab !== TabName.Home) {
setShowPersonaliseBar(false);
}
setActiveTab(selectedTab);
}
function getReportPageName(activeTab: Tab['name'], profileType: Profile): string {
let pageName: string;
// Get report page name corresponding to active tab
if (profileType === Profile.SalesPerson) {
pageName = salesPersonTabs.find((salesPersonTab) => {
return salesPersonTab.name === activeTab;
})?.reportPageName;
} else if (profileType === Profile.SalesManager) {
pageName = salesManagerTabs.find((salesManagerTab) => {
return salesManagerTab.name === activeTab;
})?.reportPageName;
} else {
console.error('Unknown user name');
}
return pageName;
}
// Change embedded report's page based on new active tab
useEffect(() => {
if (!powerbiReport) {
console.debug('Report object is null');
return;
}
// Get report page name corresponding to active tab
const pageName = getReportPageName(activeTab, props.profile);
// Set given page as active in the embedded report
const page = powerbiReport.page(pageName);
page?.setActive().catch((reason) => console.error(reason));
// Remove the customLayout property from the Settings object
setReportConfig((sampleReportConfig) => {
return {
...sampleReportConfig,
settings: {
panes: {
filters: {
visible: activeTab !== TabName.Home,
},
pageNavigation: {
visible: false,
},
},
},
};
});
}, [activeTab, powerbiReport, props.profile]);
// Create array of Tab for rendering and set isActive as true for the active tab
const tabsDetails: Array<Tab> = tabNames.map(
(tabName: Tab['name']): Tab => {
return { name: tabName, isActive: tabName === activeTab };
}
);
const navTabs = <NavTabs tabsList={tabsDetails} tabOnClick={tabOnClick} />;
const navPane = (
<nav
className={`header justify-content-between navbar navbar-expand-lg navbar-expand-md navbar-expand-sm navbar-light ${theme}`}>
<div className='d-flex align-items-center'>
<Icon className='app-name' iconId={`app-name-${theme}`} width={111.5} height={40} />
<p className={`preview-tag non-selectable ${theme}`}>PREVIEW</p>
</div>
{navTabs}
<IconBar
name={props.name}
profileImageName={props.profileImageName}
profile={props.profile}
showPersonaliseBar={activeTab === TabName.Home} // Show personalise bar when Home tab is active
personaliseBarOnClick={togglePersonaliseBar}
theme={theme}
applyTheme={switchTheme}
updateApp={props.updateApp}
/>
</nav>
);
/**
* Set the height based on width of the report-container and aspect-ratio of report page
* @param pageSection Report page whose height needs to be set in the container
*/
const setPageHeight = useCallback(
(pageSection: string) => {
const aspectRatio = pagesAspectRatio.get(pageSection);
const currentWidth = $('.report-container').width();
const newHeight = aspectRatio * (currentWidth - FilterPaneWidth - ExtraEmbeddingMargin);
resetReportContainerHeight(newHeight);
},
[pagesAspectRatio]
);
/**
* Set the new height to the report-container
* @param height
*/
function resetReportContainerHeight(height: number) {
$('.report-container').height(height);
}
/**
* Rearranges the visuals in the custom layout and updates the custom layout setting in report config state
*/
const rearrangeAndRenderCustomLayout = useCallback(async () => {
// Reset the height of the report-container based on the width and ratio when activeTab is not Home
if (activeTab !== TabName.Home) {
const activePageSection = getReportPageName(activeTab, props.profile);
setPageHeight(activePageSection);
return;
}
// Rearrange the visuals as per the layout only if the activeTab is Home
// Get active page and set the new calculated custom layout
const activePage = await getActivePage(powerbiReport);
// Calculate positions of visual groups
const newReportHeight = rearrangeVisualGroups(reportVisuals, layoutType, powerbiReport);
// Reset report-container height
resetReportContainerHeight(newReportHeight);
// Get layout details for selected visuals in the custom layout
// You can find more information at https://github.com/Microsoft/PowerBI-JavaScript/wiki/Custom-Layout
const customPageLayout = getPageLayout(activePage.name, reportVisuals);
// Update settings with new calculation of custom layout
setReportConfig((sampleReportConfig) => {
return {
...sampleReportConfig,
settings: {
// Set page height automatically
layoutType: models.LayoutType.Custom,
customLayout: {
pageSize: {
type: models.PageSizeType.Custom,
width: powerbiReport.element.clientWidth,
height: newReportHeight,
},
displayOption: models.DisplayOption.ActualSize,
pagesLayout: customPageLayout,
},
},
};
});
}, [powerbiReport, layoutType, reportVisuals, activeTab, props.profile, setPageHeight]);
// Attaches the rearrangeAndRenderCustomLayout function to resize event of window
// Thus whenever window is resized, visuals will get arranged as per the dimensions of the resized report-container
// For window.onresize the below function works as a normal function
window.onresize = rearrangeAndRenderCustomLayout;
// Update the layout of the embedded report
useEffect(() => {
if (!powerbiReport) {
return;
}
rearrangeAndRenderCustomLayout();
}, [powerbiReport, activeTab, rearrangeAndRenderCustomLayout]);
// Hide Export Data, Edit Leads and Edit Opportunities option for Anonymous User
useEffect(() => {
if (props.name === 'Anonymous') {
// Anonymous user shall not see Context Menu options (Edit Leads and Edit Opportunities)
delete sampleReportConfig.settings.extensions;
}
}, [props.name, sampleReportConfig.settings]);
/**
* Handle toggle of visual checkboxes
* @param event
*/
function handleCheckboxInput(event: React.ChangeEvent<HTMLInputElement>): void {
const checkedValue = event.target.value;
const checked = event.target.checked;
if (reportVisuals.length === 0) {
return;
}
setReportVisuals(
reportVisuals.map((visual) => {
// Visual show/hide is managed using visual's title, hence, only visuals with title are rendered
// Update checkbox of visual group with title same as selected visual's title
if (visual.mainVisual.title === checkedValue) {
visual.checked = checked;
}
return visual;
})
);
}
// Handle toggle of QNA visual
function toggleQnaVisual(): void {
if (reportVisuals.length === 0) {
return;
}
if (!qnaVisualIndex) {
console.log('No Qna visual is present on this page of report');
return;
}
// Deep copy of reportVisuals array (required for updating state)
const reportVisualsQnaToggled = Array.from(reportVisuals);
// Toggle visible state of the qna visual
reportVisualsQnaToggled[qnaVisualIndex].checked = !reportVisualsQnaToggled[qnaVisualIndex].checked;
setReportVisuals(reportVisualsQnaToggled);
}
const personaliseBar = (
<PersonaliseBar
visuals={reportVisuals}
handleCheckboxInput={handleCheckboxInput}
toggleQnaVisual={toggleQnaVisual}
qnaVisualIndex={qnaVisualIndex}
togglePersonaliseBar={togglePersonaliseBar}
setLayoutType={setLayoutType}
layoutType={layoutType}
/>
);
const bookmarksList = <BookmarksList bookmarks={bookmarks} updateBookmarks={updateBookmarks} />;
const analyticsButtonContainer = (
<div className={`btn-analytics-container ${theme}`}>
{
<AnalyticsButton
className={`${theme}`}
dataToggle={'dropdown'}
icon={`analytics-myviews-${theme}`}>
My Views
</AnalyticsButton>
}
{bookmarksList}
{
<AnalyticsButton
className={`${analyticsBtnActive} ${theme}`}
dataToggle={'modal'}
dataTarget={'#modal-capture-view'}
icon={`analytics-captureview-${theme}`}
onClick={() => setAnalyticsBtnActive('btn-analytics-active')}>
Capture View
</AnalyticsButton>
}
<div className='horizontal-rule' />
</div>
);
const reportContainer = (
<PowerBIEmbed
embedConfig={sampleReportConfig}
getEmbeddedComponent={getReport}
eventHandlers={eventHandlersMap}
cssClassName={'report-container'}
phasedEmbedding={true}
/>
);
const captureViewPopup = (
<Modal
report={powerbiReport}
captureBookmarkWithName={captureNewBookmark}
isExportInProgress={isExportInProgress}
setError={setError}
toggleExportProgressState={toggleExportProgressState}
selectedBookmark={getSelectedBookmark(bookmarks)}
updateApp={props.updateApp}
resetAnalyticsBtn={() => setAnalyticsBtnActive('')}
/>
);
function toggleEditLeadFormPopup(): void {
setEditLeadFormPopup((prevState) => !prevState);
}
function toggleUpdateOpportunityFormPopup(): void {
setUpdateOpportunityFormPopup((prevState) => !prevState);
}
function toggleAddLeadFormPopup(): void {
setAddLeadFormPopup((prevState) => !prevState);
}
function refreshReport(): void {
powerbiReport.refresh().catch(() => {
setError(WritebackRefreshFailMessage);
// Trigger report refresh after 15 sec
setTimeout(refreshReport, 15000);
});
}
return (
<ThemeContext.Provider value={theme}>
<div className={`embed-page-class d-flex flex-column ${theme}`}>
{navPane}
{showPersonaliseBar && activeTab === TabName.Home ? personaliseBar : null}
{activeTab === TabName.Analytics ? [analyticsButtonContainer, captureViewPopup] : null}
{reportContainer}
{errorPopup}
<Footer />
</div>
{editLeadFormPopup ? (
<EditLeadForm
preFilledValues={visualAutofilledData}
toggleFormPopup={toggleEditLeadFormPopup}
setError={setError}
updateApp={props.updateApp}
refreshReport={refreshReport}
isWritebackInProgress={isWritebackInProgress}
toggleWritebackProgressState={toggleWritebackProgressState}
/>
) : null}
{addLeadFormPopup ? (
<AddLeadForm
toggleFormPopup={toggleAddLeadFormPopup}
setError={setError}
updateApp={props.updateApp}
refreshReport={refreshReport}
isWritebackInProgress={isWritebackInProgress}
toggleWritebackProgressState={toggleWritebackProgressState}
/>
) : null}
{updateOpportunityFormPopup ? (
<UpdateOpportunityForm
preFilledValues={visualAutofilledData}
toggleFormPopup={toggleUpdateOpportunityFormPopup}
setError={setError}
updateApp={props.updateApp}
refreshReport={refreshReport}
isWritebackInProgress={isWritebackInProgress}
toggleWritebackProgressState={toggleWritebackProgressState}
/>
) : null}
</ThemeContext.Provider>
);
}

Просмотреть файл

@ -0,0 +1,99 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
$error-detail-color-light: #3B3C3D;
$error-detail-color-dark: #D8D8D8;
.btn-close {
background: transparent;
border: 1px solid #BCBFDE;
border-radius: 4px;
font: 700 16px 'Segoe UI';
letter-spacing: 0;
margin: auto;
opacity: 1;
padding: 6px 24px;
text-align: center;
&.light {
color: #303778;
}
&.dark {
color: #A7AEFF;
}
}
.error-title {
font: 700 14px 'Segoe UI';
letter-spacing: 0;
margin-bottom: 2px;
margin-right: 10px;
&.light {
color: $error-detail-color-light;
}
&.dark {
color: $error-detail-color-dark;
}
}
.error-val {
font: 400 14px 'Segoe UI';
letter-spacing: 0;
margin-bottom: 2px;
&.light {
color: $error-detail-color-light;
}
&.dark {
color: $error-detail-color-dark;
}
}
.modal-dialog-error {
min-width: 600px;
}
.modal-footer-error {
border-top: 0;
padding-bottom: 35px;
}
.modal-header-error {
align-items: center;
border-bottom: 0;
display: flex;
flex-direction: column;
padding-top: 35px;
}
.modal-title {
font: Bold 20px/36px 'Segoe UI';
&.light {
color: #E37FB7;
}
&.dark {
color: #FFA2D6;
}
}
.modal-content {
&.light {
background-color: #FFFFFF;
}
&.dark {
background-color: #343741;
}
}
.height-auto {
height: auto !important;
}

Просмотреть файл

@ -0,0 +1,88 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Error.scss';
import React, { useContext } from 'react';
import ThemeContext from '../../themeContext';
import { Icon } from '../Icon/Icon';
import { captializeFirstLetterOfWords } from '../utils';
export interface ErrorProps {
error: string;
setError: { (error: string): void };
}
/**
* Component to render error details in a popup
* @params {ErrorProps} error string and toggleErrorState function
*/
export function Error(props: ErrorProps): JSX.Element {
const theme = useContext(ThemeContext);
const errorIconDimension = 30;
const errorDetails: Array<string> = [];
let jsonError: Record<string, unknown>;
let isValidJson = true;
try {
jsonError = JSON.parse(props.error);
// Capture all JSON keys into an array
for (const key in jsonError) {
errorDetails.push(key);
}
} catch (error) {
isValidJson = false;
}
// Retrieve the error message based on error structure. If directly available or to be read from JSON else blank.
const error: string = errorDetails.includes('message')
? jsonError['message'].toString()
: !isValidJson
? props.error
: '';
return (
<div
id='modal-error'
className={`modal ${props.error !== '' ? 'd-block' : 'd-none'}`}
role='dialog'
aria-hidden='true'>
<div className='modal-dialog modal-dialog-centered modal-dialog-error' role='document'>
<div className={`modal-content height-auto shadow-lg ${theme}`}>
<div className='modal-header modal-header-error'>
<Icon
iconId={`error-${theme}`}
height={errorIconDimension}
width={errorIconDimension}
/>
<p className={`modal-title ${theme}`}>ERROR</p>
</div>
<div className='modal-body modal-body-error'>
<p className={`error-val ${theme}`}>{error}</p>
{errorDetails
.filter((key) => key.toLowerCase() !== 'message') // Message is rendered separately
.map((key: string, index: number) => (
<div className='d-flex' key={index}>
<p className={`error-title ${theme}`}>
{captializeFirstLetterOfWords(key)}
</p>
<p className={`error-val ${theme}`}>{jsonError[key]}</p>
</div>
))}
</div>
<div className='modal-footer modal-footer-error'>
<button
type='button'
className={`btn btn-close ${theme}`}
onClick={() => props.setError('')}>
Close
</button>
</div>
</div>
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,56 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.footer {
bottom: 0;
height: 42px;
position: relative;
width: 100%;
&.light {
background: #F7F8FA 0% 0% no-repeat padding-box;
>p, >p>a {
color: #3A3A3A;
}
}
&.dark {
background: #494C55 0% 0% no-repeat padding-box;
>p, >p>a{
color: #D8D8D8;
}
}
}
.footer>p,
.footer>p>a {
display: contents;
}
.footer-icon {
border-radius: 50%;
height: 22px;
padding: 0 6px;
}
.separator-pipe {
margin-bottom: 0;
padding-left: 6px;
}
.github-icon {
margin: 0 8px;
}
.powerbi-icon {
margin: 0 4px;
}
.footer>p>a {
padding-left: 4px;
text-decoration: underline;
}

Просмотреть файл

@ -0,0 +1,52 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Footer.scss';
import React, { useContext } from 'react';
import ThemeContext from '../../themeContext';
export function Footer(): JSX.Element {
const theme = useContext(ThemeContext);
return (
<div className={`d-flex justify-content-center align-items-center non-selectable footer ${theme}`}>
<p>
This demo is powered by Power BI Embedded
<label className='separator-pipe'>{'|'}</label>
</p>
{/* Image url taken from Microsoft Power BI official Youtube channel, visit https://www.youtube.com/user/mspowerbi */}
<img
title='Power-BI'
alt='Power-BI'
className='footer-icon'
src='https://yt3.ggpht.com/a/AATXAJy-o0POcD9iunn2z5MP34g_BZhnoMGlKcyzTD1TZQ=s100-c-k-c0xffffffff-no-rj-mo'></img>
<p>
{'Explore our'}
<a className='d-block' href='https://aka.ms/pbijs/' target='_blank' rel='noreferrer noopener'>
Embedded Playground
</a>
<label className='separator-pipe'>{'|'}</label>
</p>
{/* Image url taken from official page of GitHub logos, visit https://github.com/logos */}
<img
title='GitHub'
alt='GitHub'
className='footer-icon'
src='https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'></img>
<p>
{'Find our'}
<a
className='d-block'
href='https://github.com/microsoft/PowerBI-Developer-Samples/'
target='_blank'
rel='noreferrer noopener'>
sample code
</a>
</p>
</div>
);
}

Просмотреть файл

@ -0,0 +1,188 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Forms.scss';
import React, { useContext } from 'react';
import { useForm } from 'react-hook-form';
import { InputBox } from '../InputBox';
import {
formInputErrorMessage,
entityNameLeads,
ratingOptionsSet,
sourceOptionsSet,
leadStatus,
} from '../../constants';
import { getFormattedDate, trimInput } from '../utils';
import { saveCDSData, CDSAddRequest } from './SaveData';
import ThemeContext from '../../themeContext';
import { Lead, FormProps, DateFormat, CDSAddRequestData } from '../../models';
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner';
const createdDate = new Date();
export function AddLeadForm(props: FormProps): JSX.Element {
const theme = useContext(ThemeContext);
const { register, handleSubmit, errors } = useForm();
const onSubmit = async (formData: Lead) => {
props.toggleWritebackProgressState();
formData.crcb2_leadstatus = leadStatus['New'];
// Build request
const addRequestData: CDSAddRequestData = {
newData: JSON.stringify(formData),
addEntityType: entityNameLeads,
};
const addRequest = new CDSAddRequest(addRequestData);
const result = await saveCDSData(addRequest, props.updateApp, props.setError);
if (result) {
props.refreshReport();
props.toggleFormPopup();
}
props.toggleWritebackProgressState();
};
const inputBoxesBeforeSelect = [
{
title: 'Account Name',
name: 'parentaccountname',
className: `form-control form-element ${errors.parentaccountname && `is-invalid`}`,
placeHolder: `Enter Account Name, e.g., 'Fabrikam, Inc.'`,
errorMessage: formInputErrorMessage,
ref: register({ required: true, minLength: 1 }),
},
{
title: 'Contact Full Name',
name: 'crcb2_primarycontactname',
className: `form-control form-element ${errors.crcb2_primarycontactname && `is-invalid`}`,
placeHolder: `Enter Full Name, e.g., 'John Doe'`,
errorMessage: formInputErrorMessage,
ref: register({ required: true, minLength: 1 }),
},
{
title: 'Topic',
name: 'subject',
className: `form-control form-element ${errors.subject && `is-invalid`}`,
placeHolder: `Enter Topic, e.g.,'100 Laptops'`,
errorMessage: formInputErrorMessage,
ref: register({ required: true, minLength: 1 }),
},
];
const inputListBeforeSelect = inputBoxesBeforeSelect.map((input) => {
return (
<InputBox
onBlur={(event) => trimInput(event)}
title={input.title}
name={input.name}
className={input.className}
placeHolder={input.placeHolder}
errorMessage={input.errorMessage}
// grab value from form element
ref={input.ref}
key={input.name}
/>
);
});
const dateBox = (
<InputBox
title='Created Date'
name='createdon'
value={getFormattedDate(createdDate, DateFormat.DayMonthDayYear)}
className={`form-control form-element ${errors.createdon && `is-invalid`}`}
placeHolder='Enter Date'
errorMessage={formInputErrorMessage}
// grab value from form element
ref={register({ required: true, minLength: 1 })}
disabled={true}
/>
);
let formActionElement: JSX.Element;
if (props.isWritebackInProgress) {
formActionElement = <LoadingSpinner />;
} else {
formActionElement = (
<div className='d-flex justify-content-center btn-form-submit'>
<button className='btn btn-form' type='submit'>
Save
</button>
</div>
);
}
return (
<div className={`d-flex flex-column align-items-center overlay ${theme}`}>
<div className={`popup ${theme}`}>
<div className={`d-flex justify-content-between popup-header ${theme}`}>
<label className='popup-form-title'>Add New Lead</label>
<button
type='button'
className={`close close-button single-form-close-button p-0 ${theme}`}
aria-label='Close'
onClick={props.toggleFormPopup}>
<span aria-hidden='true'>&times;</span>
</button>
</div>
<form
noValidate
onSubmit={handleSubmit(onSubmit)}
className={`d-flex flex-column justify-content-between popup-form ${theme}`}>
<div className='form-content'>
{inputListBeforeSelect}
<div>
<label className='input-label'>Rating</label>
<select
className={`form-control form-element ${
errors.rating && `is-invalid`
} ${theme}`}
name='leadqualitycode'
// grab value from form element
ref={register({ required: true })}>
{Object.keys(ratingOptionsSet).map((option) => {
return (
<option
className={`select-list ${theme}`}
value={ratingOptionsSet[option]}
key={ratingOptionsSet[option]}>
{option}
</option>
);
})}
</select>
</div>
<div>
<label className='input-label'>Source</label>
<select
className={`form-control form-element ${
errors.source && `is-invalid`
} ${theme}`}
name='leadsourcecode'
// grab value from form element
ref={register({ required: true })}>
{Object.keys(sourceOptionsSet).map((option) => {
return (
<option
className={`select-list ${theme}`}
value={sourceOptionsSet[option]}
key={sourceOptionsSet[option]}>
{option}
</option>
);
})}
</select>
</div>
{dateBox}
</div>
{formActionElement}
</form>
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,502 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Forms.scss';
import 'react-datepicker/dist/react-datepicker.css';
import React, { useContext, useState } from 'react';
import { useForm } from 'react-hook-form';
import DatePicker from 'react-datepicker';
import { NavTabs } from '../NavTabs/NavTabs';
import { InputBox } from '../InputBox';
import { Icon } from '../Icon/Icon';
import {
editLeadPopupTabNames,
entityNameLeads,
entityNameOpportunities,
entityNameActivities,
formInputErrorMessage,
ratingOptionsSet,
sourceOptionsSet,
activityTypeOptions,
leadStatus,
activityPriorityOptions,
opportunityStatus,
opportunitySalesStage,
} from '../../constants';
import { setPreFilledValues, getFormattedDate, trimInput, removeWrappingBraces } from '../utils';
import { saveCDSData, CDSAddRequest, CDSUpdateAddRequest, CDSUpdateRequest } from './SaveData';
import ThemeContext from '../../themeContext';
import {
EditLeadFormData,
Activity,
Lead,
Opportunity,
Tab,
FormProps,
DateFormat,
CDSAddRequestData,
CDSUpdateRequestData,
CDSUpdateAddRequestData,
} from '../../models';
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner';
interface EditLeadFormProps extends FormProps {
preFilledValues?: object;
}
export function EditLeadForm(props: EditLeadFormProps): JSX.Element {
const theme = useContext(ThemeContext);
const errorIconDimension = 30;
const [dueDate, setDueDate] = useState({
dueDate: new Date(),
});
const [estimateCloseDate, setEstimateCloseDate] = useState({
estimateCloseDate: new Date(),
});
// List of tabs' name
const tabNames: Array<Tab['name']> = editLeadPopupTabNames;
// State hook to set first tab as active
const [activeTab, setActiveTab] = useState<Tab['name']>(() => {
if (tabNames?.length > 0) {
return tabNames[0];
} else {
return null;
}
});
// Create array of Tab for rendering and set isActive as true for the active tab
const tabsDetails: Array<Tab> = tabNames.map(
(tabName: Tab['name']): Tab => {
return { name: tabName, isActive: tabName === activeTab };
}
);
// Lead table visual fields in embedded report
const leadTableFields = {
LeadId: { name: 'Lead Id', value: null },
BaseId: { name: 'crcb2_baseid', value: null },
AccountId: { name: 'Account Id', value: null },
AccountName: { name: 'Account Name', value: null },
ContactName: { name: 'Contact Name', value: null },
Topic: { name: 'Topic', value: null },
Status: { name: 'Status', value: null },
Rating: { name: 'Rating', value: null },
Source: { name: 'Source', value: null },
CreatedOn: { name: 'Created on', value: null },
};
// Set values from report's table visual
setPreFilledValues(props.preFilledValues, leadTableFields);
const { register, handleSubmit, errors } = useForm();
const addActivityFormOnSubmit = async (formData: Activity) => {
props.toggleWritebackProgressState();
const formattedDueDate = getFormattedDate(dueDate.dueDate, DateFormat.YearMonthDayTime);
formData.crcb2_duedatetime = formattedDueDate;
formData.crcb2_startdatetime = formattedDueDate;
formData.crcb2_enddatetime = formattedDueDate;
delete formData['activityaccountname'];
delete formData['activitycontactfullname'];
// Remove '{' and '}' from the id captured from report table visual
formData['crcb2_LeadId@odata.bind'] = `leads(${removeWrappingBraces(leadTableFields.LeadId.value)})`;
// Build request
const addRequestData: CDSAddRequestData = {
newData: JSON.stringify(formData),
addEntityType: entityNameActivities,
};
const addRequest = new CDSAddRequest(addRequestData);
const result = await saveCDSData(addRequest, props.updateApp, props.setError);
if (result) {
props.refreshReport();
props.toggleFormPopup();
}
props.toggleWritebackProgressState();
};
const qualifyLeadFormOnSubmit = async (formData: EditLeadFormData) => {
props.toggleWritebackProgressState();
const leadData: Lead = {
crcb2_primarycontactname: formData.leadcontactfullname,
subject: formData.leadtopic,
crcb2_leadstatus: leadStatus['Qualified'],
leadqualitycode: ratingOptionsSet[leadTableFields.Rating.value],
leadsourcecode: sourceOptionsSet[leadTableFields.Source.value],
};
// Remove '{' and '}' from the id captured from report table visual
leadData['parentaccountid@odata.bind'] = `accounts(${removeWrappingBraces(
leadTableFields.AccountId.value
)})`;
const opportunityData: Opportunity = {
name: formData.leadtopic,
crcb2_quoteamount: formData.estimatedrevenue,
estimatedclosedate: getFormattedDate(
estimateCloseDate.estimateCloseDate,
DateFormat.YearMonthDay
),
estimatedvalue: formData.estimatedrevenue,
crcb2_salesstage: opportunitySalesStage['Propose'],
crcb2_opportunitystatus:
opportunityStatus[opportunityStatus.findIndex((option) => (option.value = 'New'))].code,
};
// Remove '{' and '}' from the id captured from report table visual
opportunityData['originatingleadid@odata.bind'] = `leads(${removeWrappingBraces(
leadTableFields.LeadId.value
)})`;
// Build request
const updateRequestData: CDSUpdateRequestData = {
baseId: leadTableFields.BaseId.value ?? leadTableFields.LeadId.value,
updatedData: JSON.stringify(leadData),
updateEntityType: entityNameLeads,
};
const addRequestData: CDSAddRequestData = {
newData: JSON.stringify(opportunityData),
addEntityType: entityNameOpportunities,
};
const updateAddRequestData: CDSUpdateAddRequestData = {
UpdateReqBody: updateRequestData,
AddReqBody: addRequestData,
};
const requestObject = new CDSUpdateAddRequest(updateAddRequestData);
const result = await saveCDSData(requestObject, props.updateApp, props.setError);
if (result) {
props.refreshReport();
props.toggleFormPopup();
}
props.toggleWritebackProgressState();
};
const disqualifyLeadFormOnSubmit = async () => {
props.toggleWritebackProgressState();
const leadData: Lead = {
crcb2_leadstatus: leadStatus['Disqualified'],
crcb2_primarycontactname: leadTableFields.ContactName.value,
leadqualitycode: ratingOptionsSet[leadTableFields.Rating.value],
leadsourcecode: sourceOptionsSet[leadTableFields.Source.value],
subject: leadTableFields.Topic.value,
};
// Remove '{' and '}' from the id captured from report table visual
leadData['parentaccountid@odata.bind'] = `accounts(${removeWrappingBraces(
leadTableFields.AccountId.value
)})`;
// Build request
const updateRequestData: CDSUpdateRequestData = {
baseId: leadTableFields.BaseId.value ?? leadTableFields.LeadId.value,
updatedData: JSON.stringify(leadData),
updateEntityType: entityNameLeads,
};
const updateRequest = new CDSUpdateRequest(updateRequestData);
const result = await saveCDSData(updateRequest, props.updateApp, props.setError);
if (result) {
props.refreshReport();
props.toggleFormPopup();
}
props.toggleWritebackProgressState();
};
const navTabs = <NavTabs tabsList={tabsDetails} tabOnClick={setActiveTab} />;
const addActivityInputBoxesBeforeSelect = [
{
title: 'Account Name',
name: 'activityaccountname',
className: 'form-control form-element',
// Show '--blank--' where applicable if empty field is fetched from the report
placeHolder: '--blank--',
value: leadTableFields.AccountName.value,
ref: register,
},
{
title: 'Contact Full Name',
name: 'activitycontactfullname',
className: 'form-control form-element',
placeHolder: '--blank--',
value: leadTableFields.ContactName.value,
ref: register,
},
{
title: 'Topic',
name: 'crcb2_topic',
className: 'form-control form-element',
placeHolder: '--blank--',
value: leadTableFields.Topic.value,
ref: register,
},
];
const addActivityInputListBeforeSelect = addActivityInputBoxesBeforeSelect.map((input) => {
return (
<InputBox
onBlur={(event) => trimInput(event)}
title={input.title}
name={input.name}
className={input.className}
placeHolder={input.placeHolder}
value={input.value}
disabled={true}
// grab value from form element
ref={input.ref}
key={input.name}
/>
);
});
const subjectBox = (
<InputBox
onBlur={(event) => trimInput(event)}
title='Subject'
name='crcb2_subject'
className={`form-control form-element ${errors.crcb2_subject && `is-invalid`}`}
placeHolder={`Enter Subject, e.g., '100 Laptops'`}
errorMessage={formInputErrorMessage}
// grab value from form element
ref={register({ required: true, minLength: 1 })}
/>
);
const descriptionBox = (
<InputBox
onBlur={(event) => trimInput(event)}
title='Description'
name='crcb2_description'
className={`form-control form-element ${errors.crcb2_description && `is-invalid`}`}
placeHolder='Enter Description'
errorMessage={formInputErrorMessage}
// grab value from form element
ref={register({ required: true, minLength: 1 })}
/>
);
let formActionElement: JSX.Element = <LoadingSpinner />;
if (!props.isWritebackInProgress) {
formActionElement = (
<div className='d-flex justify-content-center btn-form-submit'>
<button className='btn btn-form' type='submit'>
Add Activity
</button>
</div>
);
}
const addActivityForm = (
<form
className={`d-flex flex-column justify-content-between popup-form ${theme}`}
noValidate
onSubmit={handleSubmit(addActivityFormOnSubmit)}>
<div className='form-content'>
{addActivityInputListBeforeSelect}
<div>
<label className='input-label'>Activity Type</label>
<select
className={`form-control form-element ${
errors.activityType && `is-invalid`
} ${theme}`}
name='crcb2_activitytype'
// grab value from form element
ref={register({ required: true })}>
{Object.keys(activityTypeOptions).map((option) => {
return (
<option
className={`select-list ${theme}`}
value={activityTypeOptions[option]}
key={activityTypeOptions[option]}>
{option}
</option>
);
})}
</select>
</div>
{subjectBox}
<div>
<label className='input-label'>Priority</label>
<select
className={`form-control form-element ${errors.priority && `is-invalid`} ${theme}`}
name='crcb2_priority'
// grab value from form element
ref={register({ required: true })}>
{Object.keys(activityPriorityOptions).map((option) => {
return (
<option
className={`select-list ${theme}`}
value={activityPriorityOptions[option]}
key={activityPriorityOptions[option]}>
{option}
</option>
);
})}
</select>
</div>
{descriptionBox}
<div>
<label className='input-label'>Due Date</label>
<div className='date-container'>
<DatePicker
className={`form-control form-element date-picker ${theme}`}
name='crcb2_duedatetime'
selected={dueDate.dueDate}
value={getFormattedDate(dueDate.dueDate, DateFormat.DayMonthDayYear)}
onChange={(date: Date) => setDueDate({ dueDate: date })}
ref={register({ required: true })}
/>
</div>
</div>
</div>
{formActionElement}
</form>
);
const qualifyLeadInputBoxes = [
{
title: 'Account Name',
name: 'leadaccountname',
className: 'form-control form-element',
placeHolder: '--blank--',
errorMessage: formInputErrorMessage,
value: leadTableFields.AccountName.value,
disabled: true,
ref: register,
},
{
title: 'Contact Full Name',
name: 'leadcontactfullname',
className: 'form-control form-element',
placeHolder: '--blank--',
value: leadTableFields.ContactName.value,
disabled: true,
ref: register,
},
{
title: 'Topic',
name: 'leadtopic',
className: 'form-control form-element',
placeHolder: '--blank--',
value: leadTableFields.Topic.value,
disabled: true,
ref: register,
},
{
title: 'Estimated Revenue',
name: 'estimatedrevenue',
className: `form-control form-element ${errors.estimatedrevenue && `is-invalid`}`,
placeHolder: `Enter Estimated Revenue (in $) e.g. '10000'`,
errorMessage: formInputErrorMessage,
ref: register({ required: true, minLength: 1 }),
},
];
const qualifyLeadInputList = qualifyLeadInputBoxes.map((input) => {
return (
<InputBox
onBlur={(event) => trimInput(event)}
title={input.title}
name={input.name}
className={input.className}
placeHolder={input.placeHolder}
errorMessage={input.errorMessage}
value={input.value}
disabled={input.disabled}
ref={input.ref}
key={input.name}
/>
);
});
if (!props.isWritebackInProgress) {
formActionElement = (
<div className='d-flex justify-content-center btn-form-submit'>
<button className='btn btn-form' type='submit'>
Qualify Lead
</button>
</div>
);
}
const qualifyLeadForm = (
<form
className={`d-flex flex-column justify-content-between popup-form ${theme}`}
noValidate
onSubmit={handleSubmit(qualifyLeadFormOnSubmit)}>
<div className='form-content'>
{qualifyLeadInputList}
<div>
<label className='input-label'>Estimated Close Date</label>
<div className='date-container'>
<DatePicker
className={`form-control form-element date-picker ${theme}`}
name='estimatedclosedate'
selected={estimateCloseDate.estimateCloseDate}
value={getFormattedDate(
estimateCloseDate.estimateCloseDate,
DateFormat.DayMonthDayYear
)}
onChange={(date: Date) => setEstimateCloseDate({ estimateCloseDate: date })}
ref={register({ required: true })}
/>
</div>
</div>
</div>
{formActionElement}
</form>
);
if (!props.isWritebackInProgress) {
formActionElement = (
<div className='d-flex justify-content-center btn-form-submit'>
<button className='btn btn-form' type='submit'>
Disqualify Lead
</button>
</div>
);
}
const disqualifyLeadForm = (
<form
className={`d-flex flex-column justify-content-between popup-form ${theme}`}
noValidate
onSubmit={handleSubmit(disqualifyLeadFormOnSubmit)}>
<div className='form-content'>
<div className={`d-flex flex-row warning ${theme}`}>
<Icon
className='warning-icon'
iconId={`error-${theme}`}
height={errorIconDimension}
width={errorIconDimension}
/>
<div className='warning-message'>Are you sure you want to disqualify this lead?</div>
</div>
</div>
{formActionElement}
</form>
);
return (
<div className={`d-flex flex-column align-items-center overlay ${theme}`}>
<div className={`popup ${theme}`}>
<div className={`d-flex justify-content-between popup-header ${theme}`}>
<div className='tab-container'>{navTabs}</div>
<button
type='button'
className={`close close-button tabbed-form-close-button p-0 ${theme}`}
aria-label='Close'
onClick={props.toggleFormPopup}>
<span aria-hidden='true'>&times;</span>
</button>
</div>
{activeTab === tabNames[0]
? addActivityForm
: activeTab === tabNames[1]
? qualifyLeadForm
: activeTab === tabNames[2]
? disqualifyLeadForm
: null}
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,278 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
$form-element-dark-theme-font-color: #D8D8D8;
$chevron-margin: calc(100% - 10px);
.popup {
&.light {
background: #FFFFFF 0 0 no-repeat padding-box;
}
&.dark {
background: #343741 0 0 no-repeat padding-box;
}
margin: 120px auto;
}
.popup > * {
width: 860px;
}
.popup-form {
&.light {
.input-label{
color: #303778;
}
.form-element,
.form-element:focus {
color: #383838;
}
}
&.dark {
.input-label{
color: #A7AEFF;
}
.form-element {
color: $form-element-dark-theme-font-color;
&:focus {
background-color: #343741;
}
&:disabled {
background-color: #4D5258;
}
}
}
min-height: 683px;
padding: 0 40px 40px;
}
.form-content > * {
padding-bottom: 20px;
}
.overlay {
&.light {
background: rgba(32, 32, 32, 0.64);
}
&.dark {
background: rgba(33, 33, 33, 0.64);
}
height: 100%;
overflow: auto;
position: fixed;
top: 0;
width: 100%;
z-index: 2;
}
.popup-form-title {
font: 700 20px/27px 'Segoe UI';
margin-bottom: 0;
}
.popup-header {
&.light {
color: #303778;
}
&.dark {
color: #A7AEFF;
}
height: 107px;
padding: 40px;
}
.form-element,
.form-element:focus {
border: 1px solid #C1C1C1;
box-shadow: none;
font: 400 18px 'Segoe UI';
height: 50px;
padding: 0 20px !important;
}
.form-element::placeholder {
color: #777777;
}
.opportunity-status-radio {
margin-left: 20px;
}
.opportunity-status-text {
&.dark {
color: #D8D8D8;
}
}
.status-radio {
margin-top: 0.42rem;
}
.input-label {
font: 600 18px/36px 'Segoe UI';
}
.btn-form {
background: #D7DAF9 0 0 no-repeat padding-box;
border: none;
border-radius: 4px;
color: #303778;
font: 700 18px 'Segoe UI';
margin: 0 10px;
padding: 10px 35px;
}
.btn-form:hover,
.btn-form:focus,
.btn-form:active,
.btn-form:not(:disabled):not(.disabled):active,
.btn-form:not(:disabled):not(.disabled):active:focus {
background: #D7DAF9 0 0 no-repeat padding-box;
border: none;
box-shadow: none;
color: #303778;
}
.btn-form-submit {
padding-top: 20px;
}
.multiple-submit-buttons {
padding: 10px;
width: 190px;
}
.tab-container > div > ul {
flex-direction: row;
}
.tab-container {
margin-left: -142px;
}
.r-label {
&.light {
color: #3B3B3B;
}
&::before {
border-color: #606384;
height: 12px;
margin-top: 2px !important;
width: 12px;
}
&::after {
height: 9px;
margin: 4px 2px !important;
width: 9px;
}
font: 500 18px 'Segoe UI';
}
.date-time-container {
margin-left: 0;
}
.date-picker {
&.light {
background: url('../../assets/Icons/feather-chevron-down-light.svg') transparent no-repeat $chevron-margin !important;
}
&.dark {
background: url('../../assets/Icons/feather-chevron-down-dark.svg') transparent no-repeat $chevron-margin !important;
border: 1px solid #C1C1C1 !important;
}
}
.date {
max-width: 184px;
min-width: 170px;
&.light {
background: url('../../assets/Icons/feather-chevron-down-light.svg') transparent no-repeat $chevron-margin !important;
}
&.dark {
background: url('../../assets/Icons/feather-chevron-down-dark.svg') transparent no-repeat $chevron-margin !important;
border: 1px solid #C1C1C1 !important;
}
}
.date-container > * {
width: 100%;
}
.time {
max-width: 156px;
}
.close-button {
&.tabbed-form-close-button {
margin: 3px 6px;
}
&.single-form-close-button {
margin: -4px 6px;
}
height: 17px;
width: 17px;
}
.warning {
&.light {
background: #FFEBF6 0 0 no-repeat padding-box;
color: #000000;
}
&.dark {
background: #6D485C 0 0 no-repeat padding-box;
color: #FFFFFF;
}
font: 400 20px 'Segoe UI';
margin-top: 20px;
padding: 16px 22px;
}
.warning-message {
padding-left: 22px;
}
// Remove the original arrow and put the custom chevron
select {
appearance: none;
cursor: pointer;
&.light {
background: url('../../assets/Icons/feather-chevron-down-light.svg') transparent no-repeat $chevron-margin !important;
}
&.dark {
background: url('../../assets/Icons/feather-chevron-down-dark.svg') transparent no-repeat $chevron-margin !important;
border: 1px solid #C1C1C1 !important;
}
-webkit-appearance: none;
-moz-appearance: none;
}
select:focus {
border-color: #A7AEFF !important;
}
.select-list {
&.dark {
background-color: #262830;
color: $form-element-dark-theme-font-color;
}
}
@media only screen and (max-width: 1100px) {
.tab-container {
margin-left: -21px;
}
.popup > * {
width: 700px;
}
}

Просмотреть файл

@ -0,0 +1,91 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import { getStoredToken, checkTokenValidity } from '../utils';
import {
ServiceAPI,
UpdateApp,
CDSRequest,
CDSAddRequestData,
CDSUpdateRequestData,
CDSUpdateAddRequestData,
} from '../../models';
/**
* Invokes write back service
* @param reqObject Request object containing service API, request method and request body
* @param updateApp Callback method to re-render App component if session is expired
* @param setError Shows the server returned error
*/
export async function saveCDSData(
reqObject: CDSRequest,
updateApp: UpdateApp,
setError: { (error: string): void }
): Promise<boolean> {
const jwtToken = getStoredToken();
if (!checkTokenValidity(jwtToken)) {
alert('Session expired');
// Re-render App component
updateApp((prev: number) => prev + 1);
return false;
}
try {
const serverRes = await fetch(reqObject.cdsServiceApi, {
method: reqObject.method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwtToken}`,
},
body: reqObject.body,
});
if (!serverRes.ok) {
// Show error popup if request fails
setError(await serverRes.text());
console.error(
`Failed to perform write back operation. Status: ${serverRes.status} ${serverRes.statusText}`
);
return false;
}
} catch (error) {
console.error(`Error while performing write back operation: ${error}`);
return false;
}
return true;
}
export class CDSAddRequest implements CDSRequest {
cdsServiceApi: string;
method: string;
body: string;
constructor(cdsAddRequestData: CDSAddRequestData) {
this.cdsServiceApi = ServiceAPI.WriteBackAdd;
this.method = 'POST';
this.body = JSON.stringify(cdsAddRequestData);
}
}
export class CDSUpdateRequest implements CDSRequest {
cdsServiceApi: string;
method: string;
body: string;
constructor(cdsUpdateRequestData: CDSUpdateRequestData) {
this.cdsServiceApi = ServiceAPI.WriteBackUpdate;
this.method = 'PUT';
this.body = JSON.stringify(cdsUpdateRequestData);
}
}
export class CDSUpdateAddRequest implements CDSRequest {
cdsServiceApi: string;
method: string;
body: string;
constructor(cdsUpdateAddRequestData: CDSUpdateAddRequestData) {
this.cdsServiceApi = ServiceAPI.WriteBackUpdateAdd;
this.method = 'POST';
this.body = JSON.stringify(cdsUpdateAddRequestData);
}
}

Просмотреть файл

@ -0,0 +1,577 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Forms.scss';
import 'react-datepicker/dist/react-datepicker.css';
import React, { useContext, useState } from 'react';
import { useForm } from 'react-hook-form';
import DatePicker from 'react-datepicker';
import { NavTabs } from '../NavTabs/NavTabs';
import { InputBox } from '../InputBox';
import {
opportunityPopupTabNames,
entityNameActivities,
entityNameOpportunities,
formInputErrorMessage,
activityTypeOptions,
opportunityStatus,
opportunitySalesStage,
activityPriorityOptions,
} from '../../constants';
import { Icon } from '../Icon/Icon';
import { setPreFilledValues, createTimeOptions, trimInput, removeWrappingBraces, getFormattedDate, getCalculatedTime } from '../utils';
import { saveCDSData, CDSUpdateAddRequest, CDSUpdateRequest } from './SaveData';
import ThemeContext from '../../themeContext';
import {
UpdateOpportunityFormData,
Opportunity,
Activity,
Tab,
FormProps,
DateFormat,
CDSAddRequestData,
CDSUpdateRequestData,
CDSUpdateAddRequestData,
} from '../../models';
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner';
interface UpdateOpportunityFormProps extends FormProps {
preFilledValues?: object;
}
export function UpdateOpportunityForm(props: UpdateOpportunityFormProps): JSX.Element {
const theme = useContext(ThemeContext);
const [startDate, setStartDate] = useState({
startDate: new Date(),
});
const [endDate, setEndDate] = useState({
endDate: new Date(),
});
// List of tabs' name
const tabNames: Array<Tab['name']> = opportunityPopupTabNames;
// State hook to set first tab as active
const [activeTab, setActiveTab] = useState<Tab['name']>(() => {
if (tabNames?.length > 0) {
return tabNames[0];
} else {
return null;
}
});
// Create array of Tab for rendering and set isActive as true for the active tab
const tabsDetails: Array<Tab> = tabNames.map(
(tabName: Tab['name']): Tab => {
return { name: tabName, isActive: tabName === activeTab };
}
);
// Opportunity table visual fields in embedded report
const opportunityTableFields = {
OpportunityId: { name: 'Opportunity Id', value: null },
BaseId: { name: 'crcb2_baseid', value: null },
LeadId: { name: 'Lead Id', value: null },
AccountName: { name: 'Account Name', value: null },
PrimaryContactName: { name: 'Primary Contact Name', value: null },
Topic: { name: 'Topic', value: null },
EstimatedRevenue: { name: 'Estimated Revenue', value: null },
EstimatedCloseDate: { name: 'Estimated close date', value: null },
OpportunityStatus: { name: 'Opportunity Status', value: null },
OpportunitySalesStage: { name: 'Opportunity Sales Stage', value: null },
QuoteAmount: { name: 'Quote Amount', value: null },
};
// Set values from report's table visual
setPreFilledValues(props.preFilledValues, opportunityTableFields);
// Set status radio selection
const [radioSelection, setRadioSelection] = useState<string>(
// Set the default radio selection to the opportunity status retrieved from report
opportunityStatus[
opportunityStatus.findIndex(
(option) => option.value === opportunityTableFields.OpportunityStatus.value
)
].id
);
function setOpportunityValues(): Opportunity {
const saleStage = opportunitySalesStage[opportunityTableFields.OpportunitySalesStage.value];
const statusIndex = opportunityStatus.findIndex(
(option) => option.value === opportunityTableFields.OpportunityStatus.value
);
const opportunity: Opportunity = {
name: opportunityTableFields.Topic.value,
estimatedvalue: parseInt(opportunityTableFields.EstimatedRevenue.value),
estimatedclosedate: getFormattedDate(
opportunityTableFields.EstimatedCloseDate.value,
DateFormat.YearMonthDay
),
crcb2_opportunitystatus: opportunityStatus[statusIndex].code,
crcb2_salesstage: saleStage,
crcb2_quoteamount: parseInt(
opportunityTableFields.QuoteAmount.value ?? opportunityTableFields.EstimatedRevenue.value
),
};
// Remove '{' and '}' from the id captured from report table visual
opportunity['originatingleadid@odata.bind'] = `leads(${removeWrappingBraces(
opportunityTableFields.LeadId.value
)})`;
return opportunity;
}
const { register, handleSubmit, errors } = useForm();
const editTopicFormOnSubmit = async (formData: Opportunity) => {
props.toggleWritebackProgressState();
const opportunityData: Opportunity = setOpportunityValues();
opportunityData.name = formData.name;
// Build request
const updateRequestData: CDSUpdateRequestData = {
baseId: opportunityTableFields.BaseId.value ?? opportunityTableFields.OpportunityId.value,
updatedData: JSON.stringify(opportunityData),
updateEntityType: entityNameOpportunities,
};
const updateRequest = new CDSUpdateRequest(updateRequestData);
const result = await saveCDSData(updateRequest, props.updateApp, props.setError);
if (result) {
props.refreshReport();
props.toggleFormPopup();
}
props.toggleWritebackProgressState();
};
const meetingFormOnSubmit = async (formData: UpdateOpportunityFormData) => {
props.toggleWritebackProgressState();
const opportunityData: Opportunity = setOpportunityValues();
opportunityData.crcb2_opportunitystatus =
opportunityStatus[
opportunityStatus.findIndex((option) => option.value === 'Meeting Scheduled')
].code;
// Set time for the meeting as selected by the user
const meetingStartTime = getCalculatedTime(formData['starttime']);
startDate.startDate.setHours(meetingStartTime[0]);
startDate.startDate.setMinutes(meetingStartTime[1]);
const meetingEndTime = getCalculatedTime(formData['endtime']);
endDate.endDate.setHours(meetingEndTime[0]);
endDate.endDate.setMinutes(meetingEndTime[1]);
const formattedStartDate = getFormattedDate(startDate.startDate, DateFormat.YearMonthDayTime);
const formattedEndDate = getFormattedDate(endDate.endDate, DateFormat.YearMonthDayTime);
const activityData: Activity = {
crcb2_description: formData.description,
crcb2_startdatetime: formattedStartDate,
crcb2_enddatetime: formattedEndDate,
crcb2_duedatetime: formattedEndDate,
crcb2_activitytype: activityTypeOptions['Appointment'],
crcb2_priority: activityPriorityOptions['High'],
crcb2_subject: formData.title,
crcb2_topic: opportunityTableFields.Topic.value,
};
// Remove '{' and '}' from the id captured from report table visual
activityData['crcb2_LeadId@odata.bind'] = `leads(${removeWrappingBraces(
opportunityTableFields.LeadId.value
)})`;
// Build request
const updateRequestData: CDSUpdateRequestData = {
baseId: opportunityTableFields.BaseId.value ?? opportunityTableFields.OpportunityId.value,
updatedData: JSON.stringify(opportunityData),
updateEntityType: entityNameOpportunities,
};
const addRequestData: CDSAddRequestData = {
newData: JSON.stringify(activityData),
addEntityType: entityNameActivities,
};
const updateAddRequestData: CDSUpdateAddRequestData = {
UpdateReqBody: updateRequestData,
AddReqBody: addRequestData,
};
const requestObject = new CDSUpdateAddRequest(updateAddRequestData);
const result = await saveCDSData(requestObject, props.updateApp, props.setError);
if (result) {
props.refreshReport();
props.toggleFormPopup();
}
props.toggleWritebackProgressState();
};
const quoteFormOnSubmit = async (formData: UpdateOpportunityFormData) => {
props.toggleWritebackProgressState();
const opportunityData: Opportunity = setOpportunityValues();
opportunityData.crcb2_quoteamount = formData.editquote;
// Build request
const updateRequestData: CDSUpdateRequestData = {
baseId: opportunityTableFields.BaseId.value ?? opportunityTableFields.OpportunityId.value,
updatedData: JSON.stringify(opportunityData),
updateEntityType: entityNameOpportunities,
};
const updateRequest = new CDSUpdateRequest(updateRequestData);
const result = await saveCDSData(updateRequest, props.updateApp, props.setError);
if (result) {
props.refreshReport();
props.toggleFormPopup();
}
props.toggleWritebackProgressState();
};
const statusFormOnSubmit = async () => {
props.toggleWritebackProgressState();
const opportunityData: Opportunity = setOpportunityValues();
opportunityData.crcb2_opportunitystatus =
opportunityStatus[opportunityStatus.findIndex((option) => option.id === radioSelection)].code;
if (radioSelection === 'closedWon') {
opportunityData['actualvalue'] =
opportunityTableFields.QuoteAmount.value ?? opportunityTableFields.EstimatedRevenue.value;
}
// Build request
const updateRequestData: CDSUpdateRequestData = {
baseId: opportunityTableFields.BaseId.value ?? opportunityTableFields.OpportunityId.value,
updatedData: JSON.stringify(opportunityData),
updateEntityType: entityNameOpportunities,
};
const updateRequest = new CDSUpdateRequest(updateRequestData);
const result = await saveCDSData(updateRequest, props.updateApp, props.setError);
if (result) {
props.refreshReport();
props.toggleFormPopup();
}
props.toggleWritebackProgressState();
};
const navTabs = <NavTabs tabsList={tabsDetails} tabOnClick={setActiveTab} />;
const editTopicInputBox = (
<InputBox
onBlur={(event) => trimInput(event)}
title='Topic'
name='name'
className={`form-control form-element ${errors.name && `is-invalid`}`}
placeHolder={`Enter Topic, e.g.,'100 Laptops'`}
errorMessage={formInputErrorMessage}
value={opportunityTableFields.Topic.value}
ref={register({ required: true, minLength: 1 })}
/>
);
let formActionElement: JSX.Element = <LoadingSpinner />;
if (!props.isWritebackInProgress) {
formActionElement = (
<div className='d-flex justify-content-center btn-form-submit'>
<button className='btn btn-form' type='submit'>
Save
</button>
</div>
);
}
const editTopicForm = (
<form
className={`d-flex flex-column justify-content-between popup-form ${theme}`}
noValidate
onSubmit={handleSubmit(editTopicFormOnSubmit)}>
<div className='form-content'>{editTopicInputBox}</div>
{formActionElement}
</form>
);
const meetingFormInputBoxesBeforeDate = [
{
title: 'Title',
name: 'title',
className: 'form-control form-element',
// Show '--blank--' where applicable if empty field is fetched from the report
placeHolder: '--blank--',
value: opportunityTableFields.Topic.value,
ref: register,
},
{
title: 'Account Name',
name: 'meetingAccountName',
className: 'form-control form-element',
placeHolder: '--blank--',
value: opportunityTableFields.AccountName.value,
ref: register,
},
{
title: 'Full Name',
name: 'fullName',
className: 'form-control form-element',
placeHolder: '--blank--',
value: opportunityTableFields.PrimaryContactName.value,
ref: register,
},
];
const meetingFormInputListBeforeDate = meetingFormInputBoxesBeforeDate.map((input) => {
return (
<InputBox
onBlur={(event) => trimInput(event)}
title={input.title}
name={input.name}
className={input.className}
placeHolder={input.placeHolder}
value={input.value}
disabled={true}
ref={input.ref}
key={input.name}
/>
);
});
const meetingFormDescriptionBox = (
<InputBox
onBlur={(event) => trimInput(event)}
title='Description'
name='description'
className={`form-control form-element ${errors.description && `is-invalid`}`}
placeHolder='Enter Description'
errorMessage={formInputErrorMessage}
ref={register({ required: true, minLength: 1 })}
/>
);
const timeOptions = createTimeOptions();
if (!props.isWritebackInProgress) {
formActionElement = (
<div className='d-flex justify-content-center btn-form-submit'>
<button className='btn btn-form' type='submit'>
Save
</button>
</div>
);
}
const meetingForm = (
<form
className={`d-flex flex-column justify-content-between popup-form ${theme}`}
noValidate
onSubmit={handleSubmit(meetingFormOnSubmit)}>
<div className='form-content'>
{meetingFormInputListBeforeDate}
<div>
<label className='input-label'>Date and Time</label>
<div className='d-flex flex-row justify-content-between align-items-center date-time-container'>
<DatePicker
className={`form-control form-element date ${theme}`}
name='startdate'
selected={startDate.startDate}
onChange={(date: Date) => {
setStartDate({ startDate: date });
}}
dateFormat='MMM dd, yyyy'
ref={register({ required: true })}
/>
<select
className={`form-control form-element time ${theme}`}
name='starttime'
ref={register({ required: true })}>
{timeOptions.map((timeOption, idx) => {
return (
<option className={`select-list ${theme}`} value={timeOption} key={idx}>
{timeOption}
</option>
);
})}
</select>
<Icon
className='right-arrow'
iconId={`icon-feather-arrow-right-${theme}`}
width={16.5}
height={17}
/>
<DatePicker
className={`form-control form-element date ${theme}`}
name='enddate'
selected={endDate.endDate}
onChange={(date: Date) => setEndDate({ endDate: date })}
dateFormat='MMM dd, yyyy'
ref={register({ required: true })}
/>
<select
className={`form-control form-element time ${theme}`}
name='endtime'
ref={register({ required: true })}>
{timeOptions.map((timeOption, idx) => {
return (
<option className={`select-list ${theme}`} value={timeOption} key={idx}>
{timeOption}
</option>
);
})}
</select>
</div>
</div>
{meetingFormDescriptionBox}
</div>
{formActionElement}
</form>
);
const quoteFormInputBoxes = [
{
title: 'Account Name',
name: 'quoteaccountname',
className: 'form-control form-element',
placeHolder: '--blank--',
value: opportunityTableFields.AccountName.value,
disabled: true,
ref: register,
},
{
title: 'Contact Full Name',
name: 'contactfullname',
className: 'form-control form-element',
placeHolder: '--blank--',
value: opportunityTableFields.PrimaryContactName.value,
disabled: true,
ref: register,
},
{
title: 'Topic',
name: 'quotetopic',
className: 'form-control form-element',
placeHolder: '--blank--',
value: opportunityTableFields.Topic.value,
disabled: true,
ref: register,
},
{
title: 'Estimated Revenue',
name: 'estimatedrevenue',
className: 'form-control form-element',
placeHolder: '--blank--',
value: opportunityTableFields.EstimatedRevenue.value,
disabled: true,
ref: register,
},
{
title: 'Current Quote',
name: 'currentquote',
className: 'form-control form-element',
placeHolder: '--blank--',
value: opportunityTableFields.QuoteAmount.value ?? opportunityTableFields.EstimatedRevenue.value,
disabled: true,
ref: register,
},
{
title: 'Edit Quote',
name: 'editquote',
className: `form-control form-element ${errors.editquote && `is-invalid`}`,
placeHolder: `Enter quote amount (in $) e.g. '30000'`,
errorMessage: formInputErrorMessage,
ref: register({ required: true, minLength: 1 }),
},
];
const quoteFormInputList = quoteFormInputBoxes.map((input) => {
return (
<InputBox
onBlur={(event) => trimInput(event)}
title={input.title}
name={input.name}
className={input.className}
placeHolder={input.placeHolder}
errorMessage={input.errorMessage}
value={input.value}
disabled={input.disabled}
ref={input.ref}
key={input.name}
/>
);
});
if (!props.isWritebackInProgress) {
formActionElement = (
<div className='d-flex justify-content-center btn-form-submit'>
<button className='btn btn-form' type='submit'>
Update
</button>
</div>
);
}
const quoteForm = (
<form
className={`d-flex flex-column justify-content-between popup-form ${theme}`}
noValidate
onSubmit={handleSubmit(quoteFormOnSubmit)}>
<div className='form-content'>{quoteFormInputList}</div>
{formActionElement}
</form>
);
const statusFormOptions = opportunityStatus.map((option) => {
return (
<div className='opportunity-status-radio' key={option.id}>
<input
className='form-check-input status-radio'
type='radio'
name='opportunitystatus'
id={option.id}
value={option.value}
onChange={() => setRadioSelection(option.id)}
checked={option.id === radioSelection}
/>
<label
className={`form-check-label r-label label-radio opportunity-status-text ${theme}`}
htmlFor={option.id}>
{option.value}
</label>
</div>
);
});
if (!props.isWritebackInProgress) {
formActionElement = (
<div className='d-flex justify-content-center btn-form-submit'>
<button className='btn btn-form' type='submit'>
Save
</button>
</div>
);
}
const statusForm = (
<form
className={`d-flex flex-column justify-content-between popup-form ${theme}`}
noValidate
onSubmit={handleSubmit(statusFormOnSubmit)}>
<div className='form-content'>
<label className='input-label'>Select Opportunity Status</label>
{statusFormOptions}
</div>
{formActionElement}
</form>
);
return (
<div className={`d-flex flex-column align-items-center overlay ${theme}`}>
<div className={`popup ${theme}`}>
<div className={`d-flex justify-content-between popup-header ${theme}`}>
<div className='tab-container'>{navTabs}</div>
<button
type='button'
className={`close close-button tabbed-form-close-button p-0 ${theme}`}
aria-label='Close'
onClick={props.toggleFormPopup}>
<span aria-hidden='true'>&times;</span>
</button>
</div>
{activeTab === tabNames[0]
? editTopicForm
: activeTab === tabNames[1]
? meetingForm
: activeTab === tabNames[2]
? quoteForm
: activeTab === tabNames[3]
? statusForm
: null}
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,24 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
$width: 339px;
$height: 37px;
$profile-btn-ml: 66px;
.profile-btn {
height: $height;
margin-left: $profile-btn-ml;
width: $width;
}
#salesmanager-btn {
margin-bottom: 3.5rem;
margin-top: 25px;
}
#salesperson-btn {
margin-bottom: 1.5rem;
margin-top: 31px;
}

Просмотреть файл

@ -0,0 +1,34 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Home.scss';
import React from 'react';
import { Profile } from '../../models';
export interface HomeProps {
setProfileType: { (profileType: Profile): void };
}
export function Home(props: HomeProps): JSX.Element {
return (
<div className='card-body'>
<p className='card-title'> What type of user are you? </p>
<button
id='salesperson-btn'
onClick={() => props.setProfileType(Profile.SalesPerson)}
className='btn-block col-lg-10 col-md-10 col-sm-12 offset-lg-1 offset-md-1 profile-btn'>
Sales Person
</button>
<button
id='salesmanager-btn'
onClick={() => props.setProfileType(Profile.SalesManager)}
className='btn-block col-lg-10 col-md-10 col-sm-12 offset-lg-1 offset-md-1 profile-btn'>
Sales Manager
</button>
</div>
);
}

Просмотреть файл

@ -0,0 +1,41 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import React from 'react';
import sprite from '../../assets/Icons/spritesheet.svg';
/**
* Shape for the SVG Image to be rendered
* Some props are inherited from React.SVGProps<SVGSVGElement>
*/
export interface IconProps extends React.SVGProps<SVGSVGElement> {
iconId: string;
title?: string;
dataToggle?: string;
dataTarget?: string;
onClick?: { (): void };
}
/**
* A component to render the image from the SVG sprite-sheet
* @param props object for the SVG image props
*/
export function Icon(props: IconProps): JSX.Element {
return (
<svg
viewBox={`0 0 ${props.width} ${props.height}`}
id={props.id}
data-toggle={props.dataToggle}
data-target={props.dataTarget}
height={props.height}
width={props.width}
className={props.className}
key={props.key}
onClick={props.onClick}>
<title>{props.title}</title>
<use xlinkHref={`${sprite}#${props.iconId}`} />
</svg>
);
}

Просмотреть файл

@ -0,0 +1,39 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.dropdown-item {
padding-bottom: 0.75rem;
padding-top: 0.75rem;
}
.icon-bar {
margin-right: 18px;
position: relative;
}
.setting {
cursor: pointer;
margin-right: 30px;
}
#settings-dropdown {
border-radius: 0;
margin-top: 5px;
padding: 0;
transform: translate(-27px, 24.5px);
width: 209px;
&.light {
box-shadow: 0 0 20px #6B6B6B29;
}
&.dark {
box-shadow: 0 11px 18px #00000030;
}
}
.user-profile {
margin-right: 8px;
}

Просмотреть файл

@ -0,0 +1,59 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './IconBar.scss';
import React, { useContext } from 'react';
import { SettingsDropdown, SettingsDropdownProps } from '../SettingsDropdown/SettingsDropdown';
import { ProfileInfo, ProfileInfoProps } from '../ProfileInfo/ProfileInfo';
import { Icon } from '../Icon/Icon';
import ThemeContext from '../../themeContext';
import { Theme } from '../../models';
export interface IconBarProps extends SettingsDropdownProps, ProfileInfoProps {
profileImageName: string;
}
export function IconBar(props: IconBarProps): JSX.Element {
const theme: Theme = useContext(ThemeContext);
const settingsIconDimension = 27;
const profileIconDimension = 38;
return (
<div className='align-items-center d-flex flex-row icon-bar'>
<div className='setting'>
<Icon
className='dropdown dropdown-toggle nav-icon'
iconId={`settings-${theme}`}
height={settingsIconDimension}
width={settingsIconDimension}
dataTarget='#settings-dropdown'
dataToggle='collapse'
/>
<SettingsDropdown
showPersonaliseBar={props.showPersonaliseBar}
personaliseBarOnClick={props.personaliseBarOnClick}
applyTheme={props.applyTheme}
theme={props.theme}
updateApp={props.updateApp}
/>
</div>
<div className='user-profile'>
<Icon
// For production app, fetch profile image from the identity provider
className='nav-icon rounded-img'
iconId={props.profileImageName}
width={profileIconDimension}
height={profileIconDimension}
/>
</div>
<div className='user-info'>
<ProfileInfo name={props.name} profile={props.profile} />
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,36 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import React from 'react';
export interface InputBoxProps {
title: string;
name: string;
className: string;
placeHolder: string;
errorMessage?: string;
value?: string;
disabled?: boolean;
onBlur?: { (event): void };
}
export const InputBox = React.forwardRef((props: InputBoxProps, ref: React.LegacyRef<HTMLInputElement>) => (
<div>
<label className='input-label'>{props.title}</label>
<input
type='text'
className={props.className}
name={props.name}
defaultValue={props.value}
placeholder={props.placeHolder}
ref={ref}
disabled={props.disabled}
onBlur={props.onBlur}
/>
<div className='invalid-feedback'>{props.errorMessage}</div>
</div>
));
InputBox.displayName = 'InputBox';

Просмотреть файл

@ -0,0 +1,18 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.spinner-border {
&.light {
border-color: #303778 #303778 #303778 transparent;
}
&.dark {
border-color: #A7AEFF #A7AEFF #A7AEFF transparent;
}
}
.spinner-form {
padding-top: 20px;
}

Просмотреть файл

@ -0,0 +1,22 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './LoadingSpinner.scss';
import React, { useContext } from 'react';
import ThemeContext from '../../themeContext';
/**
* Component to render loading spinner
*/
export function LoadingSpinner(): JSX.Element {
const theme = useContext(ThemeContext);
return (
<div className='align-items-center d-flex justify-content-center spinner-form'>
<div className={`spinner-border ${theme}`} role='status'>
<span className='sr-only'>Loading...</span>
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,98 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.auth-error-info {
margin-left: 68px;
}
.auth-error-info-salesmanager {
margin-top: 23px;
}
.auth-error-info-salesperson {
margin-top: 11px;
}
.back-btn, .login-btn {
background-color: #303778;
border: none;
color: #FFFFFF;
font: 400 14px 'Segoe UI';
height: 37px;
margin: 0;
opacity: 1;
padding: 0.5rem 0;
text-align: center;
width: 161px;
}
@media only screen and (max-width: 575px) {
.back-btn {
margin-bottom: 0.5rem;
}
}
$mar-left: 68px;
.btn-anonymous {
color: #2A359A;
cursor: pointer;
font: 400 14px/19px 'Segoe UI';
letter-spacing: 0;
margin-left: $mar-left;
padding-top: 5px;
opacity: 1;
text-align: left;
}
.login-btn {
margin-left: 15px;
}
.btn-wrapper {
margin-bottom: 1rem;
margin-left: $mar-left;
margin-top: 1.5rem;
}
.form-group {
margin-left: 35px;
margin-right: 35px;
position: relative;
}
$input-width: 335px;
$input-height: 49px;
$input-border-radius: 3px;
.form-input {
border-radius: $input-border-radius;
height: $input-height;
margin-left: 34px;
padding-left: 3rem !important;
width: $input-width;
}
.icon-auth-error {
margin-right: 8px;
margin-top: -2.5px;
}
.m-top {
margin-top: 16px;
}
.set-icon {
margin-left: 12%;
position: absolute;
top: 20%;
}
.text-auth-error {
color: #AF005E;
font: 600 14px/19px 'Segoe UI';
letter-spacing: 0;
opacity: 1;
}

Просмотреть файл

@ -0,0 +1,191 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './Login.scss';
import React from 'react';
import { useForm } from 'react-hook-form';
import { Icon } from '../Icon/Icon';
import { storageKeyJWT } from '../../constants';
import { AuthResponse, Profile, ServiceAPI, UpdateApp } from '../../models';
export interface LoginProps {
backToHomeOnClick: {
(event?: React.MouseEvent<HTMLElement, MouseEvent>): void;
};
selectedProfile: Profile;
updateApp: UpdateApp;
}
export interface LoginFormProps {
username: string;
password: string;
}
/**
* DO NOT USE BELOW LOGIN IMPLEMENTATION FOR PRODUCTION APPLICATIONS,
* THE CURRENT IMPLEMENTATION IS FOR DEMO PURPOSE ONLY!!
*/
export function Login(props: LoginProps): JSX.Element {
const { register, handleSubmit } = useForm();
async function loginOnClick(formData: LoginFormProps): Promise<void> {
const { username, password } = formData;
const loginSuccess: boolean = await loginUser(username, password, props.selectedProfile);
if (!loginSuccess) {
showLoginError();
}
// Re-render App component
props.updateApp((prev: number) => prev + 1);
}
async function anonymousLoginOnClick(): Promise<void> {
await loginUser(null, null, props.selectedProfile);
// Re-render App component
props.updateApp((prev: number) => prev + 1);
}
function showLoginError() {
const authError = document.getElementsByClassName('auth-error-info')[0];
authError.classList.remove('d-none');
if (props.selectedProfile === Profile.SalesPerson) {
authError.className += ' d-flex auth-error-info-salesperson';
} else {
authError.className += ' d-flex auth-error-info-salesmanager';
}
}
function hideLoginError() {
const authError = document.getElementsByClassName('auth-error-info')[0];
authError.classList.remove('d-flex');
authError.classList.add('d-none');
}
return (
<form className='card-body' onSubmit={handleSubmit(loginOnClick)}>
<div className='form-group has-feedback'>
<span className='form-control-feedback set-icon'>
<Icon className='input-icon' iconId='user' width={12} height={16} />
</span>
<input
name='username'
ref={register}
type='text'
onChange={() => {
hideLoginError();
}}
placeholder='Username'
className='form-control form-input m-top'
required
/>
</div>
<div className='form-group has-feedback'>
<span className='form-control-feedback set-icon'>
<Icon className='input-icon' iconId='lock' width={15} height={19} />
</span>
<input
name='password'
ref={register}
type='password'
onChange={() => {
hideLoginError();
}}
placeholder='***********'
className='form-control form-input'
required
/>
</div>
<div className='no-gutters row btn-wrapper'>
<button
onClick={props.backToHomeOnClick}
className='offset-lg-1 offset-md-1 back-btn'
type='button'>
BACK
</button>
<button className='offset-lg-2 offset-md-2 offset-sm-2 login-btn' type='submit'>
LOGIN
</button>
</div>
{props.selectedProfile === Profile.SalesPerson ? (
<div className='btn-anonymous' onClick={anonymousLoginOnClick}>
Enter in demo mode
</div>
) : null}
<div className='d-none align-items-center auth-error-info'>
<div className='icon-auth-error'>
<Icon iconId='auth-error' height={16} width={16} />
</div>
<div className='align-items-center text-auth-error '>
Login failed. Invalid username or password.
</div>
</div>
</form>
);
}
/**
* DO NOT USE BELOW LOGIN IMPLEMENTATION FOR PRODUCTION APPLICATIONS,
* THE CURRENT IMPLEMENTATION IS FOR DEMO PURPOSE ONLY!!
*/
/**
* Authenticates the user credentials and stores the JWT token on successful authentication
* @param username Input username
* @param password Input password
* @param selectedProfile profile type selected on home page
* @returns Flag whether login succeeded
*/
async function loginUser(username: string, password: string, selectedProfile: string): Promise<boolean> {
const requestHeaders = new Headers({
'Content-Type': 'application/json',
});
// Not anonymous login
if (username && password && selectedProfile) {
username = username.trim();
password = password.trim();
// Check empty strings after trim
if (username === '' || password === '') {
return false;
}
// Encode credentials in base64
const encodedCreds = window.btoa(`${username}:${password}`);
// Add encodedCreds to request header
requestHeaders.append('Authorization', `Basic ${encodedCreds}`);
}
// Authenticate and fetch jwt token
const serviceRes = await fetch(ServiceAPI.Authenticate, {
method: 'POST',
credentials: 'same-origin',
headers: requestHeaders,
body: JSON.stringify({ role: selectedProfile }),
});
if (!serviceRes.ok) {
return false;
}
const serviceResString = await serviceRes.text();
// JSON object of service response
const authResponse: AuthResponse = await JSON.parse(serviceResString);
// Store token in storage
if (authResponse?.access_token) {
sessionStorage.setItem(storageKeyJWT, authResponse.access_token);
return true;
}
return false;
}

Просмотреть файл

@ -0,0 +1,86 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.m-left {
margin-left: 120px;
}
.active {
font: 700 18px/24px 'Segoe UI';
&.light {
border-bottom: 3px solid #303778;
color: #303778 !important;
}
&.dark {
border-bottom: 3px solid #A7AEFF;
color: #A7AEFF !important;
}
}
.inactive {
font: 400 18px/24px 'Segoe UI';
&.light {
color: #707070 !important;
}
&.dark {
color: #A7A7A7 !important;
}
}
li {
margin: 0 0.5rem;
}
.header {
height: 96px;
padding: 1rem;
position: sticky;
top: 0;
z-index: 2;
}
.navbar-light .navbar-nav .active a {
border-bottom: 2.5px solid #303778;
}
.nav-link {
color: #303778;
margin-left: 15px;
margin-right: 15px;
}
.nav-item {
color: #303778;
font: 700 18px/21px 'Segoe UI';
letter-spacing: 0;
opacity: 1;
text-align: left;
}
.navbar,
.navbar-nav,
.options {
&.light {
background-color: #FFFFFF;
}
&.dark {
background-color: #343741;
}
}
.navbar-nav {
flex-direction: row;
}
@media only screen and (max-width: 1230px) {
.m-left {
margin-left: 0;
}
}

Просмотреть файл

@ -0,0 +1,38 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './NavTabs.scss';
import React, { useContext } from 'react';
import ThemeContext from '../../themeContext';
import { Tab } from '../../models';
export interface NavTabsProps {
tabsList: Array<Tab>;
tabOnClick: { (selectedTab: Tab['name']): void };
}
export function NavTabs(props: NavTabsProps): JSX.Element {
const theme = useContext(ThemeContext);
return (
<div className='d-flex m-left'>
<ul className={`navbar-nav ${theme}`}>
{props.tabsList.map((tab) => {
return (
<li key={tab.name} className='nav-item' onClick={() => props.tabOnClick(tab.name)}>
<a
className={`nav-link non-selectable pl-0 pr-0 ${
tab.isActive ? 'active' : 'inactive'
} ${theme}`}
href='#'>
{` ${tab.name} `}
</a>
</li>
);
})}
</ul>
</div>
);
}

Просмотреть файл

@ -0,0 +1,204 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
$personalise-dropdown-shadow: 0 11px 16px #94949430;
.layout-img {
padding: 17px;
height: 60px;
width: 60px;
}
.layouts-list-dropdown {
border-radius: 0;
border-width: 0;
box-shadow: $personalise-dropdown-shadow;
cursor: pointer;
display: flex;
flex-flow: column wrap;
min-width: 0;
transform: translate(95px, -2px);
width: 60px;
}
.layouts-list-dropdown,
.visuals-list-dropdown {
&.light {
background: #FFFFFF 0 0 no-repeat padding-box;
box-shadow: 0 11px 16px #94949429;
}
&.dark {
background: #262830 0 0 no-repeat padding-box;
box-shadow: 0 11px 18px #00000030;
}
z-index: 1;
}
.personalise-icon-active {
background: #D7DAF9;
}
.personalise-bar {
align-content: space-between;
background: 0 0 no-repeat padding-box;
border: 0.5px solid #D4D4D4;
display: flex;
flex-flow: column wrap;
height: 60px;
margin: 34px 34px 0;
opacity: 1;
&.light {
background-color: #FFFFFF;
box-shadow: 0 16px 25px #D8D8D829;
}
&.dark {
background-color: #343741;
}
}
.personalise-icon {
background: transparent 0% 0% no-repeat padding-box;
cursor: pointer;
height: 59px;
opacity: 1;
padding: 17px;
width: 60px;
}
.personalise-icon-active {
background: #D7DAF9 !important;
}
.personalise-icon-close {
cursor: pointer;
padding: 22px;
}
.visual-checkbox-checkmark {
background: transparent;
border: 1px solid #CFCFCF;
border-radius: 0;
height: 20px;
left: 0;
position: absolute;
top: 3px;
width: 20px;
}
.visual-checkbox-checkmark::after {
content: '';
display: none;
position: absolute;
}
.visual-checkbox-li {
margin: 10px 1px;
}
.visual-checkbox-li>label {
cursor: pointer;
display: block;
font: 400 16px/21px 'Segoe UI';
margin-bottom: 20px;
padding-left: 30px;
position: relative;
}
.visual-checkbox-li>label>input {
cursor: pointer;
opacity: 0;
position: absolute;
}
.visual-checkbox-li.light>label>input:checked~.visual-checkbox-checkmark {
border: 0;
background-color: #303778;
}
.visual-checkbox-li.dark>label>input:checked~.visual-checkbox-checkmark {
border: 0;
background-color: #A7AEFF;
}
.visual-checkbox-li>label>input:checked~.visual-checkbox-checkmark::after {
display: block;
}
.visual-checkbox-li.light>label .visual-checkbox-checkmark::after {
border: 2px solid #FFF;
border-width: 0 1.5px 1.5px 0;
height: 12px;
left: 7px;
top: 2px;
transform: rotate(45deg);
width: 6px;
}
.visual-checkbox-li.dark>label .visual-checkbox-checkmark::after {
border: 2px solid #303778;
border-width: 0 1.5px 1.5px 0;
height: 12px;
left: 7px;
top: 2px;
transform: rotate(45deg);
width: 6px;
}
.visuals-list-dropdown {
border: none;
border-radius: 0;
box-shadow: $personalise-dropdown-shadow;
padding: 24px 32px 25px;
transform: translate(35px, -1px);
width: 325px;
}
.visual-list-title {
font: 700 20px/21px 'Segoe UI';
letter-spacing: 0;
text-align: left;
&.light {
color: #303778;
}
&.dark {
color: #A7AEFF;
}
}
.visual-list-subtitle {
font: 400 16px/21px 'Segoe UI';
letter-spacing: 0;
padding-bottom: 17px;
text-align: left;
&.light {
color: #33384B;
}
&.dark {
color: #D8D8D8;
}
}
.visual-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.light {
color: #4A4A4A;
}
&.dark {
color: #D8D8D8;
}
}

Просмотреть файл

@ -0,0 +1,279 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './PersonaliseBar.scss';
import '../EmbedPage/EmbedPage.scss';
import React, { useState, useEffect, useContext } from 'react';
import ThemeContext from '../../themeContext';
import { Icon } from '../Icon/Icon';
import { CheckBox } from '../Checkbox/CheckBox';
import { VisualGroup, Layout, Theme } from '../../models';
interface PersonaliseBarProps {
togglePersonaliseBar: { (): void };
visuals: VisualGroup[];
handleCheckboxInput: { (event: React.ChangeEvent<HTMLInputElement>): void };
toggleQnaVisual: { (): void };
qnaVisualIndex: number;
setLayoutType: { (layoutType: Layout): void };
layoutType: Layout;
}
export function PersonaliseBar(props: PersonaliseBarProps): JSX.Element {
// State hook to set a tab as active
const [visualDropdown, setVisualDropdown] = useState<boolean>(false);
// State hook to set a tab as active
const [layoutDropdown, setLayoutDropdown] = useState<boolean>(false);
const theme = useContext(ThemeContext);
function toggleVisualDropdown() {
setVisualDropdown((prevState) => !prevState);
setLayoutDropdown(false);
}
function toggleLayoutDropdown() {
setVisualDropdown(false);
setLayoutDropdown((prevState) => !prevState);
}
// This function will be used to close the layouts and visuals dropdown and also toggle the QnaVisual
function toggleQna() {
// Close the layout dropdown
setLayoutDropdown(false);
// Close the visuals dropdown
setVisualDropdown(false);
props.toggleQnaVisual();
}
useEffect(() => {
// Re-arrange visuals in the custom layout
}, [layoutDropdown]);
let layoutImageName = 'three-column-selected';
let layoutIconWidth = 25;
let layoutIconHeight = 25;
switch (props.layoutType) {
case Layout.oneColumnLayout:
layoutImageName = 'one-column-selected';
layoutIconWidth = 7;
layoutIconHeight = 25;
break;
case Layout.twoColumnLayout:
layoutImageName = 'two-column-selected';
layoutIconWidth = 16;
layoutIconHeight = 25;
break;
case Layout.threeColumnLayout:
layoutImageName = 'three-column-selected';
layoutIconWidth = 25;
layoutIconHeight = 25;
break;
case Layout.twoColumnRowspanLayout:
layoutImageName = 'rowspan-selected';
layoutIconWidth = 21;
layoutIconHeight = 16;
break;
case Layout.twoColumnColspanLayout:
layoutImageName = 'colspan-selected';
layoutIconWidth = 16;
layoutIconHeight = 21;
break;
}
const showQnAcheck =
props.qnaVisualIndex && props.visuals.length > 0 && props.visuals[props.qnaVisualIndex].checked;
const personaliseIcons = [
{
name: `${
visualDropdown ? 'personalise-include-visuals-light' : 'personalise-include-visuals-' + theme
}`,
onClickHandler: toggleVisualDropdown,
className: `personalise-icon ${visualDropdown ? 'personalise-icon-active' : ''}`,
width: 29,
height: 19,
},
{
name: `${
layoutDropdown ? layoutImageName + '-light' : layoutImageName + '-personalise-' + theme
}`,
onClickHandler: toggleLayoutDropdown,
className: `personalise-icon ${layoutDropdown ? 'personalise-icon-active' : ''}`,
width: layoutIconWidth,
height: layoutIconHeight,
},
{
name: `${
showQnAcheck ? 'personalise-question-answer-light' : 'personalise-question-answer-' + theme
}`,
onClickHandler: toggleQna,
className: `personalise-icon ${showQnAcheck ? 'personalise-icon-active' : ''}`,
width: 25,
height: 25,
},
];
const personaliseCloseIcon = {
name: `personalise-close-${theme}`,
onClickHandler: props.togglePersonaliseBar,
className: 'personalise-icon personalise-icon-close',
};
const layoutTypes = [
{
name: 'three-column',
selectedName: 'three-column-selected',
dropdownName: `three-column-selected-${theme}`,
layout: Layout.threeColumnLayout,
className: 'layout-img',
width: 25,
height: 25,
},
{
name: 'two-column',
selectedName: 'two-column-selected',
dropdownName: `two-column-selected-${theme}`,
layout: Layout.twoColumnLayout,
className: 'layout-img',
width: 16,
height: 25,
},
{
name: 'one-column',
selectedName: 'one-column-selected',
dropdownName: `one-column-selected-${theme}`,
layout: Layout.oneColumnLayout,
className: 'layout-img',
width: 7,
height: 25,
},
{
name: 'rowspan',
selectedName: 'rowspan-selected',
dropdownName: `rowspan-selected-${theme}`,
layout: Layout.twoColumnRowspanLayout,
className: 'layout-img',
width: 21,
height: 16,
},
{
name: 'colspan',
selectedName: 'colspan-selected',
dropdownName: `colspan-selected-${theme}`,
layout: Layout.twoColumnColspanLayout,
className: 'layout-img',
width: 16,
height: 21,
},
];
const iconList = personaliseIcons.map((icon, idx) => {
return (
<Icon
id={icon.name}
className={icon.className}
iconId={icon.name}
height={icon.height}
width={icon.width}
key={idx}
onClick={icon.onClickHandler}
/>
);
});
const closeIcon = (
<Icon
id={personaliseCloseIcon.name}
className={personaliseCloseIcon.className}
iconId={personaliseCloseIcon.name}
height={17}
width={17}
onClick={personaliseCloseIcon.onClickHandler}
/>
);
const personaliseBar = (
<div className={`personalise-bar ${theme}`}>
<div>{iconList}</div>
<div>{closeIcon}</div>
</div>
);
const visualListTitle = <div className={`visual-list-title ${theme}`}>{' Configure Report View '}</div>;
const visualListSubtitle = <div className={`visual-list-subtitle ${theme}`}> (Show/Hide) </div>;
let visualsCheckboxList: JSX.Element[] = null;
if (props.visuals?.length > 0) {
visualsCheckboxList = props.visuals
.filter((visual) => visual.mainVisual.type !== 'qnaVisual')
.map((visual, idx) => {
return (
<CheckBox
title={visual.mainVisual.title}
name={visual.mainVisual.name}
checked={visual.checked}
handleCheckboxInput={props.handleCheckboxInput}
key={idx}
/>
);
});
}
// Visuals dropdown element
const visualsCheckbox: JSX.Element = visualDropdown ? (
<div className='dropdown'>
<ul className={`dropdown-menu checkbox-menu allow-focus show visuals-list-dropdown ${theme}`}>
{visualListTitle}
{visualListSubtitle}
{visualsCheckboxList}
</ul>
</div>
) : null;
// Layouts dropdown element
const layoutsElement: JSX.Element = layoutDropdown ? (
<div className='dropdown'>
<ul className={`dropdown-menu checkbox-menu allow-focus layouts-list-dropdown ${theme}`}>
{layoutTypes.map((layoutType) => {
let imgName: string;
if (props.layoutType === layoutType.layout) {
imgName = theme === Theme.Light ? layoutType.selectedName : layoutType.dropdownName;
} else {
imgName = layoutType.name;
}
return (
<Icon
className={layoutType.className}
iconId={imgName}
width={layoutType.width}
height={layoutType.height}
key={layoutType.name}
onClick={() => {
props.setLayoutType(layoutType.layout);
}}
/>
);
})}
</ul>
</div>
) : null;
return (
<React.Fragment>
{personaliseBar}
{visualsCheckbox}
{layoutsElement}
</React.Fragment>
);
}

Просмотреть файл

@ -0,0 +1,35 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
$userprofile-color-light: #4A4A4A;
.username {
font: 700 16px/21px 'Segoe UI';
letter-spacing: 0;
max-width: 93px;
opacity: 1;
&.light {
color: $userprofile-color-light;
}
&.dark {
color: #A7AEFF;
}
}
.profile-info {
font: 400 12px/16px 'Segoe UI';
letter-spacing: 0;
opacity: 1;
&.light {
color: $userprofile-color-light;
}
&.dark {
color: #C5C5C5;
}
}

Просмотреть файл

@ -0,0 +1,30 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './ProfileInfo.scss';
import React, { useContext } from 'react';
import ThemeContext from '../../themeContext';
import { Profile } from '../../models';
export interface ProfileInfoProps {
name: string;
profile: Profile;
}
export function ProfileInfo(props: ProfileInfoProps): JSX.Element {
const theme = useContext(ThemeContext);
return (
<div className='d-flex flex-column justify-content-start'>
<div
className={`username d-inline-block text-truncate cursor-default non-selectable ${theme}`}
title={props.name}>
{props.name}
</div>
<div className={`profile-info cursor-default non-selectable ${theme}`} title={props.profile}>
{props.profile}
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,87 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
.dropdown-menu {
width: 200px;
&.light {
background: #FFFFFF;
}
&.dark {
background: #262830;
}
}
.dropdown-item {
font: 400 18px/24px 'Segoe UI';
height: 50px;
&.light {
color: #707070;
}
&.dark {
color: #D8D8D8;
}
&:active, &.selected {
background-color: #D7DAF9;
color: #303778;
font-weight: 600;
// Override hover effect of bootstrap
&:hover {
background-color: #D7DAF9;
}
}
// Override hover effect of bootstrap
&:hover {
background-color: transparent;
}
}
.form-check-label {
cursor: pointer;
}
.m-right {
margin-right: 20px;
}
.radio-themes {
margin-right: 7px !important;
margin-top: 2px !important;
}
.separator {
&.light {
border: 0.5px solid #EEEFF6;
}
&.dark {
border: 0.5px solid #EEEFF61F;
}
}
.theme-selector {
background: #606384;
display: inline-block;
margin-right: 6px;
}
.theme-selector-container {
height: 50px;
padding-left: 1.5rem;
&.light {
color: #707070;
}
&.dark {
color: #D8D8D8;
}
}

Просмотреть файл

@ -0,0 +1,121 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './SettingsDropdown.scss';
import React, { useState, useContext } from 'react';
import ThemeContext from '../../themeContext';
import { storageKeyJWT } from '../../constants';
import { useClickOutside } from '../utils';
import { Theme, UpdateApp } from '../../models';
export interface SettingsDropdownProps {
showPersonaliseBar: boolean;
personaliseBarOnClick: { (): void };
theme: Theme;
applyTheme: { (theme: Theme): void };
updateApp: UpdateApp;
}
export function SettingsDropdown(props: SettingsDropdownProps): JSX.Element {
const settingsDropdownRef = React.useRef<HTMLDivElement>();
useClickOutside(settingsDropdownRef, hideDropdown);
/**
* Logout the user
*/
function logoutUser(): void {
// Delete token in the store
sessionStorage.removeItem(storageKeyJWT);
props.updateApp((prev: number) => prev + 1);
}
/**
* Hide dropdown when clicked outside of it
*/
function hideDropdown() {
// Check whether dropdown is open
if (settingsDropdownRef.current.classList.contains('show')) {
settingsDropdownRef.current.classList.remove('show');
}
}
// State hook to toggle theme selector btns
const [themeSelector, toggleThemeSelector] = useState<boolean>(false);
const theme: Theme = useContext(ThemeContext);
const dropdownItemClass = `dropdown-item non-selectable ${theme}`;
const themeRadioBtns = (
<React.Fragment>
<div className={` d-flex align-items-center theme-selector-container ${theme}`}>
<div className='form-check form-check-inline m-right'>
<input
type='radio'
id='theme-light'
name='theme-selector'
className='form-check-input radio-themes'
value={'Light'}
checked={props.theme === Theme.Light}
onChange={() => props.applyTheme(Theme.Light)}
/>
<label className={`form-check-label ${theme}`} htmlFor={`theme-light`}>
Light
</label>
</div>
<div className='form-check form-check-inline m-right'>
<input
type='radio'
id='theme-dark'
name='theme-selector'
className='form-check-input radio-themes'
value={'Dark'}
checked={props.theme === Theme.Dark}
onChange={() => props.applyTheme(Theme.Dark)}
/>
<label className={`form-check-label ${theme}`} htmlFor={`theme-dark`}>
Dark
</label>
</div>
</div>
<div className={`separator ${theme}`} />
</React.Fragment>
);
return (
<div id='settings-dropdown' ref={settingsDropdownRef} className={`border-0 dropdown-menu ${theme}`}>
{props.showPersonaliseBar ? (
<div
className={dropdownItemClass}
data-toggle='collapse'
data-target='#settings-dropdown'
onClick={props.personaliseBarOnClick}>
Personalize Home
</div>
) : null}
<div
className={`${dropdownItemClass} ${themeSelector ? 'selected' : ''}`}
onClick={() => toggleThemeSelector(!themeSelector)}>
Colour Theme
</div>
{themeSelector ? themeRadioBtns : null}
<div className={dropdownItemClass} data-toggle='collapse' data-target='#settings-dropdown'>
Support
</div>
<div className={dropdownItemClass} data-toggle='collapse' data-target='#settings-dropdown'>
About
</div>
<div
className={dropdownItemClass}
data-toggle='collapse'
data-target='#settings-dropdown'
onClick={logoutUser}>
Logout
</div>
</div>
);
}

Просмотреть файл

@ -0,0 +1,422 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import { VisualDescriptor, models, Report } from 'powerbi-client';
import {
visualMargin,
visualAspectRatio,
visualsPerSpanTypeSection,
overlapVisualHeightRatio,
reportMargin,
} from '../constants';
import { layoutMap } from './layoutMapping';
import { VisualGroup, LayoutColumns, SpanType, Layout } from '../models';
import { visualPairs } from '../reportConfig';
/**
* Pairs the report visuals into VisualGroups based on visualPairs array
* @param reportVisuals List of visuals of the embedded report
*/
export function pairVisuals(reportVisuals: VisualDescriptor[]): VisualGroup[] {
// List of guid of overlapping visuals
const overlapVisualsPairList = visualPairs.map((visualPair) => {
return visualPair[1];
});
// List of main visuals
// Filter out overlapping visuals from all visuals, i.e. [reportVisuals] - [overlapping visuals]
const mainVisualGroupList = reportVisuals.filter((reportVisual) => {
return !overlapVisualsPairList.includes(reportVisual.name);
});
// Final list of Visual groups
const visualGroups: VisualGroup[] = mainVisualGroupList.map((mainVisual) => {
// Pair of groups visuals' guid
const visualTitlePair = visualPairs.find((visualPair) => {
// Check if this main visual has a mapped overlapping visual
return visualPair[0] === mainVisual.name;
});
let overlapVisual: VisualGroup['overlapVisual'];
if (visualTitlePair) {
// guid of the overlapping visual mapped to this reportVisual
const overlapVisualTitle = visualTitlePair[1];
// Get the overlap visual
overlapVisual = reportVisuals.find((visual) => {
return visual.name === overlapVisualTitle;
});
}
return {
mainVisual: mainVisual,
overlapVisual: overlapVisual,
// Make the QNA visual hidden by default
checked: mainVisual.type !== 'qnaVisual',
};
});
// Move qna visual at the end
const qnaVisualIndex = visualGroups.findIndex(
(visualGroup) => visualGroup.mainVisual.type === 'qnaVisual'
);
if (qnaVisualIndex !== -1) {
visualGroups.push(visualGroups.splice(qnaVisualIndex, 1)[0]);
}
// Move table visual at the end, after the qna visual
const tableVisualIndex = visualGroups.findIndex(
(visualGroup) => visualGroup.mainVisual.type === 'tableEx'
);
if (tableVisualIndex !== -1) {
visualGroups.push(visualGroups.splice(tableVisualIndex, 1)[0]);
}
return visualGroups;
}
/**
* Construct page Layout from selected visual groups
* @param reportPageName
* @param visualGroups Visual groups of the embedded report
*/
export function getPageLayout(reportPageName: string, visualGroups: VisualGroup[]): models.PagesLayout {
const visualsLayout = visualGroupsToVisualsLayout(visualGroups);
const pagesLayout: models.PagesLayout = {};
pagesLayout[reportPageName] = {
defaultLayout: {
displayState: {
// Default display mode for visuals is hidden
mode: models.VisualContainerDisplayMode.Hidden,
},
},
visualsLayout: visualsLayout,
};
return pagesLayout;
}
/**
* Construct visual layout from selected visual groups
* @param visualGroups Visual groups of the embedded report
*/
function visualGroupsToVisualsLayout(visualGroups: VisualGroup[]): models.VisualsLayout {
const visualsLayout: models.VisualsLayout = {};
for (const visualGroup of visualGroups) {
// Show only the visuals groups selected
if (!visualGroup.checked) {
continue;
}
// Construct visual layout of mainVisual
visualsLayout[visualGroup.mainVisual.name] = visualGroup.mainVisual.layout;
// Construct visual layout of overlapping visual if available
if (visualGroup.overlapVisual) {
visualsLayout[visualGroup.overlapVisual.name] = visualGroup.overlapVisual.layout;
}
}
// Convert every visual in all visual groups into type models.VisualsLayout
return visualsLayout;
}
/**
* Calculates the coordinates of all visuals for the given report
* based on the layout type selected and updates the visualGroups array
* @param visualGroups List of all visual groups of the report
* @param selectedLayout Layout type selected
* @param powerbiReport Embedded powerbi report
* @returns new report's height
*/
export function rearrangeVisualGroups(
visualGroups: VisualGroup[],
selectedLayout: Layout,
powerbiReport: Report
): number {
const reportWidth = powerbiReport.element.clientWidth;
let reportHeight = 0;
// Get number of columns corresponding to given layout
const layoutColumns = layoutMap.get(selectedLayout).columns;
// Calculating the combined width of the all visuals in a row
const visualsTotalWidth = reportWidth - visualMargin * (layoutColumns - 1);
// Calculating the combined width of the all visuals in a row
const layoutWidth = reportWidth - 2 * reportMargin;
// Calculate the width of a single visual, according to the number of columns
// For one and three columns visuals width will be a third of visuals total width
const visualWidth = visualsTotalWidth / layoutColumns;
// Calculate visualHeight with margins
let visualHeight = visualWidth * visualAspectRatio;
// Get the layoutSpanType for given layout type
const layoutSpanType = layoutMap.get(selectedLayout).spanType;
// Visuals starting point
let x = reportMargin;
let y = reportMargin;
// 2 x 2 Layout with column span in second row
// _ _
// |_|_|
// |___|
if (layoutSpanType === SpanType.ColSpan) {
for (let idx = 0, checkedCount = 0; idx < visualGroups.length; idx++) {
const element = visualGroups[idx];
// Do not render unchecked visuals
if (!element.checked) {
continue;
}
// Width of this visual grp
let width =
checkedCount % visualsPerSpanTypeSection === visualsPerSpanTypeSection - 1
? visualWidth * 2 + visualMargin
: visualWidth;
// Height of this visual grp
let height = visualHeight;
// Adjust x, y, width, height, checkedCount for qna and table visual
if (element.mainVisual.type === 'qnaVisual' || element.mainVisual.type === 'tableEx') {
// Take full width
width = layoutWidth;
// Take the height from the report
height = element.mainVisual.layout.height || visualHeight;
x = reportMargin;
if (checkedCount % visualsPerSpanTypeSection === 1) {
y += visualHeight + visualMargin;
}
// Make checkedCount to be like last visual in this layout
checkedCount += visualsPerSpanTypeSection - (checkedCount % visualsPerSpanTypeSection) - 1;
}
const mainVisualLayout = element.mainVisual.layout;
// Calc coordinates of main visual
mainVisualLayout.x = x;
mainVisualLayout.y = y;
mainVisualLayout.width = width;
mainVisualLayout.height = height;
mainVisualLayout.displayState = {
// Change the selected visual's display mode to visible
mode: models.VisualContainerDisplayMode.Visible,
};
// Update report height
reportHeight = Math.max(reportHeight, mainVisualLayout.y + mainVisualLayout.height);
if (element.overlapVisual) {
const overlapVisualLayout = element.overlapVisual.layout;
// Calc coordinates of main visual
overlapVisualLayout.x = x;
overlapVisualLayout.y = y + visualHeight * (1 - overlapVisualHeightRatio);
overlapVisualLayout.width = width;
overlapVisualLayout.height = visualHeight * overlapVisualHeightRatio;
overlapVisualLayout.displayState = {
// Change the selected visual's display mode to visible
mode: models.VisualContainerDisplayMode.Visible,
};
}
// Calculating (x,y) position for the next visual
x +=
visualMargin +
(checkedCount % visualsPerSpanTypeSection === visualsPerSpanTypeSection - 1
? visualWidth * 2
: visualWidth);
// Reset x, y
if (x + visualWidth > reportWidth) {
x = reportMargin;
y += height + visualMargin;
}
checkedCount++;
}
}
// 2 x 2 Layout with row span in first column
// _ _
// | |_|
// |_|_|
else if (layoutSpanType === SpanType.RowSpan) {
for (let idx = 0, checkedCount = 0; idx < visualGroups.length; idx++) {
const element = visualGroups[idx];
// Do not render unchecked visuals
if (!element.checked) {
continue;
}
// Width of this visual grp
let width = visualWidth;
// Height of this visual grp
let height =
checkedCount % visualsPerSpanTypeSection === 0
? visualHeight * 2 + visualMargin
: visualHeight;
// Adjust x, y, width, height, checkedCount for qna and table visual
if (element.mainVisual.type === 'qnaVisual' || element.mainVisual.type === 'tableEx') {
// Take full width
width = layoutWidth;
// Take the height from the report
height = element.mainVisual.layout.height || visualHeight;
x = reportMargin;
switch (checkedCount % visualsPerSpanTypeSection) {
case 1:
y += visualHeight * 2 + visualMargin * 2;
break;
case 2:
y += visualHeight + visualMargin;
break;
}
// Render next visual as a first visual of layout
checkedCount += visualsPerSpanTypeSection - (checkedCount % visualsPerSpanTypeSection) - 1;
}
const mainVisualLayout = element.mainVisual.layout;
// Calc coordinates of main visual
mainVisualLayout.x = x;
mainVisualLayout.y = y;
mainVisualLayout.width = width;
mainVisualLayout.height = height;
mainVisualLayout.displayState = {
// Change the selected visual's display mode to visible
mode: models.VisualContainerDisplayMode.Visible,
};
// Update report height
reportHeight = Math.max(reportHeight, mainVisualLayout.y + mainVisualLayout.height);
if (element.overlapVisual) {
const overlapVisualLayout = element.overlapVisual.layout;
// Calc coordinates of main visual
overlapVisualLayout.x = x;
overlapVisualLayout.y = y + height * (1 - overlapVisualHeightRatio);
overlapVisualLayout.width = visualWidth;
overlapVisualLayout.height = height * overlapVisualHeightRatio;
overlapVisualLayout.displayState = {
// Change the selected visual's display mode to visible
mode: models.VisualContainerDisplayMode.Visible,
};
}
// Calculating (x,y) position for the next visual
x += visualMargin + width;
// Reset x, y
if (x + visualWidth > reportWidth) {
x =
(checkedCount + 1) % visualsPerSpanTypeSection === 0
? reportMargin
: visualMargin + visualWidth;
y +=
checkedCount % visualsPerSpanTypeSection === 0 ? visualHeight * 2 : height + visualMargin;
}
checkedCount++;
}
}
// n x n Layout
else {
if (layoutColumns === LayoutColumns.One) {
visualHeight /= 2;
}
for (let idx = 0, checkedCount = 0; idx < visualGroups.length; idx++) {
const element = visualGroups[idx];
// Do not render unchecked visuals
if (!element.checked) {
continue;
}
// Width of this visual grp
let width = visualWidth;
// Height of this visual grp
let height = visualHeight;
// Adjust x, y, width, height, checkedCount for qna and table visual
if (element.mainVisual.type === 'qnaVisual' || element.mainVisual.type === 'tableEx') {
// Take full width
width = layoutWidth;
// Take the height from the report
height = element.mainVisual.layout.height || visualHeight;
x = reportMargin;
// Start render from new row if it is not the start of the row
if (checkedCount % layoutColumns !== 0) {
y += visualHeight + visualMargin;
}
// Make checkedCount to be like last visual in this layout
checkedCount += layoutColumns - (checkedCount % layoutColumns) - 1;
}
const mainVisualLayout = element.mainVisual.layout;
// Calc coordinates of main visual
mainVisualLayout.x = x;
mainVisualLayout.y = y;
mainVisualLayout.width = width;
mainVisualLayout.height = height;
mainVisualLayout.displayState = {
// Change the selected visual's display mode to visible
mode: models.VisualContainerDisplayMode.Visible,
};
// Update report height
reportHeight = Math.max(reportHeight, mainVisualLayout.y + mainVisualLayout.height);
if (element.overlapVisual) {
const overlapVisualLayout = element.overlapVisual.layout;
// Calc coordinates of main visual
overlapVisualLayout.x = x;
overlapVisualLayout.y = y + visualHeight * (1 - overlapVisualHeightRatio);
overlapVisualLayout.width = visualWidth;
overlapVisualLayout.height = visualHeight * overlapVisualHeightRatio;
overlapVisualLayout.displayState = {
// Change the selected visual's display mode to visible
mode: models.VisualContainerDisplayMode.Visible,
};
}
// Calculating (x,y) position for the next visual
x += visualMargin + width;
// Reset x, y
if (x + visualWidth > reportWidth) {
x = reportMargin;
y += height + visualMargin;
}
checkedCount++;
}
}
// Return height to resize the embedded report
return reportHeight + reportMargin;
}

Просмотреть файл

@ -0,0 +1,44 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import { Layout, LayoutMapping, SpanType } from '../models';
export const layoutMap = new Map<Layout, LayoutMapping>([
[
Layout.oneColumnLayout,
{
spanType: SpanType.None,
columns: 1,
},
],
[
Layout.twoColumnLayout,
{
spanType: SpanType.None,
columns: 2,
},
],
[
Layout.threeColumnLayout,
{
spanType: SpanType.None,
columns: 3,
},
],
[
Layout.twoColumnColspanLayout,
{
spanType: SpanType.ColSpan,
columns: 2,
},
],
[
Layout.twoColumnRowspanLayout,
{
spanType: SpanType.RowSpan,
columns: 2,
},
],
]);

Просмотреть файл

@ -0,0 +1,359 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import React, { MutableRefObject } from 'react';
import { Report, Page } from 'powerbi-client';
import { decode } from 'jsonwebtoken';
import { storageKeyJWT, tokenExpiryKey } from '../constants';
import { Bookmark, TabConfig, DateFormat } from '../models';
/**
* Gets current active page from the given report
* @param powerbiReport
* @returns active page instance
*/
export async function getActivePage(powerbiReport: Report): Promise<Page> {
const pages = await powerbiReport.getPages();
// Find active page
const activePage = pages.find((page) => {
return page.isActive;
});
return activePage;
}
/**
* Gets bookmarks that are defined in the report
* @param report
* @param callbackfn
*/
export async function getBookmarksFromReport(
report: Report,
callbackfn?: { (bookmarks?: Bookmark[]): void }
): Promise<void> {
if (!report) {
return;
}
try {
const reportBookmarks = await report?.bookmarksManager.getBookmarks();
// Filter bookmarks which have state, to exclude bookmark directories
const reportBookmarksWithState = reportBookmarks.filter((bookmark) => bookmark.state);
const bookmarks: Bookmark[] = reportBookmarksWithState.map((reportBookmark) => {
return {
...reportBookmark,
checked: false,
};
});
callbackfn(bookmarks);
} catch (error) {
console.error(error);
}
}
/**
* Gets all the pages from the report
* @param report instance
*/
export async function getPagesFromReport(report: Report): Promise<Page[]> {
if (!report) {
return;
}
try {
return await report.getPages();
} catch (error) {
console.error(error);
}
}
/**
* Returns the report page name of the specified tab
* @param tabName
* @param tabConfig
*/
export function getPageName(tabName: string, tabConfig: TabConfig[]): string {
const tab = tabConfig.find((tabs) => {
return tabs.name === tabName;
});
return tab.reportPageName;
}
/**
* Returns the bookmark that is currently applied on the embedded report
* @param bookmarks
*/
export function getSelectedBookmark(bookmarks: Bookmark[]): Bookmark {
return bookmarks.find((bookmark) => {
return bookmark.checked;
});
}
/**
* Returns stored jwt token from session storage or null when no token found
*/
export function getStoredToken(): string | null {
const storageValueJWT = sessionStorage.getItem(storageKeyJWT);
return storageValueJWT;
}
/**
* Returns true when the given token is currently active (not expired), false otherwise
* @param jwt Token
*/
export function checkTokenValidity(jwt: string): boolean {
// JWT token not present
if (!jwt) {
return false;
}
// Get token expiry property from token payload
const tokenExpiryString = getTokenPayloadProperty(jwt, tokenExpiryKey);
// Expiry time not found on the token payload
if (!tokenExpiryString) {
return false;
}
// Convert to number
const tokenExpiry: number = +tokenExpiryString;
// Check if token expiry property is not a number
if (Number.isNaN(tokenExpiry)) {
return false;
}
// Convert to milliseconds
const tokenExpiryMS = tokenExpiry * 1000;
// Check if token is expired
return Date.now() <= tokenExpiryMS;
}
/**
* Returns the decoded object of payload/claim of the given token
* @param jwt Token
*/
export function getTokenPayload(jwt: string): { [key: string]: string } {
// JWT token not present
if (!jwt) {
return null;
}
return decode(jwt, { json: true });
}
/**
* Returns the given property value in token payload if it exists, null otherwise
* @param jwt Token
*/
export function getTokenPayloadProperty(jwt: string, property: string): string | null {
// JWT token not present
if (!jwt) {
return null;
}
const decodedPayloadObject: Record<string, string> = getTokenPayload(jwt);
// Return null if decodedPayloadObject or given property in decodedPayloadObject does not exists
if (!decodedPayloadObject || !(property in decodedPayloadObject)) {
return null;
}
// Return the given property value
return decodedPayloadObject[property];
}
/**
* Converts base64 string to Uint8Array array
* @param base64 string
*/
export function base64ToArrayBuffer(base64: string): Uint8Array {
const binaryString = window.atob(base64);
const binaryLen = binaryString.length;
const bytes = new Uint8Array(binaryLen);
for (let idx = 0; idx < binaryLen; idx++) {
const ascii = binaryString.charCodeAt(idx);
bytes[idx] = ascii;
}
return bytes;
}
/**
* Downloads the file in the JSON object
* @param fileData file JSON object
*/
export function downloadFile(fileData: { [key: string]: string }): void {
console.info('Starting download process');
try {
// Create blob for file contents of given content type
const blob = new Blob([base64ToArrayBuffer(fileData.fileContents)], {
type: fileData.contentType,
});
// Creating an object URL
const URL = window.URL || window.webkitURL;
const dataUrl = URL.createObjectURL(blob);
// Downloading the file using the object URL by using anchor element
const element = document.createElement('a');
element.setAttribute('class', 'download-anchor');
element.setAttribute('href', dataUrl);
element.setAttribute('download', fileData.fileDownloadName);
document.body.appendChild(element);
element.click();
// Deleting the object URL and anchor element
document.body.removeChild(element);
URL.revokeObjectURL(dataUrl);
} catch (error) {
console.error('Error downloading file', error.toString());
}
}
/**
* General function to captialize first letter of every space separated word
* @param words sentence of words
* @returns first letter capital for each word in the string
*/
export function captializeFirstLetterOfWords(words: string): string {
return words
.split(' ')
.map((word) => word.substring(0, 1).toUpperCase() + word.substring(1))
.join(' ');
}
/**
* Returns the formatted date required by the forms
* @param date date to be formatted
* @param format date format type
* @returns formatted date in the form of string
*/
export function getFormattedDate(date: Date, format: DateFormat): string {
// Adjust local date according to UTC
const localDate = new Date(date);
localDate.setMinutes(localDate.getMinutes() - localDate.getTimezoneOffset());
switch (format) {
case DateFormat.DayMonthDayYear:
return date.toLocaleDateString(undefined, {
day: '2-digit',
month: 'long',
year: 'numeric',
weekday: 'long',
});
case DateFormat.YearMonthDay:
// Remove time from JSON formatted date eg.- '2020-05-15T00:00:00.000Z' converted to '2020-05-15'
return localDate.toJSON().slice(0, -14);
case DateFormat.YearMonthDayTime:
// Remove milliseconds from JSON formatted date eg.- '2020-05-15T00:00:00.000Z' converted to '2020-05-15T00:00:00'
return date.toJSON().slice(0, -5);
default:
return date.toString();
}
}
/**
* Returns the time array with 12-hour format
* @returns array of time options as string
*/
export function createTimeOptions(): Array<string> {
const timeOptions: Array<string> = [];
let hours = 12;
let mins = 0;
let minsText = '';
let meridiem = 'AM';
for (let i = 0; i < 48; i++) {
minsText = '';
if (hours > 12) {
hours = 1;
}
if (i >= 24) {
meridiem = 'PM';
}
if (mins === 0) minsText += mins + '0';
else minsText += mins;
timeOptions.push(hours + ':' + minsText + ' ' + meridiem);
mins += 30;
if (i % 2 !== 0) {
hours++;
mins = 0;
}
}
return timeOptions;
}
/**
* Returns the calculated hours and minutes with 24-hour format
* @param timeString time in 12-hour format as string
* @returns array of hours and minutes as numbers
*/
export function getCalculatedTime(timeString: string): [number, number] {
const time = timeString.split(/[: ]/);
const hr = parseInt(time[0]);
const min = parseInt(time[1]);
let calculatedHr: number = hr;
if (time[2] === 'PM' && hr !== 12) {
calculatedHr = hr + 12;
} else if (time[2] === 'AM' && hr === 12) {
calculatedHr = 0;
}
return [calculatedHr, min];
}
/**
* Custom hook for components who require an action when clicked outside them
* @param ref Wrapper component
* @param callback Action to perform when clicked outside
*/
export const useClickOutside = (ref: MutableRefObject<HTMLDivElement>, callback: { (): void }): void => {
const handleClick = (event) => {
if (ref.current && !ref.current.contains(event.target)) {
callback();
}
};
React.useEffect(() => {
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
});
};
/**
* Function to trim the values entered in the textbox
* @param event Get the event instance
*/
export function trimInput(event: React.FocusEvent<HTMLInputElement>): void {
event.target.value = event.target.value.trim();
}
/**
* Function to remove '{' and '}' from entity id returned from report
* @param inputString Entity Id
*/
export function removeWrappingBraces(inputString: string): string {
return inputString.replace(/{/g, '').replace(/}/g, '');
}
/**
* Function to set form field values with the values returned from report
* @param preFilledValuesObject object returned from report with values of data point
* @param tableFieldValuesObject object to set with values returned from report
*/
export function setPreFilledValues(preFilledValuesObject: object, tableFieldValuesObject: object): void {
Object.keys(preFilledValuesObject).map((index) => {
Object.keys(tableFieldValuesObject).map((key) => {
if (preFilledValuesObject[index].target.column === tableFieldValuesObject[key].name) {
return (tableFieldValuesObject[key].value = preFilledValuesObject[index].equals);
}
});
});
}

Просмотреть файл

@ -0,0 +1,227 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
/**
* SalesPerson details
*/
export const SalesPerson = {
profileImageName: 'salesperson-profile',
};
/**
* SalesManager details
*/
export const SalesManager = {
profileImageName: 'salesmanager-profile',
};
/**
* Anonymous user details
*/
export const AnonymousUser = {
profileImageName: 'anonymous-profile',
};
/**
* Link to the Power BI visual selector schema
*/
export const visualSelectorSchema = 'http://powerbi.com/product/schema#visualSelector';
/**
* Export parameters: Name of the exported file and
* URL of server side API for exporting
*/
export const exportedFileName = 'ExportedFile';
/**
* Types of export supported in Power BI Embedded
*/
export const exportTypes: Array<string> = ['pdf', 'ppt', 'png'];
/**
* Margin around each visual
*/
export const visualMargin = 20;
/**
* Left right margin around report page
*/
export const reportMargin = 0;
/**
* Width of filter pane
*/
export const FilterPaneWidth = 32;
/**
* Extra space around embedded report
*/
export const ExtraEmbeddingMargin = 24;
/**
* Aspect ratio of all visuals
*/
export const visualAspectRatio = 9 / 16;
/**
* Ratio of height of Overlap visual and Main visual
*/
export const overlapVisualHeightRatio = 1 / 4;
// Section means a single unit that will be repeating as pattern to form the layout
// These 2 variables are used for the 2 custom layouts with spanning
/**
* Row is the max number of visuals in any column of a section (repeating unit)
*/
export const rowsPerSpanTypeSection = 2;
/**
* Number of visuals in a section (repeating unit)
*/
export const visualsPerSpanTypeSection = 3;
/**
* Names of tabs in edit opportunity popup
*/
export const opportunityPopupTabNames = ['Edit Topic', 'Schedule a Meeting', 'Quote', 'Set Status'];
/**
* Names of tabs in edit lead popup
*/
export const editLeadPopupTabNames = ['Add Activity', 'Qualify Lead', 'Disqualify Lead'];
/**
* Entity names on which the operations are to be performed
*/
export const entityNameActivities = 'crcb2_activitieses';
export const entityNameOpportunities = 'opportunities';
export const entityNameLeads = 'leads';
/**
* Leads entity rating options with corresponding values in CDS
*/
export const ratingOptionsSet = { Hot: 1, Warm: 2, Cold: 3 };
/**
* Activity type options with corresponding values in CDS
*/
export const activityTypeOptions = {
Appointment: 712800000,
Email: 712800001,
'Phone Call': 712800002,
Task: 712800003,
};
/**
* Opportunity status options with corresponding values in CDS
*/
export const opportunityStatus = [
{
id: 'new',
value: 'New',
checked: true,
code: 712800004,
},
{
id: 'meetingScheduled',
value: 'Meeting Scheduled',
checked: false,
code: 712800003,
},
{
id: 'quoteSent',
value: 'Quote Sent',
checked: false,
code: 712800002,
},
{
id: 'closedWon',
value: 'Closed Won',
checked: false,
code: 712800000,
},
{
id: 'closedLost',
value: 'Closed Lost',
checked: false,
code: 712800001,
},
];
/**
* Opportunity sales stage options with corresponding values in CDS
*/
export const opportunitySalesStage = {
Qualify: 712800000,
Develop: 712800002,
Propose: 712800003,
Closed: 712800001,
};
/**
* Lead status options with corresponding values in CDS
*/
export const leadStatus = { New: 712800000, Qualified: 712800001, Disqualified: 712800002 };
/**
* Activity priority options with corresponding values in CDS
*/
export const activityPriorityOptions = { Low: 712800000, Normal: 712800001, High: 712800002 };
/**
* Leads entity source options with corresponding values in CDS
*/
export const sourceOptionsSet = {
Advertisement: 1,
'Employee Referral': 2,
'External Referral': 3,
Partner: 4,
'Public Relations': 5,
Seminar: 6,
'Trade Show': 7,
Web: 8,
'Word of Mouth': 9,
Other: 10,
};
/**
* Name of the application
*/
export const appName = 'Contoso';
/**
* Session storage key for stored theme state
*/
export const storageKeyTheme = 'themeState';
/**
* Session storage key for stored JWT token
*/
export const storageKeyJWT = 'jwt';
/**
* Key for token expiry time in JWT token's payload
*/
export const tokenExpiryKey = 'exp';
/**
* Error message to be displayed against invalid user input
*/
export const formInputErrorMessage = 'Please provide a valid value';
/**
* Error message to be displayed when anonymous user tries to perform write-back operations
*/
export const AnonymousWritebackMessage = 'Anonymous user cannot perform Add Lead write-back operation';
/**
* Error message to be displayed when user report refresh fails due to 15 sec limit
*/
export const WritebackRefreshFailMessage = 'It may take up to 15 sec for the data to refresh';
/**
* Embed token to be refreshed before expiration
*/
export const minutesToRefreshBeforeExpiration = 2;

Просмотреть файл

@ -0,0 +1,15 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
body, html {
height: 100%;
}
main {
height: 100%;
overflow: auto;
position: fixed;
width: 100%;
}

Просмотреть файл

@ -0,0 +1,15 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import './index.scss';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
// Element to which the app component will be appended
const rootElement = document.getElementById('root');
// Render app component
ReactDOM.render(<App />, rootElement);

Просмотреть файл

@ -0,0 +1,224 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import { models } from 'powerbi-client';
import { IVisualNode } from 'visualDescriptor';
/**
* Shape for the response from server end point _ServiceAPI.FetchEmbedParams_
*/
export interface EmbedParamsResponse {
Id: string;
EmbedUrl: string;
Type: string;
EmbedToken: {
Token: string;
TokenId: string;
Expiration: string;
};
DefaultPage: string | null;
MobileDefaultPage: string | null;
}
export interface Bookmark extends models.IReportBookmark {
checked?: boolean;
}
// Following are the fields names of the entities in the CDS where name starting with 'crcb2' indicates custom fields
export interface Activity {
crcb2_activitytype: number;
crcb2_subject: string;
crcb2_priority: number;
crcb2_startdatetime: string;
crcb2_enddatetime: string;
crcb2_duedatetime: string;
crcb2_description: string;
crcb2_topic: string;
}
export class Lead {
crcb2_primarycontactname: string;
subject: string;
leadqualitycode: number;
leadsourcecode: number;
crcb2_leadstatus: number;
}
export interface Opportunity {
name: string;
estimatedvalue: number;
estimatedclosedate: string;
crcb2_opportunitystatus: number;
crcb2_salesstage: number;
crcb2_quoteamount: number;
}
export interface EditLeadFormData {
leadcontactfullname: string;
leadtopic: string;
estimatedrevenue: number;
estimatedclosedate: Date;
}
export interface UpdateOpportunityFormData {
title: string;
editquote: number;
description: string;
}
/**
* Shape for the response from service end point _ServiceAPI.Authenticate_
*/
export interface AuthResponse {
access_token: string;
expiration_time?: string;
}
export interface Tab {
readonly name: string;
readonly isActive: boolean;
}
/**
* Config for tabs in EmbedPage
*
* Get reportPageName from the report's URL
* https://app.powerbi.com/groups/GroupId/reports/ReportId/ReportPageName
*/
export interface TabConfig {
name: string;
reportPageName: string;
}
export interface VisualGroup {
mainVisual: IVisualNode;
overlapVisual?: IVisualNode;
checked: boolean;
}
export enum ModalTab {
Bookmark = 'bookmark',
Export = 'export',
}
export enum Layout {
oneColumnLayout,
twoColumnLayout,
threeColumnLayout,
twoColumnColspanLayout,
twoColumnRowspanLayout,
}
export interface LayoutMapping {
spanType: SpanType;
columns: number;
}
export enum SpanType {
None = 0,
RowSpan = 1,
ColSpan = 2,
}
export enum LayoutColumns {
One = 1,
Two = 2,
Three = 3,
}
/**
* Names of all tabs
*/
export enum TabName {
Home = 'Home',
Leads = 'Leads',
Opportunities = 'Opportunities',
Accounts = 'Accounts',
MyActivities = 'My Activities',
Sellers = 'Sellers',
Analytics = 'Analytics',
}
export enum Profile {
SalesPerson = 'Sales Person',
SalesManager = 'Sales Manager',
}
export enum Theme {
Light = 'light',
Dark = 'dark',
}
/**
* Content type used to convert the file stream to required file format
*/
export enum contentTypeMapping {
PDF = 'application/pdf',
PPT = 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
PNG = 'image/png',
}
/**
* API end-points for backend service
*/
export enum ServiceAPI {
Authenticate = '/api/auth/token',
FetchEmbedParams = '/api/powerbi/EmbedParams',
ExportReport = '/api/powerbi/ExportReport',
WriteBackAdd = '/api/data/add',
WriteBackUpdate = '/api/data/update',
WriteBackUpdateAdd = '/api/data/update-add',
}
/**
* Props interface for write back forms
*/
export interface FormProps {
toggleFormPopup: { (): void };
setError: { (error: string): void };
updateApp: UpdateApp;
refreshReport: { (): void };
isWritebackInProgress: boolean;
toggleWritebackProgressState: { (): void };
}
/**
* App re-render state update function type
*/
export type UpdateApp = { (stateUpdateFunction: { (prevState: number): number }): void };
/**
* Date format types
*/
export enum DateFormat {
DayMonthDayYear = 'dddd, MMMM DD, yyyy',
YearMonthDay = 'YYYY-MM-DD',
YearMonthDayTime = 'YYYY-MM-DDTHH:mm:ss',
}
/**
* CDS request interface with CDS service API endpoint, HTTP method and request body
*/
export interface CDSRequest {
cdsServiceApi: string;
method: string;
body: string;
}
export interface CDSAddRequestData {
newData: string;
addEntityType: string;
}
export interface CDSUpdateRequestData {
baseId: string;
updatedData: string;
updateEntityType: string;
}
export interface CDSUpdateAddRequestData {
UpdateReqBody: CDSUpdateRequestData;
AddReqBody: CDSAddRequestData;
}

Просмотреть файл

@ -0,0 +1,86 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import { TabConfig, TabName } from './models';
/**
* List of tabs for sales person
*/
export const salesPersonTabs: TabConfig[] = [
{
name: TabName.Home,
reportPageName: '',
},
{
name: TabName.Leads,
reportPageName: '',
},
{
name: TabName.Opportunities,
reportPageName: '',
},
{
name: TabName.Accounts,
reportPageName: '',
},
{
name: TabName.MyActivities,
reportPageName: '',
},
];
/**
* List of tabs for sales manager
*/
export const salesManagerTabs: TabConfig[] = [
{
name: TabName.Home,
reportPageName: '',
},
{
name: TabName.Sellers,
reportPageName: '',
},
{
name: TabName.Accounts,
reportPageName: '',
},
{
name: TabName.Analytics,
reportPageName: '',
},
];
/**
* Pairs of visuals to be grouped together in the custom layout
* Format: ['main visual guid', 'overlapping visual guid']
*/
// Add guids of visuals to be paired in custom layout
export const visualPairs = [['', '']];
/**
* Commands:
* Visual commands to edit leads and opportunities
* Guid values of visuals on which the context menu should show up
*/
export const visualCommands = {
editLeads: {
name: 'EditLeads',
displayName: 'Edit Lead',
visualGuid: '',
},
editOpportunity: {
name: 'EditOpportunities',
displayName: 'Edit Opportunity',
visualGuid: '',
},
};
/**
* Title of Power BI buttons on which custom action is to be set
*/
export const visualButtons = {
addLeadButtonGuid: '',
};

Просмотреть файл

@ -0,0 +1,11 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import React from 'react';
import { Theme } from './models';
const ThemeContext = React.createContext(Theme.Light);
export default ThemeContext;

Просмотреть файл

@ -0,0 +1,57 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
import React from 'react';
import ReactDOM from 'react-dom';
import { act } from 'react-dom/test-utils';
import { CheckBox } from '../src/components/CheckBox/CheckBox';
import { Footer } from '../src/components/Footer/Footer';
describe('App Test', function () {
let container: HTMLDivElement | null;
beforeAll(function () {
container = document.createElement('div');
document.body.appendChild(container);
});
afterAll(function () {
document.body.removeChild(container);
container = null;
});
it('render custom checkbox', function () {
var isValidCheckBox = false;
function handleCheckboxInput(
event: React.ChangeEvent<HTMLInputElement>
): void {
isValidCheckBox = !isValidCheckBox;
}
act(() => {
ReactDOM.render(<CheckBox
title='checkBoxValue'
name='checkBox'
checked={isValidCheckBox}
handleCheckboxInput={handleCheckboxInput}
/>, container);
});
let checkBox: HTMLElement = document.getElementById('checkBox');
checkBox.click();
expect(isValidCheckBox).toBe(true);
});
it('render footer', function () {
act(() => {
ReactDOM.render(<Footer />, container);
});
let footer: HTMLCollection = document.getElementsByClassName('footer');
expect(footer).toBeDefined();
expect(footer.length).toBe(1);
});
});

Просмотреть файл

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
]
}

Просмотреть файл

@ -0,0 +1,19 @@
{
"include": [
"test/**/*.tsx"
],
"compilerOptions": {
"target": "es6",
"lib": [
"ES2016",
"dom"
],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"jsx": "react"
}
}

Просмотреть файл

@ -0,0 +1,59 @@
// ---------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ---------------------------------------------------------------------------
let path = require('path');
module.exports = {
mode: 'development',
entry: {
test: path.resolve('test/App.test.tsx')
},
output: {
path: path.resolve('compiledTests'),
filename: '[name].js'
},
devtool: 'source-map',
module: {
rules: [
{
test: /\.ts(x)?$/,
loader: 'ts-loader',
options: {
configFile: path.resolve('tsconfig.test.json')
},
exclude: /node_modules/
},
{
test: /\.s[ac]ss$/i,
use: [
// Creates `style` nodes from JS strings
'style-loader',
// Translates CSS into CommonJS
'css-loader',
// Compiles Sass to CSS
'sass-loader',
],
},
{
test: /\.svg$/,
use: [
{
loader: 'svg-url-loader',
options: {
limit: 10000,
},
},
],
}
]
},
resolve: {
extensions: [
'.tsx',
'.ts',
'.js'
]
},
};

Просмотреть файл

@ -0,0 +1,101 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
public class Constant
{
// Used as key for reading Key Vault items from Configuration
public const string SalesManagerUsername = "SalesManagerUsername";
public const string SalesManagerPassword = "SalesManagerPassword";
public const string SalesPersonUsername = "SalesPersonUsername";
public const string SalesPersonPassword = "SalesPersonPassword";
public const string AppInsightsInstrumentationKey = "AppInsightsInstrumentationKey";
// Used while fetching AAD token
public const string PowerBiScope = "https://analysis.windows.net/powerbi/api/.default";
public static readonly string CdsScope = $"https://{CdsBaseUrl}/.default";
// Used for naming policies
public const string GeneralUserPolicyName = "GeneralUser";
public const string FieldUserPolicyName = "FieldUser";
// Used to renew AAD token minutes before expiry
public const int RenewBeforeMinutes = 10;
// Used to remove cached values after days of inactivity
public const int ExpireInDays = 7;
// Used to create Power BI HTTP client
public const string PowerBiApiUri = "https://api.powerbi.com";
// Used to check the file format of export file
public const string PDF = "PDF";
public const string PPT = "PPT";
public const string PNG = "PNG";
// Used as media type while returning exported file
public const string MimeTypePdf = "application/pdf";
public const string MimeTypePptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
public const string MimeTypePng = "image/png";
// Used to set the name of exported file
public const string ExportFileName = "Exported";
// Used for setting locale for exporting
public const string DefaultLocale = "en-us";
// Used while polling for report export status
public const int ExportTimeoutInMinutes = 10;
// Used for returning error message during export parameters validation failure
public const string MissingPageName = "Provide a valid page name";
public const string MissingFileFormat = "Provide a valid file format";
// Used for returning error message during basic auth
public const string InvalidUsernamePassword = "Invalid username or password";
public const string InvalidRole = "Invalid role";
public const string InvalidAccessToken = "Invalid access token";
// Used for returning error message in CDS service
public const string InvalidRequest = "Invalid request parameters";
// Used while setting or checking for roles
public const string SalesPersonRole = "Sales Person";
public const string SalesManagerRole = "Sales Manager";
// CDS API urls
public const string CdsBaseUrl = "contososalesdemoorg.api.crm.dynamics.com";
public static readonly string CdsApiBaseUrl = $"{CdsBaseUrl}/api/data/v9.1";
// Note: These entity names should match the entity names in CDS
// CDS entity names
// Used for CDS API calls
public const string EntityNameActivities = "crcb2_activitieses";
public const string EntityNameOpportunities = "opportunities";
public const string EntityNameLeads = "leads";
public const string EntityNameAccounts = "accounts";
// CDS entity's id-field names
// Used for CDS API calls
public const string isLatestFieldName = "crcb2_islatest";
public const string baseIdFieldName = "crcb2_baseid";
public const string RowCreationDateFieldName = "crcb2_rowcreationdate";
public const string EntityIdFieldActivities = "crcb2_activitiesid";
public const string EntityIdFieldOpportunities = "opportunityid";
public const string EntityIdFieldLeads = "leadid";
public const string EntityIdFieldAccounts = "accountid";
// CDS constants
// Used for setting date in Date Only type fields
public const string IsLatestTrue = "1";
public const string IsLatestFalse = "0";
public const string CdsDateFormat = "yyyy-MM-dd";
// Used for returning error message for CDS apis
public const string InvalidReq = "Invalid request parameters";
public const string InvalidEntity = "Invalid entity name";
public const string InvalidUpdateDataFields = "Invalid update fields";
public const string InvalidInsertDataFields = "Invalid insert fields";
public const string DataParsingFailed = "Invalid input provided in form";
}

Просмотреть файл

@ -0,0 +1,65 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the MIT license.
-->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>Microsoft.PowerBI.ContosoSalesDemo</RootNamespace>
<AssemblyName>Microsoft.PowerBI.ContosoSalesDemo</AssemblyName>
<TargetFramework>netcoreapp5.0</TargetFramework>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<IsPackable>false</IsPackable>
<SpaRoot>ClientApp\</SpaRoot>
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.14.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.0-preview.8.20414.8" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="5.0.0-preview.7.20365.19" />
<PackageReference Include="Microsoft.Azure.KeyVault" Version="3.0.5" />
<PackageReference Include="Microsoft.Azure.Services.AppAuthentication" Version="1.5.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.AzureKeyVault" Version="3.1.8" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0-preview.8.20407.11" />
<PackageReference Include="Microsoft.Identity.Web" Version="0.2.1-preview" />
<PackageReference Include="Microsoft.PowerBI.Api" Version="3.14.0" />
<PackageReference Include="System.Runtime.Caching" Version="5.0.0-preview.7.20364.11" />
</ItemGroup>
<ItemGroup>
<!-- Don't publish the SPA source files, but do show them in the project files list -->
<Content Remove="$(SpaRoot)**" />
<None Remove="$(SpaRoot)**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
</ItemGroup>
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
<!-- Ensure Node.js is installed -->
<Exec Command="node --version" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
<Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
</Target>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
<!-- Include the newly-built files in the publish output -->
<ItemGroup>
<DistFiles Include="$(SpaRoot)build\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>%(DistFiles.Identity)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
</Project>

Просмотреть файл

@ -0,0 +1,195 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Controllers
{
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using ContosoSalesDemo.Helpers;
using ContosoSalesDemo.Models;
/**
* DO NOT USE BELOW BASIC AUTHENTICATION IMPLEMENTATION FOR PRODUCTION APPLICATIONS,
* THE CURRENT IMPLEMENTATION IS FOR DEMO PURPOSE ONLY!!
*/
[ApiController]
[Route("[controller]")]
public class BasicAuthenticationController : ControllerBase
{
private static IConfiguration Configuration { get; set; }
private static IOptions<JwtTokenConfig> JwtTokenConfig { get; set; }
private static IOptions<UserCollection> UserCollection { get; set; }
private readonly ILogger<BasicAuthenticationController> Logger;
public BasicAuthenticationController(IConfiguration configuration, IOptions<JwtTokenConfig> jwtTokenConfig, IOptions<UserCollection> userCollection, ILogger<BasicAuthenticationController> logger)
{
Configuration = configuration;
JwtTokenConfig = jwtTokenConfig;
UserCollection = userCollection;
Logger = logger;
}
/**
* DO NOT USE BELOW BASIC AUTHENTICATION IMPLEMENTATION FOR PRODUCTION APPLICATIONS,
* THE CURRENT IMPLEMENTATION IS FOR DEMO PURPOSE ONLY!!
*/
[AllowAnonymous]
[HttpPost("/api/auth/token")]
public IActionResult GetJwtToken([FromHeader] string authorization, [FromBody] JsonElement selectedRole)
{
/**
* `[FromHeader] string authorization` gets the Authorization value from request header
* `[FromBody] JsonElement selectedRole` parses the JSON request body
* An example header and body of a valid API request is shown below
* `header: { Content-Type: 'application/json', Authorization: 'Basic base64_encode(username:password)' }` // authorization parameter is optional for anonymous login
* `body: { 'role': 'Sales Manager' }`
*/
var selectedRoleValue = string.Empty;
// Parse role property from selectedRole JSON object
if (selectedRole.TryGetProperty("role", out JsonElement roleProperty))
{
selectedRoleValue = roleProperty.ToString();
}
var user = ValidateUser(authorization, selectedRoleValue);
if (user is null)
{
return BadRequest(Constant.InvalidUsernamePassword);
}
var jwtToken = GenerateJwtToken(user);
return Ok(jwtToken.ToString());
}
/// <summary>
/// Validate authentication request
/// </summary>
/// <returns>User configuration</returns>
private User ValidateUser(string authorization, string selectedRoleValue)
{
// Credentials are stored in Key Vault in the format username:password
string actualUsername;
string actualPassword;
User user;
// Check whether role is either Sales Person or Sales Manager
if (string.Equals(selectedRoleValue, Constant.SalesManagerRole, StringComparison.InvariantCultureIgnoreCase))
{
actualUsername = Configuration[Constant.SalesManagerUsername];
actualPassword = Configuration[Constant.SalesManagerPassword];
user = UserCollection.Value.SalesManager;
Logger.LogInformation($"{user.Username}, {Constant.SalesManagerRole}");
}
else if (string.Equals(selectedRoleValue, Constant.SalesPersonRole, StringComparison.InvariantCultureIgnoreCase))
{
// Return anonymous user when authorization parameter is not present
if (string.IsNullOrWhiteSpace(authorization))
{
Logger.LogInformation($"Anonymous: {Constant.SalesPersonRole}");
return UserCollection.Value.Anonymous;
}
actualUsername = Configuration[Constant.SalesPersonUsername];
actualPassword = Configuration[Constant.SalesPersonPassword];
user = UserCollection.Value.SalesPerson;
Logger.LogInformation($"{user.Username}, {Constant.SalesPersonRole}");
}
else
{
return null;
}
// Stores credential passed in user request
string[] credential;
try
{
// Get user credentials from request header
credential = ParamHelper.DecodeBase64EncodedString(authorization.Split(' ')[1].Trim()).Split(':');
}
catch (Exception)
{
// Return if request header is malformed
return null;
}
// Check whether username and password matches
if (!string.Equals(credential[0], actualUsername, StringComparison.InvariantCultureIgnoreCase) ||
!string.Equals(credential[1], actualPassword)
)
{
Logger.LogInformation($"{Constant.InvalidUsernamePassword}, {credential[0]}, {credential[1]}, {selectedRoleValue}");
return null;
}
return user;
}
/// <summary>
/// Generate JWT token
/// </summary>
/// <returns>JWT token as string</returns>
private JObject GenerateJwtToken(User user)
{
// To capture token claims
var claims = new List<Claim>();
// Get list of all user properties
var userProps = user.GetType().GetProperties();
foreach (var prop in userProps)
{
var value = prop.GetValue(user, null);
if (value != null)
{
claims.Add(new Claim(prop.Name.ToLower(), value.ToString()));
}
}
// Time after which JWT token will expire
var expirationTime = DateTime.UtcNow.AddMinutes(Convert.ToDouble(JwtTokenConfig.Value.ExpiresInMinutes));
// Time before which JWT token will not be accepted for processing
var notBeforeTime = DateTime.UtcNow;
var signingKey = Encoding.UTF8.GetBytes(Configuration[Configuration["KeyVault:KeyName"]]);
// Create token signature
var securityKey = new SymmetricSecurityKey(signingKey);
var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
// Create token header
var tokenHeader = new JwtHeader(signingCredentials);
// Create token payload
var tokenPayload = new JwtPayload(JwtTokenConfig.Value.Issuer, JwtTokenConfig.Value.Audience, claims, notBeforeTime, expirationTime);
// Create token
var token = new JwtSecurityToken(tokenHeader, tokenPayload);
var tokenHandler = new JwtSecurityTokenHandler();
var jwtToken = tokenHandler.WriteToken(token);
// Build token with necessary config here
var tokenParams = new JObject {
{ "access_token", jwtToken }
};
return tokenParams;
}
}
}

Просмотреть файл

@ -0,0 +1,241 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Controllers
{
using System;
using System.Threading.Tasks;
using Microsoft.Identity.Web;
using Microsoft.Identity.Client;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging;
using ContosoSalesDemo.Service;
using ContosoSalesDemo.Models;
using ContosoSalesDemo.Exceptions;
using System.Net;
using Newtonsoft.Json;
[ApiController]
[Route("[controller]")]
[Authorize(Policy = Constant.FieldUserPolicyName)]
public class CdsController : ControllerBase
{
private readonly ITokenAcquisition TokenAcquisition;
private readonly ILogger<CdsController> Logger;
public CdsController(ITokenAcquisition tokenAcquisition, ILogger<CdsController> logger)
{
TokenAcquisition = tokenAcquisition;
Logger = logger;
}
[HttpPut("/api/data/update")]
public async Task<IActionResult> UpdateData([FromBody] UpdateDataRequest reqBody)
{
try
{
// Generate AAD token
var aadToken = await TokenAcquisition.GetAccessTokenForAppAsync(new string[] { Constant.CdsScope });
// Init CDS Service
using (var cdsService = new CdsService(aadToken))
{
var updateEntityName = reqBody.UpdateEntityType;
// Parse row's data from Json in request as an instance of Activity/Opportunities/Leads
var (idField, newData) = cdsService.ParseDataRowFromJson(reqBody.UpdatedData, updateEntityName);
// Set current UTC date in the RowCreationDate field
cdsService.SetRowCreationDate(newData);
// Check if parsing was successful
if (newData is null)
{
return BadRequest(Constant.InvalidReq);
}
// Update the given data in CDS
await cdsService.UpdateData(reqBody.BaseId, newData, idField, updateEntityName);
};
return Ok();
}
catch (JsonException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.BadRequest, Constant.DataParsingFailed);
}
catch (CdsException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.BadRequest, ex.Message);
}
catch (MsalServiceException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode(ex.StatusCode, ex.Message);
}
catch (MsalClientException ex)
{
Logger.LogError(ex, ex.Message);
if (Int32.TryParse(ex.ErrorCode, out int errorCode))
{
return StatusCode(errorCode, ex.Message);
}
else
{
return StatusCode(403, ex.Message);
}
}
// Handling generic exception to prevent sending complete stack trace to client side
catch (Exception ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.InternalServerError);
}
}
[HttpPost("/api/data/add")]
public async Task<IActionResult> AddNewData([FromBody] AddDataRequest reqBody)
{
try
{
// Generate AAD token
var aadToken = await TokenAcquisition.GetAccessTokenForAppAsync(new string[] { Constant.CdsScope });
// Init CDS Service
using (var cdsService = new CdsService(aadToken))
{
var addEntityName = reqBody.AddEntityType;
// Parse row's data from Json in request as an instance of Activity/Opportunities/Leads
var (idField, newData) = cdsService.ParseDataRowFromJson(reqBody.NewData, addEntityName);
// Set current UTC date in the RowCreationDate field
cdsService.SetRowCreationDate(newData);
// Check if parsing was successful
if (newData is null)
{
return BadRequest(Constant.InvalidReq);
}
// Insert the given data in CDS, baseId is null as it is not known at time of insert
await cdsService.AddNewRow(newData, addEntityName, idField);
}
return Ok();
}
catch (JsonException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.BadRequest, Constant.DataParsingFailed);
}
catch (CdsException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.BadRequest, ex.Message);
}
catch (MsalServiceException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode(ex.StatusCode, ex.Message);
}
catch (MsalClientException ex)
{
Logger.LogError(ex, ex.Message);
if (Int32.TryParse(ex.ErrorCode, out int errorCode))
{
return StatusCode(errorCode, ex.Message);
}
else
{
return StatusCode(403, ex.Message);
}
}
// Handling generic exception to prevent sending complete stack trace to client side
catch (Exception ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.InternalServerError);
}
}
[HttpPost("/api/data/update-add")]
public async Task<IActionResult> UpdateAddNewData([FromBody] AddAndUpdateDataRequest reqBody)
{
try
{
// Generate AAD token
var aadToken = await TokenAcquisition.GetAccessTokenForAppAsync(new string[] { Constant.CdsScope });
// Init CDS Service
using (var cdsService = new CdsService(aadToken))
{
var addEntityName = reqBody.AddReqBody.AddEntityType;
// Parse row's data from Json in request as an instance of Activity/Opportunities/Leads for add operation
var (addEntityIdField, newData) = cdsService.ParseDataRowFromJson(reqBody.AddReqBody.NewData, addEntityName);
// Set current UTC date in the RowCreationDate field
cdsService.SetRowCreationDate(newData);
var updateEntityName = reqBody.UpdateReqBody.UpdateEntityType;
// Parse row's data from Json in request as an instance of Activity/Opportunities/Leads for update operation
var (updateEntityIdField, updatedData) = cdsService.ParseDataRowFromJson(reqBody.UpdateReqBody.UpdatedData, updateEntityName);
// Set current UTC date in the RowCreationDate field
cdsService.SetRowCreationDate(updatedData);
// Check if both parsing were successful
if (newData is null || updatedData is null)
{
return BadRequest(Constant.InvalidReq);
}
// Insert the given data in CDS, baseId is null as it is not known at time of insert
await cdsService.UpdateAddData(reqBody.UpdateReqBody.BaseId, updatedData, updateEntityName, updateEntityIdField, newData, addEntityName, addEntityIdField);
}
return Ok();
}
catch (JsonException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.BadRequest, Constant.DataParsingFailed);
}
catch (CdsException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.BadRequest, ex.Message);
}
catch (MsalServiceException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode(ex.StatusCode, ex.Message);
}
catch (MsalClientException ex)
{
Logger.LogError(ex, ex.Message);
if (Int32.TryParse(ex.ErrorCode, out int errorCode))
{
return StatusCode(errorCode, ex.Message);
}
else
{
return StatusCode(403, ex.Message);
}
}
// Handling generic exception to prevent sending complete stack trace to client side
catch (Exception ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.InternalServerError);
}
}
}
}

Просмотреть файл

@ -0,0 +1,185 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Controllers
{
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
using Microsoft.Rest;
using Newtonsoft.Json.Linq;
using System;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using ContosoSalesDemo.Helpers;
using ContosoSalesDemo.Models;
using ContosoSalesDemo.Service;
[ApiController]
[Route("[controller]")]
[Authorize(Policy = Constant.GeneralUserPolicyName)]
public class PowerBiController : ControllerBase
{
private readonly ITokenAcquisition TokenAcquisition;
private static IConfiguration Configuration { get; set; }
private static IOptions<PowerBiConfig> PowerBiConfig { get; set; }
private IMemoryCache Cache { get; set; }
private readonly ILogger<PowerBiController> Logger;
public PowerBiController(IConfiguration configuration, ITokenAcquisition tokenAcquisition, IOptions<PowerBiConfig> powerBiConfig, IMemoryCache cache, ILogger<PowerBiController> logger)
{
Configuration = configuration;
TokenAcquisition = tokenAcquisition;
PowerBiConfig = powerBiConfig;
// Get service cache
Cache = cache;
Logger = logger;
}
[HttpPost("/api/powerbi/EmbedParams")]
public async Task<IActionResult> GetEmbedParams()
{
// Get username and role of user
var userInfo = JwtAuthHelper.GetUsernameAndRole(User.Identity as ClaimsIdentity);
var embedParamsCacheKey = $"{userInfo.username}:{userInfo.role}";
// Check cache for embed params
if (Cache.TryGetValue(embedParamsCacheKey, out JObject cachedEmbedParams))
{
// Parse token
var embedToken = (string) cachedEmbedParams.SelectToken("EmbedToken.Token");
// Parse token expiration string
var tokenExpiryString = (string) cachedEmbedParams.SelectToken("EmbedToken.Expiration");
// Parse to expiration DateTime and update tokenExpiry
if (DateTime.TryParse(tokenExpiryString, out var tokenExpiry))
{
// Return token from cache if it is still valid
if (
!string.IsNullOrWhiteSpace(embedToken) &&
tokenExpiry.Subtract(DateTime.UtcNow) > TimeSpan.FromMinutes(Constant.RenewBeforeMinutes)
)
{
return Ok(cachedEmbedParams.ToString());
}
}
}
// Not found in cache or token is close to expiry, generate new embed params
try
{
// Get AAD token. This request will check memory cache first
var aadToken = await TokenAcquisition.GetAccessTokenForAppAsync(new string[] { Constant.PowerBiScope });
// Generate Embed token
var embedService = new EmbedService(aadToken);
var embedParams = embedService.GenerateEmbedParams(new Guid(PowerBiConfig.Value.WorkspaceId), new Guid(PowerBiConfig.Value.ReportId), userInfo.username, userInfo.role);
// Create cache options
var cacheOptions = new MemoryCacheEntryOptions()
// Keep in cache for this time, reset time if accessed
.SetSlidingExpiration(TimeSpan.FromDays(Constant.ExpireInDays));
// Cache the certificate
Cache.Set(embedParamsCacheKey, embedParams, cacheOptions);
return Ok(embedParams.ToString());
}
catch (MsalServiceException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode(ex.StatusCode, ex.Message);
}
catch (MsalClientException ex)
{
Logger.LogError(ex, ex.Message);
if (Int32.TryParse(ex.ErrorCode, out int errorCode))
{
return StatusCode(errorCode, ex.Message);
}
else
{
return StatusCode(403, ex.Message);
}
}
catch (HttpOperationException ex)
{
Logger.LogError(ex, ex.Message);
JObject error = ErrorHelper.ExtractPowerBiErrorInfo(ex);
return StatusCode((int)ex.Response.StatusCode, error.ToString());
}
catch (Exception ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.InternalServerError);
}
}
[HttpPost("/api/powerbi/ExportReport")]
public async Task<ActionResult<ExportParams>> GetExportedReport([FromBody] ExportParams exportParams)
{
if (string.IsNullOrWhiteSpace(exportParams.PageName))
{
return BadRequest(Constant.MissingPageName);
}
else if (string.IsNullOrWhiteSpace(exportParams.FileFormat))
{
return BadRequest(Constant.MissingFileFormat);
}
try
{
// Get AAD token. This request will check memory cache first
var aadToken = await TokenAcquisition.GetAccessTokenForAppAsync(new string[] { Constant.PowerBiScope });
// Get username and role of user
var userInfo = JwtAuthHelper.GetUsernameAndRole(User.Identity as ClaimsIdentity);
// Generated exported file
var exportService = new ExportService(aadToken);
var exportedFile = await exportService.GetExportedFile(new Guid(PowerBiConfig.Value.WorkspaceId), new Guid(PowerBiConfig.Value.ReportId), exportParams.PageName, exportParams.FileFormat, exportParams.PageState, userInfo.username, userInfo.role);
return Ok(File(exportedFile.MemoryStream.ToArray(), exportedFile.MimeType, exportedFile.FileName));
}
catch (MsalServiceException ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode(ex.StatusCode, ex.Message);
}
catch (MsalClientException ex)
{
Logger.LogError(ex, ex.Message);
if (Int32.TryParse(ex.ErrorCode, out int errorCode))
{
return StatusCode(errorCode, ex.Message);
}
else
{
return StatusCode(403, ex.Message);
}
}
catch (HttpOperationException ex)
{
Logger.LogError(ex, ex.Message);
JObject error = ErrorHelper.ExtractPowerBiErrorInfo(ex);
return StatusCode((int)ex.Response.StatusCode, error.ToString());
}
catch (Exception ex)
{
Logger.LogError(ex, ex.Message);
return StatusCode((int)HttpStatusCode.InternalServerError);
}
}
}
}

Просмотреть файл

@ -0,0 +1,21 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Exceptions
{
using System;
public class CdsException : Exception
{
public CdsException()
{
}
public CdsException(string message)
: base(message)
{
}
}
}

Просмотреть файл

@ -0,0 +1,44 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Helpers
{
using Microsoft.Rest;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Linq;
public static class ErrorHelper
{
/// <summary>
/// Extracts error details from the exception
/// </summary>
/// <returns>Error details as JSON</returns>
public static JObject ExtractPowerBiErrorInfo(HttpOperationException ex)
{
IEnumerable<string> requestId;
IEnumerable<string> clusterUri;
JObject error = JObject.Parse(ex.Response.Content)["error"] as JObject;
// Extract Request Id from the response header
ex.Response.Headers.TryGetValue("RequestId", out requestId);
// Extract Cluster Uri from the response header
ex.Response.Headers.TryGetValue("home-cluster-uri", out clusterUri);
// Add extracted values to the error JSON
if (requestId != null)
{
error.Add("requestId", requestId.FirstOrDefault());
}
if (clusterUri != null)
{
error.Add("clusterUri", clusterUri.FirstOrDefault());
}
return error;
}
}
}

Просмотреть файл

@ -0,0 +1,34 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Helpers
{
using System;
using System.Linq;
using System.Security.Claims;
public static class JwtAuthHelper
{
/// <summary>
/// Get username and role from token claims
/// </summary>
/// <returns>Username and role as Tuple</returns>
public static (string username, string role) GetUsernameAndRole(ClaimsIdentity claimsIdentity)
{
var tokenClaims = claimsIdentity.Claims;
(string username, string role) userInfo;
userInfo.role = tokenClaims.Where(claim => claim.Type == ClaimTypes.Role).FirstOrDefault() ? .Value;
userInfo.username = tokenClaims.Where(claim => claim.Type == "username").FirstOrDefault() ? .Value;
// Anonymous users' role is Sales Person and they not have an username
if (userInfo.username is null && string.Equals(userInfo.role, Constant.SalesPersonRole, StringComparison.InvariantCulture))
{
userInfo.username = "anonymous";
}
return userInfo;
}
}
}

Просмотреть файл

@ -0,0 +1,107 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Helpers
{
using System.Text;
using System.Net.Http;
using System.Net.Http.Headers;
using ContosoSalesDemo.Exceptions;
using ContosoSalesDemo.Models;
public static class MultipartHelper
{
private readonly static string defaultReqHeaders = "Content-Type: application/json;type=entry";
/// <summary>
/// Creates a multipart/mixed content for the given CDS batch requests
/// Refer: https://docs.microsoft.com/en-us/powerapps/developer/common-data-service/webapi/execute-batch-operations-using-web-api
/// </summary>
public static MultipartContent GenerateAtomicRequestContent(string batchId, string changeSetId, CdsBatchRequest[] requests)
{
MultipartContent reqContent = new MultipartContent("mixed", batchId);
MultipartContent changesetContent = new MultipartContent("mixed", changeSetId);
var reqIndex = 0;
foreach(var req in requests)
{
// Building Http request as text inside body of the batch request
// Note: We are using batch operations API of CDS which only supports HTTP/1.1
// https://docs.microsoft.com/en-us/powerapps/developer/common-data-service/webapi/execute-batch-operations-using-web-api
var httpRequest = $"{req.httpMethod} {req.requestUri} HTTP/1.1";
var reqHeaders = defaultReqHeaders;
// Get inserted row data
if (req.preferResponse)
{
reqHeaders += "\nPrefer: return=representation";
}
// Building http request message for this changeset
// Note: Http request is created as text as all values are either constants or result of serializing a CdsEntity model
var requestMessage = $"{httpRequest}\n{reqHeaders}\n\n{req.reqBodyJson}\n";
// Build the http request in text format for changesetContent
// Purpose is to create HTTP API requests in form of text in the body of our [batch API request](https://docs.microsoft.com/en-us/powerapps/developer/common-data-service/webapi/execute-batch-operations-using-web-api#example)
// We can only add HTTP content object to MultipartContent of .NET and it does not accept an HTTP request object
// Currently, there is no way to convert an HTTP request object into the required string format for making a CDS batch API call
var requestMessageContent = new StringContent(requestMessage, Encoding.UTF8);
// Add other headers
requestMessageContent.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/http");
requestMessageContent.Headers.Add("Content-Transfer-Encoding", "binary");
requestMessageContent.Headers.Add("Content-ID", System.Convert.ToString(reqIndex + 1));
// Add this request to the changeset
changesetContent.Add(requestMessageContent);
reqIndex += 1;
}
reqContent.Add(changesetContent);
return reqContent;
}
/*
The objective is to get the JSON in the response body of the last API request in the batch response.
.NET has [MultipartReader](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.webutilities.multipartreader),
but the response format of [CDS batch request](https://docs.microsoft.com/en-us/powerapps/developer/common-data-service/webapi/execute-batch-operations-using-web-api#batch-requests) is as follows:
--changeset_boundary
Response of 1st API
Response of 2nd API
Response of nth API
--changeset_boundary--
Expected multipart response is like:
--changeset_boundary
Response of exactly one request
--changeset_boundary--
*/
/// <summary>
/// Extracts JSON string from given multipart response
/// </summary>
public static string GetJsonData(string multipartResponseBody)
{
// Get JSON data string
var startIndex = multipartResponseBody.IndexOf('{');
var endIndex = multipartResponseBody.LastIndexOf('}');
if (startIndex == -1 || endIndex == -1)
{
throw new CdsException("Multipart response does not contain json");
}
return multipartResponseBody.Substring(startIndex, endIndex - startIndex + 1);
}
}
}

Просмотреть файл

@ -0,0 +1,27 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Helpers
{
using System;
using System.Text;
public static class ParamHelper
{
/// <summary>
/// Decode Base64 encoded string
/// </summary>
/// <returns>Base64 decoded string</returns>
public static string DecodeBase64EncodedString(string base64String)
{
if (string.IsNullOrWhiteSpace(base64String))
{
return null;
}
return Encoding.UTF8.GetString(Convert.FromBase64String(base64String));
}
}
}

Просмотреть файл

@ -0,0 +1,23 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Models
{
public class CdsBatchRequest
{
public string httpMethod { get; set; }
public string requestUri { get; set; }
public string reqBodyJson { get; set; }
public bool preferResponse { get; set; }
public CdsBatchRequest(string httpMethod, string requestUri, string reqBodyJson, bool preferResponse = false)
{
this.httpMethod = httpMethod;
this.requestUri = requestUri;
this.reqBodyJson = reqBodyJson;
this.preferResponse = preferResponse;
}
}
}

Просмотреть файл

@ -0,0 +1,55 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Models
{
using Newtonsoft.Json;
public partial class UpdateDataRequest
{
// DataMember and DataContract can also be used as an alternative to JsonProperty for serialization & deserialization
[JsonProperty("baseId", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string BaseId { get; set; }
[JsonProperty("updatedData", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string UpdatedData { get; set; }
[JsonProperty("updateEntityType", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string UpdateEntityType { get; set; }
}
public partial class AddDataRequest
{
[JsonProperty("newData", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string NewData { get; set; }
[JsonProperty("addEntityType", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string AddEntityType { get; set; }
}
public partial class AddAndUpdateDataRequest
{
[JsonProperty("UpdateReqBody", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public UpdateDataRequest UpdateReqBody { get; set; }
[JsonProperty("AddReqBody", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public AddDataRequest AddReqBody { get; set; }
}
public partial class UpdateDataRequest
{
public static UpdateDataRequest FromJson(string jsonString) => JsonConvert.DeserializeObject<UpdateDataRequest>(jsonString, ContosoSalesDemo.Models.Converter.Settings);
}
public partial class AddDataRequest
{
public static AddDataRequest FromJson(string jsonString) => JsonConvert.DeserializeObject<AddDataRequest>(jsonString, ContosoSalesDemo.Models.Converter.Settings);
}
public partial class AddAndUpdateDataRequest
{
public static AddAndUpdateDataRequest FromJson(string jsonString) => JsonConvert.DeserializeObject<AddAndUpdateDataRequest>(jsonString, ContosoSalesDemo.Models.Converter.Settings);
}
}

Просмотреть файл

@ -0,0 +1,205 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Models
{
using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
public partial class CdsEntity
{
[JsonProperty("crcb2_baseid", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)] // The property is not required, Ignore null values when serializing and deserializing objects
public string Baseid { get; set; }
[JsonProperty("crcb2_islatest", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public string Islatest { get; set; }
}
public partial class CdsGetResponse
{
[JsonProperty("value", Required = Required.Always, NullValueHandling = NullValueHandling.Ignore)] // The property is not required, Ignore null values when serializing and deserializing objects
public JArray Values { get; set; }
}
public partial class Activities : CdsEntity
{
// Lookup field
[JsonProperty("crcb2_LeadId@odata.bind", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Leadid { get; set; }
// Lookup field
[JsonProperty("ownerid@odata.bind", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Owner { get; set; }
// Id field
[JsonProperty(Constant.EntityIdFieldActivities, Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Id { get; set; }
[JsonProperty("crcb2_activitytype", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public int? Activitytype { get; set; }
[JsonProperty("crcb2_subject", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public string Subject { get; set; }
[JsonProperty("crcb2_priority", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public int? Priority { get; set; }
[JsonProperty("crcb2_startdatetime", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Startdatetime { get; set; }
[JsonProperty("crcb2_enddatetime", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Enddatetime { get; set; }
[JsonProperty("crcb2_duedatetime", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Duedate { get; set; }
[JsonProperty("crcb2_description", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public string Description { get; set; }
[JsonProperty("crcb2_topic", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public string Topic { get; set; }
[JsonProperty(Constant.RowCreationDateFieldName, Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string RowCreationDate { get; set; }
}
public partial class Opportunities : CdsEntity
{
// Lookup field
[JsonProperty("originatingleadid@odata.bind", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string OriginatingLead { get; set; }
// Lookup field
[JsonProperty("ownerid@odata.bind", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string OwningUser { get; set; }
// Id field
[JsonProperty(Constant.EntityIdFieldOpportunities, Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Id { get; set; }
[JsonProperty("name", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Topic { get; set; }
[JsonProperty("estimatedvalue", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public double? EstimatedRevenue { get; set; }
[JsonProperty("estimatedclosedate", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Estimatedclosedate { get; set; }
[JsonProperty("crcb2_opportunitystatus", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public int? Status { get; set; }
[JsonProperty("crcb2_salesstage", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public int? Salesstage { get; set; }
[JsonProperty("crcb2_quoteamount", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public double? QuoteAmount { get; set; }
[JsonProperty("actualvalue", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public double? Actualvalue { get; set; }
[JsonProperty("actualclosedate", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Closuredate { get; set; }
[JsonProperty("crcb2_createdon", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string RowCreationDate { get; set; }
// Lookup field (temp)
[JsonProperty("parentaccountid@odata.bind", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string ParentAccountforlead { get; set; }
// Lookup field (temp)
[JsonProperty("parentcontactid@odata.bind", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string ParentContactforLead { get; set; }
}
public partial class Leads : CdsEntity
{
// parentaccountid alternative
[JsonProperty("parentaccountname", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string ParentAccountName { get; set; }
// Lookup field
[JsonProperty("parentaccountid@odata.bind", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string ParentAccountforlead { get; set; }
// Lookup field
[JsonProperty("ownerid@odata.bind", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string SalesPersonId { get; set; }
// Id field
[JsonProperty(Constant.EntityIdFieldLeads, Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string Id { get; set; }
[JsonProperty("crcb2_primarycontactname", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public string ContactName { get; set; }
[JsonProperty("subject", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public string Topic { get; set; }
[JsonProperty("leadqualitycode", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public int? Rating { get; set; }
[JsonProperty("leadsourcecode", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public int? Source { get; set; }
[JsonProperty("crcb2_leadstatus", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public int? LeadStatus { get; set; }
[JsonProperty("crcb2_createdon", Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)]
public string RowCreationDate { get; set; }
}
public partial class Accounts
{
[JsonProperty(Constant.EntityIdFieldAccounts, Required = Required.Always, NullValueHandling = NullValueHandling.Ignore)]
public string Id { get; set; }
[JsonProperty(Constant.RowCreationDateFieldName, Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)]
public string RowCreationDate { get; set; }
}
public partial class CdsEntity
{
public static CdsEntity FromJson(string json) => JsonConvert.DeserializeObject<CdsEntity>(json, ContosoSalesDemo.Models.Converter.Settings);
}
public partial class Activities
{
public new static Activities FromJson(string json) => JsonConvert.DeserializeObject<Activities>(json, ContosoSalesDemo.Models.Converter.Settings);
}
public partial class Opportunities
{
public new static Opportunities FromJson(string json) => JsonConvert.DeserializeObject<Opportunities>(json, ContosoSalesDemo.Models.Converter.Settings);
}
public partial class Leads
{
public new static Leads FromJson(string json) => JsonConvert.DeserializeObject<Leads>(json, ContosoSalesDemo.Models.Converter.Settings);
}
public partial class CdsGetResponse
{
public static CdsGetResponse FromJson(string json) => JsonConvert.DeserializeObject<CdsGetResponse>(json, ContosoSalesDemo.Models.Converter.Settings);
}
public partial class Accounts
{
public static Accounts FromJson(string json) => JsonConvert.DeserializeObject<Accounts>(json, ContosoSalesDemo.Models.Converter.Settings);
}
internal static class Converter
{
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters =
{
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
},
};
}
}

Просмотреть файл

@ -0,0 +1,19 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Models
{
public class ExportParams
{
// Name of report page to be exported
public string PageName { get; set; }
// Format of export file
public string FileFormat { get; set; }
// State of page to be exported
public string PageState { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,21 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Models
{
using System.IO;
public class ExportedFile
{
// Stores exported report page stream
public MemoryStream MemoryStream { get; set; }
// Stores name of file with extension
public string FileName { get; set; }
// Stores the MIME type of the exported file
public string MimeType { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,14 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo
{
public class JwtTokenConfig
{
public string Issuer { get; set; }
public string Audience { get; set; }
public string ExpiresInMinutes { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,16 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Models
{
public class PowerBiConfig
{
// Id of Power BI workspace in which the report is present
public string WorkspaceId { get; set; }
// Id of Power BI report to be embedded
public string ReportId { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,15 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Models
{
public class User
{
public string Username { get; set; }
public string Name { get; set; }
public string Role { get; set; }
public string Scope { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,16 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo
{
using ContosoSalesDemo.Models;
public class UserCollection
{
public User SalesManager { get; set; }
public User SalesPerson { get; set; }
public User Anonymous { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,56 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo
{
using Microsoft.AspNetCore.Hosting;
using Microsoft.Azure.KeyVault;
using Microsoft.Azure.Services.AppAuthentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.AzureKeyVault;
using Microsoft.Extensions.Hosting;
using System.Collections.Generic;
using System.Text;
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((context, config) =>
{
var builtConfig = config.Build();
var azureServiceTokenProvider = new AzureServiceTokenProvider();
var keyVaultClient = new KeyVaultClient(
new KeyVaultClient.AuthenticationCallback(
azureServiceTokenProvider.KeyVaultTokenCallback));
// Load Secrets and Certificates from Azure Key Vault
config.AddAzureKeyVault($"https://{builtConfig["KeyVault:KeyVaultName"]}.vault.azure.net/",
keyVaultClient,
new DefaultKeyVaultSecretManager());
// Get Key from Azure Key Vault
var key = keyVaultClient.GetKeyAsync($"https://{builtConfig["KeyVault:KeyVaultName"]}.vault.azure.net/keys/{builtConfig["KeyVault:KeyName"]}/{builtConfig["KeyVault:KeyVersion"]}").Result;
var signingKey = Encoding.UTF8.GetString(key.Key.N);
IConfigurationRoot keyConfig = new ConfigurationBuilder()
.AddInMemoryCollection(new [] { new KeyValuePair<string, string>(builtConfig["KeyVault:KeyName"], signingKey) })
.Build();
// Add Key to configuration
config.AddConfiguration(keyConfig);
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}

Просмотреть файл

@ -0,0 +1,27 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:48756",
"sslPort": 44358
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"ContosoSalesDemo": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

Просмотреть файл

@ -0,0 +1,529 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Service
{
using System;
using System.Text;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using ContosoSalesDemo.Models;
using ContosoSalesDemo.Helpers;
using ContosoSalesDemo.Exceptions;
public class CdsService : IDisposable
{
private bool disposedValue;
private HttpClient cdsClient;
public CdsService(string aadToken)
{
cdsClient = new HttpClient();
cdsClient.DefaultRequestHeaders.Accept.Clear();
cdsClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
cdsClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", aadToken);
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
cdsClient.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Parse json data of a CDS row into Activity/Opportunities/Leads instance
/// </summary>
/// <returns>
/// Tuple of Id field for the parsed entity, An instance of parsed Activity, Opportunities or Leads class
/// </returns>
public (string, dynamic) ParseDataRowFromJson(string dataRowJson, string entityType)
{
// Set member variables and parse dataRowJson
switch (entityType)
{
case Constant.EntityNameActivities:
return (Constant.EntityIdFieldActivities, Activities.FromJson(dataRowJson));
case Constant.EntityNameOpportunities:
return ( Constant.EntityIdFieldOpportunities, Opportunities.FromJson(dataRowJson));
case Constant.EntityNameLeads:
return (Constant.EntityIdFieldLeads, Leads.FromJson(dataRowJson));
default:
throw new CdsException(Constant.InvalidEntity);
}
}
/// <summary>
/// Update existing data row
/// 1. Get guid of row with given baseId and isLatestFieldName 1
/// 2. Set isLatestFieldName to 0 for this row
/// 3. Insert new row with updated data fields with given baseId and isLatestFieldName set to 1
/// </summary>
/// <returns>
/// Task
/// </returns>
public async Task UpdateData(string baseRowGuid, dynamic newDataRow, string idField, string entityName)
{
string[] requiredFields = { idField, Constant.baseIdFieldName, Constant.isLatestFieldName };
// Select parameter in CDS API accepts comma separated field names
var selectQueryFields = string.Join(",", requiredFields);
// Params for select query
var selectUrlParam = $"$select={selectQueryFields}";
// Params for select query
var filterUrlParam = $"$filter={Constant.baseIdFieldName} eq {baseRowGuid} and {Constant.isLatestFieldName} eq '{Constant.IsLatestTrue}'";
UriBuilder requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = entityName;
requestUri.Query = $"{selectUrlParam}&{filterUrlParam}";
var response = await cdsClient.GetAsync(requestUri.Uri);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidUpdateDataFields);
}
// All current records
var responseString = await response.Content.ReadAsStringAsync();
// CDS get response model
var responseJson = CdsGetResponse.FromJson(responseString);
var values = responseJson.Values;
// There should be exactly one row with {isLatestFieldNameName} = 1
if (values.Count != 1)
{
throw new CdsException(Constant.InvalidUpdateDataFields);
}
var firstValue = values[0];
var oldStringGuid = Convert.ToString(firstValue[idField], CultureInfo.InvariantCulture);
// 1. Mark existing record as old. Passing the record's GUID to be marked as old
// 2. Insert updated record as new
await BatchUpdate(oldStringGuid, baseRowGuid, newDataRow, entityName);
}
/// <summary>
/// Insert new row and set baseId and isLatest
/// </summary>
/// <returns>
/// Task
/// </returns>
public async Task AddNewRow(dynamic newData, string entityName, string idField)
{
// Get account id for insert Lead
if (entityName == Constant.EntityNameLeads)
{
// Get id for the given account name, or create new account and get its id
string accountId = await GetOrGenerateId(Constant.EntityNameAccounts, "name", newData.ParentAccountName, "parentaccountid", Constant.EntityIdFieldAccounts);
if (accountId is null)
{
throw new CdsException(Constant.InvalidInsertDataFields);
}
// remove name and add id
newData.ParentAccountName = null;
newData.ParentAccountforlead = $"{Constant.EntityNameAccounts}({accountId})";
}
newData.Baseid = null;
newData.Islatest = Constant.IsLatestTrue; // Mark latest row as "1"
var jsonEntity = JsonConvert.SerializeObject(newData);
var content = new StringContent(jsonEntity, Encoding.UTF8, "application/json");
// Add "Prefer" header to return the inserted row
cdsClient.DefaultRequestHeaders.Add("Prefer", "return=representation");
UriBuilder requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = entityName;
var response = await cdsClient.PostAsync(requestUri.Uri, content);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidInsertDataFields);
}
// Parse row guid from insert response
var responseString = await response.Content.ReadAsStringAsync();
// Create JSON for update baseid operation i.e. set baseId = rowGuid of inserted row
var entity = new CdsEntity();
// Set baseid with id field's value of inserted row
switch (entityName)
{
case Constant.EntityNameActivities:
entity.Baseid = Activities.FromJson(responseString).Id;
break;
case Constant.EntityNameOpportunities:
entity.Baseid = Opportunities.FromJson(responseString).Id;
break;
case Constant.EntityNameLeads:
entity.Baseid = Leads.FromJson(responseString).Id;
break;
case Constant.EntityNameAccounts:
entity.Baseid = Accounts.FromJson(responseString).Id;
break;
default:
throw new CdsException(Constant.InvalidEntity);
}
jsonEntity = JsonConvert.SerializeObject(entity);
content = new StringContent(jsonEntity, Encoding.UTF8, "application/json");
// Update operation
requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = $"{entityName}({entity.Baseid})";
response = await cdsClient.PatchAsync(requestUri.Uri, content);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidInsertDataFields);
}
}
/// <summary>
/// Combines multiple requests into a batch to make these requests atomic, i.e. all req rollback if one fails
/// Refer: https://docs.microsoft.com/en-us/powerapps/developer/common-data-service/webapi/execute-batch-operations-using-web-api
/// </summary>
/// <returns>
/// Task
/// </returns>
public async Task BatchUpdate(string rowGuid, string baseId, dynamic newData, string entityName)
{
var batchId = $"batch_batchid";
var changesetId = $"changeset_changesetid";
var cdsRequests = new CdsBatchRequest[2];
// Custom Update
// 1. Mark old
var updateEntity = new CdsEntity();
updateEntity.Islatest = Constant.IsLatestFalse;
var updateEntityJson = JsonConvert.SerializeObject(updateEntity);
cdsRequests[0] = new CdsBatchRequest("PATCH", $"https://{Constant.CdsApiBaseUrl}/{entityName}({rowGuid})", updateEntityJson);
// 2. Insert row
newData.Baseid = baseId;
newData.Islatest = Constant.IsLatestTrue; // Mark latest row as "1"
var newDataJson = JsonConvert.SerializeObject(newData);
cdsRequests[1] = new CdsBatchRequest("POST", $"https://{Constant.CdsApiBaseUrl}/{entityName}", newDataJson);
// Build MultipartRequest content
var reqContent = MultipartHelper.GenerateAtomicRequestContent(batchId, changesetId, cdsRequests);
cdsClient.DefaultRequestHeaders.Accept.Clear();
UriBuilder requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = "$batch";
// Execute Batch request
var response = await cdsClient.PostAsync(requestUri.Uri, reqContent);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidUpdateDataFields);
}
}
/// <summary>
/// Update existing data row
/// 1. Get guid of row with given baseId and isLatestFieldName 1
/// 2. Create batch request for the 3 operations
/// </summary>
/// <returns>
/// Task
/// </returns>
public async Task UpdateAddData(
string baseRowGuid,
dynamic updatedData,
string updateEntityName,
string updateEntityIdField,
dynamic newData,
string addEntityName,
string addEntityIdField)
{
// 1. Get guid of row with given baseId and isLatestFieldName 1
string[] requiredFields = { updateEntityIdField, Constant.baseIdFieldName, Constant.isLatestFieldName };
// Select parameter in CDS API accepts comma separated field names
var selectQueryFields = string.Join(",", requiredFields);
// Params for select query
var selectUrlParam = $"$select={selectQueryFields}";
// Params for select query
var filterUrlParam = $"$filter={Constant.baseIdFieldName} eq {baseRowGuid} and {Constant.isLatestFieldName} eq '{Constant.IsLatestTrue}'";
UriBuilder requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = updateEntityName;
requestUri.Query = $"{selectUrlParam}&{filterUrlParam}";
var response = await cdsClient.GetAsync(requestUri.Uri);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidUpdateDataFields);
}
// All current records
var responseString = await response.Content.ReadAsStringAsync();
// CDS get response model
var responseJson = CdsGetResponse.FromJson(responseString);
var values = responseJson.Values;
// There should be exactly one row with {isLatestFieldNameName} == 1
if (values.Count != 1)
{
throw new CdsException(Constant.InvalidUpdateDataFields);
}
var firstValue = values[0];
var oldStringGuid = Convert.ToString(firstValue[updateEntityIdField], CultureInfo.InvariantCulture);
// 2. Create batch request for the 3 operations
await BatchUpdateAdd(oldStringGuid, baseRowGuid, updatedData, updateEntityName, newData, addEntityIdField, addEntityName);
}
/// <summary>
/// Combines multiple requests into a batch to make these requests atomic, i.e. all req rollback if one fails
/// Refer: https://docs.microsoft.com/en-us/powerapps/developer/common-data-service/webapi/execute-batch-operations-using-web-api
///
/// 1a. Mark existing record as old. Passing the record's GUID to be marked as old
/// 1b. Insert updated record as new
/// 2. Insert new record
/// 3. Set baseId = inserted row's guid
/// </summary>
/// <returns>
/// Task
/// </returns>
public async Task BatchUpdateAdd(
string rowGuid,
string baseId,
dynamic updatedData,
string updateEntityName,
dynamic newData,
string newDataIdField,
string addEntityName)
{
var batchId = $"batch_batchid";
var changesetId = $"changeset_changesetid";
var cdsRequests = new CdsBatchRequest[3];
// 1a. Mark old (Custom Update)
var updateEntity = new CdsEntity();
updateEntity.Islatest = Constant.IsLatestFalse;
string jsonTestEntity = JsonConvert.SerializeObject(updateEntity);
cdsRequests[0] = new CdsBatchRequest("PATCH", $"https://{Constant.CdsApiBaseUrl}/{updateEntityName}({rowGuid})", jsonTestEntity);
// 1b. Insert updated row
updatedData.Baseid = baseId;
updatedData.Islatest = Constant.IsLatestTrue; // Mark latest row as "1"
var updatedDataJson = JsonConvert.SerializeObject(updatedData);
cdsRequests[1] = new CdsBatchRequest("POST", $"https://{Constant.CdsApiBaseUrl}/{updateEntityName}", updatedDataJson);
// 2. Insert new row
newData.Islatest = Constant.IsLatestTrue; // Mark latest row as "1"
var newDataJson = JsonConvert.SerializeObject(newData);
// NOTE: Set preferResponse = true for atmost one API request in a batch
var preferDataResponse = true;
cdsRequests[2] = new CdsBatchRequest("POST", $"https://{Constant.CdsApiBaseUrl}/{addEntityName}", newDataJson, preferDataResponse);
// Build MultipartRequest content
var reqContent = MultipartHelper.GenerateAtomicRequestContent(batchId, changesetId, cdsRequests);
cdsClient.DefaultRequestHeaders.Accept.Clear();
UriBuilder requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = "$batch";
// Execute Batch request
var response = await cdsClient.PostAsync(requestUri.Uri, reqContent);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidUpdateDataFields);
}
// Return when data response was not requested
if (!preferDataResponse)
{
return;
}
// 3. Set baseId = inserted row's guid
var resString = await response.Content.ReadAsStringAsync();
// Get JSON data from the batch response
var insertedRowJson = MultipartHelper.GetJsonData(resString);
var insertedRow = JObject.Parse(insertedRowJson);
var insertedRowGuid = insertedRow[newDataIdField].ToString();
// Create JSON for update operation i.e. set baseId = rowGuid of inserted row
var entity = new CdsEntity();
entity.Baseid = insertedRowGuid;
var jsonEntity = JsonConvert.SerializeObject(entity);
var content = new StringContent(jsonEntity, Encoding.UTF8, "application/json");
// Update operation
requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = $"{addEntityName}({insertedRowGuid})";
response = await cdsClient.PatchAsync(requestUri.Uri, content);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidUpdateDataFields);
}
}
/// <summary>
/// Gets values in the given table with given select and filter params
/// </summary>
/// <returns>
/// Returns list of values which match the given parameters
/// </returns>
public async Task<JArray> GetValues(string entityName, string[] selectFields, string filterUrlParam)
{
// Select parameter in CDS API accepts comma separated field names
var selectQueryFields = string.Join(",", selectFields);
// Params for select query
var selectUrlParam = $"$select={selectQueryFields}";
UriBuilder requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = entityName;
requestUri.Query = $"{selectUrlParam}&$filter={filterUrlParam}";
var response = await cdsClient.GetAsync(requestUri.Uri);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidInsertDataFields);
}
// Parse response
var responseString = await response.Content.ReadAsStringAsync();
// CDS get response model
var responseJson = CdsGetResponse.FromJson(responseString);
return responseJson.Values;
}
/// <summary>
/// Generates a new Id in the given table if does not exist already
/// Inserts a new row when given row does not exist and returns the Id of the newly inserted row
/// https://docs.microsoft.com/en-us/powerapps/developer/common-data-service/webapi/retrieve-entity-using-web-api#retrieve-using-an-alternate-key
/// </summary>
/// <returns>
/// Returns Id of given queryFieldValue parameter
/// </returns>
public async Task<string> GetOrGenerateId(string queryEntityName, string queryFieldName, string queryFieldValue, string selectIdFieldName, string getIdFieldName)
{
var values = await GetValues(queryEntityName, new string[] { selectIdFieldName }, $"{queryFieldName} eq '{queryFieldValue}'");
// Check if fieldValue already exists in table
if (values != null && values.Count > 0)
{
// Return the id of first match
return values[0][getIdFieldName].ToString();
}
// An existing row not found, insert new row
// New row's data
// Note: We are creating a JObject as queryFieldName is dynamic
var insertObject = new JObject();
insertObject[queryFieldName] = queryFieldValue;
// Add row creation date property
insertObject[Constant.RowCreationDateFieldName] = DateTime.UtcNow.ToString(Constant.CdsDateFormat);
var insertObjectJson = JsonConvert.SerializeObject(insertObject);
var reqContent = new StringContent(insertObjectJson, Encoding.UTF8, "application/json");
// Add "Prefer" header to return the inserted row
cdsClient.DefaultRequestHeaders.Add("Prefer", "return=representation");
UriBuilder requestUri = new UriBuilder("https", Constant.CdsApiBaseUrl);
requestUri.Path = queryEntityName;
var response = await cdsClient.PostAsync(requestUri.Uri, reqContent);
if (!response.IsSuccessStatusCode)
{
throw new CdsException(Constant.InvalidInsertDataFields);
}
// Parse response
var responseString = await response.Content.ReadAsStringAsync();
// Return id field's value of inserted row
switch (queryEntityName)
{
case Constant.EntityNameActivities:
return Activities.FromJson(responseString).Id;
case Constant.EntityNameOpportunities:
return Opportunities.FromJson(responseString).Id;
case Constant.EntityNameLeads:
return Leads.FromJson(responseString).Id;
case Constant.EntityNameAccounts:
return Accounts.FromJson(responseString).Id;
default:
throw new CdsException(Constant.InvalidEntity);
}
}
/// <summary>
/// Insert new row in after setting baseId and isLatest = "1"
/// </summary>
/// <returns>
/// void
/// </returns>
public void SetRowCreationDate(dynamic newData)
{
var currentDate = DateTime.UtcNow;
newData.RowCreationDate = currentDate.ToString(Constant.CdsDateFormat);
}
}
}

Просмотреть файл

@ -0,0 +1,78 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Service
{
using Microsoft.PowerBI.Api;
using Microsoft.PowerBI.Api.Models;
using Microsoft.Rest;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
public class EmbedService
{
private TokenCredentials tokenCredentials;
public EmbedService(string aadToken)
{
tokenCredentials = new TokenCredentials(aadToken, "Bearer");
}
/// <summary>
/// Generate Embed token and Embed URL
/// </summary>
/// <returns></returns>
public JObject GenerateEmbedParams(Guid workspaceId, Guid reportId, string username = null, string role = null)
{
using (var pbiClient = new PowerBIClient(new Uri(Constant.PowerBiApiUri), tokenCredentials))
{
// Get report info
var pbiReport = pbiClient.Reports.GetReportInGroup(workspaceId, reportId);
// Create list of datasets
var datasets = new GenerateTokenRequestV2Dataset[] { new GenerateTokenRequestV2Dataset(pbiReport.DatasetId) };
// Create list of reports
var reports = new GenerateTokenRequestV2Report[] { new GenerateTokenRequestV2Report(reportId) };
// Create list of workspaces
var workspaces = new GenerateTokenRequestV2TargetWorkspace[] { new GenerateTokenRequestV2TargetWorkspace(workspaceId) };
// Create effective identity for current user
List<EffectiveIdentity> identities = null;
if (!string.IsNullOrWhiteSpace(username) || !string.IsNullOrWhiteSpace(role))
{
identities = new List<EffectiveIdentity> { new EffectiveIdentity(username: username, roles: new List<string> { role }, datasets: new List<string> { pbiReport.DatasetId }) };
}
// Create a request for getting Embed token
var tokenRequest = new GenerateTokenRequestV2(datasets: datasets, reports: reports, targetWorkspaces: workspaces, identities: identities);
// Get Embed token
var embedToken = pbiClient.EmbedToken.GenerateToken(tokenRequest);
// Capture embed parameters
var embedParams = new JObject
{
{ "Id", pbiReport.Id.ToString() },
{ "EmbedUrl", pbiReport.EmbedUrl },
{ "Type", "report" },
{ "EmbedToken", new JObject {
{ "Token", embedToken.Token },
{ "TokenId", embedToken.TokenId },
{ "Expiration", embedToken.Expiration.ToString() }
}
},
{ "DefaultPage", null },
{ "MobileDefaultPage", null }
};
return embedParams;
}
}
}
}

Просмотреть файл

@ -0,0 +1,180 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo.Service
{
using Microsoft.PowerBI.Api;
using Microsoft.PowerBI.Api.Models;
using Microsoft.Rest;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using ContosoSalesDemo.Models;
public class ExportService
{
private TokenCredentials tokenCredentials;
public ExportService(string aadToken)
{
tokenCredentials = new TokenCredentials(aadToken, "Bearer");
}
/// <summary>
/// Get exported report file
/// </summary>
/// <returns>Wrapper object with exported file as a stream object and its extension</returns>
public async Task<ExportedFile> GetExportedFile(Guid workspaceId, Guid reportId, string pageName, string fileFormat, string pageState = null, string username = null, string role = null)
{
FileFormat exportFormat;
string mimeType;
switch (fileFormat.ToUpper())
{
case Constant.PDF:
exportFormat = FileFormat.PDF;
mimeType = Constant.MimeTypePdf;
break;
case Constant.PPT:
exportFormat = FileFormat.PPTX;
mimeType = Constant.MimeTypePptx;
break;
case Constant.PNG:
exportFormat = FileFormat.PNG;
mimeType = Constant.MimeTypePng;
break;
default: throw new Exception("Provide a valid export file type");
}
using (var pbiClient = new PowerBIClient(new Uri(Constant.PowerBiApiUri), tokenCredentials))
{
try
{
var exportId = await InitExportRequest(pbiClient, workspaceId, reportId, pageName, exportFormat, pageState, username, role);
var fileExport = await GetFileExport(pbiClient, workspaceId, reportId, exportId);
if (fileExport.Status != ExportState.Succeeded)
{
throw new Exception("Failed to export report");
}
// Get exported file as stream object
var memoryStream = new MemoryStream();
using (var exportStream = await pbiClient.Reports.GetFileOfExportToFileAsync(workspaceId, reportId, fileExport.Id))
{
await exportStream.CopyToAsync(memoryStream);
}
return new ExportedFile
{
MemoryStream = memoryStream,
FileName = $"{Constant.ExportFileName}{fileExport.ResourceFileExtension}",
MimeType = mimeType
};
}
catch (AggregateException ae)
{
ae.Handle((ex) =>
{
if (ex is HttpOperationException)
{
return ex is HttpOperationException;
}
return false;
});
return null;
}
}
}
/// <summary>
/// Initialize export request for report
/// </summary>
/// <returns>Id of Export request</returns>
private async Task<string> InitExportRequest(PowerBIClient pbiClient, Guid workspaceId, Guid reportId, string pageName, FileFormat fileFormat, string pageState = null, string username = null, string role = null)
{
PageBookmark pageBookmark = null;
if (!string.IsNullOrWhiteSpace(pageState))
{
// To export report page with current bookmark
pageBookmark = new PageBookmark(null, pageState);
}
// Get Power BI report object
var pbiReport = pbiClient.Reports.GetReportInGroup(workspaceId, reportId);
// Create effective identity for current user
List<EffectiveIdentity> identities = null;
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(role))
{
identities = new List<EffectiveIdentity> { new EffectiveIdentity(username: username, roles: new List<string> { role }, datasets: new List<string> { pbiReport.DatasetId }) };
}
var powerBIReportExportConfiguration = new PowerBIReportExportConfiguration
{
Settings = new ExportReportSettings
{
Locale = Constant.DefaultLocale
},
// Initialize list of pages along with their state to be exported
Pages = new List<ExportReportPage>() { new ExportReportPage(pageName, pageBookmark) },
Identities = identities
};
var exportRequest = new ExportReportRequest
{
Format = fileFormat,
PowerBIReportConfiguration = powerBIReportExportConfiguration,
};
// Initiate export process
var export = await pbiClient.Reports.ExportToFileInGroupAsync(workspaceId, reportId, exportRequest);
return export.Id;
}
/// <summary>
/// Get exported file status
/// </summary>
/// <returns>Export request status object</returns>
private async Task<Export> GetFileExport(PowerBIClient pbiClient, Guid workspaceId, Guid reportId, string exportId)
{
var startTime = DateTime.UtcNow;
Export exportStatus = null;
do
{
// Return if timeout occurs
if (DateTime.UtcNow.Subtract(startTime).TotalMinutes >= Constant.ExportTimeoutInMinutes)
{
return null;
}
var httpMessage = await pbiClient.Reports.GetExportToFileStatusInGroupWithHttpMessagesAsync(workspaceId, reportId, exportId);
exportStatus = httpMessage.Body;
if (exportStatus.Status == ExportState.Running || exportStatus.Status == ExportState.NotStarted)
{
// Extract wait time from response header
var retryAfter = httpMessage.Response.Headers.RetryAfter;
int retryAfterInSec = retryAfter.Delta.Value.Seconds;
// Wait before polling again
await Task.Delay(retryAfterInSec * 1000);
}
}
// While not in a terminal state, keep polling
while (exportStatus.Status != ExportState.Succeeded && exportStatus.Status != ExportState.Failed);
return exportStatus;
}
}
}

169
ContosoSalesDemo/Startup.cs Normal file
Просмотреть файл

@ -0,0 +1,169 @@
// ----------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// ----------------------------------------------------------------------------
namespace ContosoSalesDemo
{
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.TokenCacheProviders.InMemory;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using ContosoSalesDemo.Models;
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMemoryCache();
services.AddHttpClient();
services.AddAuthentication("OAuth")
.AddJwtBearer("OAuth", options =>
{
// Create signature for JWT token
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration[Configuration["KeyVault:KeyName"]]));
// Get validation parameters
var issuer = Configuration.GetSection("JwtToken:Issuer").Value;
var audience = Configuration.GetSection("JwtToken:Audience").Value;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateAudience = true,
ValidateIssuer = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = issuer,
ValidAudience = audience,
IssuerSigningKey = signingKey
};
})
.AddMicrosoftWebApiCallsWebApi(
// Populate confidential client properties
confidentialClientOptions =>
{
confidentialClientOptions.Instance = Configuration["AzureAd:Instance"];
confidentialClientOptions.ClientId = Configuration["AzureAd:ClientId"];
confidentialClientOptions.TenantId = Configuration["AzureAd:TenantId"];
},
// Load certificate in options for client assertion
microsoftIdentityOptions =>
{
microsoftIdentityOptions.ClientCertificates = new CertificateDescription[]
{
CertificateDescription.FromBase64Encoded(Configuration[Configuration["KeyVault:CertificateName"]])
};
}
)
.AddInMemoryTokenCaches();
// Get user roles
var salesManagerRole = Configuration.GetSection("Users:SalesManager:Role").Value;
var salesPersonRole = Configuration.GetSection("Users:SalesPerson:Role").Value;
// Check whether telemetry is On
bool.TryParse(Configuration["Telemetry"], out var isTelemetryOn);
if (isTelemetryOn)
{
// Get App Insights Instrumentation key from config
var appInsightsInstrumentationKey = Configuration[Constant.AppInsightsInstrumentationKey];
// Enable App Insights telemetry collection if Instrumentation key is available
if (!string.IsNullOrWhiteSpace(appInsightsInstrumentationKey))
{
services.AddApplicationInsightsTelemetry(appInsightsInstrumentationKey);
}
}
services.AddControllersWithViews(options => {
options.Filters.Add(new AuthorizeFilter(
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireRole(salesManagerRole, salesPersonRole)
.RequireClaim("scope")
.Build()
));
});
services.AddAuthorization(options => {
// GeneralUser policy
options.AddPolicy(Constant.GeneralUserPolicyName,
policy => policy.RequireClaim("scope"));
// FieldUser policy
options.AddPolicy(Constant.FieldUserPolicyName,
policy => policy.RequireClaim("scope", new [] {Configuration.GetSection("Users:SalesPerson:Scope").Value}));
});
// In production, the React files will be served from this directory
services.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/build";
});
// Load Power BI configuration
services.Configure<PowerBiConfig>(Configuration.GetSection("PowerBi"));
// Load authentication configuration
services.Configure<JwtTokenConfig>(Configuration.GetSection("JwtToken"));
// Load authentication configuration
services.Configure<UserCollection>(Configuration.GetSection("Users"));
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSpaStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}");
});
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseReactDevelopmentServer(npmScript: "start");
}
});
}
}
}

Просмотреть файл

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

Просмотреть файл

@ -0,0 +1,57 @@
{
"KeyVault": {
"KeyVaultName": "NAME_OF_KEY_VAULT",
"CertificateName": "NAME_OF_CERTIFICATE",
"KeyName": "KEY_NAME",
"KeyVersion": "KEY_VERSION"
},
"AzureAd": {
"Instance": "INSTANCE_URL",
"ClientId": "AAD_APP_ID",
"TenantId": "AAD_TENANT_ID"
},
"PowerBi": {
"WorkspaceId": "WORKSPACE_ID",
"ReportId": "REPORT_ID"
},
"JwtToken": {
"Issuer": "https://contososalesdemo.azurewebsites.net/service",
"Audience": "ContosoSalesDemo",
"ExpiresInMinutes": "60"
},
"Users": {
"SalesManager": {
"Username": "",
"Name": "Donna Paul",
"Role": "Sales Manager",
"Scope": "ReadWrite"
},
"SalesPerson": {
"Username": "",
"Name": "June Smith",
"Role": "Sales Person",
"Scope": "ReadWrite"
},
"Anonymous": {
"Name": "Anonymous",
"Role": "Sales Person",
"Scope": "Read"
}
},
"Telemetry": true,
"Logging": {
"ApplicationInsights": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": [ "https://contososalesdemo.azurewebsites.net" ]
}

19
LICENSE.txt Normal file
Просмотреть файл

@ -0,0 +1,19 @@
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше