Merge remote-tracking branch 'powerbi/master' into main
This commit is contained in:
Коммит
6646540c7f
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -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 |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 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'>×</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'>×</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'>×</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'>×</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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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" ]
|
||||
}
|
|
@ -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.
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче