react-realtime-incidentdashboard sample added

This commit is contained in:
Ejaz Hussain 2023-10-29 22:41:56 +00:00
parent 9fc30c5e7a
commit a3b8ca8727
69 changed files with 3279 additions and 0 deletions

View File

@ -0,0 +1,379 @@
require("@rushstack/eslint-config/patch/modern-module-resolution");
module.exports = {
extends: ["@microsoft/eslint-config-spfx/lib/profiles/react"],
parserOptions: { tsconfigRootDir: __dirname },
overrides: [
{
files: ["*.ts", "*.tsx"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
ecmaVersion: 2018,
sourceType: "module",
},
rules: {
semi: 2, //Added by //O3C
"react/jsx-no-target-blank": 0, //O3C
// Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin
"@rushstack/no-new-null": 1,
// Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin
"@rushstack/hoist-jest-mock": 1,
// Require regular expressions to be constructed from string constants rather than dynamically building strings at runtime. https://www.npmjs.com/package/@rushstack/eslint-plugin-security
"@rushstack/security/no-unsafe-regexp": 1,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"@typescript-eslint/adjacent-overload-signatures": 1,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
//
// CONFIGURATION: By default, these are banned: String, Boolean, Number, Object, Symbol
"@typescript-eslint/ban-types": [
1,
{
extendDefaults: false,
types: {
String: {
message: "Use 'string' instead",
fixWith: "string",
},
Boolean: {
message: "Use 'boolean' instead",
fixWith: "boolean",
},
Number: {
message: "Use 'number' instead",
fixWith: "number",
},
Object: {
message:
"Use 'object' instead, or else define a proper TypeScript type:",
},
Symbol: {
message: "Use 'symbol' instead",
fixWith: "symbol",
},
Function: {
message:
"The 'Function' type accepts any function-like value.\nIt provides no type safety when calling the function, which can be a common source of bugs.\nIt also accepts things like class declarations, which will throw at runtime as they will not be called with 'new'.\nIf you are expecting the function to accept certain arguments, you should explicitly define the function shape.",
},
},
},
],
// RATIONALE: Code is more readable when the type of every variable is immediately obvious.
// Even if the compiler may be able to infer a type, this inference will be unavailable
// to a person who is reviewing a GitHub diff. This rule makes writing code harder,
// but writing code is a much less important activity than reading it.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"@typescript-eslint/explicit-function-return-type": [
0, //O3C
{
allowExpressions: true,
allowTypedFunctionExpressions: true,
allowHigherOrderFunctions: false,
},
],
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
// Rationale to disable: although this is a recommended rule, it is up to dev to select coding style.
// Set to 1 (warning) or 2 (error) to enable.
"@typescript-eslint/explicit-member-accessibility": 0,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"@typescript-eslint/no-array-constructor": 1,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
//
// RATIONALE: The "any" keyword disables static type checking, the main benefit of using TypeScript.
// This rule should be suppressed only in very special cases such as JSON.stringify()
// where the type really can be anything. Even if the type is flexible, another type
// may be more appropriate such as "unknown", "{}", or "Record<k,V>".
"@typescript-eslint/no-explicit-any": 0, //O3C
// RATIONALE: The #1 rule of promises is that every promise chain must be terminated by a catch()
// handler. Thus wherever a Promise arises, the code must either append a catch handler,
// or else return the object to a caller (who assumes this responsibility). Unterminated
// promise chains are a serious issue. Besides causing errors to be silently ignored,
// they can also cause a NodeJS process to terminate unexpectedly.
"@typescript-eslint/no-floating-promises": 0, // FRESH
// RATIONALE: Catches a common coding mistake.
"@typescript-eslint/no-for-in-array": 2,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"@typescript-eslint/no-misused-new": 2,
// RATIONALE: The "namespace" keyword is not recommended for organizing code because JavaScript lacks
// a "using" statement to traverse namespaces. Nested namespaces prevent certain bundler
// optimizations. If you are declaring loose functions/variables, it's better to make them
// static members of a class, since classes support property getters and their private
// members are accessible by unit tests. Also, the exercise of choosing a meaningful
// class name tends to produce more discoverable APIs: for example, search+replacing
// the function "reverse()" is likely to return many false matches, whereas if we always
// write "Text.reverse()" is more unique. For large scale organization, it's recommended
// to decompose your code into separate NPM packages, which ensures that component
// dependencies are tracked more conscientiously.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"@typescript-eslint/no-namespace": [
1,
{
allowDeclarations: false,
allowDefinitionFiles: false,
},
],
// RATIONALE: Parameter properties provide a shorthand such as "constructor(public title: string)"
// that avoids the effort of declaring "title" as a field. This TypeScript feature makes
// code easier to write, but arguably sacrifices readability: In the notes for
// "@typescript-eslint/member-ordering" we pointed out that fields are central to
// a class's design, so we wouldn't want to bury them in a constructor signature
// just to save some typing.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
// Set to 1 (warning) or 2 (error) to enable the rule
"@typescript-eslint/no-parameter-properties": 0,
// RATIONALE: When left in shipping code, unused variables often indicate a mistake. Dead code
// may impact performance.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"@typescript-eslint/no-unused-vars": [
1,
{
vars: "all",
// Unused function arguments often indicate a mistake in JavaScript code. However in TypeScript code,
// the compiler catches most of those mistakes, and unused arguments are fairly common for type signatures
// that are overriding a base class method or implementing an interface.
args: "none",
},
],
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"@typescript-eslint/no-use-before-define": [
2,
{
functions: false,
classes: true,
variables: true,
enums: true,
typedefs: true,
},
],
// Disallows require statements except in import statements.
// In other words, the use of forms such as var foo = require("foo") are banned. Instead use ES6 style imports or import foo = require("foo") imports.
"@typescript-eslint/no-var-requires": "error",
// RATIONALE: The "module" keyword is deprecated except when describing legacy libraries.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"@typescript-eslint/prefer-namespace-keyword": 1,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
// Rationale to disable: it's up to developer to decide if he wants to add type annotations
// Set to 1 (warning) or 2 (error) to enable the rule
"@typescript-eslint/no-inferrable-types": 0,
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
// Rationale to disable: declaration of empty interfaces may be helpful for generic types scenarios
"@typescript-eslint/no-empty-interface": 0,
// RATIONALE: This rule warns if setters are defined without getters, which is probably a mistake.
"accessor-pairs": 1,
// RATIONALE: In TypeScript, if you write x["y"] instead of x.y, it disables type checking.
"dot-notation": [
1,
{
allowPattern: "^_",
},
],
// RATIONALE: Catches code that is likely to be incorrect
eqeqeq: 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"for-direction": 1,
// RATIONALE: Catches a common coding mistake.
"guard-for-in": 2,
// RATIONALE: If you have more than 2,000 lines in a single source file, it's probably time
// to split up your code.
"max-lines": ["warn", { max: 2000 }],
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-async-promise-executor": 0, //O3C
// RATIONALE: Deprecated language feature.
"no-caller": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-compare-neg-zero": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-cond-assign": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-constant-condition": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-control-regex": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-debugger": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-delete-var": 2,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-duplicate-case": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-empty": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-empty-character-class": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-empty-pattern": 1,
// RATIONALE: Eval is a security concern and a performance concern.
"no-eval": 1,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-ex-assign": 2,
// RATIONALE: System types are global and should not be tampered with in a scalable code base.
// If two different libraries (or two versions of the same library) both try to modify
// a type, only one of them can win. Polyfills are acceptable because they implement
// a standardized interoperable contract, but polyfills are generally coded in plain
// JavaScript.
"no-extend-native": 1,
// Disallow unnecessary labels
"no-extra-label": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-fallthrough": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-func-assign": 1,
// RATIONALE: Catches a common coding mistake.
"no-implied-eval": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-invalid-regexp": 2,
// RATIONALE: Catches a common coding mistake.
"no-label-var": 2,
// RATIONALE: Eliminates redundant code.
"no-lone-blocks": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-misleading-character-class": 2,
// RATIONALE: Catches a common coding mistake.
"no-multi-str": 2,
// RATIONALE: It's generally a bad practice to call "new Thing()" without assigning the result to
// a variable. Either it's part of an awkward expression like "(new Thing()).doSomething()",
// or else implies that the constructor is doing nontrivial computations, which is often
// a poor class design.
"no-new": 1,
// RATIONALE: Obsolete language feature that is deprecated.
"no-new-func": 2,
// RATIONALE: Obsolete language feature that is deprecated.
"no-new-object": 2,
// RATIONALE: Obsolete notation.
"no-new-wrappers": 1,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-octal": 2,
// RATIONALE: Catches code that is likely to be incorrect
"no-octal-escape": 2,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-regex-spaces": 2,
// RATIONALE: Catches a common coding mistake.
"no-return-assign": 2,
// RATIONALE: Security risk.
"no-script-url": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-self-assign": 2,
// RATIONALE: Catches a common coding mistake.
"no-self-compare": 2,
// RATIONALE: This avoids statements such as "while (a = next(), a && a.length);" that use
// commas to create compound expressions. In general code is more readable if each
// step is split onto a separate line. This also makes it easier to set breakpoints
// in the debugger.
"no-sequences": 1,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-shadow-restricted-names": 2,
// RATIONALE: Obsolete language feature that is deprecated.
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-sparse-arrays": 2,
// RATIONALE: Although in theory JavaScript allows any possible data type to be thrown as an exception,
// such flexibility adds pointless complexity, by requiring every catch block to test
// the type of the object that it receives. Whereas if catch blocks can always assume
// that their object implements the "Error" contract, then the code is simpler, and
// we generally get useful additional information like a call stack.
"no-throw-literal": 2,
// RATIONALE: Catches a common coding mistake.
"no-unmodified-loop-condition": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-unsafe-finally": 2,
// RATIONALE: Catches a common coding mistake.
"no-unused-expressions": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-unused-labels": 1,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-useless-catch": 1,
// RATIONALE: Avoids a potential performance problem.
"no-useless-concat": 1,
// RATIONALE: The "var" keyword is deprecated because of its confusing "hoisting" behavior.
// Always use "let" or "const" instead.
//
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
"no-var": 2,
// RATIONALE: Generally not needed in modern code.
"no-void": 1,
// RATIONALE: Obsolete language feature that is deprecated.
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"no-with": 2,
// RATIONALE: Makes logic easier to understand, since constants always have a known value
// @typescript-eslint\eslint-plugin\dist\configs\eslint-recommended.js
"prefer-const": 1,
// RATIONALE: Catches a common coding mistake where "resolve" and "reject" are confused.
"promise/param-names": 2,
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"require-atomic-updates": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"require-yield": 1,
// "Use strict" is redundant when using the TypeScript compiler.
strict: [2, "never"],
// RATIONALE: Catches code that is likely to be incorrect
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
"use-isnan": 2,
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
// Set to 1 (warning) or 2 (error) to enable.
// Rationale to disable: !!{}
"no-extra-boolean-cast": 0,
// ====================================================================
// @microsoft/eslint-plugin-spfx
// ====================================================================
"@microsoft/spfx/import-requires-chunk-name": 1,
"@microsoft/spfx/no-require-ensure": 2,
"@microsoft/spfx/pair-react-dom-render-unmount": 0, //O3C
},
},
{
// For unit tests, we can be a little bit less strict. The settings below revise the
// defaults specified in the extended configurations, as well as above.
files: [
// Test files
"*.test.ts",
"*.test.tsx",
"*.spec.ts",
"*.spec.tsx",
// Facebook convention
"**/__mocks__/*.ts",
"**/__mocks__/*.tsx",
"**/__tests__/*.ts",
"**/__tests__/*.tsx",
// Microsoft convention
"**/test/*.ts",
"**/test/*.tsx",
],
rules: {
"no-new": 0,
"class-name": 0,
"export-name": 0,
forin: 0,
"label-position": 0,
"member-access": 2,
"no-arg": 0,
"no-console": 0,
"no-construct": 0,
"no-duplicate-variable": 2,
"no-eval": 0,
"no-function-expression": 2,
"no-internal-module": 2,
"no-shadowed-variable": 2,
"no-switch-case-fall-through": 2,
"no-unnecessary-semicolons": 2,
"no-unused-expression": 2,
"no-with-statement": 2,
semicolon: 2,
"trailing-comma": 0,
typedef: 0,
"typedef-whitespace": 0,
"use-named-parameter": 2,
"variable-name": 0,
whitespace: 0,
},
},
],
};

