diff --git a/samples/react-realtime-incidentdashboard/.eslintrc.js b/samples/react-realtime-incidentdashboard/.eslintrc.js new file mode 100644 index 000000000..9f0159d1a --- /dev/null +++ b/samples/react-realtime-incidentdashboard/.eslintrc.js @@ -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". + "@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, + }, + }, + ], +}; diff --git a/samples/react-realtime-incidentdashboard/.gitignore b/samples/react-realtime-incidentdashboard/.gitignore new file mode 100644 index 000000000..572f0a3c7 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/.gitignore @@ -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 \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/.npmignore b/samples/react-realtime-incidentdashboard/.npmignore new file mode 100644 index 000000000..ae0b487c0 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/.npmignore @@ -0,0 +1,16 @@ +!dist +config + +gulpfile.js + +release +src +temp + +tsconfig.json +tslint.json + +*.log + +.yo-rc.json +.vscode diff --git a/samples/react-realtime-incidentdashboard/.yo-rc.json b/samples/react-realtime-incidentdashboard/.yo-rc.json new file mode 100644 index 000000000..95a0544ce --- /dev/null +++ b/samples/react-realtime-incidentdashboard/.yo-rc.json @@ -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" + } +} diff --git a/samples/react-realtime-incidentdashboard/README.md b/samples/react-realtime-incidentdashboard/README.md new file mode 100644 index 000000000..d285e06c8 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/README.md @@ -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 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)]( "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. diff --git a/samples/react-realtime-incidentdashboard/assets/list-notification-architecture.jpg b/samples/react-realtime-incidentdashboard/assets/list-notification-architecture.jpg new file mode 100644 index 000000000..cad951feb Binary files /dev/null and b/samples/react-realtime-incidentdashboard/assets/list-notification-architecture.jpg differ diff --git a/samples/react-realtime-incidentdashboard/assets/list-notification-dashboard.jpg b/samples/react-realtime-incidentdashboard/assets/list-notification-dashboard.jpg new file mode 100644 index 000000000..d59309282 Binary files /dev/null and b/samples/react-realtime-incidentdashboard/assets/list-notification-dashboard.jpg differ diff --git a/samples/react-realtime-incidentdashboard/assets/list-notification-teams.jpg b/samples/react-realtime-incidentdashboard/assets/list-notification-teams.jpg new file mode 100644 index 000000000..c7ce93607 Binary files /dev/null and b/samples/react-realtime-incidentdashboard/assets/list-notification-teams.jpg differ diff --git a/samples/react-realtime-incidentdashboard/assets/pnp-list-template.xml b/samples/react-realtime-incidentdashboard/assets/pnp-list-template.xml new file mode 100644 index 000000000..4e4671ada --- /dev/null +++ b/samples/react-realtime-incidentdashboard/assets/pnp-list-template.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 + clienttemplates.js + + + + GRIDFIXED + + + + + + + + + + + + + + + + + + + + + + 30 + clienttemplates.js + + + + + + + + + + + + + + + + + + + + + + 30 + clienttemplates.js + + + + + + + + + + + + + + + + + + + 30 + clienttemplates.js + + + + + + + Critical + High + Normal + Low + + + + + Blocked + In progress + Completed + Duplicate + By design + Won't fix + New + + New + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/.gitignore b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/.gitignore new file mode 100644 index 000000000..ff5b00c50 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/.gitignore @@ -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 \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Cards/Incident - Copy.json b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Cards/Incident - Copy.json new file mode 100644 index 000000000..2c0a89337 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Cards/Incident - Copy.json @@ -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}" ] + } + + ] +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Cards/Incident.json b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Cards/Incident.json new file mode 100644 index 000000000..2a86dbac1 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Cards/Incident.json @@ -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 + } + ] + } + } + ] +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/AdaptiveCardHelper.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/AdaptiveCardHelper.cs new file mode 100644 index 000000000..3b9eb1293 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/AdaptiveCardHelper.cs @@ -0,0 +1,27 @@ +using AdaptiveCards.Templating; + +using Newtonsoft.Json; +using System; + +namespace O365Clinic.Function.Webhooks.Helpers +{ + public class AdaptiveCardHelper + { + public static string BindAdaptiveCardData(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; + } + + + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/FunctionHelper.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/FunctionHelper.cs new file mode 100644 index 000000000..20d69830d --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/FunctionHelper.cs @@ -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"; + } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/GraphAuthenticationManager.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/GraphAuthenticationManager.cs new file mode 100644 index 000000000..17fbbd830 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/GraphAuthenticationManager.cs @@ -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; + } + + } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/SPAuthenticationManager.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/SPAuthenticationManager.cs new file mode 100644 index 000000000..813fd5614 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Helpers/SPAuthenticationManager.cs @@ -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 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); + } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/IAuthenticationService.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/IAuthenticationService.cs new file mode 100644 index 000000000..55b00a42e --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/IAuthenticationService.cs @@ -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 GetAccessTokenAsync(string[] scopes); + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/IGraphService.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/IGraphService.cs new file mode 100644 index 000000000..794a10558 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/IGraphService.cs @@ -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 GetUsersAsync(); + Task SendMessageToChannel(); + + } + + +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/INotificationService.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/INotificationService.cs new file mode 100644 index 000000000..c4600be61 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/INotificationService.cs @@ -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); + + } + + +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/ISharePointService.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/ISharePointService.cs new file mode 100644 index 000000000..e893ec266 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Interfaces/ISharePointService.cs @@ -0,0 +1,19 @@ + +using O365Clinic.Function.Webhooks.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace O365Clinic.Function.Webhooks +{ + public interface ISharePointService + { + /// + /// Get Incidents + /// + /// + /// + //Task> GetTickets(string siteUrl); + Task GetListRecentChanges(string siteUrl, string cardPath); + + } +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Mapper/IncidentMapper.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Mapper/IncidentMapper.cs new file mode 100644 index 000000000..d30bb0905 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Mapper/IncidentMapper.cs @@ -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 result = new List + { + 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(); + + } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/AzureFunctionSettings.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/AzureFunctionSettings.cs new file mode 100644 index 000000000..22515aa72 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/AzureFunctionSettings.cs @@ -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; } + + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/IncidentItem.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/IncidentItem.cs new file mode 100644 index 000000000..d804037b8 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/IncidentItem.cs @@ -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; } + + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/NotificationModel.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/NotificationModel.cs new file mode 100644 index 000000000..9df8b7b5b --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/NotificationModel.cs @@ -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; } + } + +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/ResponseModel.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/ResponseModel.cs new file mode 100644 index 000000000..d5e5ea664 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/ResponseModel.cs @@ -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 + { + [JsonProperty(PropertyName = "value")] + public List Value { get; set; } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/SubscriptionModel.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/SubscriptionModel.cs new file mode 100644 index 000000000..3d1e29d65 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Model/SubscriptionModel.cs @@ -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; } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/O365Clinic.Function.Webhooks.csproj b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/O365Clinic.Function.Webhooks.csproj new file mode 100644 index 000000000..bba0b38ff --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/O365Clinic.Function.Webhooks.csproj @@ -0,0 +1,31 @@ + + + net6.0 + v4 + + + + + + + + + + + + + Always + + + PreserveNewest + + + Always + Never + + + Always + Never + + + diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/ProcessTicket.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/ProcessTicket.cs new file mode 100644 index 000000000..febb5d301 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/ProcessTicket.cs @@ -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(); + + var siteUrl = configSettings.TenantBaseUrl + data["siteUrl"].Value(); + + log.LogInformation($"List siteUrl: {siteUrl}"); + + sharePointService.GetListRecentChanges(siteUrl, cardPath).GetAwaiter().GetResult(); + + log.LogInformation($"notificationResource: {notificationResource}"); + } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/SPWebhookReceiver.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/SPWebhookReceiver.cs new file mode 100644 index 000000000..fc97c8c61 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/SPWebhookReceiver.cs @@ -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 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>(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"); + } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/GraphService.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/GraphService.cs new file mode 100644 index 000000000..626b55152 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/GraphService.cs @@ -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(); + _configSettings = settings; + } + + public async Task GetUsersAsync() + { + try + { + var graphClient = GraphAuthenticationManager.GetAuthenticatedGraphClient(_configSettings); + + var result = await graphClient.Users.GetAsync(); + + return result; + } + catch (Exception) + { + return null; + } + + } + public async Task 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; + } + + } + } + +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/NotificationService.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/NotificationService.cs new file mode 100644 index 000000000..efe7d2c6e --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/NotificationService.cs @@ -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; + + + } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/SharePointService.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/SharePointService.cs new file mode 100644 index 000000000..a2068c6e2 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Services/SharePointService.cs @@ -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(); + _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 GetWebTitle(string siteUrl) + { + List incidentItems = new List(); + 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; + } + + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Startup.cs b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Startup.cs new file mode 100644 index 000000000..a3b2012f8 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/Startup.cs @@ -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(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + ////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(); + } + } + } +} diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/host.json b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/host.json new file mode 100644 index 000000000..ee5cf5f83 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + } +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/local.settings.copy.json b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/local.settings.copy.json new file mode 100644 index 000000000..409f95ed5 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/backend/O365Clinic.Function.Webhooks/local.settings.copy.json @@ -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": "*" + } +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/config/config.json b/samples/react-realtime-incidentdashboard/config/config.json new file mode 100644 index 000000000..b914aabb1 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/config/config.json @@ -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" + } +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/config/deploy-azure-storage.json b/samples/react-realtime-incidentdashboard/config/deploy-azure-storage.json new file mode 100644 index 000000000..bb1fab23c --- /dev/null +++ b/samples/react-realtime-incidentdashboard/config/deploy-azure-storage.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json", + "workingDir": "./release/assets/", + "account": "", + "container": "incident-dashbaord", + "accessKey": "" +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/config/package-solution.json b/samples/react-realtime-incidentdashboard/config/package-solution.json new file mode 100644 index 000000000..1dd11047a --- /dev/null +++ b/samples/react-realtime-incidentdashboard/config/package-solution.json @@ -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" + } +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/config/sass.json b/samples/react-realtime-incidentdashboard/config/sass.json new file mode 100644 index 000000000..5e78c982d --- /dev/null +++ b/samples/react-realtime-incidentdashboard/config/sass.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/core-build/sass.schema.json" +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/config/serve.json b/samples/react-realtime-incidentdashboard/config/serve.json new file mode 100644 index 000000000..a4c03e287 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/config/serve.json @@ -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" +} diff --git a/samples/react-realtime-incidentdashboard/config/write-manifests.json b/samples/react-realtime-incidentdashboard/config/write-manifests.json new file mode 100644 index 000000000..bad352605 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/config/write-manifests.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", + "cdnBasePath": "" +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/fast-serve/config.json b/samples/react-realtime-incidentdashboard/fast-serve/config.json new file mode 100644 index 000000000..fbb6384ca --- /dev/null +++ b/samples/react-realtime-incidentdashboard/fast-serve/config.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/s-KaiNet/spfx-fast-serve/master/schema/config.latest.schema.json", + "cli": { + "isLibraryComponent": false + } +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/fast-serve/webpack.extend.js b/samples/react-realtime-incidentdashboard/fast-serve/webpack.extend.js new file mode 100644 index 000000000..22e737e60 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/fast-serve/webpack.extend.js @@ -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 +} diff --git a/samples/react-realtime-incidentdashboard/gulpfile.js b/samples/react-realtime-incidentdashboard/gulpfile.js new file mode 100644 index 000000000..8e3857742 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/gulpfile.js @@ -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')); + diff --git a/samples/react-realtime-incidentdashboard/package.json b/samples/react-realtime-incidentdashboard/package.json new file mode 100644 index 000000000..5d126ec3a --- /dev/null +++ b/samples/react-realtime-incidentdashboard/package.json @@ -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" + } +} diff --git a/samples/react-realtime-incidentdashboard/src/common/helpers/LogHelper.ts b/samples/react-realtime-incidentdashboard/src/common/helpers/LogHelper.ts new file mode 100644 index 000000000..3a163ba38 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/common/helpers/LogHelper.ts @@ -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}`; + } +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/src/common/services/SPService.ts b/samples/react-realtime-incidentdashboard/src/common/services/SPService.ts new file mode 100644 index 000000000..e0073024a --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/common/services/SPService.ts @@ -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; diff --git a/samples/react-realtime-incidentdashboard/src/index.ts b/samples/react-realtime-incidentdashboard/src/index.ts new file mode 100644 index 000000000..fb81db1e2 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/index.ts @@ -0,0 +1 @@ +// A file is required to be in the root of the /src directory by the TypeScript compiler diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/DashboardWebPart.manifest.json b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/DashboardWebPart.manifest.json new file mode 100644 index 000000000..eaa073af3 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/DashboardWebPart.manifest.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/DashboardWebPart.ts b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/DashboardWebPart.ts new file mode 100644 index 000000000..62b4acb40 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/DashboardWebPart.ts @@ -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 { + 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 { + // 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 { + 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, + }), + ], + }, + ], + }, + ], + }; + } +} diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/custom.d.ts b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/custom.d.ts new file mode 100644 index 000000000..8e94061ce --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/custom.d.ts @@ -0,0 +1,20 @@ +declare module "*.svg" { + import * as React from "react"; + + export const ReactComponent: React.FunctionComponent< + React.SVGProps & { title?: string } + >; + + const src: string; + export default src; +} +declare module "*.png" { + import * as React from "react"; + + export const ReactComponent: React.FunctionComponent< + React.SVGProps & { title?: string } + >; + + const src: string; + export default src; +} diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/icon_service.png b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/icon_service.png new file mode 100644 index 000000000..b4a1cfe61 Binary files /dev/null and b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/icon_service.png differ diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/welcome-dark.png b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/welcome-dark.png new file mode 100644 index 000000000..42f0b8d24 Binary files /dev/null and b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/welcome-dark.png differ diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/welcome-light.png b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/welcome-light.png new file mode 100644 index 000000000..69eb3b48c Binary files /dev/null and b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/assets/welcome-light.png differ diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/Dashboard.module.scss b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/Dashboard.module.scss new file mode 100644 index 000000000..f67239d8b --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/Dashboard.module.scss @@ -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 + } + } +} diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/Dashboard.tsx b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/Dashboard.tsx new file mode 100644 index 000000000..1b08410c0 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/Dashboard.tsx @@ -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 ( +
+
+ + {needsConfiguration && ( + { + appContext.webpartContext.propertyPane.open(); + }} + /> + )} + {!needsConfiguration &&
{}
} +
+
+ ); +}; diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/IDashboardProps.ts b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/IDashboardProps.ts new file mode 100644 index 000000000..ec055a289 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/IDashboardProps.ts @@ -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; +} diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/SupportTickets/SupportTickets.tsx b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/SupportTickets/SupportTickets.tsx new file mode 100644 index 000000000..eb553d3f5 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/SupportTickets/SupportTickets.tsx @@ -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({ + 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 ( + <> + + + ); + + if (tickets.error) + return ( + <> + + Something went wrong + + + ); + + return ( +
+ + {"My requests"} +
+ } + renderItem={(item) => ( + + + {item.Status} + + , + ]} + > + + } + title={ + + {item.Title} + + {item.Priority} + + + } + description={ + + {item.Description} + + {item?.DateReported + ? convertDateToDaysAgo(item.DateReported) + : null} + + + } + /> + + + )} + /> + + ); + + 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; diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/SupportTickets/TicketItem.tsx b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/SupportTickets/TicketItem.tsx new file mode 100644 index 000000000..7d319a6bb --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/SupportTickets/TicketItem.tsx @@ -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 ( + + + {title} + + + + {title} + {status} + + {description} + {priority} + + + ); +}; +export default TicketItem; diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/WebPartTitle.module.scss b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/WebPartTitle.module.scss new file mode 100644 index 000000000..27d2bb2a0 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/WebPartTitle.module.scss @@ -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; + } +} diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/WebPartTitle.tsx b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/WebPartTitle.tsx new file mode 100644 index 000000000..5ade0ec4b --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/WebPartTitle.tsx @@ -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 { + /** + * Constructor + */ + constructor(props: IWebPartTitleProps) { + super(props); + } + + /** + * Default React component render method + */ + public render(): React.ReactElement { + const color: string = + (!!this.props.themeVariant && + this.props.themeVariant?.semanticColors?.bodyText) || + ""; + + if (this.props.title || this.props.titleIcon || this.props.description) { + return ( +
+
+

{this.props.title && this.props.title}

+
+ {this.props.description && ( +
+ {this.props.description && this.props.description} +
+ )} + {this.props.moreLink && ( + + {typeof this.props.moreLink === "function" + ? this.props.moreLink() + : this.props.moreLink} + + )} +
+ ); + } + return <>; + } +} diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/index.ts b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/index.ts new file mode 100644 index 000000000..1bc3908d8 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/components/webPartTitle/index.ts @@ -0,0 +1 @@ +export * from "./WebPartTitle"; diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/hooks/AppContext.ts b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/hooks/AppContext.ts new file mode 100644 index 000000000..425fac772 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/hooks/AppContext.ts @@ -0,0 +1,6 @@ +import { createContext } from "react"; +import { IAppContext } from "../models/IAppContext"; + +export const AppContext = createContext<{ + appContext: IAppContext; +}>({ appContext: {} as IAppContext }); diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/loc/en-us.js b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/loc/en-us.js new file mode 100644 index 000000000..3b25e74a7 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/loc/en-us.js @@ -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" + } +}); \ No newline at end of file diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/loc/mystrings.d.ts b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/loc/mystrings.d.ts new file mode 100644 index 000000000..5d6891ce8 --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/loc/mystrings.d.ts @@ -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; +} diff --git a/samples/react-realtime-incidentdashboard/src/webparts/dashboard/models/IAppContext.ts b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/models/IAppContext.ts new file mode 100644 index 000000000..8ca6a9bde --- /dev/null +++ b/samples/react-realtime-incidentdashboard/src/webparts/dashboard/models/IAppContext.ts @@ -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; +} diff --git a/samples/react-realtime-incidentdashboard/teams/d04b2052-c243-49d8-ab15-d537e9f1ad1c_color.png b/samples/react-realtime-incidentdashboard/teams/d04b2052-c243-49d8-ab15-d537e9f1ad1c_color.png new file mode 100644 index 000000000..0e1f764fa Binary files /dev/null and b/samples/react-realtime-incidentdashboard/teams/d04b2052-c243-49d8-ab15-d537e9f1ad1c_color.png differ diff --git a/samples/react-realtime-incidentdashboard/teams/d04b2052-c243-49d8-ab15-d537e9f1ad1c_outline.png b/samples/react-realtime-incidentdashboard/teams/d04b2052-c243-49d8-ab15-d537e9f1ad1c_outline.png new file mode 100644 index 000000000..e8cb4b6ba Binary files /dev/null and b/samples/react-realtime-incidentdashboard/teams/d04b2052-c243-49d8-ab15-d537e9f1ad1c_outline.png differ diff --git a/samples/react-realtime-incidentdashboard/tsconfig.json b/samples/react-realtime-incidentdashboard/tsconfig.json new file mode 100644 index 000000000..680fb40cb --- /dev/null +++ b/samples/react-realtime-incidentdashboard/tsconfig.json @@ -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" + ] +}