View File

@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
release
solution
temp
*.sppkg
.heft
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts
*.scss.d.ts

View File

@ -0,0 +1,16 @@
!dist
config
gulpfile.js
release
src
temp
tsconfig.json
tslint.json
*.log
.yo-rc.json
.vscode

View File

@ -0,0 +1,21 @@
{
"@microsoft/generator-sharepoint": {
"plusBeta": false,
"isCreatingSolution": true,
"nodeVersion": "16.18.1",
"sdksVersions": {
"@microsoft/microsoft-graph-client": "3.0.2",
"@microsoft/teams-js": "2.12.0"
},
"version": "1.18.0",
"libraryName": "incident-dashbaord",
"libraryId": "2d3d9ea6-eef2-419e-8eeb-682c38aadd41",
"environment": "spo",
"packageManager": "npm",
"solutionName": "incident-dashbaord",
"solutionShortDescription": "incident-dashbaord description",
"skipFeatureDeployment": true,
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,178 @@
# List notifications + Micrsoft Teams Integrations
## Summary
1. This sample web part illustrating using the SharePoint Framework List subscription capability, which allows you to get notified of changes to custom list [Tickets] refresh the displayed data.
2. Azure Function is being used as notification URL for list webhook subscription. If any critical ticket is added or updated then a notification will be sent to the Microsoft Teams
## Solution Architecture
![Solution Architecture](./assets/list-notification-architecture.jpg "Solution Architecture")
## Demo
![Application demo](./assets/list-notification-dashboard.jpg "Application demo")
![Application demo](./assets/list-notification-teams.jpg "Application demo")
## Compatibility
| :warning: Important |
| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Every SPFx version is only compatible with specific version(s) of Node.js. In order to be able to build this sample, please ensure that the version of Node on your workstation matches one of the versions listed in this section. This sample will not work on a different version of Node. |
| Refer to <https://aka.ms/spfx-matrix> for more information on SPFx compatibility. |
![SPFx 1.17.4](https://img.shields.io/badge/SPFx-1.18.0-green.svg)
![Node.js v16.18.1](https://img.shields.io/badge/Node.js-v16.18.1-green.svg)
![Compatible with SharePoint Online](https://img.shields.io/badge/SharePoint%20Online-Compatible-green.svg)
![Does not work with SharePoint 2019](https://img.shields.io/badge/SharePoint%20Server%202019-Incompatible-red.svg "SharePoint Server 2019 requires SPFx 1.4.1 or lower")
![Does not work with SharePoint 2016 (Feature Pack 2)](<https://img.shields.io/badge/SharePoint%20Server%202016%20(Feature%20Pack%202)-Incompatible-red.svg> "SharePoint Server 2016 Feature Pack 2 requires SPFx 1.1")
![Teams Incompatible](https://img.shields.io/badge/Teams-Incompatible-lightgrey.svg)
![Local Workbench Incompatible](https://img.shields.io/badge/Local%20Workbench-Incompatible-red.svg "This solution requires access to a user's user and group ids")
![Hosted Workbench Compatible](https://img.shields.io/badge/Hosted%20Workbench-Compatible-green.svg)
![Compatible with Remote Containers](https://img.shields.io/badge/Remote%20Containers-Compatible-green.svg)
## Applies to
- [SharePoint Framework](https://aka.ms/spfx)
- [Microsoft 365 tenant](https://learn.microsoft.com/sharepoint/dev/spfx/set-up-your-developer-tenant)
> Get your own free development tenant by subscribing to [Microsoft 365 developer program](http://aka.ms/m365devprogram)
## Prerequisites
To implement this scenario, you will need to configure the following components:
### 1. Create Ticket list
- Run the following PnP PowerShell command to add new list call Tickets. You can find the **pnp-list-template.xml** under assets folder
```PowerShell
Connect-PnPOnline -Url {SiteURL} -Interactive
Invoke-PnPSiteTemplate -Path $listTemplatePath
```
### 2. Register Azure AD app for SharePoint Online Authentication using Certificate approach
- Run the following command to Create an Azure AD app and upload the certificate to Azure AD app as well as to your local machine
```PowerShell
$app = Register-PnPAzureADApp -ApplicationName "SP.List.Webhook" -Tenant contoso.onmicrosoft.com -OutPath c:\temp -CertificatePassword (ConvertTo-SecureString -String "password" -AsPlainText -Force) -Scopes "Sites.FullControl.All" -Store CurrentUser -Interactive
```
- Keep note of the EncodedBased64String value of the certificate
### 3. Create Azure Storage Account
- Follow the below link to Create Azure Storage Account.
Create a Azure storge account [Click here for more detail](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-create?tabs=azure-portal).
### 4. Create Microsoft Teams incoming webhook
1. Open the Teams channel where you want to add the webhook.
2. Click the ••• menu in the top-right corner and select Connectors.
3. Search for Incoming Webhook and select Add.
4. Click Configure and enter a name and description for your webhook.
5. Click Add to Team.
6. Copy the webhook URL. Keep it save the URL. we need this in later stages
### 5. Deploy the Azure function via Visual Studio 2022
To deploy an Azure Function from Visual Studio 2022, follow these steps:
1. Open the Azure Function project (**from backend folder**) in Visual Studio 2022.
2. Select Publish from the Build menu.
3. Select Azure Functions as the publish target.
4. Click Publish.
> Visual Studio will package the function code and deploy it to Azure. Once the deployment is complete, you will be able to access the function from the Azure portal.
### 6. Add following Azure Function Configuration Settings
```JSON
[
{
"name": "AzureWebJobsStorage",
"value": "Azure storage connection string",
},
{
"name": "CertificateBase64Encoded",
"value": "Certificate base64 encoded value",
},
{
"name": "ClientId",
"value": "xxxxxx-xxxxx-4b47-b143-db04e3b5586f",
},
{
"name": "TenantBaseUrl",
"value": "https://tenant.sharepoint.com",
},
{
"name": "TenantId",
"value": "xxxx-xxxxx-xxx-8304-0f0f2f840b5d",
},
{
"name": "WebhookUrl",
"value": "Micrsoft Teams webhook URL"
}
]
```
### 7. Adds a new Webhook subscription
Run the following command to add the list webhook subscription
```PowerShell
$AzureFunctionUrl = "https://spwebhook.azurewebsites.net/api/SPWebhookReceiver?clientId=blobs_extension"
Add-PnPWebhookSubscription -List "Tickets" -NotificationUrl $AzureFunctionUrl
```
## Contributors
- [Ejaz Hussain](https://github.com/ejazhussain)
## Version history
| Version | Date | Comments |
| ------- | ---------------- | --------------- |
| 1.0.0 | October 29, 2023 | Initial release |
## Minimal Path to Awesome
1. Complete the above listed Prerequisites
2. Clone this repository
3. From your command line, change your current directory to the directory containing this sample (`react-realtime-incidentdashboard`, located under `samples`)
4. In the command line run:
```cmd
`npm install`
`gulp bundle`
`gulp package-solution`
```
5. Deploy the package to your app catalog
6. In the command-line run:
```cmd
gulp serve --nobrowser
```
7. Open the hosted workbench on a SharePoint site - i.e. https://_tenant_.sharepoint.com/site/_sitename_/_layouts/workbench.aspx
- Add the [O365C] Incident Dashboard web part to the page.
- In the web part properties, configure the following properties under Settings group
1. Add Site URL
2. Select a list. [only custom lists are allowed]
## Features
1. Increased productivity: List webhook can help to increase productivity by automating the process of sending notifications to Micrsoft Teams Channel when the bug is critical.
2. Reduced downtime: The web part can help to reduce downtime by ensuring that team members are aware of critical tickets as soon as they are added or updated.

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,264 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# Azure Functions localsettings file
local.settings.json
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
project.fragment.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# 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
*.[Pp]ublish.xml
*.azurePubxml
# TODO: 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
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignoreable 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
# 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
node_modules/
orleans.codegen.cs
# 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
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# 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/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc

View File

@ -0,0 +1,42 @@
{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"summary": "A critical incident reported",
"sections": [
{
"title": "${Title}",
"image": "https://o365clinicstorage.blob.core.windows.net/images/icon_task_yellow.png",
"facts": [
{
"name": "Title:",
"value": "${Title}"
},
{
"name": "Description:",
"value": "${Description}"
},
{
"name": "Priority:",
"value": "${Priority}"
},
{
"name": "Date reported:",
"value": "${DateReported}"
},
{
"name": "Reported by:",
"value": "${Issueloggedby}"
}
]
}
],
"potentialAction": [
{
"@context": "http://schema.org",
"@type": "ViewAction",
"name": "View Task Details",
"target": [ "${IncidentURL}" ]
}
]
}

View File

@ -0,0 +1,118 @@
{
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": null,
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"msteams": {
"width": "Full"
},
"body": [
{
"type": "ColumnSet",
"style": "default",
"columns": [
{
"type": "Column",
"items": [
{
"type": "TextBlock",
"size": "ExtraLarge",
"weight": "Bolder",
"text": "${Title}",
"wrap": true,
"fontType": "Default",
"color": "Attention",
"isSubtle": false
}
],
"width": "stretch",
"padding": "None"
}
],
"padding": "Default",
"spacing": "None",
"bleed": true
},
{
"type": "Container",
"id": "",
"padding": "Large",
"items": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"items": [
{
"type": "Image",
"style": "Default",
"url": "https://o365clinicstorage.blob.core.windows.net/images/Images/it-support-ticket.jpeg",
"size": "Stretch"
}
],
"width": "150px",
"padding": "None",
"separator": true,
"style": "default",
"bleed": true
},
{
"type": "Column",
"items": [
{
"type": "TextBlock",
"text": "${Description}",
"wrap": true,
"spacing": "Medium",
"style": "default",
"size": "Medium",
"weight": "Bolder"
},
{
"type": "TextBlock",
"spacing": "Small",
"isSubtle": true,
"text": "${Priority}",
"wrap": true,
"size": "Small"
},
{
"type": "TextBlock",
"text": "[More info](${ItemDisplayUrl})",
"wrap": true,
"color": "Accent",
"isSubtle": false,
"fontType": "Default",
"weight": "Bolder",
"spacing": "Small"
}
],
"width": "stretch",
"padding": "None"
}
],
"spacing": "Large",
"padding": "None",
"style": "default",
"bleed": false,
"separator": true
}
],
"spacing": "Medium",
"separator": true,
"style": "emphasis",
"bleed": true
}
]
}
}
]
}

View File

@ -0,0 +1,27 @@
using AdaptiveCards.Templating;
using Newtonsoft.Json;
using System;
namespace O365Clinic.Function.Webhooks.Helpers
{
public class AdaptiveCardHelper
{
public static string BindAdaptiveCardData<T>(string adaptiveCardJson, T data)
{
if (data == null)
throw new ArgumentNullException("data");
var adaptiveCardObject = JsonConvert.DeserializeObject(adaptiveCardJson);
// Create a Template instance from the template payload
AdaptiveCardTemplate template = new AdaptiveCardTemplate(adaptiveCardObject);
// "Expand" the template - this generates the final Adaptive Card payload
string adaptiveCardPayload = template.Expand(data);
return adaptiveCardPayload;
}
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.Azure.WebJobs;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Helpers
{
public class FunctionHelper
{
internal static string GetCurrentDirectory(ExecutionContext executionContext)
{
string currentDirectory = executionContext.FunctionDirectory;
var dInfo = new DirectoryInfo(currentDirectory);
return dInfo.Parent.FullName;
}
internal static string GetFilesDirectory(ExecutionContext executionContext)
{
string currentDirectory = GetCurrentDirectory(executionContext);
return currentDirectory + "\\Cards";
}
}
}

View File

@ -0,0 +1,55 @@
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Graph;
using Microsoft.Identity.Client;
using O365Clinic.Function.Webhooks.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Helpers
{
public class GraphAuthenticationManager
{
public static GraphServiceClient GetAuthenticatedGraphClient(AzureFunctionSettings config)
{
try
{
// The client credentials flow requires that you request the
// /.default scope, and pre-configure your permissions on the
// app registration in Azure. An administrator must grant consent
// to those permissions beforehand.
var scopes = new[] { "https://graph.microsoft.com/.default" };
// Values from app registration
var clientId = config.ClientId;
var tenantId = config.TenantId;
var clientSecret = config.ClientSecret;
// using Azure.Identity;
var options = new ClientSecretCredentialOptions
{
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
};
// https://learn.microsoft.com/dotnet/api/azure.identity.clientsecretcredential
var clientSecretCredential = new ClientSecretCredential(
tenantId, clientId, clientSecret, options);
var graphClient = new GraphServiceClient(clientSecretCredential, scopes);
return graphClient;
}
catch (Exception)
{
throw;
}
}
}
}

View File

@ -0,0 +1,76 @@
using Microsoft.Identity.Client;
using Microsoft.SharePoint.Client;
using System;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Helpers
{
public class SPAuthenticationManager: IDisposable
{
private bool disposedValue;
public ClientContext GetContext(string siteUrl, string clientId, string tenantId, string certificateThumbprint, string tenantBaseUrl)
{
string accessToken = EnsureAccessTokenAsync(clientId, tenantId, certificateThumbprint, tenantBaseUrl).GetAwaiter().GetResult();
ClientContext clientContext = new ClientContext(siteUrl);
clientContext.ExecutingWebRequest +=
delegate (object oSender, WebRequestEventArgs webRequestEventArgs)
{
webRequestEventArgs.WebRequestExecutor.RequestHeaders["Authorization"] =
"Bearer " + accessToken;
};
return clientContext;
}
public async Task<string> EnsureAccessTokenAsync(string clientId, string tenantId, string certificateThumbprint, string tenantBaseUrl)
{
X509Certificate2 certificate = LoadCertificate(certificateThumbprint);
var scopes = new string[] { $"{tenantBaseUrl}/.default" };
IConfidentialClientApplication clientApp = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithCertificate(certificate)
.WithTenantId(tenantId)
.Build();
AuthenticationResult authResult = await clientApp.AcquireTokenForClient(scopes).ExecuteAsync();
string accessToken = authResult.AccessToken;
return accessToken;
}
private X509Certificate2 LoadCertificate(string certificateThumbprint)
{
// Will only be populated correctly when running in the Azure Function host
string certBase64Encoded = Environment.GetEnvironmentVariable("CertificateBase64Encoded", EnvironmentVariableTarget.Process);
if (!string.IsNullOrEmpty(certBase64Encoded))
{
// Azure Function flow
return new X509Certificate2(Convert.FromBase64String(certBase64Encoded),
"",
X509KeyStorageFlags.Exportable |
X509KeyStorageFlags.MachineKeySet |
X509KeyStorageFlags.EphemeralKeySet);
}
else
{
// Local flow
var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
var certificateCollection = store.Certificates.Find(X509FindType.FindByThumbprint, certificateThumbprint, false);
store.Close();
return certificateCollection.First();
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Interfaces
{
public interface IAuthenticationService
{
Task<string> GetAccessTokenAsync(string[] scopes);
}
}

View File

@ -0,0 +1,18 @@
using Microsoft.Graph.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Interfaces
{
public interface IGraphService
{
Task<UserCollectionResponse> GetUsersAsync();
Task<ChatMessage> SendMessageToChannel();
}
}

View File

@ -0,0 +1,18 @@
using Microsoft.Graph.Models;
using O365Clinic.Function.Webhooks.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Interfaces
{
public interface INotificationService
{
Task SendNotificationToChannel(string webhookUrl, IncidentItem incidentItem, string itemDisplayUrl, string cardPath);
}
}

View File

@ -0,0 +1,19 @@

using O365Clinic.Function.Webhooks.Models;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks
{
public interface ISharePointService
{
/// <summary>
/// Get Incidents
/// </summary>
/// <param name="siteUrl"></param>
/// <returns></returns>
//Task<List<IncidentItem>> GetTickets(string siteUrl);
Task GetListRecentChanges(string siteUrl, string cardPath);
}
}

View File

@ -0,0 +1,33 @@
using Microsoft.SharePoint.Client;
using O365Clinic.Function.Webhooks.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Mapper
{
public static class IncidentMapper
{
public static IncidentItem MapIncidents(ListItem listItem)
{
List<IncidentItem> result = new List<IncidentItem>
{
new IncidentItem
{
Title = !string.IsNullOrEmpty(listItem["Title"].ToString()) ? listItem["Title"].ToString() : string.Empty,
Description = !string.IsNullOrEmpty(listItem["Description"].ToString()) ? listItem["Description"].ToString() : string.Empty,
Status = !string.IsNullOrEmpty(listItem["Status"].ToString()) ? listItem["Status"].ToString() : string.Empty,
Priority = !string.IsNullOrEmpty(listItem["Priority"].ToString()) ? listItem["Priority"].ToString() : string.Empty,
DateReported = !string.IsNullOrEmpty(listItem["DateReported"].ToString()) ? listItem["DateReported"].ToString() : string.Empty,
IssueSource = listItem["IssueSource"] != null ? (listItem["IssueSource"] as FieldUrlValue).Url.ToString() : string.Empty,
IssueLoggedBy = listItem["Issueloggedby"] != null ? (listItem["Issueloggedby"] as FieldUserValue).Email : string.Empty,
}
};
return result.FirstOrDefault();
}
}
}

View File

@ -0,0 +1,18 @@
using System.Security.Cryptography.X509Certificates;
namespace O365Clinic.Function.Webhooks.Models
{
public class AzureFunctionSettings
{
public string TenantId { get; set; }
public string TenantBaseUrl { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string CertificateThumbprint { get; set; }
public string CertificateName { get; set; }
public string SiteUrl { get; set; }
public string WebhookUrl { get; set; }
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.SharePoint.Client;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Models
{
public class IncidentItem
{
public string Title { get; set; }
public string Description { get; set; }
public string Priority { get; set; }
public string Status { get; set; }
public string AssignedTo { get; set; }
public string DateReported { get; set; }
public string IssueSource { get; set; }
public string IssueLoggedBy { get; set; }
public string Images { get; set; }
}
}

View File

@ -0,0 +1,34 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Model
{
public class NotificationModel
{
[JsonProperty(PropertyName = "subscriptionId")]
public string SubscriptionId { get; set; }
[JsonProperty(PropertyName = "clientState")]
public string ClientState { get; set; }
[JsonProperty(PropertyName = "expirationDateTime")]
public DateTime ExpirationDateTime { get; set; }
[JsonProperty(PropertyName = "resource")]
public string Resource { get; set; }
[JsonProperty(PropertyName = "tenantId")]
public string TenantId { get; set; }
[JsonProperty(PropertyName = "siteUrl")]
public string SiteUrl { get; set; }
[JsonProperty(PropertyName = "webId")]
public string WebId { get; set; }
}
}

View File

@ -0,0 +1,15 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Model
{
public class ResponseModel<T>
{
[JsonProperty(PropertyName = "value")]
public List<T> Value { get; set; }
}
}

View File

@ -0,0 +1,27 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Model
{
public class SubscriptionModel
{
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Id { get; set; }
[JsonProperty(PropertyName = "clientState", NullValueHandling = NullValueHandling.Ignore)]
public string ClientState { get; set; }
[JsonProperty(PropertyName = "expirationDateTime")]
public DateTime ExpirationDateTime { get; set; }
[JsonProperty(PropertyName = "notificationUrl")]
public string NotificationUrl { get; set; }
[JsonProperty(PropertyName = "resource", NullValueHandling = NullValueHandling.Ignore)]
public string Resource { get; set; }
}
}

View File

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AdaptiveCards.Templating" Version="1.5.0" />
<PackageReference Include="Azure.Storage.Queues" Version="12.16.0" />
<PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.Storage" Version="5.2.1" />
<PackageReference Include="Microsoft.Graph" Version="5.30.0" />
<PackageReference Include="Microsoft.NET.Sdk.Functions" Version="4.2.0" />
<PackageReference Include="Microsoft.SharePointOnline.CSOM" Version="16.1.24009.12000" />
</ItemGroup>
<ItemGroup>
<None Update="Cards\Incident.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.copy.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,49 @@
using System;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using O365Clinic.Function.Webhooks.Models;
using System.Threading.Tasks;
using Microsoft.SharePoint.Client;
using System.Collections.Generic;
using System.IO;
using O365Clinic.Function.Webhooks.Helpers;
namespace O365Clinic.Function.Webhooks
{
public class ProcessTicket
{
private readonly AzureFunctionSettings configSettings;
private readonly ISharePointService sharePointService;
public ProcessTicket(AzureFunctionSettings settings, ISharePointService sharePointService)
{
configSettings = settings;
this.sharePointService = sharePointService;
}
[FunctionName("ProcessTicket")]
public void Run([QueueTrigger("o365c-webhookqueue", Connection = "AzureWebJobsStorage")] string myQueueItem, ILogger log, ExecutionContext executionContext)
{
log.LogInformation($"C# Queue trigger function processed: {myQueueItem}");
var cardPath = Path.Combine(executionContext.FunctionAppDirectory, "Cards", "Incident.json");
log.LogInformation($"Cards directory: {cardPath}");
//string cardsDir = FunctionHelper.GetFilesDirectory(executionContext);
var data = (JObject)JsonConvert.DeserializeObject(myQueueItem);
var notificationResource = data["resource"].Value<string>();
var siteUrl = configSettings.TenantBaseUrl + data["siteUrl"].Value<string>();
log.LogInformation($"List siteUrl: {siteUrl}");
sharePointService.GetListRecentChanges(siteUrl, cardPath).GetAwaiter().GetResult();
log.LogInformation($"notificationResource: {notificationResource}");
}
}
}

View File

@ -0,0 +1,79 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Microsoft.WindowsAzure.Storage.Queue;
using O365Clinic.Function.Webhooks.Model;
using System.Text;
using Azure.Storage.Queues;
using O365Clinic.Function.Webhooks.Models;
using System.Collections.Generic;
using O365Clinic.Function.Webhooks.Services;
using O365Clinic.Function.Webhooks.Interfaces;
using Microsoft.AspNetCore.Mvc.Filters;
namespace O365Clinic.Function.Webhooks
{
public class SPWebhookReceiver
{
private readonly AzureFunctionSettings _configSettings;
public SPWebhookReceiver(
ILoggerFactory loggerFactory,
AzureFunctionSettings settings
)
{
_configSettings = settings;
}
[FunctionName("SPWebhookReceiver")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
[Queue("o365c-webhookqueue", Connection = "AzureWebJobsStorage")] QueueClient outputQueue, ILogger log, ExecutionContext execution)
{
log.LogInformation($"Webhook was triggered!");
// Grab the validationToken URL parameter
string validationToken = req.Query["validationtoken"];
// If a validation token is present, we need to respond within 5 seconds by
// returning the given validation token. This only happens when a new
// webhook is being added
if (validationToken != null)
{
log.LogInformation($"Validation token {validationToken} received");
return new OkObjectResult(validationToken);
}
log.LogInformation($"SharePoint triggered our webhook...great :-)");
var content = await new StreamReader(req.Body).ReadToEndAsync();
log.LogInformation($"Received following payload: {content}");
var notifications = JsonConvert.DeserializeObject<ResponseModel<NotificationModel>>(content).Value;
log.LogInformation($"Found {notifications.Count} notifications");
if (notifications.Count > 0)
{
log.LogInformation($"Processing notifications...");
foreach (var notification in notifications)
{
// add message to the queue
string message = JsonConvert.SerializeObject(notification);
log.LogInformation($"Before adding a message to the queue. Message content: {message}");
await outputQueue.SendMessageAsync(message);
log.LogInformation($"Message added :-)");
}
}
// if we get here we assume the request was well received
return new OkObjectResult($"Added to queue");
}
}
}

View File

@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging;
using Microsoft.Graph.Models;
using O365Clinic.Function.Webhooks.Helpers;
using O365Clinic.Function.Webhooks.Interfaces;
using O365Clinic.Function.Webhooks.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Services
{
public class GraphService: IGraphService
{
private readonly AzureFunctionSettings _configSettings;
private readonly ILogger logger;
public GraphService(
ILoggerFactory loggerFactory,
AzureFunctionSettings settings
)
{
logger = loggerFactory.CreateLogger<GraphService>();
_configSettings = settings;
}
public async Task<UserCollectionResponse> GetUsersAsync()
{
try
{
var graphClient = GraphAuthenticationManager.GetAuthenticatedGraphClient(_configSettings);
var result = await graphClient.Users.GetAsync();
return result;
}
catch (Exception)
{
return null;
}
}
public async Task<ChatMessage> SendMessageToChannel()
{
try
{
var graphClient = GraphAuthenticationManager.GetAuthenticatedGraphClient(_configSettings);
var requestBody = new ChatMessage
{
Body = new ItemBody
{
Content = "Hello World",
},
};
var result = await graphClient.Teams["cd76833a-7bf1-410f-ad41-6dac0b388540"].Channels["19:8d963euP_6rM_P_j8-s3RVcnU189W6Vv_qsPaUJcUIQ1@thread.tacv2"].Messages.PostAsync(requestBody);
return result;
}
catch (Exception ex)
{
return null;
}
}
}
}

View File

@ -0,0 +1,57 @@
using Microsoft.SharePoint.News.DataModel;
using O365Clinic.Function.Webhooks.Helpers;
using O365Clinic.Function.Webhooks.Interfaces;
using O365Clinic.Function.Webhooks.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http.Headers;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace O365Clinic.Function.Webhooks.Services
{
public class NotificationService : INotificationService
{
public async Task SendNotificationToChannel(string webhookUrl, IncidentItem incidentItem, string itemDisplayUrl, string cardPath)
{
string cardJson = GetConnectorCardJson(incidentItem, itemDisplayUrl, cardPath);
await PostCardAsync(webhookUrl, cardJson);
}
private async Task PostCardAsync(string webhookUrl, string cardJson)
{
//prepare the http POST
HttpClient client = new HttpClient();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var content = new StringContent(cardJson, System.Text.Encoding.UTF8, "application/json");
using (var response = await client.PostAsync(webhookUrl, content))
{
// Check response.IsSuccessStatusCode and take appropriate action if needed.
}
}
public string GetConnectorCardJson(IncidentItem incidentItem, string itemDisplayUrl, string cardPath)
{
var filePath = cardPath;
var adaptiveCardJson = File.ReadAllText(filePath);
var cardData = new
{
Title = incidentItem.Title,
Description = incidentItem.Description,
Priority = incidentItem.Priority,
DateReported = incidentItem.DateReported,
Issueloggedby = incidentItem.IssueLoggedBy,
ItemDisplayUrl = itemDisplayUrl,
};
var taskCard = AdaptiveCardHelper.BindAdaptiveCardData(adaptiveCardJson, cardData);
return taskCard;
}
}
}

View File

@ -0,0 +1,136 @@

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json.Linq;
using O365Clinic.Function.Webhooks.Interfaces;
using O365Clinic.Function.Webhooks.Models;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using System.Security.Policy;
using System.Linq;
using O365Clinic.Function.Webhooks.Helpers;
using Microsoft.SharePoint.Client;
using ChangeToken = Microsoft.SharePoint.Client.ChangeToken;
using O365Clinic.Function.Webhooks.Mapper;
namespace O365Clinic.Function.Webhooks.Services
{
public class SharePointService : ISharePointService
{
private readonly AzureFunctionSettings _configSettings;
private readonly INotificationService _notificationService;
private readonly ILogger logger;
public SharePointService(
ILoggerFactory loggerFactory,
AzureFunctionSettings settings,
INotificationService notificationService
)
{
logger = loggerFactory.CreateLogger<SharePointService>();
_configSettings = settings;
_notificationService = notificationService;
}
public async Task GetListRecentChanges(string siteUrl, string cardPath)
{
logger.LogInformation("Gettinng list recent changes...");
string result = string.Empty;
using (SPAuthenticationManager authenticationManager = new SPAuthenticationManager())
using (var clientContext = authenticationManager.GetContext(siteUrl, _configSettings.ClientId, _configSettings.TenantId, _configSettings.CertificateThumbprint, _configSettings.TenantBaseUrl))
{
logger.LogInformation("SP Context initialized");
try
{
Web web = clientContext.Web;
List list = web.Lists.GetByTitle("Tickets");
clientContext.Load(list);
await clientContext.ExecuteQueryAsync();
var listId = list.Id.ToString();
var duration = "5";
ChangeToken changeTokenStart = new ChangeToken();
changeTokenStart.StringValue = string.Format("1;3;{0};{1};-1", listId, DateTime.Now.AddMinutes(Convert.ToInt32(duration) * -1).ToUniversalTime().Ticks.ToString());
ChangeToken changeTokenEnd = new ChangeToken();
changeTokenEnd.StringValue = string.Format("1;3;{0};{1};-1", listId, DateTime.Now.ToUniversalTime().Ticks.ToString());
ChangeQuery changeTokenQuery = new ChangeQuery(false, false);
changeTokenQuery.Item = true;
changeTokenQuery.Add = true;
changeTokenQuery.Update = true;
changeTokenQuery.ChangeTokenStart = changeTokenStart;
changeTokenQuery.ChangeTokenEnd = changeTokenEnd;
var changes = list.GetChanges(changeTokenQuery);
clientContext.Load(changes);
await clientContext.ExecuteQueryAsync();
logger.LogInformation("Total Changes: " + changes.Count);
if (changes.Count > 0)
{
foreach (var change in changes)
{
if (change is ChangeItem)
{
var item = (ChangeItem)change;
var itemId = item.ItemId;
var listItem = list.GetItemById(itemId);
clientContext.Load(listItem);
clientContext.Load(listItem, li => li["Title"], li => li["Description"], li => li["Status"], li => li["Priority"], li => li["IssueSource"], li => li["Issueloggedby"]);
await clientContext.ExecuteQueryAsync();
if (listItem != null)
{
IncidentItem incidentItem = IncidentMapper.MapIncidents(listItem);
if (incidentItem.Priority.Equals("Critical", StringComparison.OrdinalIgnoreCase))
{
var itemDisplayUrl = _configSettings.TenantBaseUrl + listItem["FileDirRef"].ToString() + "/"+ "DispForm.aspx?ID=" + listItem["ID"];
logger.LogInformation("Critical ticket found");
logger.LogInformation("Posting notification to IT Support Channel in Teams");
await _notificationService.SendNotificationToChannel(_configSettings.WebhookUrl, incidentItem, itemDisplayUrl, cardPath);
logger.LogInformation("Notification sent successfully");
}
}
}
}
}
result = changes.Count.ToString();
}
catch (Exception ex)
{
throw;
}
}
}
public async Task<string> GetWebTitle(string siteUrl)
{
List<IncidentItem> incidentItems = new List<IncidentItem>();
string result = string.Empty;
using (SPAuthenticationManager authenticationManager = new SPAuthenticationManager())
using (var context = authenticationManager.GetContext(siteUrl, _configSettings.ClientId, _configSettings.TenantId, _configSettings.CertificateThumbprint, _configSettings.TenantBaseUrl))
{
context.Load(context.Web, p => p.Title);
await context.ExecuteQueryAsync();
result = context.Web.Title;
}
return result;
}
}
}

View File

@ -0,0 +1,92 @@
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using O365Clinic.Function.Webhooks.Interfaces;
using O365Clinic.Function.Webhooks.Models;
using O365Clinic.Function.Webhooks.Services;
using System;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
[assembly: FunctionsStartup(typeof(O365Clinic.Function.Webhooks.Startup))]
namespace O365Clinic.Function.Webhooks
{
public class Startup : FunctionsStartup
{
public override void Configure(IFunctionsHostBuilder builder)
{
var config = builder.GetContext().Configuration;
var azureFunctionSettings = new AzureFunctionSettings();
config.Bind(azureFunctionSettings);
// Add our configuration class
builder.Services.AddSingleton(options => { return azureFunctionSettings; });
builder.Services.AddSingleton<ISharePointService, SharePointService>();
builder.Services.AddSingleton<INotificationService, NotificationService>();
builder.Services.AddSingleton<IGraphService, GraphService>();
////Register the code page provider
//Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
//// Add and configure PnP Core SDK
//builder.Services.AddPnPCore(options =>
//{
// // Load the certificate to use
// X509Certificate2 cert = LoadCertificate(azureFunctionSettings);
// // Disable telemetry because of mixed versions on AppInsights dependencies
// options.DisableTelemetry = true;
// // Configure an authentication provider with certificate (Required for app only)
// var authProvider = new X509CertificateAuthenticationProvider(
// azureFunctionSettings.ClientId,
// azureFunctionSettings.TenantId,
// cert
// );
// // And set it as default
// options.DefaultAuthenticationProvider = authProvider;
// // Add a default configuration with the site configured in app settings
// options.Sites.Add("Default",
// new PnPCoreSiteOptions
// {
// SiteUrl = azureFunctionSettings.SiteUrl,
// AuthenticationProvider = authProvider
// });
//});
}
private static X509Certificate2 LoadCertificate(AzureFunctionSettings azureFunctionSettings)
{
// Will only be populated correctly when running in the Azure Function host
string certBase64Encoded = Environment.GetEnvironmentVariable("CertificateFromKeyVault", EnvironmentVariableTarget.Process);
if (!string.IsNullOrEmpty(certBase64Encoded))
{
// Azure Function flow
return new X509Certificate2(Convert.FromBase64String(certBase64Encoded),
"",
X509KeyStorageFlags.Exportable |
X509KeyStorageFlags.MachineKeySet |
X509KeyStorageFlags.EphemeralKeySet);
}
else
{
// Local flow
var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
var certificateCollection = store.Certificates.Find(X509FindType.FindByThumbprint, azureFunctionSettings.CertificateThumbprint, false);
store.Close();
return certificateCollection.First();
}
}
}
}

View File

@ -0,0 +1,12 @@
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
},
"enableLiveMetricsFilters": true
}
}
}

View File

@ -0,0 +1,16 @@
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "Azure Storage Account Key",
"FUNCTIONS_WORKER_RUNTIME": "dotnet",
"TenantBaseUrl": "https://TenantName.sharepoint.com",
"SiteUrl": "https://TenantName.sharepoint.com/sites/incident-mgmt/",
"TenantId": "zzzzzzz-9ebc-4eb1-xxxx-xxxxxxxx",
"ClientId": "xxxxxxx-1178-xxxxx-xxx-db04e3b5586f",
"CertificateThumbPrint": "zxxxxxx3A7DE372AC7FC5C5ED2xxx",
"WebhookUrl": "https://TenantName.webhook.office.com/webhookb2/xxxxx-7bf1-410f-ad41-xxxxxxxx@xxxxxxx-9ebc-4eb1-8304-xxxxxxx/IncomingWebhook/xxxxxxxxxxxxxxxxxxxxxxxxxxx/2a5de346-1d63-4c7a-897f-xxxxxxxxxxxxxxxx"
},
"Host": {
"CORS": "*"
}
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"dashboard-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/dashboard/DashboardWebPart.js",
"manifest": "./src/webparts/dashboard/DashboardWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"DashboardWebPartStrings": "lib/webparts/dashboard/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./release/assets/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "incident-dashbaord",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,40 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "O365C Incident Dashbaord",
"id": "2d3d9ea6-eef2-419e-8eeb-682c38aadd41",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"isDomainIsolated": false,
"developer": {
"name": "",
"websiteUrl": "",
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": "Undefined-1.18.0"
},
"metadata": {
"shortDescription": {
"default": "O365C Incident Dashbaord description"
},
"longDescription": {
"default": "O365C Incident Dashbaord description"
},
"screenshotPaths": [],
"videoUrl": "",
"categories": []
},
"features": [
{
"title": "O365C Incident DashbaordFeature",
"description": "The feature that activates elements of the incident-dashbaord solution.",
"id": "78ea4350-7b84-4c43-978b-4999534d87e7",
"version": "1.0.0.0"
}
]
},
"paths": {
"zippedPackage": "solution/o365c-incident-dashbaord.sppkg"
}
}

View File

@ -0,0 +1,3 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/sass.schema.json"
}

View File

@ -0,0 +1,6 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://{tenantDomain}/_layouts/workbench.aspx"
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/s-KaiNet/spfx-fast-serve/master/schema/config.latest.schema.json",
"cli": {
"isLibraryComponent": false
}
}

View File

@ -0,0 +1,24 @@
/*
* User webpack settings file. You can add your own settings here.
* Changes from this file will be merged into the base webpack configuration file.
* This file will not be overwritten by the subsequent spfx-fast-serve calls.
*/
// you can add your project related webpack configuration here, it will be merged using webpack-merge module
// i.e. plugins: [new webpack.Plugin()]
const webpackConfig = {
}
// for even more fine-grained control, you can apply custom webpack settings using below function
const transformConfig = function (initialWebpackConfig) {
// transform the initial webpack config here, i.e.
// initialWebpackConfig.plugins.push(new webpack.Plugin()); etc.
return initialWebpackConfig;
}
module.exports = {
webpackConfig,
transformConfig
}

View File

@ -0,0 +1,22 @@
'use strict';
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
var getTasks = build.rig.getTasks;
build.rig.getTasks = function () {
var result = getTasks.call(build.rig);
result.set('serve', result.get('serve-deprecated'));
return result;
};
/* fast-serve */
const { addFastServe } = require("spfx-fast-serve-helpers");
addFastServe(build);
/* end of fast-serve */
build.initialize(require('gulp'));

View File

@ -0,0 +1,53 @@
{
"name": "incident-dashbaord",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=16.13.0 <17.0.0 || >=18.17.1 <19.0.0"
},
"main": "lib/index.js",
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test",
"serve": "gulp bundle --custom-serve --max_old_space_size=4096 && fast-serve"
},
"dependencies": {
"@fluentui/react": "^8.106.4",
"@microsoft/sp-component-base": "1.18.0",
"@microsoft/sp-core-library": "1.18.0",
"@microsoft/sp-list-subscription": "1.18.0",
"@microsoft/sp-lodash-subset": "1.18.0",
"@microsoft/sp-office-ui-fabric-core": "1.18.0",
"@microsoft/sp-property-pane": "1.18.0",
"@microsoft/sp-webpart-base": "1.18.0",
"@pnp/logging": "^3.19.0",
"@pnp/sp": "^3.19.0",
"@pnp/spfx-controls-react": "3.15.0",
"@pnp/spfx-property-controls": "3.14.0",
"antd": "^5.10.2",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-prettier": "5.0.1",
"react": "17.0.1",
"react-dom": "17.0.1",
"styled-components": "^6.1.0",
"tslib": "2.3.1"
},
"devDependencies": {
"@microsoft/eslint-config-spfx": "1.18.0",
"@microsoft/eslint-plugin-spfx": "1.18.0",
"@microsoft/rush-stack-compiler-4.7": "0.1.0",
"@microsoft/sp-build-web": "1.18.0",
"@microsoft/sp-module-interfaces": "1.18.0",
"@rushstack/eslint-config": "2.5.1",
"@types/react": "17.0.45",
"@types/react-dom": "17.0.17",
"@types/webpack-env": "~1.15.2",
"ajv": "^6.12.5",
"eslint": "8.7.0",
"eslint-plugin-react-hooks": "4.3.0",
"gulp": "4.0.2",
"spfx-fast-serve-helpers": "~1.18.0",
"typescript": "4.7.4"
}
}

View File

@ -0,0 +1,36 @@
import { Logger, LogLevel } from "@pnp/logging";
export class LogHelper {
public static verbose(className: string, methodName: string, message: string):void {
message = this._formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Verbose);
}
public static info(className: string, methodName: string, message: string):void {
message = this._formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Info);
}
public static warning(className: string, methodName: string, message: string):void {
message = this._formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Warning);
}
public static error(className: string, methodName: string, message: string):void {
message = this._formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Error);
}
public static exception(className: string, methodName: string, error: Error):void {
error.message = this._formatMessage(className, methodName, error.message);
Logger.error(error);
}
private static _formatMessage(className: string, methodName: string, message: string): string {
const d:Date = new Date();
const dateStr:string = d.getDate() + '-' + (d.getMonth() + 1) + '-' + d.getFullYear() + ' ' +
d.getHours() + ':' + d.getMinutes() + ':' + d.getSeconds() + '.' + d.getMilliseconds();
return `${dateStr} ${className} > ${methodName} > ${message}`;
}
}

View File

@ -0,0 +1,46 @@
import { SPFI } from "@pnp/sp";
import { RenderListDataOptions } from "@pnp/sp/lists";
import { LogHelper } from "../helpers/LogHelper";
import "@pnp/sp/webs";
import "@pnp/sp/lists";
import "@pnp/sp/items";
import "@pnp/sp/items/get-all";
class SPService {
private static _sp: SPFI;
public static Init(sp: SPFI) {
this._sp = sp;
LogHelper.info("SPService", "constructor", "PnP SP context initialised");
}
public static getTicketsAsync = async (listId: string) => {
try {
const items: any = await this._sp.web.lists
.getById(listId)
.items.select("*", "ID", "Title")
.orderBy("Modified", false)
.getAll();
console.log("SPService -> getTicketsAsync", items);
return items;
} catch (err) {
LogHelper.error("SPService", "getTicketsAsync", err);
return null;
}
};
public static getListItemsStreamAsync = async (listName: string) => {
try {
const items: any = await this._sp.web.lists
.getByTitle(listName)
.renderListDataAsStream({
RenderOptions: RenderListDataOptions.ListData,
});
return items;
} catch (err) {
LogHelper.error("PnPSPService", "getListItemsStreamAsync", err);
return null;
}
};
}
export default SPService;

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,39 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "d04b2052-c243-49d8-ab15-d537e9f1ad1c",
"alias": "DashboardWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportedHosts": [
"SharePointWebPart",
"TeamsPersonalApp",
"TeamsTab",
"SharePointFullPage"
],
"supportsThemeVariants": true,
"preconfiguredEntries": [
{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
"group": {
"default": "Advanced"
},
"title": {
"default": "[O365C] Incident Dashboard"
},
"description": {
"default": "Incident Dashboard description"
},
"officeFabricIconFontName": "Page",
"properties": {
"title": "[O365C] Incident Dashboard",
"description": "This web part allows you to see latest information about the reported incidents"
}
}
]
}

View File

@ -0,0 +1,215 @@
import * as React from "react";
import * as ReactDom from "react-dom";
import { Version } from "@microsoft/sp-core-library";
import {
type IPropertyPaneConfiguration,
PropertyPaneTextField,
} from "@microsoft/sp-property-pane";
import { BaseClientSideWebPart } from "@microsoft/sp-webpart-base";
import { IReadonlyTheme } from "@microsoft/sp-component-base";
import * as strings from "DashboardWebPartStrings";
import {
PropertyFieldListPicker,
PropertyFieldListPickerOrderBy,
PropertyFieldTextWithCallout,
} from "@pnp/spfx-property-controls";
import { CalloutTriggers } from "@pnp/spfx-property-controls/lib/common/callout/Callout";
import { ListSubscriptionFactory } from "@microsoft/sp-list-subscription";
import { IAppContext } from "./models/IAppContext";
import { AppContext } from "./hooks/AppContext";
import { Dashboard } from "./components/Dashboard";
import { ConsoleListener, Logger } from "@pnp/logging";
import { SPFx, spfi } from "@pnp/sp";
import SPService from "../../common/services/SPService";
export interface IDashboardWebPartProps {
title: string;
description: string;
siteUrl: string;
libraryId: string;
}
const LOG_SOURCE: string = "DashboardWebPart";
export default class DashboardWebPart extends BaseClientSideWebPart<IDashboardWebPartProps> {
private _isDarkTheme: boolean = false;
private _environmentMessage: string = "";
public render(): void {
console.log(this._isDarkTheme);
console.log(this._environmentMessage);
// One main context that will hold all necessary context, properties for your webpart
const appContext: IAppContext = {
webpartContext: this.context,
properties: this.properties,
listSubscriptionFactory: new ListSubscriptionFactory(this),
};
const element: React.ReactElement = React.createElement(
AppContext.Provider,
{
value: {
appContext: appContext,
},
},
React.createElement(Dashboard)
);
ReactDom.render(element, this.domElement);
}
protected async onInit(): Promise<void> {
// return this._getEnvironmentMessage().then((message) => {
// this._environmentMessage = message;
// });
this._environmentMessage = await this._getEnvironmentMessage();
// subscribe a listener
Logger.subscribe(
ConsoleListener(LOG_SOURCE, { warning: "#e36c0b", error: "#a80000" })
);
//Init SharePoint Service
const sp = spfi().using(SPFx(this.context));
SPService.Init(sp);
return super.onInit();
}
private _getEnvironmentMessage(): Promise<string> {
if (!!this.context.sdks.microsoftTeams) {
// running in Teams, office.com or Outlook
return this.context.sdks.microsoftTeams.teamsJs.app
.getContext()
.then((context) => {
let environmentMessage: string = "";
switch (context.app.host.name) {
case "Office": // running in Office
environmentMessage = this.context.isServedFromLocalhost
? strings.AppLocalEnvironmentOffice
: strings.AppOfficeEnvironment;
break;
case "Outlook": // running in Outlook
environmentMessage = this.context.isServedFromLocalhost
? strings.AppLocalEnvironmentOutlook
: strings.AppOutlookEnvironment;
break;
case "Teams": // running in Teams
case "TeamsModern":
environmentMessage = this.context.isServedFromLocalhost
? strings.AppLocalEnvironmentTeams
: strings.AppTeamsTabEnvironment;
break;
default:
environmentMessage = strings.UnknownEnvironment;
}
return environmentMessage;
});
}
return Promise.resolve(
this.context.isServedFromLocalhost
? strings.AppLocalEnvironmentSharePoint
: strings.AppSharePointEnvironment
);
}
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
if (!currentTheme) {
return;
}
this._isDarkTheme = !!currentTheme.isInverted;
const { semanticColors } = currentTheme;
if (semanticColors) {
this.domElement.style.setProperty(
"--bodyText",
semanticColors.bodyText || null
);
this.domElement.style.setProperty("--link", semanticColors.link || null);
this.domElement.style.setProperty(
"--linkHovered",
semanticColors.linkHovered || null
);
}
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse("1.0");
}
protected onPropertyPaneFieldChanged(
propertyPath: string,
oldValue: any,
newValue: any
): void {
if (propertyPath === "libraryId" && newValue) {
// // push new list value
// super.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
// // refresh the item selector control by repainting the property pane
this.context.propertyPane.refresh();
// // re-render the web part as clearing the loading indicator removes the web part body
this.render();
} else {
//super.onPropertyPaneFieldChanged(propertyPath, oldValue, oldValue);
}
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription,
},
groups: [
{
groupName: "Header",
groupFields: [
PropertyPaneTextField("title", {
label: "Title",
}),
PropertyPaneTextField("description", {
label: strings.DescriptionFieldLabel,
}),
],
},
{
groupName: "Settings",
groupFields: [
PropertyFieldTextWithCallout("siteUrl", {
calloutTrigger: CalloutTriggers.Click,
key: "siteUrlFieldId",
label: "Site URL",
calloutContent: React.createElement(
"span",
{},
"URL of the site where the document library to show documents from is located. Leave empty to connect to a document library from the current site"
),
calloutWidth: 250,
value: this.properties.siteUrl,
}),
PropertyFieldListPicker("libraryId", {
label: "Select a document library",
selectedList: this.properties.libraryId,
includeHidden: false,
orderBy: PropertyFieldListPickerOrderBy.Title,
disabled: false,
onPropertyChange: this.onPropertyPaneFieldChanged.bind(this),
properties: this.properties,
context: this.context as any,
deferredValidationTime: 0,
key: "listPickerFieldId",
webAbsoluteUrl: this.properties.siteUrl,
baseTemplate: 100,
}),
],
},
],
},
],
};
}
}

View File

@ -0,0 +1,20 @@
declare module "*.svg" {
import * as React from "react";
export const ReactComponent: React.FunctionComponent<
React.SVGProps<SVGSVGElement> & { title?: string }
>;
const src: string;
export default src;
}
declare module "*.png" {
import * as React from "react";
export const ReactComponent: React.FunctionComponent<
React.SVGProps<SVGSVGElement> & { title?: string }
>;
const src: string;
export default src;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,66 @@
@import "~@fluentui/react/dist/sass/References.scss";
.root {
box-sizing: border-box;
-webkit-box-flex: 0;
flex-grow: 0;
overflow: hidden;
padding: 1em;
color: "[theme:bodyText, default: #323130]";
color: var(--bodyText);
&.teams {
font-family: $ms-font-family-fallbacks;
}
.container {
outline: 0px;
-webkit-tap-highlight-color: transparent;
display: block;
text-decoration: none;
color: rgb(0, 0, 0);
box-sizing: border-box;
border-radius: 0.5rem;
box-shadow:
rgba(0, 0, 0, 0.05) 0px 0.0625rem 0.1875rem,
rgba(0, 0, 0, 0.05) 0px 0.625rem 0.9375rem -0.3125rem,
rgba(0, 0, 0, 0.04) 0px 0.4375rem 0.4375rem -0.3125rem;
position: relative;
overflow: hidden;
background-color: rgb(255, 255, 255);
padding: 1rem;
}
:global {
.ant-avatar-image {
width: 60px;
height: 60px;
background: inherit;
}
}
}
.welcome {
text-align: center;
}
.welcomeImage {
width: 100%;
max-width: 420px;
}
.links {
a {
text-decoration: none;
color: "[theme:link, default:#03787c]";
color: var(
--link
); // note: CSS Custom Properties support is limited to modern browsers only
&:hover {
text-decoration: underline;
color: "[theme:linkHovered, default: #014446]";
color: var(
--linkHovered
); // note: CSS Custom Properties support is limited to modern browsers only
}
}
}

View File

@ -0,0 +1,33 @@
import * as React from "react";
import styles from "./Dashboard.module.scss";
import { AppContext } from "../hooks/AppContext";
import { WebPartTitle } from "./webPartTitle";
import SupportTickets from "./SupportTickets/SupportTickets";
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
export const Dashboard: React.FunctionComponent = () => {
const { appContext } = React.useContext(AppContext);
const needsConfiguration: boolean = !appContext.properties.libraryId;
return (
<section className={`${styles.root}`}>
<div className={styles.container}>
<WebPartTitle
title={appContext.properties.title}
description={appContext.properties.description}
/>
{needsConfiguration && (
<Placeholder
iconName="Edit"
iconText="Configure your web part"
description="Please configure the web part."
buttonLabel="Configure"
onConfigure={() => {
appContext.webpartContext.propertyPane.open();
}}
/>
)}
{!needsConfiguration && <div>{<SupportTickets />}</div>}
</div>
</section>
);
};

View File

@ -0,0 +1,37 @@
import { DisplayMode } from "@microsoft/sp-core-library";
import { ListSubscriptionFactory } from "@microsoft/sp-list-subscription";
export interface IDashboardProps {
/**
* Web part display mode
*/
displayMode: DisplayMode;
/**
* ID of the list to retrieve documents from. Undefined, if no library
* has been selected
*/
libraryId?: string;
/**
* Instance of the ListSubscriptionFactory to use to create a list
* subscription
*/
listSubscriptionFactory: ListSubscriptionFactory;
/**
* Event handler after clicking the 'Configure' button in the Placeholder
* component
*/
onConfigure: () => void;
/**
* URL of the site where the selected library is located. Undefined, if the
* selected library is in the current site
*/
siteUrl?: string;
/**
* Web part title to show in the title
*/
title: string;
/**
* Web part description to show in the header
*/
description: string;
}

View File

@ -0,0 +1,171 @@
import { Guid } from "@microsoft/sp-core-library";
import { IListSubscription } from "@microsoft/sp-list-subscription";
import * as React from "react";
import { useContext, useEffect, useState } from "react";
import { AppContext } from "../../hooks/AppContext";
import SPService from "../../../../common/services/SPService";
import { MessageBar, MessageBarType, Spinner } from "@fluentui/react";
//import TicketItem from "./TicketItem";
import { Avatar, List, Skeleton, Space, Tag, Typography } from "antd";
import defaultIcon from "../../assets/icon_service.png";
const { Text, Paragraph } = Typography;
interface ITicketResponse {
loading: boolean;
error: any;
value: any[];
}
const SupportTickets = (): JSX.Element => {
const { appContext } = useContext(AppContext);
const [tickets, setTickets] = useState<ITicketResponse>({
loading: true,
error: null,
value: [],
});
const loadTickets = async () => {
try {
const tickets = await SPService.getTicketsAsync(
appContext.properties.libraryId
);
setTickets({
...tickets,
loading: false,
value: tickets,
});
} catch (error) {
console.log("loadTickets -> error", error);
setTickets({
...tickets,
loading: false,
error: error,
});
}
};
// subscribe to list
useEffect(() => {
let listSub: IListSubscription;
console.log("Subscribing");
const subscribeForTicketList = async () => {
listSub = await appContext.listSubscriptionFactory.createSubscription({
listId: Guid.parse(appContext.properties.libraryId),
callbacks: {
notification: async () => {
console.log("Something changed in Ticket list - Reload");
await loadTickets();
},
},
});
};
subscribeForTicketList();
return () => {
console.log("Remove subscription");
appContext.listSubscriptionFactory.deleteSubscription(listSub);
};
}, []);
useEffect(() => {
loadTickets();
}, []);
if (tickets.loading)
return (
<>
<Spinner />
</>
);
if (tickets.error)
return (
<>
<MessageBar messageBarType={MessageBarType.error}>
Something went wrong
</MessageBar>
</>
);
return (
<div>
<List
loading={tickets.loading}
itemLayout="horizontal"
pagination={{ position: "bottom", align: "center", pageSize: 3 }}
dataSource={tickets.value}
header={
<div style={{ fontSize: "24px", fontWeight: 600 }}>
{"My requests"}
</div>
}
renderItem={(item) => (
<List.Item
actions={[
<Space size={0} key={item.Id}>
<Tag
color={
item.Status === "In Progress"
? "processing"
: item.Status === "Completed"
? "success"
: "default"
}
>
{item.Status}
</Tag>
</Space>,
]}
>
<Skeleton avatar title={false} loading={item.loading} active>
<List.Item.Meta
avatar={<Avatar src={defaultIcon} />}
title={
<Space
style={{ gap: 10 }}
wrap
direction="horizontal"
size={0}
key={item.Id}
>
<a href="#">{item.Title}</a>
<Tag
color={item.Priority === "Critical" ? "error" : "default"}
>
{item.Priority}
</Tag>
</Space>
}
description={
<Space
direction="vertical"
size="middle"
style={{ display: "flex" }}
>
<Paragraph>{item.Description}</Paragraph>
<Text italic>
{item?.DateReported
? convertDateToDaysAgo(item.DateReported)
: null}
</Text>
</Space>
}
/>
</Skeleton>
</List.Item>
)}
/>
</div>
);
function convertDateToDaysAgo(dateString: string) {
const date = new Date(dateString);
const today = new Date();
const differenceInMilliseconds = today.getTime() - date.getTime();
const daysAgo = Math.floor(
differenceInMilliseconds / (1000 * 60 * 60 * 24)
);
return `${daysAgo}d ago`;
}
};
export default SupportTickets;

View File

@ -0,0 +1,88 @@
import * as React from "react";
import styled from "styled-components";
const TicketCard = styled.div`
display: flex;
flex-direction: row;
width: 100%;
margin: 10px 0;
border-radius: 5px;
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.15);
background-color: #fff;
padding: 10px;
`;
const IconContainer = styled.div`
display: flex;
justify-content: center;
width: 150px;
height: 150px;
background-color: #f5f5f5;
`;
const TicketInfo = styled.div`
display: flex;
flex-direction: column;
padding: 10px;
`;
const TicketHeader = styled.div`
display: flex;
justify-content: space-between;
padding: 10px 0;
`;
const TicketTitle = styled.h4`
font-size: 16px;
font-weight: 600;
margin: 0px;
`;
const TicketStatus = styled.span`
font-size: 14px;
color: #999;
`;
const TicketDescription = styled.p`
font-size: 14px;
margin: 0;
`;
const TicketPriority = styled.span`
font-size: 12px;
color: #999;
`;
interface ITaskListProps {
title: string;
icon: string;
status: string;
description: string;
priority: string;
}
const TicketItem = ({
title,
icon,
status,
description,
priority,
}: ITaskListProps) => {
return (
<TicketCard>
<IconContainer>
<img src={icon} alt={title} />
</IconContainer>
<TicketInfo>
<TicketHeader>
<TicketTitle>{title}</TicketTitle>
<TicketStatus>{status}</TicketStatus>
</TicketHeader>
<TicketDescription>{description}</TicketDescription>
<TicketPriority>{priority}</TicketPriority>
</TicketInfo>
</TicketCard>
);
};
export default TicketItem;

View File

@ -0,0 +1,43 @@
.webPartHeader {
border-top: 0px;
margin-bottom: 10px;
border-bottom: 0.0625rem solid rgb(222, 226, 230);
.webPartTitle {
margin-bottom: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
h2 {
margin: 0;
flex-grow: 1;
display: flex;
align-items: flex-start;
color: #183453;
font-family: "Segoe UI";
font-size: 1.875rem;
line-height: 1.55;
text-decoration: none;
font-weight: 500;
}
}
.webpartDescription {
margin-bottom: 5px;
margin-top: 0px;
font-size: 0.85rem;
}
// View mode
span {
// Nothing at the moment
a:link {
text-decoration: none;
}
}
.moreLink {
margin-bottom: 11px;
}
}

View File

@ -0,0 +1,62 @@
import * as React from "react";
import type { IReadonlyTheme } from "@microsoft/sp-component-base";
import styles from "./WebPartTitle.module.scss";
export interface IWebPartTitleProps {
title?: string;
titleIcon?: string;
description?: string;
className?: string;
placeholder?: string;
moreLink?: JSX.Element | (() => React.ReactNode);
themeVariant?: IReadonlyTheme;
}
/**
* Web Part Title component
*/
export class WebPartTitle extends React.Component<IWebPartTitleProps, {}> {
/**
* Constructor
*/
constructor(props: IWebPartTitleProps) {
super(props);
}
/**
* Default React component render method
*/
public render(): React.ReactElement<IWebPartTitleProps> {
const color: string =
(!!this.props.themeVariant &&
this.props.themeVariant?.semanticColors?.bodyText) ||
"";
if (this.props.title || this.props.titleIcon || this.props.description) {
return (
<div
className={`${styles.webPartHeader} ${
this.props.className ? this.props.className : ""
}`}
>
<div className={styles.webPartTitle} style={{ color: color }}>
<h2>{this.props.title && this.props.title}</h2>
</div>
{this.props.description && (
<div className={styles.webpartDescription}>
{this.props.description && this.props.description}
</div>
)}
{this.props.moreLink && (
<span className={styles.moreLink}>
{typeof this.props.moreLink === "function"
? this.props.moreLink()
: this.props.moreLink}
</span>
)}
</div>
);
}
return <></>;
}
}

View File

@ -0,0 +1 @@
export * from "./WebPartTitle";

View File

@ -0,0 +1,6 @@
import { createContext } from "react";
import { IAppContext } from "../models/IAppContext";
export const AppContext = createContext<{
appContext: IAppContext;
}>({ appContext: {} as IAppContext });

View File

@ -0,0 +1,16 @@
define([], function() {
return {
"PropertyPaneDescription": "Description",
"BasicGroupName": "Group Name",
"DescriptionFieldLabel": "Description Field",
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
"AppLocalEnvironmentOutlook": "The app is running on your local environment in Outlook",
"AppSharePointEnvironment": "The app is running on SharePoint page",
"AppTeamsTabEnvironment": "The app is running in Microsoft Teams",
"AppOfficeEnvironment": "The app is running in office.com",
"AppOutlookEnvironment": "The app is running in Outlook",
"UnknownEnvironment": "The app is running in an unknown environment"
}
});

View File

@ -0,0 +1,19 @@
declare interface IDashboardWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
AppLocalEnvironmentSharePoint: string;
AppLocalEnvironmentTeams: string;
AppLocalEnvironmentOffice: string;
AppLocalEnvironmentOutlook: string;
AppSharePointEnvironment: string;
AppTeamsTabEnvironment: string;
AppOfficeEnvironment: string;
AppOutlookEnvironment: string;
UnknownEnvironment: string;
}
declare module 'DashboardWebPartStrings' {
const strings: IDashboardWebPartStrings;
export = strings;
}

View File

@ -0,0 +1,9 @@
import { ListSubscriptionFactory } from "@microsoft/sp-list-subscription";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { IDashboardWebPartProps } from "../DashboardWebPart";
export interface IAppContext {
webpartContext: WebPartContext;
properties: IDashboardWebPartProps;
listSubscriptionFactory: ListSubscriptionFactory;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 B

View File

@ -0,0 +1,26 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-4.7/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"outDir": "lib",
"inlineSources": false,
"noImplicitAny": true,
"typeRoots": ["./node_modules/@types", "./node_modules/@microsoft"],
"types": ["webpack-env"],
"lib": ["es5", "dom", "es2015.collection", "es2015.promise"]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/webparts/dashboard/assets/custom.d.ts"
]
}